404 Not Found

404 Not Found


nginx

テスト戦略

テストは品質の保証です。テストなしでコードを書くのは, シートベルトなしで運転するようなものです。遅かれ早かれ事故は起こります。

1. 学ぶこと


2. 品質エンジニアの実話

(1) ペインポイント: 1つのバグを修正すると3つのバグが生まれる

Aliceは注文キャンセルロジックを修正しました。キャンセル機能にしか影響しないと思っていましたが, 在庫復元にも問題が発生し, それがレポート統計にも影響を与えました。コードを変更するたびに緊張しています。回帰を検証する自動テストがないからです。Bobは深夜に緊急修正の電話で何度も起こされ, チームは毎週30%の時間をバグ修正に費やしています。

(2) 自動テストのソリューション

良いテストを書いて, コード修正時の「セーフティネット」を提供します:

JAVA
@SpringBootTest
class OrderServiceTest {

    @Test
    void shouldCancelOrderAndRestoreStock() {
        // Given: 在庫10の商品, 数量3の注文
        // When: 注文をキャンセル
        // Then: 在庫が13に復元, 注文ステータスはCANCELLED
    }
}

(3) 成果

AliceはOrderFlowのコアプロセスに50のテストケースを書き, 85%のコードカバレッジを達成しました。コードが変更されるたびにテストが自動的に実行され, 回帰問題は5分以内に検出されます。Bobはもう深夜にバグ修正をする必要がなくなりました。


3. テストピラミッド

(1) 3層テスト戦略

100%
graph LR
    A["Unit Tests<br/>70% - 高速・分離<br/>JUnit + Mockito"] --> B["Integration Tests<br/>20% - 中速度<br/>@SpringBootTest + H2"]
    B --> C["E2E Tests<br/>10% - 低速・フルスタック<br/>TestContainers + REST Assured"]
レベル 割合 速度 依存関係 テスト内容
単体テスト 70% ミリ秒レベル Mock ビジネスロジック
統合テスト 20% 秒単位 H2/TestContainers コンポーネント間連携
E2Eテスト 10% 分単位 フル環境 ビジネスプロセス

(1) ▶ サンプル:単体テスト

JAVA
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private ProductRepository productRepository;

    @InjectMocks
    private OrderServiceImpl orderService;

    @Test
    void shouldCreateOrderSuccessfully() {
        // Given
        Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));

        // When
        Order order = orderService.createOrder(1L, 3);

        // Then
        assertThat(order).isNotNull();
        assertThat(product.getStock()).isEqualTo(7);
        verify(orderRepository).save(any(Order.class));
    }

    @Test
    void shouldThrowWhenInsufficientStock() {
        Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));

        assertThatThrownBy(() -> orderService.createOrder(1L, 5))
            .isInstanceOf(InsufficientStockException.class);

        verify(orderRepository, never()).save(any());
    }
}

出力:

TEXT
// 実行成功

4. @WebMvcTest スライステスト

(1) コントローラーレベルのテスト

(1) ▶ サンプル:コントローラースライステスト

JAVA
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderById() throws Exception {
        OrderResponse order = new OrderResponse(
            1L, "bob", "PENDING", BigDecimal.valueOf(999),
            List.of(), Instant.now());

        when(orderService.getOrder(1L)).thenReturn(order);

        mockMvc.perform(get("/api/v1/orders/1")
                .with(httpBasic("bob", "pass123")))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.status").value("PENDING"));
    }

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        when(orderService.getOrder(999L))
            .thenThrow(new ResourceNotFoundException("Order", 999L));

        mockMvc.perform(get("/api/v1/orders/999")
                .with(httpBasic("bob", "pass123")))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.code").value("RESOURCE_NOT_FOUND"));
    }

    @Test
    void shouldReturn400WhenValidationFails() throws Exception {
        String body = """
            {"productId": null, "quantity": 0}
            """;

        mockMvc.perform(post("/api/v1/orders")
                .with(httpBasic("bob", "pass123"))
                .contentType(MediaType.APPLICATION_JSON)
                .content(body))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.code").value("VALIDATION_ERROR"));
    }
}

出力:

TEXT
// 実行成功
テストアノテーション 読み込み範囲 適用レベル
@WebMvcTest コントローラー + Web層 コントローラー
@DataJpaTest リポジトリ + JPA層 リポジトリ
@SpringBootTest フルApplicationContext 統合テスト

5. @DataJpaTest データ層テスト

(1) ▶ サンプル:リポジトリテスト

JAVA
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindByNameContaining() {
        entityManager.persist(new Product("Laptop Pro", BigDecimal.valueOf(1299), 50));
        entityManager.persist(new Product("Laptop Air", BigDecimal.valueOf(899), 30));
        entityManager.persist(new Product("Wireless Mouse", BigDecimal.valueOf(29), 200));

        List<Product> results = productRepository.findByNameContaining("Laptop");

        assertThat(results).hasSize(2);
    }

    @Test
    void shouldFindOutOfStockProducts() {
        Product outOfStock = new Product("Old Model", BigDecimal.valueOf(99), 0);
        entityManager.persist(outOfStock);
        entityManager.persist(new Product("New Model", BigDecimal.valueOf(199), 10));

        List<Product> results = productRepository.findOutOfStockProducts();

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getName()).isEqualTo("Old Model");
    }

    @Test
    void shouldCheckStockBeforeDeduction() {
        Product product = entityManager.persist(
            new Product("Laptop", BigDecimal.valueOf(999), 5));

        Product found = productRepository.findById(product.getId()).orElseThrow();
        found.deductStock(3);

        Product updated = productRepository.findById(product.getId()).orElseThrow();
        assertThat(updated.getStock()).isEqualTo(2);
    }
}

出力:

TEXT
// 実行成功

6. TestContainers: コンテナ化統合テスト

(1) ライブデータベーステスト

(1) ▶ サンプル:MySQL TestContainer

XML
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

出力:

TEXT
// 実行成功
JAVA
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("orderflow_test")
        .withUsername("test")
        .withPassword("test");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", mysql::getJdbcUrl);
        registry.add("spring.datasource.username", mysql::getUsername);
        registry.add("spring.datasource.password", mysql::getPassword);
    }

    @Autowired
    private OrderService orderService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldCreateOrderAndDeductStock() {
        Product product = productRepository.save(
            new Product("Laptop", BigDecimal.valueOf(999), 10));

        Order order = orderService.createOrder(product.getId(), 3);

        assertThat(order.getStatus()).isEqualTo("PENDING");

        Product updated = productRepository.findById(product.getId()).orElseThrow();
        assertThat(updated.getStock()).isEqualTo(7);
    }
}

(2) ▶ サンプル:Redis TestContainer

JAVA
@SpringBootTest
@Testcontainers
class RedisCacheIntegrationTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
        .withExposedPorts(6379);

    @DynamicPropertySource
    static void configureRedis(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldCacheProductInRedis() {
        Product product = productRepository.save(
            new Product("Laptop", BigDecimal.valueOf(999), 10));

        // 1回目の呼び出し: データベースクエリ
        productService.getProduct(product.getId());
        // 2回目の呼び出し: Redisキャッシュヒット
        Product cached = productService.getProduct(product.getId());

        assertThat(cached).isNotNull();
    }
}

出力:

TEXT
// 実行成功

7. テストカバレッジ

(1) ▶ サンプル:JaCoCo設定

XML
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</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>

出力:

TEXT
// 実行成功
カバレッジの次元 目標 説明
行カバレッジ ≥ 80% コアビジネスコード
分岐カバレッジ ≥ 70% if/elseロジック
メソッドカバレッジ ≥ 80% パブリックメソッド

8. 総合サンプル:OrderFlowのコア注文プロセステスト

JAVA
// OrderServiceTest.java (単体テスト)
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private OrderRepository orderRepository;
    @Mock private ProductRepository productRepository;
    @InjectMocks private OrderServiceImpl orderService;

    @Test
    void createOrder_success() {
        Product product = new Product("Laptop", BigDecimal.valueOf(999), 10);
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));
        when(orderRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));

        Order order = orderService.createOrder(1L, 3);

        assertThat(order.getStatus()).isEqualTo("PENDING");
        assertThat(product.getStock()).isEqualTo(7);
    }

    @Test
    void createOrder_insufficientStock_throws() {
        Product product = new Product("Laptop", BigDecimal.valueOf(999), 2);
        when(productRepository.findById(1L)).thenReturn(Optional.of(product));

        assertThatThrownBy(() -> orderService.createOrder(1L, 5))
            .isInstanceOf(InsufficientStockException.class);
    }

    @Test
    void cancelOrder_success_restoresStock() {
        Product product = new Product("Laptop", BigDecimal.valueOf(999), 7);
        Order order = new Order();
        order.setStatus("PENDING");
        order.addItem(product, 3);
        when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));

        orderService.cancelOrder(1L);

        assertThat(order.getStatus()).isEqualTo("CANCELLED");
        assertThat(product.getStock()).isEqualTo(10);
    }

    @Test
    void cancelOrder_nonPending_throws() {
        Order order = new Order();
        order.setStatus("SHIPPED");
        when(orderRepository.findByIdWithItems(1L)).thenReturn(Optional.of(order));

        assertThatThrownBy(() -> orderService.cancelOrder(1L))
            .isInstanceOf(OrderStateException.class);
    }
}

// OrderControllerTest.java (スライステスト)
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;

    @Test
    @WithMockUser(roles = "CUSTOMER")
    void createOrder_validRequest_returns201() throws Exception {
        when(orderService.createOrder(any(), eq("bob"))).thenReturn(
            new OrderResponse(1L, "bob", "PENDING", BigDecimal.valueOf(999), List.of(), Instant.now()));

        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productId\":1,\"quantity\":3}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.status").value("PENDING"));
    }

    @Test
    void createOrder_unauthenticated_returns401() throws Exception {
        mockMvc.perform(post("/api/v1/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"productId\":1,\"quantity\":3}"))
            .andExpect(status().isUnauthorized());
    }
}

❓ よくある質問

Q @MockBeanと@Mockの違いは何ですか?
A @MockはMockitoのアノテーションで, 純粋なモックオブジェクトを作成します。Springには依存しません。@MockBeanはSpring Bootのアノテーションで, モックオブジェクトをSpring ApplicationContextに注入してコンテナ内のBeanを置き換えます。単体テストでは@Mockを, 統合テストでは@MockBeanを使用します。
Q @SpringBootTestと@WebMvcTestはどちらを選ぶべきですか?
A @WebMvcTestはWeb層 (Controller + Filter)のみを読み込み, 高速でコントローラーの単体テストに適しています。@SpringBootTestはフルApplicationContextを読み込み, エンドツーエンドの統合テストに適しています。
Q TestContainersはテストを遅くしますか?
A 初回のコンテナ起動時はイメージのダウンロードが必要ですが, コンテナはテストクラス間で共有される (staticフィールド経由)ため, 以降のテストは直接再利用します。一般的なオーバーヘッド:初回実行は30秒, 以降は5秒未満です。CI環境でのイメージ事前取得をお勧めします。
Q H2とMySQLは同じ動作をしますか?
A ほぼ一致しますが, いくつか違いがあります:1) MySQLのAUTO_INCREMENTとH2のIDENTITY; 2) MySQL固有の関数 (GROUP_CONCAT); 3) 異なるデフォルトのトランザクション分離レベル。本番環境ではTestContainers + MySQLの使用をお勧めします。
Q @Asyncメソッドのテスト方法は?
A 1) 非同期メソッドを直接テストする (プロキシを使わない); 2) CompletableFuture.get()で結果を待機; 3) 同期実行器 (SyncTaskExecutor)を設定してテストを簡素化。
Q テストカバレッジ80%で十分ですか?
A 80%はコアビジネスコードの最低基準です。85%以上をお勧めします。カバレッジは参考値に過ぎません。最も重要なのはテストの品質です。正常フローだけでなく, 境界条件, 例外パス, 並行シナリオもカバーしてください。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度:⭐): OrderServiceに対して5つの単体テストを書いてください。注文作成, 在庫不足, 注文キャンセル, ステータス例外, リソース未検出などのシナリオをカバーします。

  2. 応用問題 (難易度:⭐⭐): @WebMvcTestを使ってOrderControllerのスライステストを書き, 正常リクエスト, バリデーション失敗, 404エラー, 401 (未認証)エラーなどのシナリオをカバーします。@DataJpaTestを使ってProductRepositoryのクエリテストを書いてください。

  3. チャレンジ (難易度:⭐⭐⭐): TestContainers (MySQL + Redis)を統合し, エンドツーエンドの統合テストを書いてください。商品作成, 注文と在庫引当, Redisキャッシュヒット, 注文キャンセルと在庫復元の全プロセスをカバーし, カバレッジ率 ≥ 80%を達成します。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%