破れた試し刷りの横に、透明なケースに守られたファイルがあるイラスト

設計・テスト・レビュー

JUnitの@TempDirで上書きをテストする|出力に失敗しても既存ファイルを残す

この記事でわかること

JUnitの @TempDir に前回の連携ファイルを作り、書き込み途中で IOException を起こします。前回分の全バイトが残るか、一時ファイルが残らないかを、実際のファイルで確認します。

新人SE
新人SE
バッチが途中で失敗したら、例外をログに出しています。テストもIOExceptionを確認すれば大丈夫ですか?

ポンコツSE
ポンコツSE
例外が出た後、連携先が読む daily.tsv を開いてみよう。前回の内容が残っているかまで確かめたい。

毎朝のバッチが daily.tsv を作り、別システムは決まったパスから読み込みます。入力の読み込みに失敗した日は連携ファイルを更新せず、前回分を残す運用です。エラーが返っても、出力先をすでに上書きしていたら、その運用は成り立ちません。

実行用サンプル

ZIPには、直接書く実装と一時ファイルへ書く実装、SnapshotPublisherTest.java、pom.xml、READMEを収録。JDK 17とMaven 3.9系で mvn test を実行できます。DBやSpringは不要です。

公開先に直接書くと、失敗後に前回分が消える

最初に、公開先をそのまま開く実装を見ます。Files.newOutputStream(target) はオプションを省略すると、既存のファイルを開いた時点で中身を切り詰めます。その後に見出しだけ書いて入力の読み込みで失敗すると、daily.tsv には見出ししか残りません。

package info.syachiku.snapshot;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public final class DirectSnapshotPublisher {
    public void publish(Path target, SnapshotWriter writer) throws IOException {
        try (var output = Files.newOutputStream(target)) {
            writer.write(output);
        }
    }
}

サンプルでは、前回分に「日付・状態」の見出しと11月3日の明細を入れます。今回分の処理は、見出しを書いた直後に IOException を投げるようにしています。公開ファイルには例外前に書いた見出しが残り、前回の明細は読めなくなります。例外を捕まえられることと、ファイルを壊さないことは、別々に確かめる必要があります。

ここで調べるのはファイル更新の途中で起きる故障です。CSVのカンマ・改行・引用符をどう読み取るかは、入力データ側の検証です。

関連記事JavaでCSVを読み込む方法|カンマ・改行・ダブルクォートを含むデータに対応する 入力側のCSVで、セル内の改行・引用符や不正な列数をどう扱うかを確かめられます。 記事を読む

@TempDirに前回分を作り、失敗後の中身まで確認する

テストクラスの Path directory に @TempDir を付けると、JUnitがテスト用の一時ディレクトリを用意します。このサンプルではそこへ daily.tsv を作ります。普段の出力先や、チームで使っている検証用の連携ファイルをテストから開かずに済みます。

注目するのは、途中で失敗した後の状態です。assertThrows で例外を確かめたあと、Files.readAllBytes で公開ファイルを開き直します。前回分のバイト列と比較すれば、ファイル名は残っていても、明細が欠けたり改行が変わったりした場合に検出できます。さらに、ディレクトリのファイル名を一覧にして、作業中のファイルが残っていないことを確かめます。

初回起動のように前回ファイルがない状態も別のテストにします。途中で失敗した場合に期待するのは、空の daily.tsv ではなく、まだその名前で公開されていない状態です。連携先が「ファイルがある」ことを開始条件にしていれば、空ファイルの有無も動作に影響します。

一時ファイルへ書き、閉じてから公開先を切り替える

修正版は、公開先と同じディレクトリに作業用ファイルを作ります。入力を読みながら書き込む先は一時ファイルです。書き込みと出力ストリームのクローズが両方成功した場合に、公開パスへ移動します。途中で例外が出たら、公開ファイルを開かないまま一時ファイルを削除し、失敗を呼び出し元へ返します。

package info.syachiku.snapshot;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

import static java.nio.file.StandardCopyOption.ATOMIC_MOVE;

public final class SnapshotPublisher {
    private final AtomicMove move;

    public SnapshotPublisher() {
        this((staged, target) -> Files.move(staged, target, ATOMIC_MOVE));
    }

    SnapshotPublisher(AtomicMove move) {
        this.move = move;
    }

    public void publish(Path target, SnapshotWriter writer) throws IOException {
        Path destination = target.toAbsolutePath();
        Path staged = Files.createTempFile(destination.getParent(), ".report-", ".tmp");
        try {
            try (var output = Files.newOutputStream(staged)) {
                writer.write(output);
            }
            move.move(staged, destination);
        } catch (IOException | RuntimeException failure) {
            try {
                Files.deleteIfExists(staged);
            } catch (IOException | SecurityException cleanupFailure) {
                failure.addSuppressed(cleanupFailure);
            }
            throw failure;
        }
    }

    @FunctionalInterface
    interface AtomicMove {
        void move(Path staged, Path target) throws IOException;
    }
}

SnapshotWriter は出力ストリームを受け取り、ファイルの中身を書くインターフェースです。テストはここに、見出しのバイトを書いたあとに例外を投げる処理を渡します。実際のファイルに書き込む点は保ったまま、入力元の障害を毎回同じ場所で起こせます。公開ディレクトリは事前に用意し、このバッチが書き込める状態にします。

公開処理では ATOMIC_MOVE を指定します。ここで目指すのは、連携先が公開パスを新しく開いたときに、切り替え途中のバイト列を見せないことです。Javaの契約では、ATOMIC_MOVE と同時に渡したほかのオプションは無視され、既存ファイルがある場合の置換可否はファイルシステム実装に依存します。REPLACE_EXISTING も一緒に指定すれば必ず置き換わる、とは扱いません。置換できない環境ではエラーを返す設計です。 JavaのFiles.moveの仕様

例外を捕まえて公開ファイルを先に削除したり、非原子的な移動へ切り替えたりはしません。運用環境で置換に対応するかは、実際にバッチを動かすホスト・JDK・マウント先で別に確認します。この記事のテストはプロセス内の例外とパスの切り替えを対象とし、停電後の保存、複数の書き手による競合、ファイルの所有者や権限の引継ぎは対象外です。

書き込み失敗と、公開時の環境差を分けてテストする

テストの全文です。上から、直接書くと壊れる再現、修正後の既存あり・なしの失敗、初回の公開、既存ファイルの置換、atomic moveに対応しない場合を扱います。正常時に作る今回分は、11月4日の明細です。

package info.syachiku.snapshot;

import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.AtomicMoveNotSupportedException;
import java.nio.file.FileAlreadyExistsException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.stream.Collectors;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

class SnapshotPublisherTest {
    private static final byte[] PREVIOUS = "date\tstatus\n2026-11-03\tOK\n".getBytes(UTF_8);
    private static final byte[] UPDATED = "date\tstatus\n2026-11-04\tOK\n".getBytes(UTF_8);
    private static final byte[] PARTIAL = "date\tstatus\n".getBytes(UTF_8);

    @TempDir
    Path directory;

    @Test
    void 直接上書きでは例外の前に既存ファイルが部分出力へ変わる() throws IOException {
        Path target = Files.write(directory.resolve("daily.tsv"), PREVIOUS);

        assertThrows(IOException.class,
                () -> new DirectSnapshotPublisher().publish(target, this::failAfterPartialWrite));

        assertArrayEquals(PARTIAL, Files.readAllBytes(target));
    }

    @Test
    void 一時出力の失敗後も既存ファイルの全バイトが残る() throws IOException {
        Path target = Files.write(directory.resolve("daily.tsv"), PREVIOUS);

        IOException failure = assertThrows(IOException.class,
                () -> new SnapshotPublisher().publish(target, this::failAfterPartialWrite));

        assertEquals("入力の読み込みに失敗", failure.getMessage());
        assertArrayEquals(PREVIOUS, Files.readAllBytes(target));
        assertEquals(Set.of("daily.tsv"), names());
    }

    @Test
    void 初回の一時出力で失敗したら公開ファイルを作らない() throws IOException {
        Path target = directory.resolve("daily.tsv");

        assertThrows(IOException.class,
                () -> new SnapshotPublisher().publish(target, this::failAfterPartialWrite));

        assertFalse(Files.exists(target));
        assertEquals(Set.of(), names());
    }

    @Test
    void 初回の出力が成功すると新規公開し一時ファイルは残らない() throws IOException {
        Path target = directory.resolve("daily.tsv");
        try {
            new SnapshotPublisher().publish(target, output -> output.write(UPDATED));
        } catch (AtomicMoveNotSupportedException unsupported) {
            assumeTrue(false, "この一時ディレクトリではatomic moveが使えない");
        }

        assertArrayEquals(UPDATED, Files.readAllBytes(target));
        assertEquals(Set.of("daily.tsv"), names());
    }

    @Test
    void 既存ファイルを置換できる環境では完成した内容へ入れ替わる() throws IOException {
        Path target = Files.write(directory.resolve("daily.tsv"), PREVIOUS);
        try {
            new SnapshotPublisher().publish(target, output -> output.write(UPDATED));
        } catch (AtomicMoveNotSupportedException | FileAlreadyExistsException unsupported) {
            assumeTrue(false, "この一時ディレクトリでは既存ファイルのatomic置換を確認できない");
        }

        assertArrayEquals(UPDATED, Files.readAllBytes(target));
        assertEquals(Set.of("daily.tsv"), names());
    }

    @Test
    void atomic非対応を模擬したら例外を返し別の上書きを試さない() throws IOException {
        Path target = Files.write(directory.resolve("daily.tsv"), PREVIOUS);
        var publisher = new SnapshotPublisher((staged, destination) -> {
            assertEquals(target, destination);
            assertEquals(target.getParent(), staged.getParent());
            assertArrayEquals(UPDATED, Files.readAllBytes(staged));
            throw new AtomicMoveNotSupportedException(
                    staged.toString(), destination.toString(), "テスト用");
        });

        assertThrows(AtomicMoveNotSupportedException.class,
                () -> publisher.publish(target, output -> output.write(UPDATED)));

        assertArrayEquals(PREVIOUS, Files.readAllBytes(target));
        assertEquals(Set.of("daily.tsv"), names());
    }

    private void failAfterPartialWrite(OutputStream output) throws IOException {
        output.write(PARTIAL);
        throw new IOException("入力の読み込みに失敗");
    }

    private Set<String> names() throws IOException {
        try (var files = Files.list(directory)) {
            return files.map(path -> path.getFileName().toString()).collect(Collectors.toSet());
        }
    }
}

実際にファイルを移動する正常系では、一時ディレクトリがatomic moveに対応しない場合や、既存ファイルがあると FileAlreadyExistsException を返す場合を、JUnitの仮定で中断します。中断は成功に含まれません。別の原因の例外はテストを失敗させ、調べる対象にします。

最後のテストでは、完成済みの一時ファイルを受け取った段階で AtomicMoveNotSupportedException を発生させます。ここで分かるのは、アプリが失敗を呼び出し元へ伝え、別の上書きを行わなかったことです。運用先のファイルシステムがどの例外を返すかを、この模擬テストから判断することはできません。

ZIPを展開し、6件の結果とスキップ数を見る

ZIPを展開したディレクトリで mvn test を実行します。JDK 17とMaven 3.9系で確認した環境では、以下の6件が成功し、スキップは0件でした。環境依存の2件が中断した場合は、公開先のファイルシステムで別途試してください。

Tests run: 6, Failures: 0, Errors: 0, Skipped: 0

直接上書きのテストも成功として数えます。そのテストの目的は、故障版が「見出ししか残さない」という再現結果を確認することです。修正版では同じ失敗を起こし、前回ファイルの全バイトが残ることを別のテストで確認しています。

レビューに、失敗後も残すものを書いておく

入力の読み込みで失敗した場合は、前回のdaily.tsvを全バイト残します。初回の失敗ではdaily.tsvを作りません。公開先と同じディレクトリへ一時出力し、クローズ後に切り替えます。atomic置換ができない環境ではエラーにして、別方式への上書きは行いません。

  • 一部を書いた後で例外を出し、失敗後に前回ファイルを開き直した。
  • 既存あり・既存なしで、公開先に残る内容またはファイルの有無を確認した。
  • テスト結果の失敗数とスキップ数を確認した。運用先での置換可否も確認項目にした。

ファイルを書いたという呼び出し履歴より、失敗した後に利用者から見える結果をテストへ残す判断を深めたい場合は、『単体テストの考え方/使い方』が参考になります。

書籍「単体テストの考え方/使い方」の表紙
商品画像:Amazon.co.jp
PR テストを学ぶ若手向け

単体テストの考え方/使い方

Vladimir Khorikov (著), 須田智之 (翻訳)

単体テストで「何を検証するか」の判断基準を学ぶ。

モックの使いどころや壊れにくいテストの考え方を整理できます。コード例はC#です。JUnitの操作入門ではなく、テストの設計と価値を深める本です。

  • テスト対象の切り分けに迷っている
  • 保守しやすい単体テストを書きたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事JUnit 5入門|Mavenで最初のテストを実行し、失敗を直す JUnitを初めて動かすときは、送料の例でmvn testを実行し、失敗時にどの値を読むかを試せます。 記事を読む 関連記事Javaのtry-with-resourcesとは|close漏れを防ぐ書き方 正常時・例外時のクローズと、ファイルの読み込み中に起きた例外の扱いを確認できます。 記事を読む

例外を確かめたら、公開先のファイルを開き直す

ファイル更新で失敗したときは、公開先に前回の内容が残るか、初回ならファイルが作られないかを確かめます。JUnitでは @TempDir に前回分を用意し、途中で例外を出したあと、公開先と一時ファイルの状態を別々に確認できます。

-設計・テスト・レビュー
-, , ,