Slim3でDIコンテナっぽくテストする方法

2週間ほど前に、急遽SAStruts+Seasar2+S2JDBCの現場のヘルプに呼ばれました。
ところがテツさん、Seasar2はおろかDIコンテナ自体やったことなくて、大急ぎで勉強する羽目になりまして。
今も勉強しながら作業しているんですが、そんな中ふと「あれ、Slim3でもDIっぽく作れるのでは…?」という考えが浮かんできました。

思い立ったら早速検証!

まずは、DI(Dependency Injection)についてのおさらい

例えばとあるメソッドを

public int getCount(){
    Foo foo = new Foo();
    Bar bar = new Bar();
    return foo.getCount() + bar.getCount();
}

みたいに作ると、Foo#getCount()はDatastoreにアクセスしていて、Bar#getCount()はURLフェッチを使用している、なんてことになると事前準備が大変でテストが面倒くさい。

だから、

@Resource
protected Foo foo;
@Resource
protected Bar bar;

public int getCount(){
    return foo.getCount() + bar.getCount();
}

みたいに作っておけば、フィールドのfoo・barをモックに入れ替えられるので、テストがやりやすくなります。
※ 「@Resource」は、Seasar2に自動的に正しいインスタンスを注入させるためのアノテーション
(って、勉強してわずか2週間のクセにえらそうですが・・)

Slim3の「service」で実現する方法

さて、まずはSlim3のservice(controllerとmodelの間の、おもにビジネスロジック書くところ)でやってみます。
と言ってもserviceはPOJOなので、DIコンテナを使わずにDIっぽいことをやるための一般的な話になります。

初期化メソッド(↓ではsetUp)を用意して、インスタンスの注入は全部そこでやります。
こんな感じ。

public class FooService {
    
    protected User usr;
    protected BarService barService;

    public FooService setUp(User usr) {
        this.usr = usr;
        this.barService = new BarService();
        return this;
    }

    public Profile getProfile() {
        return Datastore.get(Profile.class, Datastore.createKey(
            Profile.class,
            usr.getEmail()));
    }
    
    public int getCount(){
        return barService.getCount() % 100;
    }
}

setUpがthisを返却しているのは、呼び出し元が

fooService = new FooService().setUp(usr);

と書けるようにするためです。

テストはこんな感じになります。

    @Test
    public void getProfile() throws Exception {
        
        // Mockを注入
        service.usr = new User("wifeofhiromi@gmail.com", "gmail.com");
        
        // テスト
        Profile profile = service.getProfile();
        assertThat(profile.getName(), is("Iyo"));
        assertThat(profile.getSex(), is(Profile.FEMALE));
        assertThat(profile.getAge(), is(16));
    }
    
    @Test
    public void getCount() throws Exception {
        
        // Mockを注入
        service.barService = new BarService(){
            @Override
            public int getCount(){
                return 242;
            }
        };
        
        // テスト
        assertThat(service.getCount(), is(42));
    }

getCountの「Mockを注入」の部分は匿名インナークラスを使用しています。Mockのためにいちいちクラス作るの面倒だからね。
世の中にはMock生成用のライブラリとかもあるのですが、ここでは説明を簡単にするため割愛します。(※ウソです。単に使い方知らないだけです・・・。)

「Controller」で実現する方法

Slim3向けの話はここから!

http://sites.google.com/site/slim3documentja/documents/slim3-controller/run
を見るとわかるとおり、Controllerでは事前処理を行うsetUpメソッドが用意されています。
このsetUpでインスタンスの注入をやればイケそうですが、そう簡単ではありません。

ControllerはPOJOではなく、JUnitでテストするときにも「tester」というControllerTesterクラスのインタンスをヘルパーとして使い、擬似的なrequestを送信する形でテストします。

        tester.start("/dicon/");

この一文で、リクエスト受信 ⇒ Controller生成 ⇒ setUp ⇒ run ⇒ tearDownまで一気に実行してしまいます。
なのでserviceでやったようにMockを注入するのは簡単ではありません。
で、今回考えた手は、

  1. Controller#setUpにはserviceでやったみたいに、普通にインスタンスの注入を実装
  2. テストクラスでは、テスト対象のController#setUpでMockを注入させるようオーバーライドしたものを生成
  3. testerでのController生成処理もオーバーライドし、↑で生成したものを返却させる

というものです。

たとえば、テスト対象のControllerがこんな感じだとします。

public class IndexController extends Controller {

    protected FooService fooService;

    @Override
    protected Navigation setUp() {
        User usr = UserServiceFactory.getUserService().getCurrentUser();
        fooService = new FooService().setUp(usr);
        return null;
    }

    @Override
    public Navigation run() throws Exception {
        requestScope("name", fooService.getProfile().getName());
        requestScope("count", fooService.getCount());
        return forward("index.jsp");
    }

}


すると、テストクラスはこんな感じになります。

public class IndexControllerTest extends ControllerTestCase {

    @Test
    public void run() throws Exception {

        // Mockを注入
        TestUtil.setMockController(tester, new IndexController() {
            @Override
            protected Navigation setUp() {
                this.fooService = new FooService() {
                    @Override
                    public Profile getProfile() {
                        Profile profile = new Profile();
                        profile.setName("tetz42");
                        return profile;
                    }

                    @Override
                    public int getCount() {
                        return 42;
                    }
                };
                return null;
            }
        });

        // テスト
        tester.start("/dicon/");
        IndexController controller = tester.getController();
        assertThat(controller, is(notNullValue()));
        assertThat(tester.isRedirect(), is(false));
        assertThat(tester.getDestinationPath(), is("/dicon/index.jsp"));
        assertThat(tester.asString("name"), is("tetz42"));
        assertThat(tester.asInteger("count"), is(42));
    }
}

IndexController#setUpをオーバーライドして、その中でFooServiceのMockを注入しています。
TestUtilってのは今回このために作ったクラスで、ソースは↓です。

public final class TestUtil {

    public static void setMockController(ControllerTester tester,
            final Controller mock) throws NoSuchFieldException {
        FrontController frontController = new FrontController() {
            @Override
            protected Controller createController(String path) {
                return mock;
            }
        };
        TestUtil.copyFields(
            tester.frontController,
            frontController,
            "charset",
            "bundleName",
            "defaultLocale",
            "defaultTimeZone",
            "servletContext",
            "servletContextSet",
            "rootPackageName");
        tester.frontController = frontController;
    }

    public static void copyFields(Object src, Object dst, String... fieldNames)
            throws NoSuchFieldException {
        for (String fieldName : fieldNames) {
            copyField(src, dst, fieldName);
        }
    }

    @SuppressWarnings("unchecked")
    public static void copyField(Object src, Object dst, String fieldName)
            throws NoSuchFieldException {
        Class clazz = src.getClass();
        while (clazz != null) {
            try {
                Field field = clazz.getDeclaredField(fieldName);
                field.setAccessible(true);
                Object value = field.get(src);
                field.set(dst, value);
                return;
            } catch (Exception e) {
                clazz = clazz.getSuperclass();
            }
        }
        throw new NoSuchFieldException(src.getClass().getSimpleName()
            + " has no "
            + fieldName);
    }
}

「匿名インナークラスの中から、finalのローカル変数にアクセスできる」という少々マイナーな仕様を利用している箇所があるんで、ご注意を。
リクエストのURLとかrootPackageとかからControllerを生成するメソッドの、FrontController#createControllerをオーバーライドして、パラメタで受け取ったControllerを返却する偽FrontControllerを生成しています。
その後は、本物から必要なフィールドをコピーして、本物と入れ替えています。

さて…

これで、最初に自分がやりたいと思っていたことはおおよそ実現できたました。
なんかもっと良いやり方があるような気もしてるんだけど・・・、今後の研究課題とします。

ちなみに、TestUtilの偽FrontController生成部分は、こうした方が良い気がしてきました。

        FrontController frontController = new FrontController() {
            @Override
            protected Controller createController(String path) {
            	Controller original = super.createController(path);
            	if( !original.class.isInstance(mock) ){
    	        	throw new RuntimeException("Oh, no!");
            	}
                return mock;
            }
        };

こうすればオーバーライドしてしまっても、「リクエストのURLとかrootPackageとかから生成されたController」が正しいものであることの検証できるしね。

3/31 ソースにミスを見つけたので、修正