演習1〜4 振り返り

Day 1 で作ったものを整理します。今日の演習はすべてこの上に積み上がります。

1
TaskDto — データの「入れ物」を定義する
変更したファイル
  • dto/TaskDto.java
やったこと
  • record 型で5つのフィールドを宣言
  • toEntity() でDTOをEntityに変換
ポイント:Web層とDB層を直接つながせない設計。外から来た値をそのままDBに渡さない。
2
Repository — クエリをメソッド名で表現する
変更したファイル
  • repository/TaskRepository.java
やったこと
  • findByDueDateBefore(LocalDate) を追加
  • findByStatus(TaskStatus) を追加
ポイント:SQLを書かずにメソッド名だけで問い合わせを定義。Spring が実装を自動生成する。
3
Service — ビジネスロジックを実装する
変更したファイル
  • service/TaskService.java
やったこと
  • findAll / findById / create
  • update / delete を実装
  • 存在しないIDは TaskNotFoundException を投げる
ポイント:「statusがnullなら TODOを入れる」のような判断はServiceに書く。Controllerに書かない。
4
コードレビュー — 動くけど危ないを見つける
変更したファイル
  • _review/UserSearchExample.java
やったこと
  • SQLインジェクション脆弱性の発見と修正
  • try-with-resources でリソース管理
  • 例外を握りつぶさない
ポイント:Copilotが生成したコードを鵜呑みにしない。3つの観点(セキュリティ・例外・リソース)で確認する癖。
演習3でできた TaskService がテストの対象
演習5では、この TaskService が「正しく動くか」を自動で確認するテストコードを書きます。演習3が完成していることが前提です。

単体テストとは何か

受入れ基準との関係

みなさんは「受入れ基準(Acceptance Criteria)」は知っています。たとえば:

受入れ基準の例(今まで書いてきたもの)
  • 存在するIDを指定したとき、そのタスクが返ること
  • 存在しないIDを指定したとき、エラーになること
  • statusを指定しないで作成したとき、TODOになること

単体テストは、この受入れ基準をコードに翻訳して、自動で確認する仕組みです。

受入れ基準を書く
(今まで)
テストコードに翻訳する
(演習5)
自動で確認する
(./mvnw test)

なぜテストを書くのか

手動確認テストコード
コードを変えるたびに自分で画面を開いて確認する ./mvnw test を1回実行すると全パターンを自動確認
確認忘れがある。再現手順を覚えていないと確認できない コードに残っているので何度でも同じ条件で確認できる
「動いている」は確認できるが「壊れていない」の確認は難しい 変更後にテストが全件PASSすれば「壊れていない」が保証される

単体テストの「単体」とは

「DB・画面・外部APIから切り離して、1クラス(1メソッド)だけを集中的に確認する」という意味です。今回は TaskService のロジックだけを確認します。DBは起動しません。

今日テストするのはここ
ブラウザ
Controller
TaskService ← ここ
Repository
DB

使う3つのライブラリ

追加インストールは不要です。spring-boot-starter-test に3つすべて含まれています。

ライブラリ役割使う主な書き方
JUnit 5 テストを定義・実行する土台。@Testを付けたメソッドがテストとして認識される。 @Test
Mockito DBなど「外のもの」の偽物(モック)を作る道具。本物のDBなしにロジックだけテストできる。 @Mock @InjectMocks
when().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 で追加します。
1

テストファイルを開く

VSCode でプロジェクトを開き、左のエクスプローラーから以下のファイルを開きます。

src/test/java/jp/idhd/taskapp/service/TaskServiceTest.java

今の中身はプレースホルダー(placeholder_テスト未生成)が1つあるだけです。これは後で削除します。

2

/tests でひな形を生成する

まず TaskService.java を開き、クラス宣言の行(public class TaskService の行)にカーソルを置きます。

次に Copilot Chat(サイドパネル)を開き、以下を入力して送信します。

/tests TaskService の単体テストを JUnit 5 と Mockito で書いてください。AssertJ の assertThat を使って、findById・create の正常系と異常系をテストしてください。説明は日本語でお願いします

Copilot が生成したテストコードを TaskServiceTest.java に貼り付けます。

貼り付けるときの注意
Copilot が class TaskServiceTest { ... } のようにクラスごと出してきた場合は、クラスが二重になってコンパイルエラーになります。そのときはファイルを Ctrl+A で全選択してから丸ごと貼り付けるか、メソッド部分だけを既存クラスの中に貼り付けます。 既存の placeholder_テスト未生成 メソッドは削除してください。
3

テストを実行して確認する

VSCode の統合ターミナルで実行します。

./mvnw test

実行結果の読み方:

表示意味
BUILD SUCCESS全件PASSした。正常終了。
BUILD FAILURE1件以上失敗した。FAILEDと表示されたメソッド名を確認する。
Tests run: 4, Failures: 14件実行して1件失敗。expected:but was: を見て原因を探る。
失敗したときの確認手順
まず「テストの期待値が正しいか」を読み直します。次に「Serviceの実装が正しいか」を確認します。それでも分からなければ Copilot Chat にエラーメッセージごと貼って「このテストが失敗する理由を教えてください」と聞きます。
4

境界値テストを Chat で追加する

「端っこの条件」のテストを1つ追加します。Copilot Chat に次のように入力します。

TaskServiceTest に「status を指定せずに create したタスクは TODO になること」を確認するテストを追加してください。status が null のタスクを渡し、返ってきたタスクの status が TODO であることを確認します

生成されたテストメソッドを TaskServiceTest.java の末尾(最後の } の直前)に追加します。

「境界値」とは
「今日が期限のタスク」「nullを渡す」「最大値ちょうど」のような、端っこや想定外の条件のことです。こういう条件でバグが潜みやすいため、受入れ基準に「境界値」を含めることが重要です。
5

再度テストを実行して全件PASSを確認する

./mvnw test

BUILD SUCCESS が出れば完了です。Tests run の数字が4以上になっていれば、追加分も含めて実行されています。

6

テストの中身を 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);
    }
}

完了チェック

演習5 完了条件
  • TaskServiceTest に4つ以上の @Test メソッドがある
  • ./mvnw test で全件 PASS する(BUILD SUCCESS)
  • 境界値テスト(status を指定せずに create すると TODO になる)が1つ以上ある
テストが書けた意味
テストが全件PASSしている状態でコードを変更すれば、「変更前と変更後で動きが変わっていないか」を自動で確認できます。配属後に機能追加やバグ修正をするとき、この「安全網」があるかどうかで作業速度と安心感が大きく変わります。

うまくいかないとき

症状確認すること
/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 にエラーごと貼って原因を聞く