DBFlute Hamcrestを利用したDB無しの単体テスト
先日、DBFluteのユニットテストをサポートするDBFlute Hamcrestというライブラリをリリースしました。名前から想像される通り、JUnitなどで利用するHamcrestの、DBFlute用カスタムMatcher(assertThat(a, is(b))
のis()
の代わりに使うもの)となっております。
また、「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スキーマについては、DBFluteのexampledbをご覧ください。
テスト対象クラス
退会済みでない(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ユーザの集いに質問頂くか、直接 @taktos9 に聞いていただければできる限り回答します。
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での設定値を確認しておきます。
JenkinsにJabber Pluginをインストールする
他のプラグインを入れる方法と変わりありません。アップデートセンターからインストールできます。 HipChat Pluginとの違いは、このプラグイン自体がbot機能を持っていることです。
HipChatはXMPPプロトコルに対応しているので、HipChat PluginのようにAPIを経由せずとも、XMPPに対応するJabber Pluginで普通に利用できるのです。
Jenkinsの設定
Jenkinsのシステム設定で、Jabber Pluginの設定を行います。
HipChatで作成したJenkins用アカウントのJabber IDとPasswordを入れ、「高度な設定」を押すと出てくるNicknameとJenkins UsernameにHipChatのRoom nicknameを入力します。
Initial group chatには、botコマンドを待ち受けるグループチャットを任意の数追加できます。Nameには、HipChatのルーム名+@conf.hipchat.com
を入力します。
この設定後、JenkinsのログにHipChatとの接続状況が出力されます。もしエラーが出てる場合は、メッセージを見ておかしいところを修正してみてください。
使ってみる
ジョブ結果の通知
Jenkinsからジョブの結果を通知するには、ジョブの設定ページにて、ビルド後の処理にJabber Notificationを追加します。
Targetsには、*
+ HipChatのルーム名 + @conf.hipchat.com
を指定します。高度な設定から、通知する内容を変更することもできます。
botコマンド
Initial group chatに設定したHipChatのチャットルームで !help
と打ってみましょう。うまく接続できていれば、Jenkinsがボットコマンドの一覧を返してくれます。
!build <ジョブ名>
で任意のジョブをキックしたり、!status
でジョブの成功・失敗を一覧で取得したりすることができます。
というわけで、Jenkinsのジョブをキックするためのスクリプトを書いたりせず、お気軽に連携させることができました。めでたしめでたし。
DBFluteをできるだけ簡単に説明してみる
「DBFluteとは何か」と思って調べてみたら「DBFluteの紹介の時点で長すぎっ!!」って思った人向け。 いつも説明に困るのでまとめておく。
DBFluteとは、
です。
DBFluteの特徴
O/Rマッパーとして
- データベースのメタ情報を元にコードを自動生成し、SQL発行をタイプセーフに行うことができる。
- 手書きのSQL(外だしSQLと呼んでいる)も、対応するコードを生成してタイプセーフに実行。
- 生成されるコードはGeneration Gapパターン。
- JDBC APIにしか依存しない。JPAは使わない。独自のトランザクション管理もない。
- データベースのテーブル構造のまま、Javaから操作できるようにするだけ。
- O/Rマッパーとは言えないのかも。その分シンプルかつ強力。
- マッピング設定などは不要で、テーブルがそのままクラスになり、カラムがフィールドとなる。
- 外部キーから関連の多重度を推測し、適切なフィールドを追加してくれる(1:Nならば1側にList
を、N側にTを)。
- データベースのテーブル構造に対応するSQL生成コードが生成される。逆に言うと、構造上正しくないSQLが発行できない。
データベース周りのツールとして
データベースに関する様々なツール(タスクと呼ばれる)がある。
- O/Rマッピング用コード生成ツール(jdbc, generate, sql2entity, doc, manage regenerate)
- 既存のデータベースにJDBCで接続し、メタ情報を元にコードを生成する。
- 異常なまでに細かい沢山のカスタマイズ項目があるが、慣れるまでは無視して問題ない。
- データベーススキーマを作成する(ReplaceSchema)
- その他多数
- 「旧スキーマ+ALTER文=新スキーマ」であることをデータベースメタ情報をチェックして検証してくれるAlterCheck
- テーブルデータをExcelシートに吐き出すLoadDataReverseとかいろいろ
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の機能を参照のこと。
困ったときは
- DBFluteのドキュメントを見る http://dbflute.seasar.org/
- Googleグループで質問する https://groups.google.com/forum/#!forum/dbflute
- Twitterで #DBFlute を入れてつぶやくと、@jflute が捕捉してくれる(はず)
- id:jflute のブログにもたくさんの情報が。
結局かなり長くなってしまった。。。
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でvalidateTransactionState
をfalse
にするのと、上で作った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 Pluginのsonar
ゴールで解析を行うことが出来ます。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 |
長いので省略してしまいましたが、MySQLのJDBCドライバを使用するときは、JDBCのバッチ実行を高速化するため rewriteBatchedStatements=true
を付けた方がよいです。
各データベース製品におけるJDBC設定の具体例は、SonarQube Maven Pluginのページを参照してください。
Sonarが利用するデータベースとはいえ、パスワードをPOMに記載するのははばかられるため、sonar.jdbc.username
等の設定はsettings.xml
に指定するか、毎回実行時に-Dsonar.jdbc.url=xxx
の形で指定するとよいでしょう。
とりあえず実行するだけであれば、これだけでおしまいです。SonarQubeがデフォルトで持っているルールを使い、Javaコードの静的解析が行われ、結果が蓄積されています。 ブラウザでSonarQubeサーバに接続すれば、解析結果を見ることができます。
SonarQubeの公開ポート番号を変更する
SonarQubeをデフォルト設定でインストールした場合、9000番ポートとなり、公開する上でちょっと面倒な場合があります。普通の80番ポートで公開するにはいくつか選択肢があります。
- SonarQubeの設定を変更する
- mod_proxyを使う
- 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の追加インストールは不要です。