テスト戦略
テストは品質の保証です。テストなしでコードを書くのは, シートベルトなしで運転するようなものです。遅かれ早かれ事故は起こります。
1. 学ぶこと
@SpringBootTest統合テストと@WebMvcTestスライステスト- Mockito
@MockBean/@SpyBeanモック依存関係 @DataJpaTestデータ層テストと組み込みH2データベース- TestContainers: MySQLとRedisのコンテナ化統合テスト
- AliceはOrderFlowのコア注文プロセスのテストカバレッジを80%以上に向上させました
2. 品質エンジニアの実話
(1) ペインポイント: 1つのバグを修正すると3つのバグが生まれる
Aliceは注文キャンセルロジックを修正しました。キャンセル機能にしか影響しないと思っていましたが, 在庫復元にも問題が発生し, それがレポート統計にも影響を与えました。コードを変更するたびに緊張しています。回帰を検証する自動テストがないからです。Bobは深夜に緊急修正の電話で何度も起こされ, チームは毎週30%の時間をバグ修正に費やしています。
(2) 自動テストのソリューション
良いテストを書いて, コード修正時の「セーフティネット」を提供します:
@SpringBootTest
class OrderServiceTest {
@Test
void shouldCancelOrderAndRestoreStock() {
// Given: 在庫10の商品, 数量3の注文
// When: 注文をキャンセル
// Then: 在庫が13に復元, 注文ステータスはCANCELLED
}
}
(3) 成果
AliceはOrderFlowのコアプロセスに50のテストケースを書き, 85%のコードカバレッジを達成しました。コードが変更されるたびにテストが自動的に実行され, 回帰問題は5分以内に検出されます。Bobはもう深夜にバグ修正をする必要がなくなりました。
3. テストピラミッド
(1) 3層テスト戦略
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) ▶ サンプル:単体テスト
@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());
}
}
出力:
// 実行成功
4. @WebMvcTest スライステスト
(1) コントローラーレベルのテスト
(1) ▶ サンプル:コントローラースライステスト
@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"));
}
}
出力:
// 実行成功
| テストアノテーション | 読み込み範囲 | 適用レベル |
|---|---|---|
@WebMvcTest |
コントローラー + Web層 | コントローラー |
@DataJpaTest |
リポジトリ + JPA層 | リポジトリ |
@SpringBootTest |
フルApplicationContext | 統合テスト |
5. @DataJpaTest データ層テスト
(1) ▶ サンプル:リポジトリテスト
@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);
}
}
出力:
// 実行成功
6. TestContainers: コンテナ化統合テスト
(1) ライブデータベーステスト
(1) ▶ サンプル:MySQL TestContainer
<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>
出力:
// 実行成功
@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
@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();
}
}
出力:
// 実行成功
7. テストカバレッジ
(1) ▶ サンプル:JaCoCo設定
<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>
出力:
// 実行成功
| カバレッジの次元 | 目標 | 説明 |
|---|---|---|
| 行カバレッジ | ≥ 80% | コアビジネスコード |
| 分岐カバレッジ | ≥ 70% | if/elseロジック |
| メソッドカバレッジ | ≥ 80% | パブリックメソッド |
8. 総合サンプル:OrderFlowのコア注文プロセステスト
// 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());
}
}
❓ よくある質問
staticフィールド経由)ため, 以降のテストは直接再利用します。一般的なオーバーヘッド:初回実行は30秒, 以降は5秒未満です。CI環境でのイメージ事前取得をお勧めします。SyncTaskExecutor)を設定してテストを簡素化。📖 まとめ
- テストピラミッド:70%の単体テスト + 20%の統合テスト + 10%のE2Eテスト
- 単体テストには
@ExtendWith(MockitoExtension.class)+@Mockを使用 - コントローラーテスト:
@WebMvcTest+MockMvc - リポジトリテスト:
@DataJpaTest+ H2 - TestContainers: 実際のデータベースコンテナでテストし, H2との差異を排除
- JaCoCoでテストカバレッジを監視;コアビジネスコード ≥ 80%
📝 練習問題
-
基本問題 (難易度:⭐): OrderServiceに対して5つの単体テストを書いてください。注文作成, 在庫不足, 注文キャンセル, ステータス例外, リソース未検出などのシナリオをカバーします。
-
応用問題 (難易度:⭐⭐):
@WebMvcTestを使ってOrderControllerのスライステストを書き, 正常リクエスト, バリデーション失敗, 404エラー, 401 (未認証)エラーなどのシナリオをカバーします。@DataJpaTestを使ってProductRepositoryのクエリテストを書いてください。 -
チャレンジ (難易度:⭐⭐⭐): TestContainers (MySQL + Redis)を統合し, エンドツーエンドの統合テストを書いてください。商品作成, 注文と在庫引当, Redisキャッシュヒット, 注文キャンセルと在庫復元の全プロセスをカバーし, カバレッジ率 ≥ 80%を達成します。



