ユニットテストの実装:命名規則、構造、カバレッジ

1. 概要

良いユニットテストは単なる「テストが通る」だけではありません。読みやすく、保守しやすく、失敗したときには問題がどこにあるかすぐにわかる必要があります。その日一日デバッグに費やす必要はなくなります。この記事は3つの柱を通して説明します:テストの名前を自己説明的にする、AAA(Arrange-Act-Assert)パターンでテストを構成する、そして「100%のカバレッジなのに役に立たない」というワナに陥らないようにカバレッジの正しい理解です。

この記事を読み終わったとき、あなたはプルリクエスト作成前に自分のテストをレビューするための具体的なチェックリストを手にしているでしょう。

2. 必要な依存関係

この記事の例ではJUnit 5とAssertJを使用します。Spring Bootプロジェクトの場合、spring-boot-starter-testには両方が含まれています。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. テストの命名:名前で物語を語る

テストの名前は、テストが失敗したときに最初に目にするドキュメントです。良い名前は3つの質問に答えます:何をテストしているのか、どのような条件で、何を期待するのか。

3.1. methodName_condition_expectedResult パターン

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

説明:

  • 名前はテスト対象のメソッド(calculateTotal)から始まり、when...で条件を示し、最後に期待される結果を述べます。
  • CI報告でテスト名を読むだけで、どのロジック分岐で失敗したかすぐにわかります — コードを開く必要はありません。

注意: test1()、testCalculateTotal()といった名前は避けてください。これらはコンテキストを何も伝えず、何がテストされているか理解するにはテスト本体を見ざるを得なくなります。

3.2. テスト名で十分に表現できないときは @DisplayName を使う

複雑なビジネスルールの場合、テストメソッド名は長く読みづらくなります。テストメソッド名は短く保ちながら、@DisplayNameを使ってより自然な文で説明します。

@Test
@DisplayName("顧客がVIPで、注文が100万円以上の場合、さらに5%割引を適用")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

Maven Surefireまたはインテリジェンスビルドで実行すると、テスト名の代わりに@DisplayNameの文が表示されるため、報告書で読みやすくなります。

4. AAA構造:Arrange – Act – Assert

各テストは3つの部分に明確に分かれ、順番を守り、混在しないようにします。これにより、読者はすばやく確認できます:どの行でデータを準備し、どの行で実際の動作を呼び出し、どの行で結果を検証するのか。

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

説明:

  • Arrangeで初期残高を持つAccountオブジェクトを作成します。
  • Actでテスト対象の正確に1つのアクション(withdraw)を呼び出します。
  • Assertで結果を検証し、AssertJを使って読みやすい自然言語のようなアサーションを書きます。

4.1. 1つのテスト、1つの動作

テストが「多くの仕事をする」ほど、失敗時のデバッグが困難になります。このようなテストは避けてください:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // エラー:残高不足
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

最初のアサーションが失敗すると、後の2つのアクションは実行されません — 情報を失ってしまいます。3つの独立したテストに分割してください:deposit_...、withdraw_sufficientBalance_...、withdraw_insufficientBalance_throwsException。

4.2. 例外のテストは、型とメッセージの両方を検証する

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Insufficient balance");
}

assertThatThrownByで例外を一度にキャッチして検証できます。古い@Test(expected = ...)スタイルは使わないようにしましょう。例外発生後のメッセージや状態を検証する余地がありません。

5. @ParameterizedTest で重複コードを減らす

複数の異なる入力が同じロジックをテストするとき、複数のメソッドをコピーペーストする代わりにパラメータ化されたテストを使用します。

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

説明:

  • @CsvSourceが入力/出力のセットを提供し、各行が独立したテストケースです。
  • 報告書には各ケースが個別に表示され、どのケースが失敗したかすぐにわかります。推測する必要はありません。
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. 適切なレベルでのモック化:モック過多を避ける

外部の依存関係(データベース、HTTPクライアント、他のサービス)にはモックを使用し、テスト対象のオブジェクト自体はモック化しません。

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

説明:

  • @MockでPaymentGatewayの偽オブジェクトを作成します — これは外部の呼び出し(実際のAPIかもしれません)です。
  • @InjectMocksでそのモックをテスト対象のPaymentServiceに注入します。
  • verifyでchargeが正しいパラメータで呼ばれたことを確認します。戻り値だけでなく、メソッドが呼ばれたこともチェックします。

注意: 1つのテストで3~4個以上の依存関係をモック化する必要がある場合、それはクラスが多くのことをしすぎているしるしです。テストを分割する前に、クラスを分割することを検討してください。

7. カバレッジ:参考指標であって、目標ではない

カバレッジが高いからといって、テストが良いとは限りません。たとえば、次のテストはdivideメソッドで100%のラインカバレッジを達成しますが、バグを検出できません:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

このテストはコード全行を実行します(100%カバレッジ)が、b = 0の場合をテストしていません — プロダクションでArithmeticExceptionがスローされる場所です。カバレッジはどの行が実行されたかを測定しますが、正しく検証されたかまでは測定できません。

7.1. カバレッジを使って盲点を見つけ、報告を飾るためではなく

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

mvn testを実行すると、JaCoCoはtarget/site/jacoco/index.htmlにHTMLレポートを生成します。このレポートで「どのブランチがまだテストに触れられていないか」という質問に答えてください。そしてさらに自問します「そのブランチに重要なロジックがあるか」 — 絶対的な%の数字に従わないでください。

要素             ミスされた命令   カバレッジ
Calculator       12 of 40         70%
  divide()       4 of 8           50%  ← b=0のケースがまだテストされていない

7.2. エッジケースのテストは、%の数字より重要

上記で見落とされたケースの補足テスト:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

1行のこのテストは、通常のケースを繰り返すだけで%カバレッジを上げるためにさらに5つのテストを書くより、はるかに価値があります。

8. テストの自己レビューチェックリスト

プルリクエストを作成する前に、確認してください:

  • テスト名は条件と期待値を明確に述べているか、それとも単にtest1、testXか?
  • 各テストは正確に1つの動作、1つの失敗理由を持っているか?
  • エッジケースがあるか:null、empty、0、負の値、上限?
  • エラーケース(例外)のテストがあるか、ハッピーパスのみか?
  • カバレッジが低いのはなぜか — 見落とされた重要なロジックか、テスト不要なgetter/setterか?

9. 結論

良いユニットテストは、失敗したときにデバッグなしで問題がどこにあるか即座にわかるテストです。method_condition_expectedパターンで名前を付け、AAA構造を明確に保ちながら各テストで1つの動作のみをテストし、カバレッジを達成すべき目標ではなく盲点を見つけるツールとして使用する — これら3つのシンプルな原則であなたのテストスイートの品質を1段階上げることができます。最も難しい部分はアサーションを書くことではなく、本番環境であなたのコードが遭遇する可能性のあるエッジケースについて時間をかけて考えることです。

testingjunitjavabest-practicesspring-boot