S2Junit、テスト対象コンポーネントの依存コンポーネントを後から設定した場合に正しくDIしてくれない事象の解決策

更新履歴

モチベーション

S2Junitでmockitoを使いたかったのですよ。コード的にはこんなのを想定していた。

@RunWith(Seasar2.class)
public class HogeLogicTest {
  HogeLogic hogeLogic; // app.diconでコンポーネントが登録されてる

  // HogeLogicの実装が依存しているコンポーネント。HogeLogicの生成時にDIされる。
  // app.diconで指定されているが、テストの際はこれをmockで置き換えたい。
  HogeDao hogeDao;

  // S2Junitが提供するテスト制御用インタフェース。
  TestContext ctx;

  @Before
  public void before() {
    // mockitoでHogeDaoのmockを生成
    hogeDao=mock(HogeDao.class);
    // コンポーネントとして登録
    ctx.register(hogdDao,"hogdDao");
  }

  @Test
  public test_xxx() {
    // 以上の設定により、mockを使ったテストが可能になる!!
    when(hogeDao.getValue()).thenReturn(null);
    hogeLogic.xxx();
  }

}

現象

テスト開始前にHogeLogicが自動でDIされるが、ctx.register()で登録した依存クラス(HogeDao)を反映してくれない。

理由

フィールドインジェクションできないのは、S2Containerの自動インジェクション時のルールによる。

S2Containerのデフォルト実装S2ContainerImplにおいてS2Container#getComponentでコンポーネントを取得する場合、

  1. getComponentDefを呼び出してコンポーネント定義(ComponentDef)を得る
  2. ComponentDef#getComponent()でコンポーネントを構築して返す

さて、ComponentDefのデフォルト実装ComponentDefImplにおいては、コンポーネント生成をInstanceDef経由でComponentDeployer#deployに委譲しその後なんだかんだあってインスタンスを構築します*1。その後生成したインスタンスにフィールド/プロパティインジェクションを行い、完成したインスタンスを返すという流れになります。
さて、インスタンス生成時のインジェクション処理が問題。たとえばコンストラクタインジェクションを行うAbstractAssemblerクラスにおいては、

// AbstractAssembler.java L:86 
// AbstractAssembler#getArgs
 args[i] = getComponentDef().getContainer().getComponent(
                        argTypes[i]);

という処理でコンストラクタに渡す依存コンポーネントを設定しています。そして依存コンポーネントを探索する対象のコンテナは、「構築対象のコンポーネントが定義されたコンテナ」なんですね。

さて、S2Junitにおけるテストメソッドの実行はS2TestMethodRunner#runMethod()で行われています。このメソッドは、

  1. setUpTestContext() でコンテナとテストコンテキストの作成
  2. runBefore() でbefore処理実行
  3. initContainer() でコンテナを初期化(s2junit4.diconのinclude)
  4. bindFields() でテストクラスのフィールドに対してDIを実行
  5. runTest() でテストメソッド実行

という流れになっています。

さて、テスト時のコンテナの構造はどうなっているのかというと、

  1. S2TestMethodRunner#setUpTestContext においてルートとなるコンテナを作成
    1. SingletonS2ContainerおよびTestContextが使用するのはこのコンテナです
  2. beforeメソッドでコンテナに対して何か操作した場合、ルートコンテナに対する処理となります。
  3. initContainer() でルートコンテナに対してs2junit4.diconをinclude
    1. この処理により、s2junit4.diconによる設定を反映したコンテナが作成され、「ルートコンテナの子供」として登録されます
  4. bindFields()でDI実行

さて、S2Containerのコンポーネント探索においては、

となっています(参照: S2コンテナのインスタンス階層について)

したがって、「テストコンテキスト経由でルートコンテナに定義したmock(HogeDao)コンポーネント」を「s2junit4.diconで定義したHogeLogicコンポーネントの属するコンテナ」から参照することはできないわけですね!!!

解決方法

上記の理由により、テストクラスのフィールドインジェクション前に設定したコンポーネントを反映させるのは不可能っぽい。

S2Containerのコンポーネントルックアップの挙動はS2ContainerBehavior.setProviderを使ってフックすることができる。この機構を使って、一時的に上書き用の定義を最優先で見に行くようにした。

	// 上書きしたいコンポーネントを定義したコンテナ
	private S2Container containerForOverride;

	public <T> T getComponent(Class<T> componentClass) {
		S2ContainerBehavior
				.setProvider(new S2ContainerBehavior.DefaultProvider() {
					@Override
					public ComponentDef acquireFromGetComponent(
							S2Container container, Object key) {
						if (containerForOverride.hasComponentDef(key))
							return containerForOverride.getComponentDef(key);
						else
							return super
									.acquireFromGetComponent(container, key);
					}
				});
		final T component = componentClass.cast(SingletonS2Container
				.getComponent(componentClass));
		S2ContainerBehavior
				.setProvider(new S2ContainerBehavior.DefaultProvider());
		return component;
	}
	// コンポーネント取得時は、自動フィールドインジェクションやSingletonS2Containerを使用せず
	// getComponent経由で取得する。

古いコード(一応残しとくけど無駄に複雑なことやってるので使わないほうがいいですよ)

試行錯誤の結果、こんなコードを書いた。

/** このコンテナに登録されたコンポーネントは、通常のDI設定を上書きする */
final S2Container containerForOverride = new S2ContainerImpl();

/**
 * 指定したクラスのモックを作成し、コンテナに登録してから返す
 * 
 * @param <T>
 *            対象の型
 * @param targetClass
 *            対象のクラス
 * @return モックされ、コンテナに登録されたオブジェクト
 */
private <T> T mockAndRegister(Class<T> targetClass) {
	final T mocked = mock(targetClass);
	containerForOverride.register(mocked, targetClass.getSimpleName());
	return mocked;
}

public <T> T getComponent(Class<T> componentClass) {
	// テスト環境が使用しているルートのS2Containerを取得
	final S2Container root = SingletonS2ContainerFactory.getContainer();
	// 生成対象コンポーネントの定義をルートコンテナから取得
	final ComponentDef def = root.getComponentDef(componentClass);
	// 生成時に依存コンポーネントを解決するのに使われるのは、コンポーネント定義が所有するコンテナ。
	// 生成対象コンポーネントおよびその依存コンポーネントが使用するコンテナを、
	// 「元のコンテナ」と「上書き用コンテナ」を合成したコンテナに変更

	overrideContainer(root);

	// コンポーネントを生成して返す
	return componentClass.cast(def.getComponent());
}

private void overrideContainer(S2Container root) {
	System.err.println(root.getNamespace());
	final S2Container newContainer = new S2ContainerImpl();
	newContainer.include(containerForOverride);
	newContainer.include(root);
	final List<ComponentDef> defs = Lists.newArrayList();
	for (int i = 0; i < root.getComponentDefSize(); i++)
		defs.add(root.getComponentDef(i));
	for (ComponentDef cd : defs) {
		cd.setContainer(newContainer);
		System.err.println(cd.getComponentName());
	}
	final List<S2Container> children = Lists.newArrayList();
	for (int i = 0; i < root.getChildSize(); i++)
		children.add(root.getChild(i));
	for (S2Container c : children)
		overrideContainer(c);
}
@Test
public void test() {
	final HogeDao mocked=mockAndRegister(HogeDao.class);
	final HogeLogic target=getComponent(HogeLogic.class);

	when(mocked).doSomething().thenReturn(0);

	assertThat(target.doSomething(), is(0));
	// ...
}

というわけで、さっきのコードでは各コンポーネント定義が参照するコンテナを「上書き対象のコンポーネントを優先的に検索するコンテナ」に変更することでどうにかしたのでした。

*1:詳細については、生成対象クラスのコンストラクタにブレークポイント張ってデバッガでスタックトレース眺めるとわかりやすいです