DBFlute Hamcrestを利用したDB無しの単体テスト

先日、DBFluteユニットテストをサポートするDBFlute Hamcrestというライブラリをリリースしました。名前から想像される通り、JUnitなどで利用するHamcrestの、DBFlute用カスタムMatcher(assertThat(a, is(b))is()の代わりに使うもの)となっております。

Mavenからの利用方法などはドキュメントをご覧ください。

また、「DBFluteってなに?」という方は、以前書いたこちらのエントリをご覧ください。 taktos.hatenablog.com

ここでは、DBFlute Hamcrestでできることと、簡単な使い方などご紹介したいと思います。

DBFlute Hamcrest (+Mockito)でできること

DBFlute Hamcrestは、主に以下の機能を備えます。

  • ConditionBeanの検索条件のマッチ(SQLのWHERE句に相当)
  • ConditionBeanの取得カラムのマッチ(同じくSELECT句に相当)
  • DBFlute-1.1のみ)Behaviorのラムダ式をキャプチャしたConditionBeanの取得

これらの機能を、モックライブラリであるMockitoとあわせて使うことで、「取得したレコードが期待通りか」ではなく「設定された検索条件が期待通りか」でデータアクセスのテストが行えるようになります。 レコードの取得を行わずにテストできる、ということは、事前のテストデータ準備や長いテスト実行時間などから解放されることを意味します。

これまでテストにかかっていたオーバーヘッドを無くし、よりテストを書きやすく、実行しやすくするのが、DBFlute Hamcrestの目標です。

使用例など

それでは、実際の利用方法など、コードで見てみましょう。 利用しているDBスキーマについては、DBFluteexampledbをご覧ください。

テスト対象クラス

退会済みでない(MEMBER_WITHDRAWALテーブルにMEMBER_IDを持つレコードが存在しない)MEMBERにつき、引数のnameでMEMBER_NAMEカラムを前方一致検索し、見つかったエンティティのリストを返す単純なサービスです。

@Component
public class MemberService {
    @Autowired private MemberBhv memberBhv;

    public List<Member> searchFormalMemberByName(String name) {
        MemberCB cb = memberBhv.newConditionBean();
        cb.query().setMemberName_LikeSearch(name, new LikeSearchOption().likePrefix());
        cb.query().queryMemberWithdrawalAsOne().setMemberId_IsNull();
        return memberBhv.selectList(cb);
    }
}

データアクセスを伴う普通のテストコード

このコードを、期待通りのレコードが取得できるかでテストしようとすると、少なくとも4パターン(MEMBER_NAMEにマッチする/しない、MEMBER_WITHDRAWALにレコードあり/なし)のテストデータが必要となりますね。テストコードはこんな感じでしょうか。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class MemberServiceTest {
    @Autowired MemberService memberService;
    @Test
    public void Johnから始まる名前で退会済みでない会員が取得できること() {
        List<Member> result = memberService.searchFormalMemberByName("John");
        assertThat(result, hasSize(1));
        Member member = result.get(0);
        assertThat(member.getMemberName(), is(startsWith("John")));
        assertThat(member.getMemberId(), is(not(3))); // MEMBER_ID=3には退会レコードが存在するので対象外
    }
}

MEMBERテーブルにこのテスト用の4レコードしか存在しないうちは上手く行きます。しかし、他のデータが入ってきた途端、 assertThat(result, hasSize(1)) が失敗します。コードはまったく変更していないのに、他のテストコードで必要なデータを追加したことで、意図せず他のテストが破壊されてしまうのです。

このようなテストを行っているチームでは、最初から質の良いテストデータを用意したり、テストデータ投入を含んだCIを回したりすることで早期に破壊を検知する、テストごとにデータを入れ替える機能(DBUnitなど)を使って各テストの独立性を高めるなどの対策を行っていることと思います。しかし、データが用意されるまでテストが書けなくなったり、用意したデータが複数の開発者間で競合したり、テスト毎にデータ投入・ロールバックを繰り返すことでテスト実行が遅くなったりするなど、様々な辛みが出てきて、次第に「データが豊富な結合テスト環境で画面ぽちぽちすればいーや」みたいなテスト書かない風潮が広がっていくのは一度は経験されているのではないでしょうか。

DBFlute Hamcrest(+Mockito)を使ったテストコード

MockitoとDBFlute Hamcrestを使ったテストコードは、前述の通り「検索結果が期待通りに取得できたか」ではなく、「検索条件が期待通りに設定されているか」でコードの正しさを検証します。

import static org.dbflute.testing.DBFluteMatchers.*;
@RunWith(MockitoJUnitRunner.java)
public MemberServiceTest {
    @Mock MemberBhv memberBhv;
    @InjectMock MemberService memberService;
    @Test
    public void Johnから始まる名前で退会済みでない会員を検索すること() {
        MemberCB cb = new MemberCB();
        when(memberBhv.newConditionBean()).thenReturn(cb);

        memberService.searchFormalMemberByName("John");

        assertThat(cb, hasCondition("memberName", like("John%")));
        assertThat(cb, hasRelation("memberWithdrawalAsOne", hasCondition("memberId", isNull())));

        verify(memberBhv).selectList(cb);
    }
}

when(memberBhv.newConditionBean()).thenReturn(cb) では、テストコード側で用意したMemberCBのインスタンスを返すよう振る舞いを変えています。このcbインスタンスに対し、テスト対象であるmemberService.searchFormalMemberByName("John");内で検索条件設定が行われるようにするためです。

cbに対し検索条件が意図通りに設定されたかは、次のassertThatでアサートしています。 hasCondition(String, Matcher)メソッドは、第一引数に検索対象のカラム名を、第二引数に演算子とその値を取るカスタムMatcherです。次のhasRelation(String, hasCondition(...))は、関連テーブルに対して設定された条件を検証するためのカスタムMatcherです。

これらのアサート文により、以下のようなJOINとWHEREが存在することが検証されてます。

SELECT ...
FROM MEMBER
    LEFT OUTER JOIN MEMBER_WIDHDRAWAL ON MEMBER.MEMBER_ID = MEMBER_WITHDRAWAL.MEMBER_ID
WHERE
    MEMBER.MEMBER_NAME LIKE ('John%')
    AND MEMBER_WITHDRAWAL.MEMBER_ID IS NULL
;

最後のverifyは、コードで組み立てられたMemberCBのインスタンスが検索条件として使われていることを検証しています。せっかくMemberCBが正しく組み立てられていても、memberBhv.selectList(new MemberCB());とされると台無しになってしまいますからね。

というわけで、MemberBhvをモックしてデータベース不要でテスト実行しつつ、MemberCBに期待通りの検索条件が設定されていることが、上記テストコードで検証できています。

Java 8対応版(DBFlute 1.1系)の場合

さて次に、Java8に完全対応したDBFlute-1.1系でのコードを見てみましょう。

@Component
public class MemberService {
    @Autowired private MemberBhv memberBhv;
    public List<Member> searchFormalMemberByName(String name) {
        return memberBhv.selectList(cb -> {
            cb.query().setMemberName_LikeSearch(name, op -> op.likePrefix());
            cb.query().queryMemberWithdrawalAsOne().setMemberId_IsNull();
        });
    }
}

DBFlute-1.1では、BehaviorへConditionBeanを渡すこれまでのスタイルから、 BehaviorからConditionBeanが降ってくる スタイルに変わっています。そのため、1.0のときのように、テストコードで用意したConditionBeanをアサートすることが不可能になりました。

そこで、DBFlute Hamcrestでは、Behaviorに渡されるラムダ式をキャプチャしたConditionBeanを返す機能を用意しました。実際のコードをご覧ください。

import static org.dbflute.testing.DBFluteMatchers.*;
@RunWith(MockitoJUnitRunner.class)
public class MemberServiceTest {
    @Mock
    MemberBhv memberBhv;
    @InjectMocks
    MemberService memberService;

    @Test
    public void Johnから始まる名前で退会済みでない会員を検索すること() throws Exception {
        BehaviorArgumentCaptor<MemberCB> captor = captor(MemberCB.class);

        memberService.searchFormalMemberByName("John");

        verify(memberBhv).selectList(captor.capture());

        MemberCB cb = captor.getCB();
        assertThat(cb, hasCondition("memberName", like("John%")));
        assertThat(cb, hasRelation("memberWithdrawalAsOne", hasCondition("memberId", isNull())));
    }
}

MockitoのArgumentCaptorと似たような使い方で、プロダクションコード内のラムダをキャプチャしたConditionBeanインスタンスを取得できるようになっています。ConditionBeanインスタンスさえ取得できれば、DBFlute-1.0系と同じくhasConditionなどのカスタムMatcherでアサートできるようになっています。これで、DBFlute-1.1でラムダ式を使い放題使っても、さくさくユニットテストを書いて実行することができますね!

まとめ

MockitoにDBFlute Hamcrestを組み合わせて使うことで、データベースアクセスを伴うユニットテストを「取得したレコードが期待通りか」ではなく「設定された検索条件が期待通りか」で行えるようになり、テスト作成に掛かるオーバーヘッドを削減できることを解説しました。

ソースコードGithubで、成果物はMaven Centralにあります。

ここで紹介していない機能については、使い方もしくはJavaDocをご覧ください。もし解決できない問題などあれば、Google GroupのDBFluteユーザの集いに質問頂くか、直接 @ に聞いていただければできる限り回答します。

これからDBFluteを使う際には、DBFlute Hamcrestのことも思い出してみて頂ければ幸いです。