概要
Spring BootでMVC関連の処理やログイン関連の処理のテストコードについて、あれこれ試行錯誤しながら書いたので、備忘録としてまとめておきます。
※ログイン関連の処理については別記事に書いています。
もっといい書き方等ご存知でしたらやさしく教えて頂けるとうれしいです(╹◡╹)
ソースコードはGitHubで公開しています。
読まなくても何とかなる前置き(クリックで開閉します)
昨今、テストコードを書いた方がいい、という話がしばしば耳に入ってきていました。 それなら書いてみるか、と思って調べてみると、大体四則演算レベルのテストで止まってしまっており、実際のアプリでどう書いたらいいの...!? となってしまい、いまいちテストコードを書くのに踏み切れていませんでした。このままではマズいなと思ったので、お盆休みを生贄に捧げ、Spring Bootでエラーに苦しめられながら、試行錯誤しつつテストコードを書いてみました。 まだおぼつかない点も多々ありますが、とりあえずある程度「書き方のパターン」のようなものは確立できてきたので、備忘録も兼ねてまとめてみようかと思います。
Spring Bootのテストコードに関する記事は日本語・英語問わず体系的にまとまっているものがあまり無いので、色々と手探りの部分もありますが、少しでも理解の一助となれば幸いです('ω')
対象読者・ゴール
本記事ではSpring Bootでのテストコードの書き方にフォーカスしていくので、以下を満たしていると、つらくないかと思います。
- MVCの雰囲気は理解していて、Spring Bootで簡単なTODOリストレベルのアプリは作れる
- JUnitは何となく触ったことがあって、四則演算レベルならとりあえずテストコードは書ける
上記の前提があった上で、本記事を読むことで、(きっと)以下のことが可能となります。
- Spring Bootを利用した簡単なデータベース操作のユニットテスト
- Spring Bootを利用した簡単なCRUDアプリのユニットテスト
- Spring Bootを利用したログインが必要なアプリの基本的な処理のユニットテスト(別記事)
また、今回は画面の表示やJS部分等のテストについては対象外としています。将来的にはその辺りもカバーしていくつもりですが、いきなり全てをテストしようとすると複雑になり過ぎてしまうので、まずは小さなところから始めていきます。ステップアップ大事。
補足: テストはどこまで自動でやるか(クリックで開閉します)
テストコードを書くことの重要性については、頻繁に取りあげられていますが、手動テストを自動テストに置き換えるのは多くの経験・コストを必要とします。業務アプリケーションであれば、色々とあれやこれやがあって、自動テストを中々取り入れられないということがあったりします。 しかし、個人開発では何の制約も無いからといって、いきなり全てを自動化だ!!と躍起になると大体心が折れます(私は折れましたƪ(˘⌣˘)ʃ)。
そこで、まずは「自動テストで何ができると助かるか」という部分にフォーカスし、できる範囲で自動テストできる部分を少しずつ増やしていくのが、つらくないやり方かと思います。 テストコードを書くのはフレームワーク・言語の十分な理解が必要となってくるので、自分の技術力の成長に応じて、長く付き合っていくのがよいかと思います。
さて、話を今回のテストコードの範囲に戻しましょう。 Spring Bootでアプリをごりごり書いていると、大体以下のような部分でバグが入り込むのを(個人的に)多く経験してきました。
・ Viewへ渡すModelへ想定した値が渡されていない ・ Daoレイヤーの処理の実行結果をDBで見てみたら想定通りになっていない ・ ユーザの権限に応じた処理が思うように動作していない
上記は今回のカバー範囲といい感じに重なっています。 まずはバグがたくさん発生し、しんどい思いをしている部分のデバッグを効率良くできるようにすることで、開発の楽しさを上げていくことを目指していけたら、と思います。
上記のカバー範囲を簡単に図で表すと、以下のようになります。
リクエストが投げられてからViewさんに表示が依頼される辺りまでを触れていきます。そのため、今回はViewさんは基本的にはおやすみです。
環境
詳細はGitHubのpom.xmlを見て頂くとよいかと思います。
<!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-engine</artifactId>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/org.dbunit/dbunit -->
<dependency>
<groupId>org.dbunit</groupId>
<artifactId>dbunit</artifactId>
<version>2.5.1</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/com.github.springtestdbunit/spring-test-dbunit -->
<dependency>
<groupId>com.github.springtestdbunit</groupId>
<artifactId>spring-test-dbunit</artifactId>
<version>1.3.0</version>
<scope>test</scope>
</dependency>
テスト範囲
さて、ここから実際にテストコードの書き方について見ていきます。
しかし、Spring Bootのテストコードはいくつかの知識を組み合わせて記述していくため、一気に全てをカバーしようとすると、難易度が跳ね上がってしまいます。
そのため、以下の4つのステップに分けていきたいと思います。
- Level1. HelloWorldをテストする
- Level2. データベース操作をテストする
- Level3. POSTリクエストをテストする
- (Level4. ログインが必要なアプリをテストする)
※レベル4に関しては、本記事で取りあげるテストコードの知識を総動員する必要があり、ボリュームが大きくなってしまうため、別記事に分けます。
本記事で扱うレベル1〜3のカバー範囲については、大雑把に図で表すと、以下のようになります。
それでは、早速、HelloWorldから見ていきましょう。
Level1. Hello Worldをテストする
お馴染みのハローワールドさんです。
以下の「/hello/init」へリクエストが投げられると、view名として「hello」が返される、というようなよくある処理について見ていきます。
@Controller
@RequestMapping("/hello")
public class HelloController {
@RequestMapping("/init")
private String init() {
return "hello";
}
}
やっていることは単純なのですが、テストコードを書く際にはいくつか新しい知識が必要となるので、Spring Bootのテストコードへ入門する上では、とても大事な部分となります。
まずは、コード自体はあまり長くないので、全体像を掴むためにも、以下へ実際のテストコードを記述します。
@AutoConfigureMockMvc
@SpringBootTest(classes = DbMvcTestApplication.class)
public class HelloControllerTest {
//mockMvc TomcatサーバへデプロイすることなくHttpリクエスト・レスポンスを扱うためのMockオブジェクト
@Autowired
private MockMvc mockMvc;
// getリクエストでviewを指定し、httpステータスでリクエストの成否を判定
@Test
void init処理が走って200が返る() throws Exception {
// andDo(print())でリクエスト・レスポンスを表示
this.mockMvc.perform(get("/hello/init")).andDo(print())
.andExpect(status().isOk());
}
いきなり普段アプリを書いている上では、見慣れないものがたくさん出てきてしまいました。
このテストコードの処理は、大きく分けて「2つ」のブロックに分かれているので、それぞれについて詳しく見ていきましょう。
クラスのアノテーション
クラスへ付与されているアノテーションは、テストコードの全体像を知る上で重要な情報を持っています。
普段使わないものばかりですが、しっかりと概要を掴んでおくと、テストコードとの距離がぐっと縮まります。
AutoConfigureMockMvcアノテーション
MockMvc
というものを利用するためのアノテーションです。では、MockMvcとは一体何者なのでしょうか。
これは、物理的な「サーバ」と作成した「Webアプリケーション」を切り離す為のものです。
いまいちピンと来ないので、切り離すことで何が嬉しいのか、という観点で見てみましょう。
- なんらかのエラーが発生した場合、原因はWebアプリケーションに「限定」される
- テストの度にサーバが起動するまで待つ必要がないので、テストケースの実行時間が短縮される
補足: モックを使う必要性(クリックで開閉します)
モックを使うメリットで実感しやすいのは、テストコードの実行時間の短縮なのですが、影響範囲の限定というのも大事な要素です。例えば、Dao層にアクセスするサービス層の処理をテストする際、簡単な処理であれば、モックを使わずともテストはさほど苦労することなく実現できるかと思います。 しかし、コードが複雑になり、更に作る人も別々というようになった場合、バグが発生すると原因が「サービス層」にあるのか、「Dao層」にあるのか、切り分けの為の調査が必要になってしまいます。 ここで、Dao層を「適切に」モック化しておき、サービス層のテストを先に完了させてしまえば、実クラスに差し替えた際にバグが発生した場合、Dao層に原因を限定させることが可能となります。
実際のところ、個人開発ではモックを使わずとも、一から百まで全て自分で実装しないといけないので、「実行時間」を抑える以外の目的でモックを使う機会はあまり無いかもしれません。しかし、テストコードに限らず、影響範囲を限定させる、という考え方は重要になってきますし、チーム開発では全てを自分一人で作るわけではないので、モックの考え方・扱い方については、概要レベルでも知っておくとよいかと思います。
ただ、私もモックが完全に使いこなせているとは、とても言えないような状態なので、もっといい考え方等ご存知でしたら、教えて頂けるととてもうれしいです。以下、参考資料です。 [Mockオブジェクトとは](https://www.itmedia.co.jp/im/articles/1111/07/news180.html) [Mockのメリット](https://stackoverflow.com/questions/22783772/what-is-the-benefits-of-mocking-the-dependencies-in-unit-testing)
個人開発をする上では、サーバ部分をモック化してテストコードの実行時間を抑え、チーム開発では、自身が携わっていない部分をモック化し、影響範囲を限定した上でテストコードを書く、という形で進めるのが良いかと思います。
※メール送信処理といった外部サービスと連携が必要な処理については、個人開発でもモックを使った方がよいかと思います。
以下、参考資料です。
SpringBootTestアノテーション
いかにも重要そうなアノテーションです。実際すごく重要なアノテーションです。
Spring Bootのユニットテストでは、必ずと言っていい程出てくるアノテーションで、たくさんの機能を備えているので、段階的に機能を見ていく形にしたいと思います。
ここで重要となるのは、以下の2つの機能です。
- ExtendWithアノテーションの隠蔽
- ApplicationContextの指定
まずは、ExtendWithアノテーション
についてです。Spring Bootのテストについて解説したものではよく、RunWithアノテーションが用いられていましたが、RunWithアノテーション
はJunit4用のアノテーションで、ExtendWithアノテーションはJunit5用のアノテーションとなっています。
用途としては、テストの事前処理や事後処理などを汎用的に実装するために用います。そして、valueプロパティである、汎用処理の実装が書かれた「Extension」クラスには、SpringExtensionが渡されます。
発展的な話になるので、今回は割愛しますが、このExtensionクラスは、例えば、DIコンテナの役割を持つApplicationContextのインスタンス化といった重要な部分を担っています。
詳細は公式をば。
さて、長々と書きましたが、Spring BootでDIコンテナを利用するのは最早当たり前となっており、毎回ExtendWithアノテーションを記述するのは手間となってしまいます。
ドキュメントを見て頂くと分かる通り、使うのが当たり前になっているなら内包しちゃえば良いじゃない、ということでSpringBootTestアノテーションだけでOK、という形となりました。
このように複数の機能を内包したアノテーションを自前でも作成することで、テストコードの前提の記述を簡略化することもできますが、今回は分かりやすさを重視する、ということで、統合用のアノテーションは割愛しております。
続いて、ApplicationContextの設定について見ていきます。
度々言葉として出てきているApplicationContextですが、ここではざっくりと「DIコンテナ」ぐらいに思って頂いても問題はないかと思います。
※DIコンテナに関する説明はこの辺りが分かりやすくておすすめです。
Spring Bootのユニットテストでは、DIコンテナが「どのクラスを登録するのか」明示しておく必要があります。
といっても、特別なことが必要というわけではなく、SpringBootApplicationアノテーション
が付与されたクラスをclassesプロパティへ設定することで、Configurationアノテーションが付与された設定クラス、ComponentScanの対象となるクラスを自動的にDIコンテナへ登録してくれます。
参考
単純にメインクラスを渡せば全て解決、というわけにはいかないのですが、その辺りはHTTPリクエスト・レスポンスを必要としないDaoレイヤーでのテストの辺りで触れたいと思います。
補足: classesプロパティへ渡すべきもの(クリックで開閉します)
[ドキュメント](https://docs.spring.io/spring-boot/docs/current/api/org/springframework/boot/test/context/SpringBootTest.html#classes--)にある通り、「classes」プロパティに設定されるクラスは、「Configuration」の役割を持ったもので、DIコンテナを実現するために利用されます。先ほどの説明の中では、SpringBootApplicationアノテーションが付与されたものがDIコンテナのための「設定」を実現していました。しかし、今回のHelloControllerはそもそも(見えている範囲では)他のクラスへは依存していないので、「classes」プロパティへ「HelloController」を渡しても動作はします。 ですが、「classes」プロパティは本来、設定クラスとしての役割を持ったクラスが渡されることが想定されているので、SpringBootApplicationアノテーションが付与されたクラス、あるいはテストに必要な設定を定義したコンフィグクラスを渡すのがよいでしょう。 何か機能を利用する際には、ドキュメントに記載されているパラメータが想定しているものは何か、ということだけでも最低限目を通しておくと、予期せぬ動作でハマって途方に暮れる、ということも少なくなるかと思います。(自戒)
※実際、HelloControllerのみをWebApplicationContextとして定義してしまうと、MockMvcのレスポンスのBody部分が空となってしまいます。 (中身までは見られていませんが、ViewResolverやViewがインスタンス化されていないことが原因だと思われます。)
設定だけで説明が長くなってしまいましたが、一度理解してしまえば他のテストコードを書く際にも活用できる重要な部分なので、しっかり腰を据えて学習してみるとよいかもしれません。
(私はDIとか全く理解していないことを理解したので、Springについて復習するいい機会になりました)
テストコード
ようやく実際のテストコードへたどり着けました。
今までの細かな設定よりはシンプルで、そして楽しい部分です。
間隔があいてしまったので、テストコードに関する部分をもう一度見てみましょう。
// getリクエストでviewを指定し、httpステータスでリクエストの成否を判定
@Test
void init処理が走って200が返る() throws Exception {
// andDo(print())でリクエスト・レスポンスを表示
this.mockMvc.perform(get("/hello/init")).andDo(print())
.andExpect(status().isOk());
}
上記のテストコードは、「リクエストの実行」、「レスポンスの検証」の二つの処理を行なっています。
いずれの処理も、「MockMvc」のインスタンスをベースに行なっており、基本的には上記のように一つの文で記述します。
一つの文にまとめることで、テストが何をやっているのか、英語の文章のような形で紐解くことが可能となります。
実際にどのように書かれているのか見てみましょう。
- 実行しなさい(perform)
- /hello/initへのGETリクエストを(get)
- 結果を表示しなさい(andDo(print))
- (結果を)期待します(andExpect)
- HTTPステータスコードが200であることを(status().isOK())
上記をまとめると、以下のような英文になるでしょう。(ライティングはあまり得意ではないので雰囲気で感じ取ってくださいƪ(˘⌣˘)ʃ)
Perform get request to [/hello/init] and print the result, and I expect that status is 200 OK.
プログラミング言語は英語で書かれているので、当然、英語ネイティブの方であれば、普段慣れている英語の文章のような構造でテストコードを読み書きすることができる、というメリットがあります。
(英語から逃げる手段は無さそうなので、せめてリーディング・リスニングだけでもそれなりにかじっておくと、つらくないかもしれません)
少し話は逸れましたが、テスト方法・期待結果を見やすい形で記述することで、システムの仕様や、実際のソースコードを理解する上で重要な情報がテストコードから容易に手に入るようになります。
裏を返せば、仕様を満たさないようなテストコードはバグを見逃してしまう上、仕様の理解にも繋がらないので、ダメなコードとなってしまいます。
こういう簡単なコードの段階から、実装した処理では何を機能として実現できていればよいか
ということを常に意識しながらテストコードを書く習慣をつけると良いのかもしれません。
色々と余談を挟みましたが、HelloWorldをテストコードで検証した際のリクエスト・レスポンスは、以下のようになります。
上記で記した内容が満たされていることが確認できているかと思います。
これでようやくHelloWorldが正しく動いていることが確認できました。やったぜ。
Modelをテストする
HelloWorldのテストコードだけでは検証できることが限られてしまっているので、レベル1ではもう一つ、実用的なものとして、Modelの検証について見ていきたいと思います。
ここでのModelは「Viewから参照されるJavaオブジェクト」を指しており、Model.addAttribute
等でよく書かれるものです。
Modelへいい感じに値を詰めたと思ったら実は入っていませんでした、ということは非常によくあることなので、サーバを起動して画面へかちかちアクセスして...とすることなく、一発で動いているかどうかが検証できたら、とても便利です。
以下では、実際にModelの中身をテストコードで検証する方法について見ていきます。
これまで触れてきたものと比べればサクッと理解できる内容なので、しっかり使い方をマスターして頂ければと思います。
検証対象コード
まずは、テスト対象となるコードを見ていきます。
といっても、先ほどの HelloControllerを少し拡張した程度のもので、全体を通してさほど難しいことはしていないので、ここで一気に全体を載せてしまいます。
@Controller
@RequestMapping("/hello")
public class HelloController {
@RequestMapping("/init")
private String init(Model model) {
// ユーザリスト まずは手動で生成
List<User> userList = new ArrayList<User>();
User user = new User();
user.setUserId(0L);
user.setUserName("test0");
User user2 = new User();
user2.setUserId(1L);
user2.setUserName("test1");
userList.add(user);
userList.add(user2);
// フォームにユーザのリストを設定し、モデルへ追加することでモデルへ正常に追加されたか検証するための土台を整える
DbForm form = new DbForm();
form.setUserList(userList);
model.addAttribute("message", "hello!");// 1
model.addAttribute("user", user);// 2
model.addAttribute("dbForm", form);// 3
return "hello";
}
}
以下では、model.addAttribute
について、それぞれのパターンを追っていきます。
① ModelにStringが格納されたか
最初は単純な例から見ていきましょう。
model.addAttribute("message", "hello!");
では、単純にモデルはkeyとして「message」を、valueとして「hello」を格納しています。
これを検証するためのテストコードは、以下のようになります。
@Test
void init処理でモデルのメッセージにhelloが渡される() throws Exception {
this.mockMvc.perform(get("/hello/init"))
.andExpect(model().attribute("message", "hello!"));
}
テストコードもすごくシンプルで、andExpect(model().attribute("message", "hello!"))
という部分を見て頂くと分かる通り、実際のModelへの詰め込みとほぼ同じような形でテストコードも書くことができます。
実際に結果を見てみると、Model部分へ正しく値が詰め込まれていることが分かります。
単純なオブジェクトであれば、プロパティが一階層のみとなっているので、シンプルに書くことができます。
しかし、結果の中をよく見て頂くと、何やらvalue値が怪しげな文字列で書かれたものがあります。
これは、オブジェクトのインスタンスそのものを表しています。当然、オブジェクトがnullでないかを検証したいときもありますが、大体の場合、オブジェクトの中の特定のプロパティが想定通りの値になっているか、という部分まで知りたいものです。
以下ではこういったネストしたプロパティが詰め込まれたModelの検証について、見ていきます。
②Modelへ詰め込まれたユーザEntityのuserNameプロパティの値は想定通りか
さて、ネストしたオブジェクトについての検証は、少しだけテストコードが複雑になりますが、アノテーションの解釈に比べれば、とてもシンプルなので、一つ一つ探っていきましょう。
ここでは、model.addAttribute("user", user);
の処理について、「user」インスタンスのプロパティ「userName」が想定通り(ここでは、「test0」という値)になっているか、検証していきます。
何はともあれ、実際のテストコードを見てみることから、始めていきます。
@Test
void init処理でモデルへユーザEntityが格納される() throws Exception {
this.mockMvc.perform(get("/hello/init"))
.andExpect(model()
.attribute("user", hasProperty(
"userName", is("test0")
)
)
);
}
いきなり構造がガラッと変わりました。
これは、Spring Bootのテストコードというよりは、「Hamcrest」というテストの妥当性を検証する、いわゆる「Matcher」を扱うフレームワークによる処理となっています。
※HamcrestはMatchersのアナグラムを表しています。
オブジェクトのプロパティを検証するためのメソッドhasProperty
はstaticインポートで使用しているので、正確には、HasPropertyWithValue.hasProperty
という構造となっています。
そして、このメソッドは以下のような役割を持ちます。(公式より引用)
Creates a matcher that matches when the examined object has a JavaBean property with the specified name whose value satisfies the specified matcher.
何やら難解な英文が出てきましたが、実際の例を見てみるとしっくり来るかと思います。
assertThat(myBean, hasProperty("foo", equalTo("bar"))
これは、「myBean」というオブジェクトは、「foo」というプロパティを持っており、fooプロパティの値は「bar」である、ということを表しています。まさに今回のテストで検証したいことそのものですね。
今回の例を簡単に書くと、assertThat(user, hasProperty("userName", is("test0"));
というようになります。
hasPropertyの戻り値はMatcher型に属するので、プロパティをネストして書くこともできます。
ネストしたプロパティに対する検証はどうしてもコードが長くなり、括弧の対応関係が分かりづらくなってしまうので、上記の例のようにインデントを工夫する等読みやすくするための何らかの配慮は必要になるかと思います。
何はともあれ、これでModelが複雑になっても対応できるようになりました。
最後のModelの例として、今度はListオブジェクトに関するテストコードを見ていきたいと思います。
補足: Matcherとは
割と当たり前のように出てきたMatcherですが、JUnitに触れていないとあまり馴染みが無いかと思いますので、補足で簡単に触れておきます。 Matcher自体はインタフェースで、テストでの検証(値が等しいか、等しく無いか、全て条件を満たすかなど...)処理を簡単に、かつ読みやすい形で記述するために作られました。例えば、よく使われる「assertThat」メソッドは`assertThat(1 + 1, is(2));`といった形で記述されます。これは、先ほどのperform処理と同様、英語の読みやすい形でテストコードを記述するためのものです。 そして、assertThatメソッドの第二引数に指定するものこそが「Matcher」を表しているのです。実際にassertThatメソッドの中身を追ってみると、 `if (!matcher.matches(actual)) { 一致しなかったときの処理 }` という記述があり、ここでは、第二引数に指定したMatcher型のインスタンスのmatchesメソッドを呼び出しています。 この結果はbooleanとなるので、Mathcerというのは、単に検証の成否を格納するための真偽値というようにざっくり解釈しておくとよいかと思います。
ネストしたプロパティであっても、結局は最下層の特定のプロパティ値が想定したものと等しいかを検証しているに過ぎないので、あまり身構えず、シンプルに考えると案外スッキリ理解できるかもしれないです。
③Modelへ詰め込まれたリストは想定通りのプロパティを保持しているか
最後のModelのパターンとして、ネストしていて、かつ、リスト構造を持つものについて見ていきましょう。
model.addAttribute("dbForm", form);
というよくある例として、「Formオブジェクトの中のユーザのリスト」が想定したものとなっているかを検証します。
例のごとく、まずは以下へコードを記載します。
// リスト要素については、hasItemで順番を問わずリストへアクセスし、指定されたプロパティが指定の値となる要素が存在するかを検証。
// 存在する場合のみテストをグリーンとする
@Test
void init処理でモデルのフォームへユーザリストが格納される() throws Exception {
this.mockMvc.perform(get("/hello/init"))
.andExpect(model().attribute("dbForm", hasProperty(
"userList", hasItem(
hasProperty(
"userName", is("test1")
)
)
)));
}
新たにhasItem
というメソッドが出てきました。
公式さんいわく、リスト形式のオブジェクトに対してのみ使用可能となっています。
そして、ここで利用しているメソッドは、引数として、Matcherを持ちます。
つまり、ざっくりまとめると、hasItemメソッドの実行対象となるリストの中に、引数として渡されるMatcherを満たすものが一つでも存在するか、ということを検証しています。
今回の例では、ユーザリストの中の各ユーザ要素について、「userName」プロパティが「test1」であるものが一つでも存在するか
が検証したいこと、となります。
例でのリストは要素数が「2」程度の小さなものなので、中身を全て調べることも可能ではありますが、実際のアプリでは数百、数千と要素が詰まったリストが渡されることがよくあります。これを全て検証するのはいくらコード化できるとはいえ、しんどそうです。
こういう場合、先頭、中間、末尾辺りの要素について、仕様を満たしているか検証できればある程度信頼性は担保できそうです。
よって、リストの要素を丸ごと全て検証するのではなく、代表要素として、一部さえ検証できればよいので、hasItemメソッドでいくつかテストケースを設けてあげるのが、よいかと思います。
※もちろん、業務要件が厳密で、一つの誤りが致命的な損失に繋がる場合は、この限りではないです。
さてさて、色々と補足説明を交えながらとなったので、だいぶ長くなってしまいましたが、これでレベル1のテストコードは検証完了です。
レベル1をこなすことによって、以下のことが可能となりました。
- 簡単なGETリクエストであれば、リクエスト・レスポンスが正常であるか検証できる
- Viewへ渡されるModelオブジェクトへ、想定した値が詰め込まれているか検証できる
- Spring Bootでのユニットテストで最低限必要なアノテーションの概要が理解できる
続いて、レベル2では、アプリケーションの要である、データベースについての検証を見ていきたいと思います。
Level2. データベース操作をテストする
Spring Bootでのユニットテストでは、「DbUnit」というものを利用し、データベースの検証を行います。
こう書くと、Spring Bootだけでも手一杯なのに、更にまた勉強することが増えるのか...となってしまいますが、DbUnitは簡単な使い方を覚える程度で十分かと思います。
重要なのは、DbUnitとSpring Bootを組み合わせることで、「どのような作業が楽になるのか」を意識することです。
いきなりDbUnitについてごりごり書いていくと、イメージがわきづらいかと思うので、まずは、データベース操作が手動テストからどのように自動テストへ置き換わるか、流れを追ってみましょう。
手動テスト
最初に、手動でデータベース操作をテストすることを考えてみましょう。
大体、以下のような流れでテストが進められるかと思います。
-
アプリ側でデータベースからSELECTでレコードを取得する処理を実行
-
取得結果が想定したものとなっているか取得されたレコードを見ながら検証
-
アプリ側でデータベースのレコードをUPDATE、DELETEする処理を実行
-
処理の適用前後でデータベースのレコードを比較し、想定通りの結果となっているか検証
手動テストの是非についてはここでは置いておいて、ここでは、「テストの再現性」について着目してみます。
チームで開発を行っていればSELECTの結果は刻一刻と変化しますし、UPDATE、DELETE処理も、同じ条件を揃えようと思ったら事前準備がある程度必要となってしまいます。
実行したテストに再現性が無ければ、リファクタリング、リグレッションテスト等で繰り返しテストを行った際、本当にデグレードが起こっていないのか特定が困難になってしまいます。
それでは、手動テストについて、もう少し工夫してみましょう。
テスト状態の手動構築
再現性を担保するため、上記の手動テストへ以下のような工程を取り入れてみました。
- CSV(もしくはXML)ファイル等でテスト開始時のレコード状態をデータベースとは別で作成
- データベースの既存レコードはバックアップ等で退避し、テスト用ファイルによって差し替え
- UPDATE、DELETEの検証用のファイルを別途用意し、処理の適用結果が想定と等しくなるか、レコードとファイルを比較することで検証
- データベースの既存レコードを復元させてテスト完了
これでテストの再現性は担保できました。これなら安心してテストできる...!!ということには当然なってくれません。
小規模なアプリであれば実現できないことは無いかもしれませんが、ちょっとした検証の度にデータベースを丸ごとバックアップして、丸ごと削除して、丸ごと復元して...とやっていると、テストに膨大な時間が掛かってしまいます。
せっかく再現可能なテスト状態を作っても、テスト自体の実行や、結果が返ってくるまでに多大な時間が掛かってしまうと、適切なタイミングでテストが実行されなくなってしまいます。
ちょっとデバッグしたいと思っただけなのに、結果が返ってくるまでに数十分、下手したら数時間待たされているようでは結局後回しにされ、手動でカチカチテストする形に戻ってきてしまいます。
ここまで、データベース操作のテストを再現性を担保しながら行うのは、ややハードルが高いように感じられたかもしれません。
しかし、Spring BootとDbUnitを組み合わせることで、多少の準備は必要になりますが、上記のようなテストをボタン一発で実行可能となります。
それでは、レベル2の本題として、Spring Boot, DbUnitを利用したテストコードについて、見ていきましょう。
検証対象コード
まずは、王道のSELECTで結果を取得する処理について見ていきます。
以下へ検証対象となるDao層のコードを記載します。
/**
* DBから全てのユーザレコードを取得する
* 今回はテストのため、処理を簡単なものとした。
* @return ユーザエンティティのリスト
*/
public List<User> findAllUser() {
QueryBuilder query = new QueryBuilder();
query.append("select user_id, user_name from tm_user");
return findResultList(query.createQuery(User.class, getEm()));
}
色々と処理が書かれていますが、注目すべきは、以下の2点となります。
- ユーザテーブルから全てのユーザをSELECT
- 結果をユーザエンティティのリストとして返却
データベースからレコードをSELECTする処理はこれからも繰り返し出てくるので、まずは最初のステップとして、データベースのレコード数 = リストのサイズ
を検証します。
検証のため、早速DbUnitを利用していくのですが、実際のテストコードを書く前に、いくつか前準備が必要となります。
前準備は少しやることが多いですが、一度マスターしてしまえば、以降のデータベース操作のテストではルーチン化できるので、ゆっくりと見ていきたいと思います。
CSVでデータを管理したい
最初に、データベースのレコードをファイルで管理するための土台を作っていきます。
DbUnitの標準機能としては、XMLファイルでレコード・トランザクション設定等を記述していくのですが、今回はCSVでレコードを管理していきます。
理由は色々とありますが、ざっくりまとめると、シンプルに書ける
ということが大きなものとなります。
以下ではいくつかのステップを踏んでいきますが、どれもやっていること自体はシンプルなので、DbUnit自体にあまり踏み込まなくてもある程度直感的に理解できるかと思います。
ということで、まずはCSVファイルをテストで利用するためのクラスを作成していきます。
CsvDataSetLoader
public class CsvDataSetLoader extends AbstractDataSetLoader{
@Override
protected IDataSet createDataSet(Resource resource) throws Exception {
return new CsvURLDataSet(resource.getURL());
}
}
以下では重要な要素について、補足説明を記載します。
- AbstractDataSetLoader
文字通り、何らかのデータセットを読みこむための抽象クラスです。ここでのデータセットは、「テーブルの集合」ということを表しています。この抽象クラスはDataSetLoaderインタフェースの実装クラスとなっているので、作成するクラスは、「DataSetLoader」型となります。
つまり、クラスレベルで作成したクラスを見てみると、「これはデータセットを読みこむ為のクラスです」という情報を記述しているだけのシンプルなものとなっています。
- createDataSet
これまた名前から分かる通り、データセットを作成するためのファクトリメソッドとなっています。
引数として渡されるResource型の「resouce」オブジェクトは、「実ファイル」へアクセスするための情報・振る舞いを持っています。
実際のテストでは、resourceオブジェクトは処理対象のCSVファイルのパスが格納されている、といった形となります。
- CsvURLDataSet
公式でThis class constructs an IDataSet given a base URL containing CSV files
とあるように、上記のresourceオブジェクトをもとにCSVの実ファイルを取得し、データセットオブジェクトへ変換することで、DbUnitが処理できるようにします。
いくつか処理は書かれていましたが、このクラスは、クラス名が表す通り、CSV実ファイルを読み取り、データベース操作のテストで利用可能とするためのものとなっています。
一度書いてしまえば、他のアプリでCSVファイルを利用したデータベース操作のテストを行う際にも使いまわせるので、ここでは、各処理が「何を表しているのか」という概要が掴めれば問題は無いかと思います。
CSVを読みこむためのクラスは完成したので、続いては、実際に読みこませる対象のCSVファイルを作ってみましょう。
ファイルのサンプルはGitHubをば。
CSVファイル自体の作り方は色々とありますが、個人的には、DBeaverを利用してレコードからCSVをはき出すと、そのまま使えるのでおすすめです。
CSVファイルを作成する上での注意点としては、以下のものがあります。
- ファイル名はテーブル名と合わせる
- 先頭行にカラム名をカンマ区切りで記述
- カラムはテーブルに存在するすべてのものを記載する必要はなく、テストしたいもののみを記載
また、CSVファイルの置き場所にも気をつける必要があります。
(私は変なところに置いてしまってハマりまくりました。)
基本的には、src/test/resources
以下へ配置する形となります。
参考
具体的なファイル・フォルダ構成は以下のようになります。
src/test/resources/testData
配下にテーブル名っぽい感じのCSVファイルがあることが分かります。
そして、隣に見慣れないtable-ordering.txt
というテキストファイルが配置されています。
これは、外部キー制約を防ぐためのもので、データベースのテーブルを読み込む順番を指定します。
具体的な書き方は、以下のようになります。
TableA
TableB
TableC
※外部キー制約を利用していない場合であっても、table-orderingファイルを作成しないと、DataSetExceptionが発生してしまうので、テスト単位でフォルダを分け、各フォルダへ配置しておくのがよいでしょう。
テストコード
さて、ようやく準備が整ったので、テストコードへ入ることができます。
アノテーションがいきなりぶわっと増えますが、ここを乗り越えればテストを書ける範囲がグッと広がるので、頑張りどころです。
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class)
@TestExecutionListeners({
DependencyInjectionTestExecutionListener.class,
TransactionalTestExecutionListener.class,
DbUnitTestExecutionListener.class
})
@SpringBootTest(classes = {DaoTestApplication.class}, webEnvironment = SpringBootTest.WebEnvironment.NONE)
public class DBSelectTest {
@Autowired
private UserDao userDao;
// DatabaseSetupのvalueにCSVファイルのパスを設定することで、「table-ordering.txt」を参照し、
// 順次テーブルを作成することでテスト用のテーブル群を作成する
// このとき、valueのパスは「src/test/recources」配下を起点とし、CSVファイルのファイル名は
// テーブルの名前と対応させることとする
//
// また、@Transactionalアノテーションを付与することで、テストが終わるとトランザクションをロールバックすることで
// 環境を汚すことなくテストができる
@Test
@DatabaseSetup(value = "/testData/")
@Transactional
public void contextLoads() throws Exception {
List<User> userList = userDao.findAllUser();
// Daoで正常にテーブルからレコードを取得できたか
assertThat(userList.size(), is(2));
}
}
クラスのアノテーション
まずは全体像を掴むためにも、クラスに付与されているアノテーションから始めていきます。
DbUnitConfiguration
読んで字のごとく、DbUnitのあれこれの設定を行うためのアノテーションです。
「dataSetLoader」プロパティへ上記でつくったCsvDataSetLoader
を指定することで、CSVファイルの読み込みが可能となります。
設定は他にも色々とあるようですが、現段階ではCSVを読み込ませるために使う、という認識で問題はないかと思います。
TestExecutionListeners
今回のテストコードで最も難しそうな部分です。このアノテーションは、概要をおさえて、何をプロパティに渡すべきかが整理できれば十分だと思います。
概要としては、TestExecutionListener
という、テストコードの実行の前後に行われる処理を定義したもののうち、必要なものを読み込ませるためのものです。
各リスナーの説明は、公式にざっくりとまとめられていますが、ここでもよく使うものについて、簡単に記載しておきます。
- DependencyInjectionTestExecutionListener
DIをテストコードでも利用する場合に指定します。指定することで、テスト対象クラスをAutowired等でDIコンテナからインジェクションすることが可能となります。
- TransactionalTestExecutionListener
DB操作の際のトランザクションを設定する際に指定します。DB操作後は、DBを元の状態に戻しておくのが基本なので、DBを扱うテストでは基本的には必須となります。
- DbUnitTestExecutionListener
テストの前後でのデータベースの状態を後述のアノテーションで設定する際に指定します。
名前の通り、DbUnitを利用する際には、基本的には追加しておきます。
補足: DirtiesContextとリスナーのデフォルト設定
ここでは紹介しませんでしたが、`DirtiesContextTestExecutionListener`というリスナーもよく見かけるかと思います。 これは、DirtiesContextのハンドラの役割を持っています。DirtiesContextは、テストコードの実行に利用するコンテキストのキャッシュを無効化するためのものです。DirtiesContextを有効にすると、テスト単位でコンテキストがキャッシュされないため、テストコードの実行に時間が掛かってしまいます。テストコードがコンテキストに変更を加えてしまい、後続のテストへ影響が出る場合に使うもののようですが、基本レベルではまず使うことはなさそうなので、気にしなくてもよさそうです。 当然、使わない、という選択肢をとる場合、「なぜ使わないか」は明確にしておく必要があるので、概念ぐらいはおさえておくとよいかと思います。[参考](https://docs.spring.io/spring/docs/current/spring-framework-reference/testing.html#testcontext-ctx-management-caching)
続いて、話は変わりまして、リスナーのデフォルト設定についての補足です。 公式をぱっと見た感じだと、デフォルトでいくつかが有効になっていて、追加したいものを渡せばいいんだなー、というように思っていました。 しかし、実際は有効にはなっておらず、なんだこれ...と思って公式さんを読みこんでみると、`TestExecutionListenersアノテーション`で明示していない場合のみデフォルト設定が有効になるようです。 ここでの例は、DbUnit用のリスナーを有効にするため、明記しましたが、この場合はデフォルト設定に書かれているものであっても、必要なものを渡さなければ動作しなくなってしまいます。 [参考](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/context/TestContextBootstrapper.html#getTestExecutionListeners--)
SpringBootTest
さて、二度目の登場です。ここでは、新たにwebEnvironment
プロパティが渡されています。
デフォルト値には「MOCK」が設定されていおり、mock servlet environment
なるものを作成しているようです。詳細は公式にもあまり書かれていませんでしたが、コンソール出力を見る限り、MockMvcで利用するテスト用のDispatcherServletを生成する処理のことを表しているようです。
※DispatcherServletの参考資料
Dao層単体では、サーバとリクエスト・レスポンスのやりとりを行う必要がないので、プロパティ値として、「NONE」を設定します。
こうすることでMockMvc用のコンテキストを生成する処理がカットされ、わずかにですが、テストが早く終わってくれます。
そして、順序は逆になってしまいましたが、classesプロパティへ渡されるものも変化しています。
といっても、大きく処理が変わったというわけでもなく、コンポーネントスキャンの対象を狭めているだけです。
実際のコードを見て頂くとピンとくるかと思います。
@EntityScan("app.db.entity")
@SpringBootApplication(scanBasePackages = "app.db.dao")
public class DaoTestApplication {
public static void main(String[] args) {
SpringApplication.run(DaoTestApplication.class, args);
}
}
読み込む対象を「Dao層」、「Daoが扱うEntity」に限定しています。
アプリの規模が大きくなってくると、パッケージを読み込む時間も長くなり、地味に時間が掛かるようになってしまいます。データベース操作関連の処理は頻繁に変更が加えられ、かつテストコードもばりばり走らせたいものなので、少しでも時間が短くなるよう、読み込み範囲を最小限にしています。
このぐらい工夫をしてあげると、大体ストレッチがてら首を一回まわすぐらいでテストが完了してくれます。
更に速度を追求する場合は、Configクラスをごりごり書いてEntityManager周りをあれこれする必要がありますが、それほど致命的にテストが遅いというわけでもないので、そこまでは踏み込まないでおきます。
上記の設定はサクッとできますし、それなりに効果も得られるので、基本段階では、このぐらいで十分かと思います。
メソッドのアノテーション
またまたアノテーションの話になってしまいますが、クラスレベルのアノテーションよりはシンプルなので、すっと頭に入ってくれるかと思います。
DatabaseSetup
これは、データベースの「初期状態」を定義するためのアノテーションです。
valueプロパティにCSVファイルが配置されているディレクトリを指定してあげると、CSVファイルをもとにデータベースのテーブルに値を詰めこんでくれます。
また、ディレクトリを切り替えることで、異なる状態を作成することもできます。
これにより、手動でテーブルへINSERTしなくても、テストを開始したいときのテーブルの状態をいつでも再現できるようになります。ありがてえ。
Transactional
実際のアプリ開発でもよく使われるお馴染みのものです。通常、このアノテーションが付与されたメソッドは、正常に動作すればコミットし、想定しない動作をすればロールバックする、というように動作します。
ですが、テストコードの場合、テストの度にデータベースを書き換えてしまうと、テストの再現性が失われてしまうので、デフォルトではメソッドの実行のたびにロールバックします。
上記二つのアノテーションを組み合わせることによって、手動でデータベースをバックアップして、ファイルからレコードを詰め込んで、最後に戻して...といったことを自動でやってくれるようになります。
実際にテストを実行すると、Daoのメソッドでユーザエンティティのリストが取得でき、想定通りの結果(リストのサイズ=レコード数)が得られていることが検証できます。
SELECT処理を検証することで、データベース操作のテストコードの基本はある程度網羅できたので、他の処理についても一気に見ていきたいと思います。
補足: EntityManagerはどこで設定するべきか
テストそのものの話とは少しずれますが、個人的な経験として、EntityManagerがnullになってしまい、テストが動作しない、ということが度々ありました。 DaoクラスのフィールドにEntityManagerを宣言しておいて、`PersistenceContext`アノテーションをつけておけばとりあえず動くだろう、程度の認識だったので、中々解決できず、それなりにハマりました。実際の動きとしては、`LocalContainerEntityManagerFactoryBean`というBeanがEntityManagerFactoryを初期化し、DIによってEntityManagerを生成してくれる、というようになっています。この際、DIコンテナが必要となりますが、SpringではApplicationContextがその役割を担ってくれているようです。
上記のように動作するため、テストクラスでEntityManagerをAutowiredやPersistenceContextアノテーションで取得し、テスト対象クラスへ渡すことでも、一応動くことには動きます。 しかし、それでは実際のアプリ上での動作とテストコードでの動作が異なってしまうので、個人的には以下の形で利用するのがよいかと思います。
・ Daoの基底クラスを作成し、基底クラスのフィールドにEntityManagerを設定。(このとき、PersistenceContextアノテーションを付与) ・ 基底クラスではEntityManagerのgetterのみを公開し、派生クラスではgetterのみを意識させるように制限 ・ EntityManagerを利用した基本的なメソッド(merge, persistなど)は基底クラス側で定義しておく EntityManagerのあれこれは情報も少なく、下手にごりごり書こうとするとハマってしまったので、最初のうちはSpringさん側に任せてしまう形にするのがよいかと思います。テストコードで詰まって実装が全然進まなかったりすると精神衛生上よろしくなかったりするので、備忘録がてら書いておきます。[参考](https://builder.japan.zdnet.com/sp_oracle/35067018/)
※Repositoryを利用する場合はEntityManagerは完全に隠蔽されているので、意識する必要はおそらくほとんど無いかと思います。
[Daoの基底クラス](https://github.com/a-pompom/SpringBoot-MVC-Test/blob/master/dbMVCTest/src/main/java/app/db/dao/BaseDao.java) [Daoの派生クラス](https://github.com/a-pompom/SpringBoot-MVC-Test/blob/master/dbMVCTest/src/main/java/app/db/dao/UserDao.java)
データベースのCRUD処理をテストする
CRUD処理のうち、SELECTについては検証できたので、残りの更新・作成処理についても見ていきます。大枠はこれまで得た知識で理解できるかと思いますので、以下へ実際のテストコードを記載します。
// テストメソッド実行後の状態をデータベースに反映させるための処理
// 通常、更新系の処理はトランザクションがコミットされるタイミングでデータベースと同期化されるが、
// テスト処理ではコミットしないため、明示的に同期化を行う
@AfterEach
void tearDown() {
userDao.getEm().flush();
}
/**
* create処理で新規レコードが作成されるか検証する
* エンティティによってDBが想定通りに書き換えられたかExpectedDatabaseと比較することで検証
*/
@Test
@DatabaseSetup(value = "/CRUD/setUp/forCreate")
@ExpectedDatabase(value = "/CRUD/create/", assertionMode=DatabaseAssertionMode.NON_STRICT)
void createメソッドでユーザが新しく生成される() {
User user = new User();
user.setUserName("test3");
userDao.saveOrUpdate(user);
}
/**
* update処理で既存レコードがupdateされるか検証する
* エンティティによってDBが想定通りに書き換えられたかExpectedDatabaseと比較することで検証
*/
@Test
@DatabaseSetup(value = "/CRUD/setUp/")
@ExpectedDatabase(value = "/CRUD/update/", assertionMode=DatabaseAssertionMode.NON_STRICT)
void updateメソッドでユーザ1を書き換えられる() {
User user = new User();
user.setUserId(1L);
user.setUserName("test1mod");
userDao.saveOrUpdate(user);
}
/**
* delete処理でレコードが削除されるか検証する
* 処理前後のDBを用意し、削除後に想定結果となるか比較することで妥当性を検証
*/
@Test
@DatabaseSetup(value = "/CRUD/setUp/")
@ExpectedDatabase(value = "/CRUD/delete/", assertionMode=DatabaseAssertionMode.NON_STRICT)
void deleteメソッドでユーザ1を削除できる() {
userDao.delete(1);
}
いくつか新しいものが出てきたので、それぞれについての概要を簡単に触れていきます。また、データベース操作のCURD処理のテストコードについて、いくつか注意点があるので、その辺りも見ていきましょう。
AfterEach
これは、JUnit5用のアノテーションで、各テストメソッドの実行後に差し込みたい処理を記述します。
ここでは、EntityManagerのflushメソッドを明示的に呼び出しています。
flushメソッドは、永続性コンテキストにあるエンティティをデータベースのレコードと同期させるための処理を行なっています。
通常、このメソッドは意識せずとも、トランザクションがコミットされるタイミングで自動的に呼ばれるようになっています。 参考
しかし、今回のテストコードでは、データベース操作が終わったらデータベースを元に戻すため、RollBack
する必要があります。すると、flushメソッドが呼ばれなくなってしまうため、テストメソッドの期待結果がデータベースに反映されず、テストが通らない、という不具合が起こってしまいます。
いくつか対処する方法はありますが、アプリの実行と同じように、各メソッドでトランザクションがコミットされる、すなわち、処理が完了したタイミングでflushメソッドを明示的に呼び出すのがよいかと思われます。
以上より、各テストメソッドの実行後にflushメソッドを呼び出すことで、期待結果の検証を正しく行うことができるようになります。
ExpectedDatabase
DatabaseSetupアノテーションと同時に用いられ、名前の通り、テストメソッドの実行後のデータベースの状態を検証するためのものです。
DatabaseSetupアノテーションと同様、value値に期待結果のテーブル状態を記述したCSVファイルが格納されているディレクトリを指定してあげます。
更に、「assertionMode」というプロパティが設定されていますが、ここに「NON_STRICT」を設定することで、全てのカラムではなく、CSVファイルで指定したカラムのみを検証してくれるようになります。
Transactionalアノテーション
このテストクラスでは、Transactionalアノテーション
をクラスレベルで付与しています。
クラスレベルでこのアノテーションを設定した場合、クラス内の全てのメソッドに対してアノテーションを付与したことと同様となります。
Controllerのテストではトランザクション制御が必要ないものもあったりしますが、各メソッドに毎回設定しようとすると抜け漏れが出てしまうので、クラスレベルで一括で設定しておくのがよいでしょう。
これでCRUD処理に必要な処理はある程度理解できたかと思います。
レベル2の最後に、(私がハマりまくったので)データベース操作をテストする際の注意点をいくつか記載しておきます。何かの参考になれば幸いです。
update/delete処理の記述方法について
上記の処理は、既存のレコードを書き換える処理です。
複数レコードに対して処理することもあることは思いますが、Webアプリケーションでは多くの場合、一つのレコードを対象とするかと思います。
このとき、処理対象を明確にするため、キー情報が必要となります。
やり方は色々あるかと思いますが、「CSVファイルのレコードへIDも明示しておく」のがシンプルに書きやすいかと思います。
ここでの注意点としては、テストを実現するためのロジックの考え方についてです。少し長くなりそうということから、補足に記載しましたので、興味があったら見てみてください。
補足: テストをいかに実現するか
さて、上記の、「キー情報」を取得する方法について、テストを書きやすくするためにテスト用のユーティリティクラスを用意したり、果てはテストしやすいようアプリ側のコードへメソッドを追加する、というようなやり方もできないことはないですが、あまり推奨はされません。まず、テストを書きやすくするためのユーティリティクラスを作ることについて、テストを書くのに行き詰まった場合、考えるべきは`いかにテストを通すか`ではありません。 `もっとシンプルに書けないか`ということについて思考すべきです。 簡単なCRUD処理レベルからテスト用のメソッド・クラスを作成していると、実務レベルのアプリケーションでは、テストコードがどのようなことになるかは想像に難くないかと思います。
また、しばしば、テストを書きやすいコードはいいコードだ、というように言われることがあります。 これは、アプリ側でテストを通すことを書くことを考えて書くべき、という話ではなく、「個々のモジュール同士が密結合となり、テスト時の依存解決が困難になる」といったことを防ぐべきというものです。こういった実装はテストがしづらいだけでなく、拡張・変更の際にも影響範囲が広がり、予期せぬバグを生み出すことも繋がってしまいます。
テストコードに注力していると実装よりもテストコード寄りになり、逆もまた然りで最初の内は中々気づきにくい部分ではありますが、ひと呼吸置いて、基本に立ち返ってみると、よいかもしれません。 エラそうに書きましたが、私自身テストコードを書いていてハマった部分なので、自戒も兼ねて書いておきます。
create処理のID値について
テストメソッドで新規レコードをデータベースに登録する場合について考えてみます。
例えば、IDが自動採番されるものであった場合、セットアップのレコードにIDが割り振られていると、キー重複を起こしてしまうことがあります。
※というか大体重複します):
それなら、結果セットのレコードも採番されるIDを設定しておけば...ともなりましたが、自動採番される値はへたにコントロールしない方が無難だったりします。
解決策としては、新規レコードの生成を検証したい場合、IDを除いたCSVファイルを利用し、IDの関与しない状態で中身のカラムのみを検証する、という方針で進めるとよいかと思います。
レベル2も新しいことがたくさん出てきましたね。
ですが、ある程度慣れてしまえばサクサク書くこができますし、何よりも手動テストのようにサーバーを起動して、ページへアクセスして、実際に処理してDBを見に行って...とすることなく検証ができるので、非常に恩恵の大きい部分だと思います。
ですので、レベル2までの範囲をマスターしておくだけでも、開発時の不具合修正の効率がグッと上がる...はずです。
レベル2をこなすことによって、以下のことが可能となるはずです。
- CSVファイルによってテストコードの実行前後のデータベースの状態を定義できる
- データベースのCRUD処理を検証することができる
- データベースに関するテストコードで必要なアノテーションの概要が理解できる
さて、Spring BootはWebアプリケーションを作成するためのフレームワークなので、実際にデータベースを操作する際には、POSTリクエストを利用するのが一般的です。
そのため、レベル3ではPOSTリクエストの検証について見ていきたいと思います。
レベルこそ上がりますが、これまでの知識で十分に理解できるものなので、最後までついてきて頂けるとうれしいです(╹◡╹)
※RESTful APIに関しては今回は対象外とします。
Level3. POSTリクエストをテストする
続いて、POSTリクエストを検証する際のテストコードについて見ていきます。
検証対象コード全体を載せると長くなってしまうので、ここではアプリの概要のみを記述し、テストコードへ注力したいと思います。
レベル3では、題材として、TODOリストを利用します。
以下のような、よくある簡単なCRUD処理ができるシンプルなものです。
POSTリクエストに関する新しい知識も多少は出てきますが、これまでの知識があれば理解できるものとなっているので、本記事の総復習がてら、テストコードを見ていただけたらと思います。
以下へ実際のテストコードを記載します。
多少長めとなっていますが、大半は理解できる...とうれしいです。
@DbUnitConfiguration(dataSetLoader = CsvDataSetLoader.class)
@TestExecutionListeners({
DependencyInjectionTestExecutionListener.class,
TransactionalTestExecutionListener.class,
DbUnitTestExecutionListener.class
})
@AutoConfigureMockMvc
@SpringBootTest(classes = {DbMvcTestApplication.class})
@Transactional
public class TodoControllerTest {
//mockMvc TomcatサーバへデプロイすることなくHttpリクエスト・レスポンスを扱うためのMockオブジェクト
@Autowired
private MockMvc mockMvc;
@Autowired
private TodoDao todoDao;
@AfterEach
void tearDown() {
todoDao.getEm().flush();
}
/**
* viewが正しく返されるか検証
* @throws Exception
*/
@Test
void init処理でviewとしてtodoが渡される() throws Exception {
this.mockMvc.perform(get("/todo/init"))
.andExpect(status().isOk())
.andExpect(view().name("todo"));
}
/**
* モデルへDBから取得したレコードが設定されたか検証する
* 今回は複雑な処理でもないので、DBの中の1レコードがモデルに渡されていれば正常に動作しているとみなした
*
* @throws Exception
*/
@Test
@DatabaseSetup(value = "/TODO/setUp/")
void init処理で既存のタスクがモデルへ渡される() throws Exception {
// mockMvcで「/todo/init」へgetリクエストを送信
this.mockMvc.perform(get("/todo/init"))
// モデルへDBのレコードがリストとして渡される
.andExpect(model().attribute("todoForm", hasProperty(
"todoList", hasItem(
hasProperty(
"task", is("task1")
)
)
)));
}
/**
* 画面の入力から新規レコードがDBへ登録されるか検証
* @throws Exception
*/
@Test
@DatabaseSetup(value = "/TODO/setUp/create")
@ExpectedDatabase(value = "/TODO/create/", assertionMode=DatabaseAssertionMode.NON_STRICT)
void save処理で新規タスクがDBへ登録される() throws Exception {
this.mockMvc.perform(post("/todo/save")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.param("newTask", "newTask"));
}
/**
* 画面の入力で既存レコードが更新されるか検証
* 今回は画面情報を利用しないので、対象の自動採番されるIDを取得することができない。
* そのため、今回はアップデート対象を手動で指定する。
* 基本的には、リストの順番は保証されないので、SELECT時にソートしておく等の処理は必要になると思われる
* @throws Exception
*/
@Test
@DatabaseSetup(value = "/TODO/setUp/")
@ExpectedDatabase(value = "/TODO/update/", assertionMode=DatabaseAssertionMode.NON_STRICT)
void update処理で既存タスクが更新される() throws Exception{
// mockMvcで「todo/update」へpostリクエストを送信
long updateTargetId = 3L;
int updateTargetIndex = 2;
this.mockMvc.perform(post("/todo/update/" + updateTargetIndex + "/" + updateTargetId)
.param("todoList[" + updateTargetIndex + "].task", "task3mod")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
);
}
/**
* 画面で選択したタスクが削除されるかどうか検証する
* @throws Exception
*/
@Test
@DatabaseSetup(value = "/TODO/setUp/")
@ExpectedDatabase(value = "/TODO/delete/", assertionMode=DatabaseAssertionMode.NON_STRICT)
void delete処理で既存タスクが消去される() throws Exception {
long deleteTargetId = 3L;
this.mockMvc.perform(post("/todo/delete/" + deleteTargetId)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
);
}
}
以下では、新しく登場したPOSTリクエスト関連のメソッドや、POSTリクエストを扱う際の注意点について記述していきます。やっていること自体はパラメータを設定してリクエストを投げているだけなので、こわくないと思います。
POSTのパラメータ
POSTリクエストのテストコードで特筆すべき点としては、リクエストに渡されるパラメータの設定方法があります。
といっても、POSTリクエストがある程度理解できていれば、直感的に設定することができます。
POSTリクエストについては、われらがMDNさんに分かりやすく書かれているのでおすすめです。
さて、MockMvc
を利用したテストコードでは、レベル1でperformメソッドでGETリクエストを行なっていた部分を、POSTリクエストに変えてあげます。
その後、paramメソッドでkey-value形式でパラメータを渡すことができるので、実際のリクエストで渡される形に合わせてリクエストを作ってあげるだけでOKです。
GETリクエストでもparamメソッドを呼び出すことはできますが、その場合は、クエリパラメータとして送信されます。ここでは、フォームをもとにPOSTリクエストを送信するので、パラメータはリクエストボディに格納されます。
また、contentTypeは指定しなくても動くには動きますが、実際のPOSTリクエストになるべく近づけておくためにも、設定しておくのがよいかと思います。
POSTリクエストをテストする場合の注意点
今回は、POSTリクエストによって、データベースが正しく更新されるか、といったことを中心に検証しています。
このとき、問題になるのは、POSTリクエストによって処理される対象です。
各CRUD処理についてざっくりと見ておきたいと思います。
- 新規作成
レコードを新規で作成する場合、新規レコード用のパラメータはテーブルとは独立しているので、特に気にすることはないかと思います。
- 読み出し
今回はViewにview名が渡されるまでが検証範囲となっているので、Modelへ渡されるオブジェクトの検証さえできればOKです。よって、ここも特に気にするようなことはない...はずです。
- 削除
削除対象のIDはリクエストのパスによって判定されるので、MockMvcでリクエストを作成する際にも、決め打ちで削除対象を明示しておく必要があります。
こういった決め打ちは「実装」では避けるべきですが、テストコードではあまり気にする必要はないかと思います。
そもそもテストコードではデータベースの状態を「決め打ち」としているので、変更・拡張を想定するよりは、一定の入力から一定の出力が常に得られるか
という部分に注力するべきでしょう。
何度実行しても常に同じ結果が得られるのがテストコードの強みなので、実装とは書き方についても分けて考えるべきかなと個人的には思っています。
- 更新
更新処理についても同様のことが言えます。
ただし、更新する際、エンティティのリストのうち、一レコードを対象としたい場合、編集対象をリストから切り離して個別の更新用エンティティに格納しておく等、多少の工夫が必要になります。
今回の更新処理では、リストのインデックス・エンティティのIDを決め打ちとしていますが、リストのオーダーは基本的には保証されてはいないので、業務レベルのアプリケーションでは上記のような形で、「常に同じ入力から同じ結果が得られる状態」を作っておく必要があります。
その辺の話は、画面系のテストコードにも慣れてきたら書いてみたいなーと思います(願望)
レベル3と銘打ってはいましたが、大半の部分は今までの総復習の形となっていたので、分かる...分かるぞ...!!となって頂けていたらとてもうれしいです(:
レベル3のテストコードを理解することで、以下のことが可能となります。
- POSTリクエストについても、データベースが正常に更新されたか検証することができる
- MVCのModel、Controllerのレイヤーについて、簡単なテストコードを書くことができる
まとめ
思った以上に長くなってしまいましたが、これで簡単なCRUD処理のテストコードの書き方について、見ていくことができました。
普段実装部分を書く上では意識しないようなところまで意識する必要があり、中々ハードな部分もあったかと思います。ですが、テストコードを書くことによって、フレームワーク・言語への理解が深まり、開発も効率化することができ、たくさんの恩恵が得られます。
そして、何より、テストが通ったときの喜びは実装だけでは味わうことのできないもので、最初の壁を乗り越えれば、テストコードを書くことはとても楽しくなります。
本記事を通して、少しでもSpring Bootでテストコード書けそうかも...?と思っていただけましたら幸いです。
まだまだ私自身テストコードに関しては未熟なので、テストコードに関する解説なんかがもう少し増えてくれたらいいなーと思います。