JavaでNIO.2のPathオブジェクトを作成する際、Path.of()やPaths.get()を使用することが多いですが、テスタビリティの観点からFileSystem#getPath()を使用する方が良いです。
Path.ofを使う場合に発生しうる問題
Path.of()やPaths.get()を使用する場合、実際のファイルシステムに依存するため、テスト時に以下の問題が発生する可能性があります:
- OSに依存したパスセパレータの違い
- パーミッションの問題
- テスト環境の状態に依存する可能性
次のような単純な例を見てみましょう:
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<String> lines) throws IOException {
Path path = Path.of(filename);
Files.write(path, lines);
}
public List<String> readFile(String filename) throws IOException {
Path path = Path.of(filename);
return Files.readAllLines(path);
}
}
Note
そもそもfilenameをStringで受け取るよりも、Pathで受け取る方が良いのですが、本稿の趣旨に合わせるため、ここではStringを使用しています。
このFileServiceをテストしてみましょう。次のテストコードは一見正しく動作しそうです:
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<String> lines = List.of("line1", "line2", "line3");
Path dir = Path.of("/test");
Files.createDirectories(dir);
fileService.writeFile(dir.resolve("data.txt").toString(), lines);
List<String> readLines = fileService.readFile(dir.resolve("data.txt").toString());
assertThat(readLines).containsExactlyElementsOf(lines);
}
}
しかし、実際には筆者のMac端末では次のような例外が発生します:
java.nio.file.FileSystemException: /test: Read-only file system
Windowsでは別の問題が起こる可能性もあります。
おそらく多くの方は@TempDirなどを使って次のように一時ファイルを使ったテストを書くのではないでしょうか:
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<String> lines = List.of("line1", "line2", "line3");
Path dir = tempDir.resolve("test");
Files.createDirectories(dir);
fileService.writeFile(dir.resolve("data.txt").toString(), lines);
List<String> 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メソッドを使用します。実装は次のようになっています:
public static Path of(String first, String... more) {
return FileSystems.getDefault().getPath(first, more);
}
FileSystemはJDKに組み込まれているファイルシステムの抽象化であり、テスト時にインメモリファイルシステムを使用することができます。FileSystemの実装はjava.nio.file.spi.FileSystemProviderというSPIを通じて拡張可能で、例えばS3の実装やAzure Blob Storageの実装もあります。
次のように、FileSystemをコンストラクタインジェクションで受け取る設計にすることで、テスト時に任意のFileSystemを注入できます。Pathの作成Path#ofではなくFileSystem#getPathを使用します:
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<String> lines) throws IOException {
Path path = fileSystem.getPath(filename);
Files.write(path, lines);
}
public List<String> readFile(String filename) throws IOException {
Path path = fileSystem.getPath(filename);
return Files.readAllLines(path);
}
}
プロダクションコードでは、デフォルトのFileSystemを使用します。Springの場合は次のようなBean定義を行います:
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はGoogleがOSSで公開しているインメモリファイルシステムの実装です。
これを使用することで、実際のファイルシステムに依存しないテストが可能になります。
次の依存関係をpom.xmlに追加することでJimfsをテストで使用できます:
<dependency>
<groupId>com.google.jimfs</groupId>
<artifactId>jimfs</artifactId>
<version>1.3.1</version>
<scope>test</scope>
</dependency>
Jimfsを使用して、先述のテストを書き換えると次のようになります:
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<String> lines = List.of("line1", "line2", "line3");
Path dir = fileSystem.getPath("/test");
Files.createDirectories(dir);
fileService.writeFile(dir.resolve("data.txt").toString(), lines);
List<String> readLines = fileService.readFile(dir.resolve("data.txt").toString());
assertThat(readLines).containsExactlyElementsOf(lines);
}
}
}
FileSystemはインメモリ上に作られるので、環境による差異に悩まされることなくテストが実行できます。
また、プロダクションコードには一切手を入れることなく、透過的にFileSystemを切り替えられます。
FileSystem#getPath()を使用してPathを作成する設計にすることで、テスト時にJimfsなどのインメモリファイルシステムを注入でき、より高速で安定したテストが実現できます。
特に、ファイルI/Oが多いアプリケーションや、クロスプラットフォーム対応が必要な場合には、この方法が有効です。