演習1〜4 振り返り
Day 1 で作ったものを整理します。今日の演習はすべてこの上に積み上がります。
dto/TaskDto.java
record型で5つのフィールドを宣言toEntity()でDTOをEntityに変換
repository/TaskRepository.java
findByDueDateBefore(LocalDate)を追加findByStatus(TaskStatus)を追加
service/TaskService.java
findAll/findById/createupdate/deleteを実装- 存在しないIDは
TaskNotFoundExceptionを投げる
_review/UserSearchExample.java
- SQLインジェクション脆弱性の発見と修正
- try-with-resources でリソース管理
- 例外を握りつぶさない
TaskService が「正しく動くか」を自動で確認するテストコードを書きます。演習3が完成していることが前提です。
単体テストとは何か
受入れ基準との関係
みなさんは「受入れ基準(Acceptance Criteria)」は知っています。たとえば:
- 存在するIDを指定したとき、そのタスクが返ること
- 存在しないIDを指定したとき、エラーになること
- statusを指定しないで作成したとき、TODOになること
単体テストは、この受入れ基準をコードに翻訳して、自動で確認する仕組みです。
(今まで)
(演習5)
(./mvnw test)
なぜテストを書くのか
| 手動確認 | テストコード |
|---|---|
| コードを変えるたびに自分で画面を開いて確認する | ./mvnw test を1回実行すると全パターンを自動確認 |
| 確認忘れがある。再現手順を覚えていないと確認できない | コードに残っているので何度でも同じ条件で確認できる |
| 「動いている」は確認できるが「壊れていない」の確認は難しい | 変更後にテストが全件PASSすれば「壊れていない」が保証される |
単体テストの「単体」とは
「DB・画面・外部APIから切り離して、1クラス(1メソッド)だけを集中的に確認する」という意味です。今回は TaskService のロジックだけを確認します。DBは起動しません。
使う3つのライブラリ
追加インストールは不要です。spring-boot-starter-test に3つすべて含まれています。
| ライブラリ | 役割 | 使う主な書き方 |
|---|---|---|
| JUnit 5 | テストを定義・実行する土台。@Testを付けたメソッドがテストとして認識される。 |
@Test |
| Mockito | DBなど「外のもの」の偽物(モック)を作る道具。本物のDBなしにロジックだけテストできる。 | @Mock @InjectMockswhen().thenReturn() |
| AssertJ | 結果が期待通りかを確認する道具。assertThat(実際値).isEqualTo(期待値) のように書く。 |
assertThat(...).isEqualTo(...) |
テストクラスの骨格
@ExtendWith(MockitoExtension.class) // Mockitoを有効化する class TaskServiceTest { @Mock private TaskRepository repository; // 偽物のRepository @InjectMocks private TaskService service; // テスト対象。偽物のRepositoryが自動で差し込まれる @Test void findById_存在するIDの場合タスクを返す() { // ここにテストを書く } }
@ExtendWith(MockitoExtension.class)— このクラスでMockitoを使う、という宣言@Mock— 「本物に見えるが空っぽの偽物」を作る@InjectMocks— テスト対象クラスに偽物を差し込む@Test— このメソッドはテストだ、と JUnit に伝える
モックと when().thenReturn() の仕組み
テストで一番「なんのこと?」となりやすいのが when().thenReturn() です。丁寧に説明します。
なぜ「偽物のRepository」が必要か
本物の TaskRepository は、動いているDBがないと動きません。テストのためだけにDBを起動・セットアップするのは大変です。そこで「呼ばれたら決まった値を返す台本どおりの偽物」を使います。
// 「repository.findById(1L) が呼ばれたら Optional.of(task) を返す」という台本 when(repository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(task));
when() — このメソッドが呼ばれたとき
thenReturn() — この値を返す
given / when / then の3段構造
テストは必ずこの3段で書きます。受入れ基準と対応しています。
@Test void findById_存在するIDの場合タスクを返す() { // ① Given(準備): 偽物のRepositoryに台本を与える Task task = new Task(); task.setId(1L); task.setTitle("テストタスク"); when(repository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(task)); // ② When(実行): テスト対象のメソッドを呼ぶ Task result = service.findById(1L); // ③ Then(確認): 返ってきた値が期待通りか確認する assertThat(result.getId()).isEqualTo(1L); assertThat(result.getTitle()).isEqualTo("テストタスク"); }
| 段階 | コード | 受入れ基準との対応 |
|---|---|---|
| Given 準備 |
when().thenReturn() |
「id=1のタスクが存在する状態」を作る |
| When 実行 |
service.findById(1L) |
「id=1を指定して取得する」操作 |
| Then 確認 |
assertThat(...).isEqualTo(...) |
「タスクが返ること」を確認 |
異常系テスト(例外が投げられることを確認)
@Test void findById_存在しないIDの場合TaskNotFoundExceptionを投げる() { // Given: id=999 は存在しない(空を返す台本) when(repository.findByIdAndDeletedAtIsNull(999L)) .thenReturn(Optional.empty()); // When + Then: 呼び出すと TaskNotFoundException が投げられることを確認 assertThatThrownBy(() -> service.findById(999L)) .isInstanceOf(TaskNotFoundException.class); }
assertThat(期待値).isEqualTo(実際値)と左右を逆に書くと、失敗メッセージが読みにくくなります。左が実際値、右が期待値。- Copilotが生成したimportが
org.junit.jupiter.api.Assertions.assertThatになっていることがあります。AssertJのorg.assertj.core.api.Assertions.assertThatに直してください。
演習5 手順解説
TaskServiceTest.java に4つ以上のテストメソッドを追加し、./mvnw test で全件PASSさせます。Copilotの /tests コマンドでひな形を生成し、境界値テストを Chat で追加します。
テストファイルを開く
VSCode でプロジェクトを開き、左のエクスプローラーから以下のファイルを開きます。
src/test/java/jp/idhd/taskapp/service/TaskServiceTest.java
今の中身はプレースホルダー(placeholder_テスト未生成)が1つあるだけです。これは後で削除します。
/tests でひな形を生成する
まず TaskService.java を開き、クラス宣言の行(public class TaskService の行)にカーソルを置きます。
次に Copilot Chat(サイドパネル)を開き、以下を入力して送信します。
/tests TaskService の単体テストを JUnit 5 と Mockito で書いてください。AssertJ の assertThat を使って、findById・create の正常系と異常系をテストしてください。説明は日本語でお願いします
Copilot が生成したテストコードを TaskServiceTest.java に貼り付けます。
class TaskServiceTest { ... } のようにクラスごと出してきた場合は、クラスが二重になってコンパイルエラーになります。そのときはファイルを Ctrl+A で全選択してから丸ごと貼り付けるか、メソッド部分だけを既存クラスの中に貼り付けます。
既存の placeholder_テスト未生成 メソッドは削除してください。
テストを実行して確認する
VSCode の統合ターミナルで実行します。
./mvnw test
実行結果の読み方:
| 表示 | 意味 |
|---|---|
BUILD SUCCESS | 全件PASSした。正常終了。 |
BUILD FAILURE | 1件以上失敗した。FAILEDと表示されたメソッド名を確認する。 |
Tests run: 4, Failures: 1 | 4件実行して1件失敗。expected: と but was: を見て原因を探る。 |
境界値テストを Chat で追加する
「端っこの条件」のテストを1つ追加します。Copilot Chat に次のように入力します。
TaskServiceTest に「status を指定せずに create したタスクは TODO になること」を確認するテストを追加してください。status が null のタスクを渡し、返ってきたタスクの status が TODO であることを確認します
生成されたテストメソッドを TaskServiceTest.java の末尾(最後の } の直前)に追加します。
再度テストを実行して全件PASSを確認する
./mvnw test
BUILD SUCCESS が出れば完了です。Tests run の数字が4以上になっていれば、追加分も含めて実行されています。
テストの中身を Copilot Chat に解説させる
生成されたテストコードにある when().thenReturn() の行を選択して、Copilot Chat に次のように入力します。
このテストの when().thenReturn() の意味を、テストを書いたことがない人向けに日本語で説明してください
Copilot の説明を読んで、このページで学んだ内容と照らし合わせてみてください。自分の言葉で説明できれば理解できている証拠です。
生成されるテストの全体像(参考)
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class TaskServiceTest { @Mock private TaskRepository repository; @InjectMocks private TaskService service; @Test void findById_存在するIDの場合タスクを返す() { // Given Task task = new Task(); task.setId(1L); when(repository.findByIdAndDeletedAtIsNull(1L)) .thenReturn(Optional.of(task)); // When Task result = service.findById(1L); // Then assertThat(result.getId()).isEqualTo(1L); } @Test void findById_存在しないIDの場合TaskNotFoundExceptionを投げる() { when(repository.findByIdAndDeletedAtIsNull(999L)) .thenReturn(Optional.empty()); assertThatThrownBy(() -> service.findById(999L)) .isInstanceOf(TaskNotFoundException.class); } @Test void create_statusを指定した場合はそのまま保存される() { Task task = new Task(); task.setStatus(TaskStatus.DOING); when(repository.save(task)).thenReturn(task); Task result = service.create(task); assertThat(result.getStatus()).isEqualTo(TaskStatus.DOING); } @Test void create_statusがnullの場合TODOに補正される() { // 境界値テスト Task task = new Task(); task.setStatus(null); when(repository.save(task)).thenReturn(task); Task result = service.create(task); assertThat(result.getStatus()).isEqualTo(TaskStatus.TODO); } }
完了チェック
TaskServiceTestに4つ以上の@Testメソッドがある./mvnw testで全件 PASS する(BUILD SUCCESS)- 境界値テスト(status を指定せずに create すると TODO になる)が1つ以上ある
うまくいかないとき
| 症状 | 確認すること |
|---|---|
/tests を打っても反応しない |
TaskService.java のクラス宣言行にカーソルを置く。それでも出なければ手順2の例文を文章で送る |
mockitoExtension が赤波線 |
import 文に org.mockito.junit.jupiter.MockitoExtension が必要。Copilot Chat に「import を追加してください」と依頼する |
assertThat が動かない・候補が少ない |
import が org.assertj.core.api.Assertions.assertThat になっているか確認する。org.junit 側のものだと使えるメソッドが少ない |
| テストは実行されるが FAIL する | エラーメッセージの expected: と but was: を読む。左右を逆に書いていないか確認する。Copilot Chat にエラーごと貼って原因を聞く |