テスト時にプロパティファイルを動的に差し替える
はじめに
この記事は、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#getSystemResouce
、ClassLoader#getSystemResouceAsStream
を使っている場合、この記事の方式では差し替えられません。webアプリの場合、これらのメソッドを使っていることはまあ無いと思うので大丈夫だと思いますが。