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のことも思い出してみて頂ければ幸いです。

Jenkins + HipChatをHubotなしで連携する

JenkinsとHipChatを連携させると、Jenkinsジョブの結果をHipChatに通知したり、HipChatからbotコマンドを投げてJenkinsのジョブを起動したりすることができます。 HipChatそのものや、Hubotを使った連携のさせ方は以下を参照。

iOSアプリ開発でもCI/継続的デリバリしようぜ(終):Jenkins+HipChat+Hubotをチーム開発に導入してお手軽CI (1/3) - @IT

で、Hubotを運用するのも面倒なので、Jenkinsプラグインだけでお気軽にbotコマンド対応させる方法がこちらです。

設定方法

HipChatにJenkins用のアカウントを作成する

Jabber Pluginが使用する、HipChatのアカウントを作ります。Jenkins側では、XMPP/Jabber infoで表示される値を設定していきます。上記のサイトに詳しい手順が載っています。

アカウントを作成したら、利用したいチャットルームを作成しておき、アカウント設定ページからJabberでの設定値を確認しておきます。

f:id:taktos:20140826191952p:plain

JenkinsにJabber Pluginをインストールする

他のプラグインを入れる方法と変わりありません。アップデートセンターからインストールできます。 HipChat Pluginとの違いは、このプラグイン自体がbot機能を持っていることです。

HipChatはXMPPプロトコルに対応しているので、HipChat PluginのようにAPIを経由せずとも、XMPPに対応するJabber Pluginで普通に利用できるのです。

Jenkinsの設定

Jenkinsのシステム設定で、Jabber Pluginの設定を行います。

f:id:taktos:20140826192509p:plain

HipChatで作成したJenkins用アカウントのJabber IDとPasswordを入れ、「高度な設定」を押すと出てくるNicknameJenkins UsernameにHipChatのRoom nicknameを入力します。

Initial group chatには、botコマンドを待ち受けるグループチャットを任意の数追加できます。Nameには、HipChatのルーム名+@conf.hipchat.comを入力します。

この設定後、JenkinsのログにHipChatとの接続状況が出力されます。もしエラーが出てる場合は、メッセージを見ておかしいところを修正してみてください。

使ってみる

ジョブ結果の通知

Jenkinsからジョブの結果を通知するには、ジョブの設定ページにて、ビルド後の処理にJabber Notificationを追加します。

f:id:taktos:20140826193734p:plain

Targetsには、* + HipChatのルーム名 + @conf.hipchat.com を指定します。高度な設定から、通知する内容を変更することもできます。

botコマンド

Initial group chatに設定したHipChatのチャットルームで !help と打ってみましょう。うまく接続できていれば、Jenkinsがボットコマンドの一覧を返してくれます。

f:id:taktos:20140826193535j:plain

!build <ジョブ名> で任意のジョブをキックしたり、!statusでジョブの成功・失敗を一覧で取得したりすることができます。

というわけで、Jenkinsのジョブをキックするためのスクリプトを書いたりせず、お気軽に連携させることができました。めでたしめでたし。

DBFluteをできるだけ簡単に説明してみる

DBFluteとは何か」と思って調べてみたら「DBFluteの紹介の時点で長すぎっ!!」って思った人向け。 いつも説明に困るのでまとめておく。

DBFluteとは、

  • データベースアクセスを行うO/Rマッパー。(DBFlute Runtime)
  • データベース周りのかゆいところに手が届くツール類。(DBFlute Client)

です。

DBFluteの特徴

O/Rマッパーとして

  1. データベースのメタ情報を元にコードを自動生成し、SQL発行をタイプセーフに行うことができる。
  2. JDBC APIにしか依存しない。JPAは使わない。独自のトランザクション管理もない。
  3. データベースのテーブル構造のまま、Javaから操作できるようにするだけ。
    • O/Rマッパーとは言えないのかも。その分シンプルかつ強力。
    • マッピング設定などは不要で、テーブルがそのままクラスになり、カラムがフィールドとなる。
    • 外部キーから関連の多重度を推測し、適切なフィールドを追加してくれる(1:Nならば1側にListを、N側にTを)。
  4. データベースのテーブル構造に対応するSQL生成コードが生成される。逆に言うと、構造上正しくないSQLが発行できない。
    • 例)Table AにTable Bへの外部キーがあれば、Table AにTable BをJOINするためのメソッド、Table Bに関連するTable Aをフェッチするためのメソッドが生成される、など

データベース周りのツールとして

データベースに関する様々なツールタスクと呼ばれる)がある。

  1. O/Rマッピング用コード生成ツールjdbc, generate, sql2entity, doc, manage regenerate
    • 既存のデータベースにJDBCで接続し、メタ情報を元にコードを生成する。
    • 異常なまでに細かい沢山のカスタマイズ項目があるが、慣れるまでは無視して問題ない。
  2. データベーススキーマを作成する(ReplaceSchema
    • DDLを実行し、Excel/CSV/TSVからデータをINSERTする。
    • DDL実行前に、既存のスキーマに存在するテーブルは自動で全てDROPしてくれる。
    • root権限で動かすDDLを別に指定できるので、MySQL等がインストールしてあってrootでアクセスできれば何でもできる。
  3. その他多数

DBFluteの主要クラス

DBFluteを使ってDBアクセスするコードを書くにあたり、以下の3つを押さえておくこと。

  • Entity
    • レコードの値を保持するクラス。テーブルと1:1で対応する。
    • SELECT結果の戻り値として、またINSERT/UPDATEのパラメータを詰めるために使用する。
  • ConditionBean (CB)
    • クエリの組み立てに使用するクラス。これもテーブルと1:1で対応する。
  • Behavior (Bhv)

利用例

DBFluteを使ってのCRUDは以下のようになる。DBを操作するためのメソッドが生成されており、文字列でSQLを組み立てる箇所が一切ないことに注目。

public class ProductRepositoryTest {
  @Inject
  private ProductBhv productBhv;

  @Test
  public void selectRow() {
    ProductCB cb = productBhv.newMyConditionBean();
    cb.query().setProductCode_Equal("XXXXX");
    Product product = productBhv.selectEntity(cb);
    assertThat(product.getProductCode(), is("XXXXX"));
  }

  @Test
  public void selectRows() {
    ProductCB cb = productBhv.newMyConditionBean();
    cb.query().setProductName_LikePrefix("iPad");
    List<Product> products = productBhv.selectList(cb);
    assertThat(products.stream().map(Product::getProductName).collect(toList()), hasItems("iPad", "iPad Mini", "iPad Air"));
  }

  @Test
  public void selectJoin() {
    ProductCB cb = val.newMyConditionBean();
    cb.query().setProductCode_Equal("XXXXX");
    cb.setupSelect_Manufacture();
    Product product = productBhv.selectEntity(cb);
    assertThat(product.getManufacture().getName(), is("Foxconn"));
  }

  @Test
  public void insert() {
    Product product = new Product();
    product.setProductCode("ZZZZZ");
    product.setProductName("iPad Nano");
    productBhv.insert(product);

    ProductCB cb = new ProductCB();
    cb.query().setProductName_LikePrefix("iPad");
    assertThat(productBhv.selectCount(cb), is(4));
  }

  @Test
  public void update() {
    Product product = new Product();
    product.setProductCode("ZZZZZ");
    product.setProductName("iPad Micro");
    productBhv.update(product);

    assertThat(productBhv.selectByPKValue("ZZZZZ").getProductName(), is("iPad Micro"));
  }

  @Test
  public void delete() {
    Product product = new Product();
    product.setProductCode("ZZZZZ");
    product.setProductName("iPad Micro");
    productBhv.delete(product);

    assertThat(productBhv.selectByPKValue("ZZZZZ"), is(nullValue()));
  }
}

ここで使った以外にも、ConditionBeanでは様々なSQLが発行できる。詳しく知るにはConditionBeanの機能を参照のこと。

困ったときは

結局かなり長くなってしまった。。。

Spring Batchのトランザクションをステップレベルからジョブレベルに変更する方法

Spring Batchでは、通常ステップがトランザクション境界となり、ステップ処理ごとにコミットが行われる。 チャンクの設定によって、一定件数処理するごと(1000件ごとなど)にコミットすることも可能。 大量データを処理する場合に、デフォルトでこの仕組みが備わっているのはありがたい。

しかし、ステップ間のデータに関連性があり、ステップごとにコミットしたくないという場合、Spring Batchではそれを実現する仕組みを提供していないため、自分で対応する必要がある。

ただ、必要なのは、ステップ実行の前に独自でトランザクションを開始する仕組みと、JobRepositoryFactoryBeanで既存トランザクションがあるか検証しているのをやめさせることの2点のみなので、意外と簡単に実現できる。

ジョブ起動時にトランザクションを開始するJobLauncher

ステップ実行前にトランザクションを開始するために適当なポイントはいくつかあると思われるが、JobLauncher#run@Transactional アノテーションをつけるのが手軽。

public class TransactionalJobLauncher extends SimpleJobLauncher {

    @Transactional(propagation = Propagation.REQUIRED)
    @Override
    public JobExecution run(Job job, JobParameters jobParameters) throws JobExecutionAlreadyRunningException, JobRestartException, JobInstanceAlreadyCompleteException, JobParametersInvalidException {
        return super.run(job, jobParameters);
    }
}

バッチの設定

JobRepositoryでvalidateTransactionStatefalseにするのと、上で作ったJobLauncherを使うように設定する。

<bean id="jobRepository" class="org.springframework.batch.core.repository.support.MapJobRepositoryFactoryBean">
    <property name="validateTransactionState" value="false" />
</bean>
<bean id="jobLauncher" class="com.example.TransactionalJobLauncher" p:jobRepository-ref="jobRepository" />

ThymeleafでSpringのMessageSourceを使ってテキストをローカライズする

ThymeleafはSpringとうまく結合されているので、Spring管理下にあるMessageSourceは特に意識せずともテンプレートで使用することができます。

Spring側で必要なのは、MessageSourceをBeanとして登録してあげるだけ。

@Bean
public MessageSource messageSource() {
    ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
    messageSource.setBasenames("WEB-INF/messages/messages");
    messageSource.setDefaultEncoding("UTF-8");
    return messageSource;
}

Thymeleafのテンプレートでは、#{messagekey} という形式でメッセージリソースを参照できます。

<span th:text="#{messagekey}"></span>

では、メッセージリソースが引数を取る場合はどのように記載したらよいのでしょうか?

答えは「括弧で引数を指定する」です。

<span th:text="#{messagekey(arg)}"></span>

複数の引数を取る場合はカンマでつなげばOKです。

<span th:text="#{messagekey(arg1, arg2, arg3)}"></span>

引数に変数を与えることもできます。括弧が多くなるので大変ですが。

<span th:text="#{messagekey(${model.value}, #{anothermessage(#{argmessage})})}"></span>

SonarQubeでJavaのコードを解析する(Maven編)

SonarQubeでは複数の方法でコード解析を実行することが出来ます。

MavenもしくはGradleで管理されたプロジェクトでは、それぞれ対応するプラグインを利用するのが最も簡単かと思います。また、Jenkins等のCI環境があれば、そちらにプラグインを入れて実行することもできます。

コード行数が増えてくるとそれなりに解析に時間もかかりますし、個人的にはJenkinsプラグインでの実行がお勧めです。

この記事では、手始めとして、最も簡単なMavenプラグインを使った方法を紹介します。

SonarQube Maven Pluginを使って解析を実行する

SonarQube Maven Pluginsonarゴールで解析を行うことが出来ます。POMファイルは特に編集しなくても、以下のコマンドで実行可能です。

mvn sonar:sonar

SonarQubeサーバがローカルの9000番ポートで上がっていて、デフォルトであるH2をデータベースとして使っているのであれば、本当にこれだけで解析が実行されます。

SonarQubeサーバがリモートにあり、データベースもMySQL等他の製品を利用している場合は、以下の設定を追加してくだい。

キー 設定する内容 値の例
sonar.host.url SonarQubeサーバのURL。ブラウザでトップページを表示する際のアドレスと同じ値を指定する。 http://localhost:9000
sonar.jdbc.url SonarQubeが参照するデータベースに接続するためのJDBC URL。 jdbc:mysql://localhost:3306/sonar
sonar.jdbc.username データベースに接続するためのユーザ名 sonar
sonar.jdbc.password データベースに接続するためのパスワード sonar

長いので省略してしまいましたが、MySQLJDBCドライバを使用するときは、JDBCのバッチ実行を高速化するため rewriteBatchedStatements=true を付けた方がよいです。 各データベース製品におけるJDBC設定の具体例は、SonarQube Maven Pluginのページを参照してください。

SonarQube Maven Plugin - Use Enterprise Database

Sonarが利用するデータベースとはいえ、パスワードをPOMに記載するのははばかられるため、sonar.jdbc.username等の設定はsettings.xmlに指定するか、毎回実行時に-Dsonar.jdbc.url=xxxの形で指定するとよいでしょう。

とりあえず実行するだけであれば、これだけでおしまいです。SonarQubeがデフォルトで持っているルールを使い、Javaコードの静的解析が行われ、結果が蓄積されています。 ブラウザでSonarQubeサーバに接続すれば、解析結果を見ることができます。

SonarQubeの公開ポート番号を変更する

SonarQubeをデフォルト設定でインストールした場合、9000番ポートとなり、公開する上でちょっと面倒な場合があります。普通の80番ポートで公開するにはいくつか選択肢があります。

  1. SonarQubeの設定を変更する
  2. mod_proxyを使う
  3. mod_proxy_ajpを使う

1. SonarQubeの設定を変更する

sonar.properties でポート番号を設定できます。

sonar.web.port=9000

デフォルトで9000となっており、任意の番号に変更できます。

2. mod_proxyを使う

apache等のHTTPサーバが稼働していれば、mod_proxyを使うことができます。

ProxyPass        / http://localhost:9000/
ProxyPassReverse / http://localhost:9000/

サーバのルートではなくコンテキストパスを設定したい場合は、sonar.propertiesに以下の設定を追加します。

sonar.web.context=/sonar

/sonarは任意のパスを設定可能です。Apache側の設定も合わせて変更が必要ですのでお忘れなく。

3. mod_proxy_ajpを使う

SonarQube 4.3はAJPにも対応しています*1。デフォルトでは無効化されているため、sonar.propertiesに設定を追加します。

sonar.ajp.port=9009

sonar.propertiesでの設定に合わせて、Apache側にAJPの設定を追加します。

ProxyPass        / ajp://localhost:9009/
ProxyPassReverse / ajp://localhost:9009/

CentOS 6.5であれば、httpdをインストールするだけで環境が整うので、mod_proxyやmod_proxy_ajpの追加インストールは不要です。

*1:3.7系では元々対応していたのですが、4系で削られてしまいました。このチケットで再度有効にされました