Spring Bootにおける高度なテスト戦略と実装手法

現代のソフトウェア開発において、品質保証と保守性の向上は不可欠です。テストピラミッドの基盤となる単体テストは、最小のコード単位の振る舞いを検証するだけでなく、バグの早期発見やリファクタリングの安全性を担保します。本記事では、Javaエコシステムで標準的なフレームワークであるSpring Bootを使用した、実践的で堅牢なテストコードの書方について解説します。JUnit 5やMockitoを活用し、モダンなアーキテクチャに適したテスト手法を網羅します。

1. 開発環境のセットアップと依存関係

Spring Bootでのテストを開始するには、適切な依存ライブラリをプロジェクトに追加する必要があります。Spring Bootはspring-boot-starter-testというスターターを提供しており、これ一つでテストに必要な主要なライブラリが統合されます。

これには以下が含まれます:

  • JUnit 5: Javaの事実上の標準テストフレームワーク。
  • Mockito: モックオブジェクトを生成・操作するためのフレームワーク。
  • AssertJ: 流れるようなインターフェースを持つ豊富なアサーションライブラリ。
  • Spring Test & Spring Boot Test: アプリケーションコンテキストのテスト支援機能。

Mavenを使用する場合、pom.xmlに以下を追加します。

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

2. JUnit 5による基礎的なテスト構築

Spring Boot 2.x以降では、デフォルトのテストフレームワークとしてJUnit 5(Jupiter)が採用されています。ここでは、Springの機能を使わない純粋なJavaクラスのテスト例を見ていきましょう。

アサーションとライフサイクル

テストメソッドは@Testアノテーションでマークします。また、@BeforeEach@BeforeAllを使用して、テスト前のセットアップ処理を実装できます。

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;

class MathUtilsTest {

    private MathUtils mathUtils;

    @BeforeEach
    void setUp() {
        mathUtils = new MathUtils();
    }

    @Test
    void multiply_shouldReturnCorrectProduct() {
        long result = mathUtils.multiply(10, 5);
        assertEquals(50, result, "10 * 5 should be 50");
    }

    @Test
    void divide_whenDividingByZero_shouldThrowException() {
        Exception thrown = assertThrows(ArithmeticException.class, () -> {
            mathUtils.divide(100, 0);
        });
        assertTrue(thrown.getMessage().contains("division by zero"));
    }
}

この例では、assertEqualsで戻り値を検証し、assertThrowsで例外処理の挙動を確認しています。

3. Spring Bootのテストスライスとアノテーション

Spring Bootは、テスト対象のレイヤーに応じて必要なコンポーネントだけをロードする「テストスライス」という仕組みを提供しています。これにより、テスト実行時間を短縮し、テストの隔離性を高めることができます。

アノテーション対象範囲と用途
@WebMvcTestController層のみをロード。MockMvcを使用したHTTPリクエスト/レスポンスの検証に適する。
@DataJpaTestJPA関連コンポーネント(Repository等)のみをロード。インメモリDBでの永続化テストに適する。
@ServiceTestService層に注目したテスト。Spring Boot 3.2以降で利用可能。
@SpringBootTest全コンテキストをロードする統合テスト用。

4. レイヤー別のテスト実装

ここでは、在庫管理システムを想定し、「商品(Product)」を扱うAPIを例に、各レイヤーのテストコードをリファクタリングしつつ実装します。

4.1 Service層のテスト

Service層のテストでは、依存するRepositoryや外部APIのクライアントをモック化し、ビジネスロジックそのものを検証します。Springコンテナを起動する必要がないため、@ExtendWith(MockitoExtension.class)を使用した純粋な単体テストとして記述するのが一般的です。

検証対象クラス (ProductService):

public class ProductService {
    private final ProductRepository productRepository;
    private final InventoryClient inventoryClient;

    public ProductService(ProductRepository productRepository, InventoryClient inventoryClient) {
        this.productRepository = productRepository;
        this.inventoryClient = inventoryClient;
    }

    public Product registerProduct(Product product) {
        if (productRepository.existsBySku(product.getSku())) {
            throw new DuplicateSkuException("SKU already exists: " + product.getSku());
        }
        // 在庫クライアントへの通知
        inventoryClient.notifyNewArrival(product.getSku());
        return productRepository.save(product);
    }
}

テストクラス (ProductServiceTest):

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.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;

@ExtendWith(MockitoExtension.class)
class ProductServiceTest {

    @Mock
    private ProductRepository productRepository;

    @Mock
    private InventoryClient inventoryClient;

    @InjectMocks
    private ProductService productService;

    @Test
    void registerProduct_withUniqueSku_shouldSaveAndNotify() {
        // Given: モックの振る舞いを定義
        Product newProduct = new Product("SKU-001", "Laptop", 1200);
        when(productRepository.existsBySku("SKU-001")).thenReturn(false);
        when(productRepository.save(any(Product.class))).thenReturn(newProduct);

        // When: メソッド実行
        Product result = productService.registerProduct(newProduct);

        // Then: 検証
        assertNotNull(result);
        verify(productRepository).save(newProduct);
        verify(inventoryClient).notifyNewArrival("SKU-001");
    }

    @Test
    void registerProduct_withDuplicateSku_shouldThrowException() {
        Product duplicate = new Product("SKU-001", "Mouse", 25);
        when(productRepository.existsBySku("SKU-001")).thenReturn(true);

        assertThrows(DuplicateSkuException.class, () -> productService.registerProduct(duplicate));
        verify(inventoryClient, never()).notifyNewArrival(anyString());
    }
}

4.2 Controller層のテスト (@WebMvcTest)

Controller層では、HTTPリクエストのマッピング、バリデーション、レスポンスのステータスコードを検証します。@WebMvcTestを使用すると、Service層はロードされないため、@MockBeanでモックを注入する必要があります。

検証対象クラス (ProductController):

@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @PostMapping
    public ResponseEntity<Product> create(@RequestBody @Valid ProductDto productDto) {
        Product product = new Product(productDto.getSku(), productDto.getName(), productDto.getPrice());
        Product saved = productService.registerProduct(product);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }
}

テストクラス (ProductControllerTest):

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private ProductService productService;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void createProduct_withValidData_returnsCreated() throws Exception {
        ProductDto dto = new ProductDto("SKU-999", "Keyboard", 50);
        Product savedEntity = new Product("SKU-999", "Keyboard", 50);

        when(productService.registerProduct(any(Product.class))).thenReturn(savedEntity);

        mockMvc.perform(post("/api/products")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(dto)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.sku").value("SKU-999"));
    }
}

4.3 Repository層のテスト (@DataJpaTest)

Data JPAのテストでは、実際のデータベース操作を検証します。@DataJpaTestはインメモリデータベース(H2など)を使用し、テスト終了後にトランザクションをロールバックするため、データの状態管理が容易です。

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private TestEntityManager entityManager;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void findBySku_whenProductExists_returnsProduct() {
        Product product = new Product("SKU-123", "Monitor", 300);
        entityManager.persistAndFlush(product);

        Optional<Product> found = productRepository.findBySku("SKU-123");

        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Monitor");
    }
}

5. Mockitoによる高度なモック操作

テストシナリオが複雑になるにつれて、単純なthenReturnだけでなく、より動的なモック操作が必要になります。

メソッド呼び出しの回数検証

特定のメソッドが何回呼ばれたか、あるいは一度も呼ばれていないかを検証するにはverifyを使用します。

verify(productRepository, times(1)).save(any());
verify(inventoryClient, never()).notifyNewArrival("INVALID");

引数のキャプチャ (ArgumentCaptor)

モックメソッドに渡された引数の内容を詳細に検証したい場合に使用します。

ArgumentCaptor<Product> captor = ArgumentCaptor.forClass(Product.class);
verify(productRepository).save(captor.capture());

Product savedProduct = captor.getValue();
assertThat(savedProduct.getName()).isEqualTo("Expected Name");

6. AssertJを使った可読性の高いアサーション

AssertJは、メソッドチェーンによって自然言語に近い形で検証条件を記述できます。特にコレクションやオブジェクトのプロパティ検証において強力です。

import static org.assertj.core.api.Assertions.assertThat;

List<Product> products = Arrays.asList(p1, p2);

// コレクションの検証
assertThat(products)
    .hasSize(2)
    .extracting("sku")
    .containsExactlyInAnyOrder("SKU-1", "SKU-2");

// オブジェクトのフィールド検証
assertThat(p1)
    .isNotNull()
    .hasFieldOrPropertyWithValue("price", 1000);

7. 統合テストと本番に近い環境での検証

単体テストが十分であっても、システム全体の挙動を確認する統合テストも重要です。@SpringBootTestを使用して、完全なアプリケーションコンテキストを起動し、TestRestTemplateWebTestClientでエンドツーエンドのテストを行います。

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class ProductIntegrationTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void testGetProductEndpoint() {
        String url = "http://localhost:" + port + "/api/products/SKU-999";
        ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
        
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    }
}

また、データベース周りのテストにおいて、H2の方言制約を避けるために、Testcontainersを使用してDockerコンテナ上で実際のMySQLやPostgreSQLを起動してテストする手法も推奨されます。これにより、本番環境との挙動の乖離を最小限に抑えることができます。

タグ: SpringBoot JUnit5 Mockito TDD Java

8月3日 18:59 投稿