--- title: テスタビリティの高いjava.nio.file.Pathの作り方 tags: ["Java", "Testing", "Jimfs", "JUnit"] categories: ["Programming", "Java", "java", "nio", "file"] date: 2025-07-16T01:42:20Z updated: 2025-07-16T02:08:15Z --- JavaでNIO.2の`Path`オブジェクトを作成する際、`Path.of()`や`Paths.get()`を使用することが多いですが、テスタビリティの観点から`FileSystem#getPath()`を使用する方が良いです。 ### Path.ofを使う場合に発生しうる問題 `Path.of()`や`Paths.get()`を使用する場合、実際のファイルシステムに依存するため、テスト時に以下の問題が発生する可能性があります: - OSに依存したパスセパレータの違い - パーミッションの問題 - テスト環境の状態に依存する可能性 次のような単純な例を見てみましょう: ```java import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.springframework.stereotype.Component; @Component public class FileService { public void writeFile(String filename, List lines) throws IOException { Path path = Path.of(filename); Files.write(path, lines); } public List readFile(String filename) throws IOException { Path path = Path.of(filename); return Files.readAllLines(path); } } ``` > [!NOTE] > そもそも`filename`を`String`で受け取るよりも、`Path`で受け取る方が良いのですが、本稿の趣旨に合わせるため、ここでは`String`を使用しています。 この`FileService`をテストしてみましょう。次のテストコードは一見正しく動作しそうです: ```java import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class FileServiceTest { @Test void testWriteAndReadFile() throws Exception { FileService fileService = new FileService(); List lines = List.of("line1", "line2", "line3"); Path dir = Path.of("/test"); Files.createDirectories(dir); fileService.writeFile(dir.resolve("data.txt").toString(), lines); List readLines = fileService.readFile(dir.resolve("data.txt").toString()); assertThat(readLines).containsExactlyElementsOf(lines); } } ``` しかし、実際には筆者のMac端末では次のような例外が発生します: ``` java.nio.file.FileSystemException: /test: Read-only file system ``` Windowsでは別の問題が起こる可能性もあります。 おそらく多くの方は`@TempDir`などを使って次のように一時ファイルを使ったテストを書くのではないでしょうか: ```java import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import static org.assertj.core.api.Assertions.assertThat; class FileServiceTest { @Test void testWriteAndReadFileWithTempDir(@TempDir Path tempDir) throws Exception { FileService fileService = new FileService(); List lines = List.of("line1", "line2", "line3"); Path dir = tempDir.resolve("test"); Files.createDirectories(dir); fileService.writeFile(dir.resolve("data.txt").toString(), lines); List readLines = fileService.readFile(dir.resolve("data.txt").toString()); assertThat(readLines).containsExactlyElementsOf(lines); } } ``` 一見テストがうまくいっているように見えても、注意深くコードを書かないと、WindowsとUnix系でパスのセパレータが異なるため、テストが失敗することがあります。 例えば、実際にはLinux上で動作するバッチ処理のテストをWindowsで実行すると、パスのセパレータが異なるためにテストが失敗することがあります。 同じプロジェクトで開発者にWindowsを使っている人とMacを使っている人がいる場合、ある人の環境ではテストが通るのに、別の人の環境では失敗することもあります。 ### FileSystem#getPathを使用した実装 `Path.of(String)`は内部的にデフォルトの`FileSystem`(`FileSystems.getDefault()`)の`getPath`メソッドを使用します。実装は次のようになっています: ```java public static Path of(String first, String... more) { return FileSystems.getDefault().getPath(first, more); } ``` `FileSystem`はJDKに組み込まれているファイルシステムの抽象化であり、テスト時にインメモリファイルシステムを使用することができます。 `FileSystem`の実装は[`java.nio.file.spi.FileSystemProvider`](https://docs.oracle.com/javase/7/docs/api/java/nio/file/spi/FileSystemProvider.html)というSPIを通じて拡張可能で、例えば[S3の実装](https://aws.amazon.com/jp/blogs/storage/extending-java-applications-to-directly-access-files-in-amazon-s3-without-recompiling/)や[Azure Blob Storageの実装](https://learn.microsoft.com/en-us/java/api/overview/azure/storage-blob-nio-readme)もあります。 次のように、FileSystemをコンストラクタインジェクションで受け取る設計にすることで、テスト時に任意の`FileSystem`を注入できます。`Path`の作成`Path#of`ではなく`FileSystem#getPath`を使用します: ```java import java.io.IOException; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.springframework.stereotype.Component; @Component public class FileService { private final FileSystem fileSystem; public FileService(FileSystem fileSystem) { this.fileSystem = fileSystem; } public void writeFile(String filename, List lines) throws IOException { Path path = fileSystem.getPath(filename); Files.write(path, lines); } public List readFile(String filename) throws IOException { Path path = fileSystem.getPath(filename); return Files.readAllLines(path); } } ``` プロダクションコードでは、デフォルトのFileSystemを使用します。Springの場合は次のようなBean定義を行います: ```java import java.nio.file.FileSystem; import java.nio.file.FileSystems; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration(proxyBeanMethods = false) class AppConfig { @Bean FileSystem fileSystem() { return FileSystems.getDefault(); } } ``` これにより、`FileService`では`Path.of(String)`を使っているのと実質的に同じ動作を実現しつつ、テスト時には異なる`FileSystem`実装を注入できる柔軟性を持たせることができます。 > [!NOTE] > Pathの抽象化の仕組みとしては他にも、`Path.of(URI)`を使って、`Path.of(URI.create("file:///test/data.txt"))`のようにURIスキーマを指定する方法があります。 > この場合、スキーマに応じた`FileSystem`が使用されます。スキーマの追加はSPIである`java.nio.file.spi.FileSystemProvider`を実装して登録することで、ServiceLoader経由で読み込まれます。 > ただし、次に説明するJimfsはSPI経由でのURIスキーマをサポートしていないのと、Dependency Injectionの仕組みでは`Path`の文字列よりも`FileSystem`を切り替える方が簡単なので、`FileSystem#getPath()`を使う方法を紹介しています。 ### Jimfsを使用したテスト [Jimfs](https://github.com/google/jimfs)はGoogleがOSSで公開しているインメモリファイルシステムの実装です。 これを使用することで、実際のファイルシステムに依存しないテストが可能になります。 次の依存関係を`pom.xml`に追加することでJimfsをテストで使用できます: ```xml com.google.jimfs jimfs 1.3.1 test ``` Jimfsを使用して、先述のテストを書き換えると次のようになります: ```java import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class FileServiceTest { @Test void testWriteAndReadFile() throws Exception { try (FileSystem fileSystem = Jimfs.newFileSystem(Configuration.unix())) { FileService fileService = new FileService(fileSystem); List lines = List.of("line1", "line2", "line3"); Path dir = fileSystem.getPath("/test"); Files.createDirectories(dir); fileService.writeFile(dir.resolve("data.txt").toString(), lines); List readLines = fileService.readFile(dir.resolve("data.txt").toString()); assertThat(readLines).containsExactlyElementsOf(lines); } } } ``` FileSystemはインメモリ上に作られるので、環境による差異に悩まされることなくテストが実行できます。 また、プロダクションコードには一切手を入れることなく、透過的にFileSystemを切り替えられます。 --- `FileSystem#getPath()`を使用して`Path`を作成する設計にすることで、テスト時にJimfsなどのインメモリファイルシステムを注入でき、より高速で安定したテストが実現できます。 特に、ファイルI/Oが多いアプリケーションや、クロスプラットフォーム対応が必要な場合には、この方法が有効です。