テスト後にファイルが消されることを保障しようとするルール

JUnit4.7から「ルール」なるものが導入され、テスト実行後にファイルの削除が保障される?ルールがデフォルトで備わっている。それに該当するものがTemporaryFolderなるクラスらしいのだが、名前どおりあくまで一時ファイル・ディレクトリを管理するクラスである様子。したがってテスト対象が生成したファイル・ディレクトリは基本的に削除対象にならない。テスト中に生成したファイル・ディレクトリも削除したかったので 前述の処理を行うルールを記述した。

指定ファイルをテスト終了時に消すルール
import java.io.File;
import java.util.ArrayList;
import java.util.List;

import org.junit.rules.MethodRule;
import org.junit.runners.model.FrameworkMethod;
import org.junit.runners.model.Statement;

public class FileResource implements MethodRule {

    private ArrayList<File> fileList;
    private String methodName;

    // テスト失敗結果とファイル存在チェックのうち、どちらを優先するか
    private boolean prefferTestResult; 

    public FileResource() {
        this(true);
    }
    
    public FileResource(boolean testResultPreffered) {
        this.prefferTestResult = testResultPreffered;
        this.fileList = new ArrayList<File>();
    }
    
    public Statement apply(final Statement statement, 
            final FrameworkMethod method, Object obj) {
        return new Statement() {
            public void evaluate() throws Throwable {
                AssertionError testError = null;
                RuntimeException afterError = null;
                methodName = method.getName();
                try {
                    before();
                    statement.evaluate();
                } catch (AssertionError e) {
                    testError = e;
                } finally {
                    try {
                        after();
                    } catch (final RuntimeException e) {
                        afterError = e;
                    }
                    
                    fileList.clear();
                    methodName = null;
                    if (prefferTestResult && testError != null) {
                        throw testError;
                    } else if (afterError != null){
                        throw afterError;
                    }
                }
            }
        };
    }
    
    protected void before() {
    }
    
    protected void after() {
        // createFile()の逆順で消す
        List<File> deleteFailList = new ArrayList<File>();
        for (int i=fileList.size()-1; i>=0; i--) {
            File file = fileList.get(i);
            deleteRecursive(file, deleteFailList);
        }
        if (deleteFailList.size() > 0) {
            throw new RuntimeException("failed to clean up files. method:" 
                    + methodName + ", files:" + deleteFailList.toString());
        }
    }
    
    private void deleteRecursive(File file, List<File> deleteFailList) {
        if (file.exists()) {
            if (file.isDirectory()) {
                File[] subs = file.listFiles();
                for (File sub : subs) {
                    deleteRecursive(sub, deleteFailList);
                }
            }
            if ( ! file.delete()) {
                deleteFailList.add(file);
            }
        }
    }
    
    public File createFile(String name) {
        File file = new File(name);
        fileList.add(file);
        return file;
    }
}

org.junit.rules.ExternalResourceを拡張しようとすると apply() が final でメソッド名が取れない。メソッド名はほしかったので MethodRule を実装。また、テスト失敗時の例外(AssertionError)とファイル削除時の例外が同時に起こったとき、どちらを優先するかのフラグ testを持つ。後は、ExternalResourceと同じようにつぶしが聞くように before()/after()で拡張できるようにしてある。

使い方
import org.junit.Test;

import static org.junit.Assert.*;
import static org.hamcrest.CoreMatchers.*;

public class FileUtilTest {

    @Rule
    public FileResource fileResource = new FileResource();
    
    @Test
    public void testGetDataBaseFile() throws Exception {
        fileResource.createFile("__b");
        fileResource.createFile("__d");
        FileUtil util = new FileUtil("__b", "__d");
        
        File baseFile = util.getDataBaseFile("hoge");
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MMdd");
        File expectedFile = new File("__d/" + sdf.format(new Date()) + "/ho/hoge.dat");
        assertThat(baseFile, is(expectedFile));
    }
}

FileResourceのフィールドを定義し、そこで @Rule (org.junit.rules.Rule) アノテーションをつける。@Ruleなフィールドは public でないといけない。FileUtilの各メソッド(といっても上記はgetDataBaseFile())は __testdata, __testbuffer ディレクトリ以下にディレクトリ・ファイルを生成する。しかし、上記のテスト実行後はそれらが削除される。

上記の例ではディレクトリを指定してそのディレクトリ配下をテスト後削除対象にしている。FileResource#createFile()の戻り値をそのまま使って個別に扱うもよし。

ファイルが消せなかった時のメッセージ

指定ディレクトリ内にストリームがを開きっぱなしのファイルがあるとそれを消せず、FileResource#after()で実行時例外が発生する。たとえば、上記テストメソッドを

    public void testGetDataBaseFile() throws Exception {
        fileResource.createFile("__b");
        fileResource.createFile("__d");
        FileUtil util = new FileUtil("__b", "__d");
        
        File baseFile = util.getDataBaseFile("hoge");
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MMdd");
        File expectedFile = new File("__d/" + sdf.format(new Date()) + "/ho/hoge.dat");
        assertThat(baseFile, is(expectedFile));
     
        // ファイルを開いたままにする。
        dataFile.mkdirs();
        java.io.FileOutputStream out = new java.io.FileOutputStream("__d/2009/1005/ho/hoge.dat", true);
        out.write("hoge".getBytes());
    }

のようにしてテストを再実行すると、

java.lang.RuntimeException: failed to clean up files. method:testGetDataBaseFile, files:[__d\2009\1005\ho\hoge.dat, __d\2009\1005\ho, __d\2009\1005, __d\2009, __d]

のようなメッセージが出てテストに失敗する。

実行順

@Before -> ルール -> テストメソッド(FileResource.apply()の中のevaluate()) -> @After 順みたい。以下 FileResource#after()で例外を起こしたときのスタックトレース

	at com.estijl.ticktank.junit.FileResource.after(FileResource.java:56)
	at com.estijl.ticktank.junit.FileResource$1.evaluate(FileResource.java:32)
	at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:28)
	at org.junit.internal.runners.statements.RunAfters.evaluate(RunAfters.java:31)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:76)


参考:
JUnit 4.7 のリリースノート
JUnit 4.7 : テストごとのルール