イマカラブログ

イマカラメガネの中の人が、芝居と関係あることないこと好き勝手に書くブログ。

テスト時にプロパティファイルを動的に差し替える

はじめに

この記事は、Java8+JUnit4を使っています。

プロパティファイルを動的に差し替えたい

JUnitを使ってテストしていると、テスト対象のクラス内部でプロパティファイルを読み込んでいると、そのプロパティファイルをテストケース毎に差し替えたいことがある。

具体的にはこんな感じの実装になってるやつ。

public class Hoge{
    public String example() {
        Properties prop = new Properties();
        prop.load(this.getClass().getResourceAsStream("hoge.properties"));
        // 以降、propを使った処理が続く
    }
}

hoge.propertiesの内容によってexampleメソッドの返却値が変わるので、このClass#getResourceAsStreamで返却される内容を差し替えたいんだけど、外から差し替えるポイントがない。

Mavenを使ってる環境なら、src/test/resources配下にテスト用のhoge.propertiesを置いとくこともできるけど、それではバリエーションを持たせたテストが出来ない(いや、target/test-classes配下をテストケースから書き換えれば出来なくもないか?出来たとしてもあまり選択したくない方法だな)。

本来、設計(実装)から見直して欲しいところだけれども、諸事情によりテスト対象のクラスを修正するわけにもいかず、そのままテストせざるを得ない。ライブラリの内部で間接的に使われてるときとかもそう。

今回もそんなケースだったので、プロパティファイルを動的に差し替えるためにクラスローダーでゴニョゴニョした記録を残しておく。なお、良い子はマネしちゃいけません。

クラスローダーを作る

プロパティファイルというか、クラスパス上のリソースを読み込む処理というのは、多くの場合、根本を辿るとClassLoader#getResourceで得たURLオブジェクトを通してリソースを読み込むという動作になる(ここは説明が面倒なので省略)。したがって、自分にとって都合のよいURLオブジェクトがClassLoader#getResourceから返却されるような仕掛けを用意すればよい。

ということで、テスト用のクラスローダーを作成。

以下、ポイントを絞ってコードを抜粋。

public class TestClassLoader extends URLClassLoader {

    private static Map<String, URL> testResourceMap = new HashMap<>();

    /**
     * リソース名とテストリソースのマッピングを追加する
     * @param resourceName テストリソース名
     * @param testResourceURL テストリソースを読み込むためのURL
     */
    public static void addTestResourceMap(String resourceName, URL testResourceURL) {
        testResourceMap.put(resourceName, testResourceURL);
    }

    /**
     * テストリソースのマッピングをクリアする
     */
    public static void clearTestResourceMap() {
        testResourceMap.clear();
    }

    //省略

    /**
     * @param name リソース名
     * @return リソース名にテストリソースがマッピングされていたら、そのリソースを読み込むためのURL。
     *         マッピングされていない場合は、デフォルトの動作で得られたURL。
     */
    @Override
    public URL getResource(String name) {
        if (testResourceMap.containsKey(name)) {
            return testResourceMap.get(name);
        } else {
            return super.getResource(name);
        }
    }
}

ClassLoader#getResouceをオーバーライドして、引数のリソース名がtestResourceMapのキーとして存在したら、そのリソース名にマッピングされたURLオブジェクトを返却している。testResourceMapには、addTestResouceMapというstaticメソッドを通して、リソース名とURLオブジェクトのマッピングを追加できるようにした。clearTestResourceMapは、addTestResourceMapで追加したマッピングを全てクリアする。

このクラスローダーがあることを前提に、JUnitのテストケースが次のように実装できる。

@RunWith(TestRunner.class)
public class HogeTest {

    @Before
    public void setUp() {
        TestClassLoader.clearTestResourceMap();
    }

    @Test
    public void testHoge01() throws IOException {
        TestClassLoader.addTestResourceMap(
                "hoge.properties",
                Paths.get("testdata/hoge_test01.properties").toUri().toURL());

        Hoge hoge = new Hoge();
        String actual = hoge.exampale();
        //...
    }

    @Test
    public void testHoge02() throws IOException {
        TestClassLoader.addTestResourceMap(
                "hoge.properties",
                Paths.get("testdata/hoge_test02.properties").toUri().toURL());

        Hoge hoge = new Hoge();
        String actual = hoge.example();
        //...
    }
}

各テストメソッドの先頭でTestClassLoader#addTestResourceMapを使ってリソース名とリソース(URLオブジェクト)のマッピングを行う。これにより、テスト対象メソッド内部のhoge.propertiesの読み込みは、マッピングしたリソースへの読み込みに置き換えられる。なお、マッピングTestClassLoaderのstatic変数に保持しているので、@Beforeを付けたsetUpメソッドにてマッピングをクリアしている。

以上でプロパティファイルを動的に差し替えることができました。

おわり。

って、説明が足りてないね。

この仕掛けを成立させるには、テスト対象のクラスHogeをテスト用クラスローダーTestClassLoaderにロードさせる必要がある。

自作のクラスローダーを使うときは、javaコマンド実行時にシステムプロパティ「java.system.class.loader」で指定すれば使えるんだけど、この場合、自作クラスローダーがシステムクラスローダーとして動作するだけの実装が必要で、ハードルが高い。また、JUnit実行するときにシステムプロパティを指定するのも手間。

そこで、JUnitが動く仕掛けのなかで自作クラスローダーを差し込むポイントが無いか探った。

テストランナーを作る

JUnitを拡張するならまずは@Rule@ClassRuleだろうと探ってみたけどクラスローダーを差し込むポイントは見つけられなかった。じゃあテストランナー作るしかないなとJUnit4のデフォルトのテストランナーのコードを見たら以外とあっさり見つかった。

で、作ったテストランナーの実装はこれ。

public class TestRunner extends BlockJUnit4ClassRunner {

    private static TestClassLoader testClassLoader = new TestClassLoader();

    public TestRunner(Class<?> testClass) throws InitializationError, ClassNotFoundException {
        super(testClassLoader.loadClass(testClass.getName()));
    }
}

テストランナーのコンストラクタにテストケースのクラス(今回の例だとHogeTest)がJUnitCoreから引き渡される。テストランナーは、このテストケースのクラスのインスタンスをリフレクションで生成して、各テストメソッドを実行している。そこで、このコンストラクタに引き渡されたテストケースのクラスを自作クラスローダーでロードし直して、それが以降の処理で使われるようにした。

あとは、@RunWithでこのテストランナーを使うように指定して完成。

@RunWith(TestRunner.class)
public class HogeTest {
    //...
}

おわりに

クラスローダー周りは面倒なので説明を端折っちゃった。

全コードをgithubに上げておいたので興味のある人はどうぞ。もし、誰かの役に立てばなによりです。

なお、テスト対象がClassLoader#getSystemResouceClassLoader#getSystemResouceAsStreamを使っている場合、この記事の方式では差し替えられません。webアプリの場合、これらのメソッドを使っていることはまあ無いと思うので大丈夫だと思いますが。