Тесты с временной директорией
Если ваш код работает с файлами, для его тестирования нужны временные директории. Директории нужно создавать, наполнять тестовым содержимым и удалять после прогона тестов.
Тесты с временными файлами я неоднократно писал на java, groovy и даже на scala. В этот раз решил зафиксировать в блоге scala реализацию (Да, что бы копипастить в следующие проекты).
В посте покажу 2 реализации:
- грязные функции с эксэпшенами
F[_]: Sync
и cats.effect.Resource
Грязные функции
Начинаем с проектирования. Для тестов мне нужны только текстовые файлы, поэтому метод будет принимать varargs с именами файлов и содержимым. Имя файла и контент пусть будут строками (В тестах обойдусь без refined и имплиситов)
Сигнатура функции может выглядеть следующим образом:
def withTempDirectory[A](files: (String, String)*)(f: Path => A): A
Пример теста:
val sum: Int = withTempDirectory(
"dir1/a.txt" -> "1",
"dir2/b.txt" -> "2"
)(path =>
Files
.walk(path)
.iterator()
.asScala
.filter(Files.isRegularFile(_))
.map(Files.readString)
.map(_.toInt)
.sum
)
assert(sum == 3)
Начнём со вспомогательных функций.
Удаление непустой директории. Метод postVisitDirectory вызывается, когда на всех внутренних файлах уже был вызван visitFile.
def deleteDirectoryRecursively(path: Path): Unit =
Files.walkFileTree(
path,
new SimpleFileVisitor[Path] {
override def postVisitDirectory(
dir: Path,
exc: IOException
): FileVisitResult = {
Files.delete(dir)
FileVisitResult.CONTINUE
}
override def visitFile(
file: Path,
attrs: BasicFileAttributes
): FileVisitResult = {
Files.delete(file)
FileVisitResult.CONTINUE
}
}
)
Проверка, что удаляемый файл находится внутри временной директории.
Для путей вида dir2/../dir1/b.txt
необходима нормализация.
def isInside(parent: Path, child: Path): Boolean =
child
.normalize()
.toAbsolutePath
.startsWith(parent.normalize().toAbsolutePath)
Создание родительских директорий и запись текста в файл
def write(
path: Path,
text: String,
openOptions: Seq[OpenOption] = Seq.empty,
charset: Charset = Charset.defaultCharset()
): Path =
Files.createDirectories(path.getParent)
Files.write(path, text.getBytes(charset), openOptions: _*)
Вспомогательные функции готовы, переходим к реализации withTempDirectory
.
- Создаём временную директорию
- Создаём файлы внутри
- В случае успеха, выполняем пользовательскую функцию
- Удаляем директорию, даже если не удалось создать файлы или произошла ошибка в пользовательской функции
def withTempDirectory[A](files: (String, String)*)(f: Path => A): A =
val dir = Files.createTempDirectory("java_nio_tmp_dir")
val t = Try {
files.foreach { case (localPath, content) =>
val resolved = dir.resolve(localPath)
if (!isInside(dir, resolved))
throw new IllegalArgumentException(
s"child file is outside of parent directory (${dir.toString}, ${resolved.toString})"
)
write(resolved, content)
}
}
val tryResult = t.map(_ => f(dir))
deleteDirectoryRecursively(dir)
tryResult.get
котоэффекты
Для тестовой директории идеально подходит cats.effect.Resource:
def tempDirectory(files: (String, String)*): Resource[F, Path]
Котовые ресурсы можно мапить в нужный для тестов тип, например java.io.File:
def tempDirectoryJavaFile(
files: (String, String)*
): Resource[F, java.io.File] =
tempDirectory(files: _*).map(_.toFile)
Код тестов несколько изменился:
FilesF[IO]
.tempDirectory(
"dir1/a.txt" -> "1",
"dir2/b.txt" -> "2"
)
.use(path =>
Sync[IO].interruptible(true)(
Files
.walk(path)
.iterator()
.asScala
.filter(Files.isRegularFile(_))
.map(Files.readString)
.map(_.toInt)
.sum
)
)
.flatMap { sum =>
IO(assert(sum == 3))
}
Следующий код повторяет код из грязных функций выше, но теперь функции возвращают F[_]
.
import cats.effect.IO
import cats.effect.IOApp
import cats.effect.Resource
import cats.effect.Sync
import cats.syntax.all.*
import java.io.IOException
import java.nio.charset.Charset
import java.nio.file.FileVisitResult
import java.nio.file.Files
import java.nio.file.OpenOption
import java.nio.file.Path
import java.nio.file.SimpleFileVisitor
import java.nio.file.attribute.BasicFileAttributes
class FilesF[F[_]: Sync]:
def deleteDirectoryRecursively(path: Path): F[Unit] =
Sync[F].interruptible(true) {
Files.walkFileTree(
path,
new SimpleFileVisitor[Path] {
override def postVisitDirectory(
dir: Path,
exc: IOException
): FileVisitResult = {
Files.delete(dir)
FileVisitResult.CONTINUE
}
override def visitFile(
file: Path,
attrs: BasicFileAttributes
): FileVisitResult = {
Files.delete(file)
FileVisitResult.CONTINUE
}
}
)
}
def createDirectories(path: Path): F[Unit] =
Sync[F].delay(Files.createDirectories(path.getParent))
def createTempDirectory(): F[Path] =
Sync[F].delay(Files.createTempDirectory("java_nio_tmp_dir"))
def write(
path: Path,
text: String,
openOptions: Seq[OpenOption] = Seq.empty,
charset: Charset = Charset.defaultCharset()
): F[Unit] =
for
_ <- createDirectories(path)
_ <- Sync[F].interruptible(true) {
Files.write(path, text.getBytes(charset), openOptions: _*)
}
yield ()
def isInside(parent: Path, child: Path): Boolean =
child
.normalize()
.toAbsolutePath
.startsWith(parent.normalize().toAbsolutePath)
def fillDirectory(dir: Path, files: (String, String)*): F[Unit] =
files.traverse_ { case (localPath, content) =>
val resolved = dir.resolve(localPath)
if (!isInside(dir, resolved))
Sync[F].raiseError[Unit](
new IllegalArgumentException(
s"child file is outside of parent directory (${dir.toString}, ${resolved.toString})"
)
)
else
write(resolved, content)
}
def tempDirectory(files: (String, String)*): Resource[F, Path] =
def acquire: F[Path] =
for
dir <- createTempDirectory()
_ <- fillDirectory(dir, files: _*)
yield dir
Resource.make[F, Path](acquire)(deleteDirectoryRecursively)
Если не нравится оборачивать каждое использование java.nio
в Sync,
есть библиотека, делающая это за вас "io.github.akiomik" %% "cats-nio-file"
Отдельный проект на github я не создавал под эти примеры.