単体テストの始め方/作り方
タイトルは『単体テストの単体テストの考え方/使い方』のパクりオマージュです[1].
AIによって生成した音声概要. 不正確な情報や音声の乱れが含まれる場合があります.
今日, 業務で扱うコードでは品質向上や変更容易性といった観点から開発者自身が行う自動テストが重要な役割を担っています. しかしプログラミングの学習ではテストを中心として学ぶ機会はそれほど多くないでしょう. そのため単体テストを書いてみようと思っても, そもそも書き方がわからないといった悩みを抱える人も多いのではないでしょうか. プログラミングには「適切な変数名をつける」とか「早期リターンを使う」といったプラクティスがあります. これと同様に単体テストにも適切に書くためのコツがあります. ここでは単体テストを書くときにつまずきやすいポイントを取り上げ, その解決策やより良い単体テストの書き方について解説します. またこの記事ではたくさんの参考文献を取りあげます. この記事を読んだだけではわからなかったことやもっと深く学びたいことがあれば, ぜひ参考文献を手にとってみてください.
環境
この記事ではJavaを使って単体テストの書き方を解説します. JavaのテストフレームワークとしてはJUnitを使います. 参考コードは以下のリポジトリにあります.
- Java 21
- JUnit 5
- Mockito 5
参考コードの題材としてECサイトの注文処理を扱います. Webアプリケーションサーバーとして単一の商品コードを受け取る簡単なアプリケーションです. 詳しい仕様については以下のドキュメントを参照してください.
またこの記事では直接関係しませんが, 参考コードでは Spring Boot を使ってアプリケーションを構築しています.
- Spring Boot 3.5
テスト書いてと言われたけどさっぱりわからない
業務ではプロダクトコード[2]を書くだけではなくテストコードを書くことも求められます.
まずはテストから書いてみましょう. え? だからテストの書き方がわからないって? 大丈夫です. まずは型からはじめましょう. テストからはじめるための型はテスト駆動開発と呼ばれています[3].
『テスト駆動開発』においてテスト駆動開発は以下のように簡潔に定義されています.
- コードを書く前に、失敗する自動テストコードを必ず書く。
- 重複を除去する。
ここでもこの定義に従ってプロダクトコードを書く前にテストを書いてみましょう. たいしたことではありません. まずは日本語(自然言語)で書くだけです. TODOリストのようなテストシナリオを一行書くだけです. 今回の例では注文処理に予約注文機能を追加することにします. これを日本語で書いてみます.
予約商品を注文すると, 成功する
これを詳しく掘り下げてテストコードを書いていきます. そのためにはAAAパターンを使います. AAAパターンはテストコードを以下の3つの部分に分けて書く方法です[4].
- Arrange(準備): テストケースの事前条件を満たすようにテスト対象システムとその依存の状態を設定する.
- Act(実行): テスト対象システムのメソッドを呼び出すことで, テスト対象の振る舞いを実行させる.
- Assert(確認): 実行結果が想定した結果であることを確認する
先ほど日本語で書いたテストシナリオをAAAパターンに従って書き直してみましょう. まずは最もわかりやすいActから書いてみます.
Act: 注文する
シンプルですね. これで十分です.
次にAssertを書いてみましょう. テストシナリオでは「成功する」と書いていました. これはいったいどういう意味でしょうか. もう少し詳しく考える必要がありそうです. 既存の注文処理では注文が成功したとき, 注文結果をクライアントに返し, データベースに注文情報を保存していることがわかりました. このことからAssertは以下のように書けそうです.
Assert: 注文結果の商品コードが注文した商品コードと一致するか
Assert: 注文結果の数量が注文した数量と一致するか
Assert: 注文結果に金額が含まれているか
Assert: 注文情報がデータベースに保存されているか
最後にArrangeを書いていきます. Arrangeではこれまで書いたActとAssertに従ってそれを満たすように書く必要があります. テストを実行するにはどんな設定が必要か, 検証内容を満たすような条件は何かを考えます. 暗黙のうちに仮定されているものがないか注意しましょう. 今回の例では商品が予約商品であることと, その商品が存在していることが条件となります.
Arrange: 商品が予約商品のとき
Arrange: 商品が存在しているとき
これでAAAパターンに従って詳しいテストシナリオが書けました. まずは先ほど書いたテストシナリオをまとめておきます.
// 存在する予約商品の注文コードで注文したとき, 成功する
@Test
public void testProcessOrderWhenReservationOrderSucceeded() {
// Arrange: 商品が予約商品のとき
// Arrange: 商品が存在しているとき
// Act: 注文する
// Assert: 注文結果の商品コードが注文した商品コードと一致するか
// Assert: 注文結果の数量が注文した数量と一致するか
// Assert: 注文結果に金額が含まれているか
// Assert: 注文情報がデータベースに保存されているか
}
あとはこれに対応するようにテストコードを埋めていくだけです.
このテストはどうやって書けばいいの?
さて, ここまででテストシナリオが書けました. あとは実際にテストコードに書き換えるだけでよさそうです. とはいえテストシナリオと既存のプロダクトコードや具体的なテストコードとがどのように対応していくのかがわからないといった悩みもあるでしょう. ここでは具体的なテストコードに落とし込むときに必要な知識について解説します.
テストコードを書くときの一番の問題はArrangeやAssertの書き方がわからないということです. これにはテスト対象システムの入力と出力という振る舞いについての理解が必要です. テスト対象システムには振る舞いを示す4つの入力と出力があります.
- 入力: 関数の引数
- 出力: 関数の戻り値
- 隠れた入力
- 隠れた出力
隠れた入力・隠れた出力
隠れた入力とは関数の引数ではないが, 関数の振る舞いに影響を与える入力のことです. 例えば時刻のような依存関係, カウンタなどの内部状態, HTTPリクエストやデータベースの読み出しのような外部プロセスなどです. 関数の中で参照されるが, 引数として陽に渡されないものが隠れた入力です.
隠れた出力とは関数の戻り値ではないが, 関数の振る舞いに影響を与える出力のことです. 例えばカウンタのように関数実行のたびに変化する内部状態や, ログ出力, データベースへの書き込みなどです. 関数の中で行われるが, 戻り値として陽に返されないものが隠れた出力です.
隠れた入力・隠れた出力のあるテスト対象システムに対してどのようにテストコードを書けばよいのでしょうか. これには二つの方法があります.
- 公開する
- テストダブルを使う
公開するというのは, テスト対象システムの隠れた入力・隠れた出力を関数の引数や戻り値として公開することです. 例えばデータベースから読み出した値をJSONに変換してHTTPレスポンスとして返すような関数であれば, データベースから読み出した値のオブジェクトを引数として受け取り, JSON文字列を返すような関数とすることで隠れた入力・隠れた出力が公開されます.
テストダブル
テストダブルとはこれらの隠れた入力・隠れた出力の振る舞いを模倣するオブジェクトのことです. 一般的には依存関係注入 (DI: Dependency Injection)を 使ってテストダブルを注入します[5].
- モック (Mock): テスト対象の依存関係である関数が呼び出されたときに, その呼び出しを検証するためのオブジェクト.
- フェイク (Fake): 本物の実装同様に振る舞う軽量な実装. インメモリーデータベースなど.
- スタブ (Stub): テスト対象の依存関係である関数が呼び出されたときに返す値を直接指定するオブジェクト.
Mockito などのモッキングフレームワークを使う場合, モックとスタブの違いを意識する必要はなく, どちらも同じように使うことができます. ただしテストの振る舞いへの影響は異なるため, モックとスタブの違いを理解しておくことは重要です.
さて, ここまででテストコードを書くために必要な道具は揃いました. 実際にテストコードを書いてみましょう. 先ほど書いたテストシナリオがテストコードときちんと対応しているか確認しましょう. 実際にテストコードを書いてみると足りなかったArrangeが見つかります. 精度の高いテストシナリオが書けるようにきちんとふりかえりましょう.
// 存在する予約商品の注文コードで注文したとき, 成功する
@Test
public void testProcessOrderWhenReservationOrderSucceeded() throws Exception {
String expectedProductCode = "reservation-test";
int expectedQuantity = new Random().nextInt(1, 10);
int stock = 10;
ProductRepository productRepositoryMock = mock(ProductRepository.class);
// Arrange: 商品が予約商品のとき
// Arrange: 注文の商品コードが存在するとき
when(productRepositoryMock.findByCode(anyString()))
.thenReturn(
Optional.of(
new Product(
expectedProductCode,
"Reservation Product",
BigDecimal.valueOf(100),
stock,
true)));
OrderRepository orderRepositoryMock = mock(OrderRepository.class);
when(orderRepositoryMock.save(any(Order.class)))
.thenReturn(
new Order(
"reservation-test-id",
expectedProductCode,
expectedQuantity,
BigDecimal.valueOf(100),
Instant.now(),
OrderType.RESERVATION));
OrderService orderService = new OrderService(productRepositoryMock, orderRepositoryMock);
// Act: 注文する
Order order =
orderService.processOrder(new OrderRequest(expectedProductCode, expectedQuantity));
assertThat(order.id()).isNotBlank();
// Assert: 注文の商品コードが一致するか
assertThat(order.productCode()).isEqualTo(expectedProductCode);
// Assert: 注文の数量が一致するか
assertThat(order.quantity()).isEqualTo(expectedQuantity);
// Assert: 注文結果に金額が含まれているか
assertThat(order.amount().longValue()).isGreaterThan(0);
// Assert: 注文情報がデータベースに保存されているか
verify(orderRepositoryMock).save(any(Order.class));
// Assert: 予約商品の場合、商品情報の更新は行わないか
verify(productRepositoryMock, never()).updatedProduct(any(Product.class));
}
テストが読みづらいって言われたけど, どうすればいいの?
テストはDRYよりDAMP
さてこれでテストコードを書くことができました. プロダクトコードと同様にテストコードも読みやすさが重要です. ではテストコードでも共通化や再利用を意識してDRY (Don't Repeat Yourself) に書くべきでしょうか[8].
@Test
public void testProcessOrderWhenReservationOrderSucceeded() {
OrderRequest orderRequest = setupAndOrderReservationProductWithDefaultQuantity();
OrderService orderService = OrderServiceFactoryForTest.createOrderService();
Order order = orderService.order(orderRequest);
verifyOrderIsReservationProduct(order);
}
このDRYなテストコードは読みやすいでしょうか. たしかにテストコードの重複はなくなり, 簡潔になりました. しかし, これではテストコードの意図や振る舞いがわかりません. テストコードはDRYよりもDAMP (Descriptive And Meaningful Phrases) を目指すべきです[9][10].
つまりテストコードでは多少冗長であっても, 明解であることのほうが重要なのです.
String expectedProductCode = "reservation-test";
int expectedQuantity = new Random().nextInt(1, 10);
int stock = 10;
ProductRepository productRepositoryMock = mock(ProductRepository.class);
// Arrange: 商品が予約商品のとき
// Arrange: 注文の商品コードが存在するとき
when(productRepositoryMock.findByCode(anyString()))
.thenReturn(
Optional.of(
new Product(
expectedProductCode,
"Reservation Product",
BigDecimal.valueOf(100),
stock,
true)));
OrderRepository orderRepositoryMock = mock(OrderRepository.class);
when(orderRepositoryMock.save(any(Order.class)))
.thenReturn(
new Order(
"reservation-test-id",
expectedProductCode,
expectedQuantity,
BigDecimal.valueOf(100),
Instant.now(),
OrderType.RESERVATION));
OrderService orderService = new OrderService(productRepositoryMock, orderRepositoryMock);
正しい検証を使う
上に挙げたDRYなテストコードでは検証部分もDRYになっていました. これにはテストコードの読みやすさ以外にも問題があります. それはテスト失敗時のエラーがわからなくなることです. AssertJ や Hamcrest などのアサーションライブラリには豊富な検証メソッドが用意されています[11][12]. 検証したい内容に合致する検証メソッドがないか探してみましょう.
- // Assert: 注文結果の商品コードが注文した商品コードと一致するか
- assertThat(actualOrderIds.get(0)).isEqualTo(expectedOrderId);
+ // Assert: 注文結果の商品コードに注文した商品コードが含まれているか
+ assertThat(actualOrderIds).contains(expectedOrderId);
この記事では初学者が単体テストを書くときにつまづきやすいポイントを抑えながら, 具体的なテストコードの書き方について解説しました. AAAパターンを活用して日本語で書くテストシナリオや隠れた入力・隠れた出力の扱い方は一朝一夕で身につくものではありません. ぜひ実践を通じて単体テストの書き方を身に着けてください.
参考文献
記事では参照しなかった参考文献をふくめ, 単体テストに関する文献を以下に挙げます. 単体テストはプログラマーに広く使われておりプラクティスや方法も多岐にわたります. この記事で取りあげなかった知見やより深い理解を得るために, これらの書籍を参考にしてみてください.
- 単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略, Vladimir Khorikov, 須田 智之, マイナビ出版, 2022 http://book.mynavi.jp/ec/products/detail/id=134252
単体テストに関する知見に関して非常に網羅的に書かれています. とはいえ初学者には難しいため, この記事はこの書籍を読むためのとっかかりとして作成しました.
- Googleのソフトウェアエンジニアリング―持続可能なプログラミングを支える技術、文化、プロセス, Titus Winters, Tom Manshreck, Hyrum Wright, 竹辺 靖昭, 久富木 隆一, オライリー・ジャパン, 2021 https://www.oreilly.co.jp/books/9784873119656/
本書はソフトウェアエンジニアリング全般に関する書籍ですが, 23ある章のうち4つの章を使って開発者テストに関する内容が書かれています. 比較的平易な単体テストの書き方に関する内容からテスト環境の構築といったソフトウェアエンジニアリング全般に関する内容まで幅広く書かれています.
『Googleのソフトウェアエンジニアリング』の原著は無料で公開されています.
また『Googleのソフトウェアエンジニアリング』の一部のプラクティスはGoogle Testing Blogにて公開されています. これはGoogleの Testing on the Toilet という取り組みが元になっています.
- テスト駆動開発, Kent Beck, 和田 卓人, オーム社, 2017 https://www.ohmsha.co.jp/book/9784274217883/
テスト駆動開発の原典です. 本書では具体的なコードと合わせてテスト駆動開発の実践的な手法に関して詳しく平易に書かれています.
-
単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略, Vladimir Khorikov, 須田 智之, マイナビ出版, 2022 http://book.mynavi.jp/ec/products/detail/id=134252 ↩︎
-
実際にユーザーに提供されるコードのこと. ユーザーが直接使うことのないテストコードなどの対比として呼ばれる. ↩︎
-
テスト駆動開発, Kent Beck, 和田 卓人, オーム社, 2017 https://www.ohmsha.co.jp/book/9784274217883/ ↩︎
-
第3章 単体テストの構造的解析, 単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略 より一部改変. ↩︎
-
項目5 資源を直接結び付けるよりも依存関係注入を選ぶ, Effective Java 第3版, Joshua Bloch, 柴田 芳樹, 丸善出版, 2018 https://www.maruzen-publishing.co.jp/item/b303054.html ↩︎
-
第5章 モックの利用とテストの壊れやすさ, 単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略 ↩︎
-
13章 テストダブル, Googleのソフトウェアエンジニアリング―持続可能なプログラミングを支える技術、文化、プロセス, Titus Winters, Tom Manshreck, Hyrum Wright, 竹辺 靖昭, 久富木 隆一, オライリー・ジャパン, 2021 https://www.oreilly.co.jp/books/9784873119656/ ↩︎
-
9 DRY原則─二重化の過ち, 達人プログラマー(第2版)熟達に向けたあなたの旅, David Thomas, Andrew Hunt, 村上 雅章, オーム社, 2020 https://www.ohmsha.co.jp/book/9784274226298/ ↩︎
-
12章 ユニットテスト, Googleのソフトウェアエンジニアリング―持続可能なプログラミングを支える技術、文化、プロセス ↩︎
-
Google Testing Blog: Testing on the Toilet: Tests Too DRY? Make Them DAMP! https://testing.googleblog.com/2019/12/testing-on-toilet-tests-too-dry-make.html ↩︎
-
AssertJ - fluent assertions java library https://assertj.github.io/doc/#assertj-core-common-assertions ↩︎
-
Matchers (Hamcrest 3.0 API) https://hamcrest.org/JavaHamcrest/javadoc/3.0/org/hamcrest/Matchers.html ↩︎
Discussion