はじめに

Spring Boot REST APIの開発において、統合テストは本番環境と同等の信頼性を担保するために欠かせません。@WebMvcTestによるユニットテストでは、コントローラ層のみを検証しますが、統合テストでは実際のデータベースや外部サービスとの連携を含めた全レイヤーの動作を検証します。

本記事では、@SpringBootTestによる全レイヤーテスト、TestRestTemplateを使ったHTTP通信によるAPIテスト、そしてTestcontainersによる一時的なPostgreSQLコンテナの起動と@DynamicPropertySourceによる接続情報の動的注入について、実践的なサンプルコードとともに解説します。

実行環境と前提条件

本記事のサンプルコードは以下の環境で動作確認しています。

項目 バージョン
Java 21
Spring Boot 3.4.x
JUnit 5.x
Testcontainers 2.x
PostgreSQL 16.x
Docker 実行可能な環境

前提知識

以下の知識があることを前提としています。

  • Spring Boot REST APIの基本(@RestController@Service@Repository
  • Spring Data JPAによるデータアクセス
  • JUnit 5の基本的なテストの書き方
  • Dockerの基本概念

統合テストとユニットテストの違い

Spring Bootのテスト戦略において、統合テストとユニットテストは明確に役割が異なります。

graph TD
    subgraph ユニットテスト
        A["@WebMvcTest"] --> B[コントローラ層のみ]
        B --> C[サービス層はモック]
        C --> D[高速実行]
    end
    subgraph 統合テスト
        E["@SpringBootTest"] --> F[全レイヤー]
        F --> G[実際のDB接続]
        G --> H[本番に近い検証]
    end
テスト種別 アノテーション 対象レイヤー DB接続 実行速度
ユニットテスト @WebMvcTest コントローラのみ モック 高速
統合テスト @SpringBootTest 全レイヤー 実DB 低速

統合テストは実行時間がかかりますが、コンポーネント間の連携やデータベースとの実際のやり取りを検証できるため、本番環境での問題を事前に検出できます。

必要な依存関係

統合テストとTestcontainersを使用するために、以下の依存関係を追加します。

Mavenの場合

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<dependencies>
    <!-- Spring Boot Starter Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Spring Boot Testcontainers -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers JUnit Jupiter -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Testcontainers PostgreSQL -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradleの場合

1
2
3
4
5
6
dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
}

テスト対象のAPIとエンティティ

本記事では、以下のシンプルなユーザー管理APIを例にテストコードを解説します。

Userエンティティ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.example.demo.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String email;

    public User() {
    }

    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // Getter/Setter
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

UserRepository

1
2
3
4
5
6
7
8
9
package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}

UserService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example.demo.service;

import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public List<User> findAll() {
        return userRepository.findAll();
    }

    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new RuntimeException("User not found: " + id));
    }

    public User create(User user) {
        return userRepository.save(user);
    }

    public User update(Long id, User user) {
        User existing = findById(id);
        existing.setName(user.getName());
        existing.setEmail(user.getEmail());
        return userRepository.save(existing);
    }

    public void delete(Long id) {
        userRepository.deleteById(id);
    }
}

UserController

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.demo.controller;

import com.example.demo.entity.User;
import com.example.demo.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping
    public List<User> findAll() {
        return userService.findAll();
    }

    @GetMapping("/{id}")
    public User findById(@PathVariable Long id) {
        return userService.findById(id);
    }

    @PostMapping
    public ResponseEntity<User> create(@RequestBody User user) {
        User created = userService.create(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public User update(@PathVariable Long id, @RequestBody User user) {
        return userService.update(id, user);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

@SpringBootTestによる統合テストの基本

@SpringBootTestは、Spring Bootアプリケーション全体のApplicationContextを起動してテストを実行するアノテーションです。

webEnvironmentオプション

@SpringBootTestにはwebEnvironment属性があり、テスト時のサーバー起動方法を制御できます。

オプション 説明
MOCK(デフォルト) モック環境でテストを実行。サーバーは起動しない
RANDOM_PORT ランダムなポートでサーバーを起動
DEFINED_PORT application.propertiesで定義されたポートで起動
NONE Web環境なしでApplicationContextを起動

統合テストでは、RANDOM_PORTを使用して実際のHTTPリクエストを送信することが推奨されます。

基本的な統合テストの構造

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.test.web.server.LocalServerPort;

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

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

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void contextLoads() {
        assertThat(restTemplate).isNotNull();
    }

    private String getBaseUrl() {
        return "http://localhost:" + port + "/api/users";
    }
}

@LocalServerPortを使用すると、起動時に割り当てられたランダムポートを取得できます。

TestRestTemplateによるAPIテスト

TestRestTemplateは、統合テストでHTTPリクエストを送信するためのクライアントです。RestTemplateをラップしており、テスト用に便利な機能が追加されています。

GETリクエストのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Test
void shouldReturnEmptyListWhenNoUsers() {
    ResponseEntity<User[]> response = restTemplate.getForEntity(
        getBaseUrl(),
        User[].class
    );

    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    assertThat(response.getBody()).isEmpty();
}

@Test
void shouldReturnUserById() {
    // Given: ユーザーを事前に作成
    User newUser = new User("田中太郎", "tanaka@example.com");
    ResponseEntity<User> createResponse = restTemplate.postForEntity(
        getBaseUrl(),
        newUser,
        User.class
    );
    Long userId = createResponse.getBody().getId();

    // When: IDで検索
    ResponseEntity<User> response = restTemplate.getForEntity(
        getBaseUrl() + "/" + userId,
        User.class
    );

    // Then: 作成したユーザーが取得できる
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
    assertThat(response.getBody().getName()).isEqualTo("田中太郎");
    assertThat(response.getBody().getEmail()).isEqualTo("tanaka@example.com");
}

POSTリクエストのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Test
void shouldCreateNewUser() {
    // Given
    User newUser = new User("山田花子", "yamada@example.com");

    // When
    ResponseEntity<User> response = restTemplate.postForEntity(
        getBaseUrl(),
        newUser,
        User.class
    );

    // Then
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    assertThat(response.getBody()).isNotNull();
    assertThat(response.getBody().getId()).isNotNull();
    assertThat(response.getBody().getName()).isEqualTo("山田花子");
}

PUTリクエストのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void shouldUpdateExistingUser() {
    // Given: ユーザーを作成
    User newUser = new User("佐藤一郎", "sato@example.com");
    ResponseEntity<User> createResponse = restTemplate.postForEntity(
        getBaseUrl(),
        newUser,
        User.class
    );
    Long userId = createResponse.getBody().getId();

    // When: ユーザー情報を更新
    User updatedUser = new User("佐藤次郎", "sato-jiro@example.com");
    restTemplate.put(getBaseUrl() + "/" + userId, updatedUser);

    // Then: 更新されていることを確認
    ResponseEntity<User> response = restTemplate.getForEntity(
        getBaseUrl() + "/" + userId,
        User.class
    );
    assertThat(response.getBody().getName()).isEqualTo("佐藤次郎");
    assertThat(response.getBody().getEmail()).isEqualTo("sato-jiro@example.com");
}

DELETEリクエストのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
void shouldDeleteUser() {
    // Given: ユーザーを作成
    User newUser = new User("削除テスト", "delete@example.com");
    ResponseEntity<User> createResponse = restTemplate.postForEntity(
        getBaseUrl(),
        newUser,
        User.class
    );
    Long userId = createResponse.getBody().getId();

    // When: ユーザーを削除
    restTemplate.delete(getBaseUrl() + "/" + userId);

    // Then: 削除されていることを確認(404が返る)
    ResponseEntity<User> response = restTemplate.getForEntity(
        getBaseUrl() + "/" + userId,
        User.class
    );
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR);
}

WebTestClientの活用

Spring WebFluxがクラスパスにある場合、WebTestClientも使用できます。WebTestClientはフルエント(fluent)なAPIを提供し、レスポンスの検証がより直感的に記述できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.demo;

import com.example.demo.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.web.reactive.server.WebTestClient;

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

    @Autowired
    private WebTestClient webTestClient;

    @Test
    void shouldCreateAndRetrieveUser() {
        User newUser = new User("WebTestClient Test", "webtestclient@example.com");

        // POSTでユーザーを作成
        webTestClient.post()
            .uri("/api/users")
            .bodyValue(newUser)
            .exchange()
            .expectStatus().isCreated()
            .expectBody(User.class)
            .value(user -> {
                assertThat(user.getId()).isNotNull();
                assertThat(user.getName()).isEqualTo("WebTestClient Test");
            });
    }

    @Test
    void shouldReturnAllUsers() {
        webTestClient.get()
            .uri("/api/users")
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(User.class);
    }
}

Testcontainersによるデータベースコンテナの利用

Testcontainersは、Dockerコンテナをテスト中に一時的に起動できるライブラリです。これにより、本番と同じデータベースエンジン(PostgreSQL、MySQLなど)を使用した統合テストが可能になります。

H2との比較

項目 H2(インメモリDB) Testcontainers
環境構築 不要 Docker必須
本番との互換性 SQL方言の違いあり 完全一致
実行速度 高速 やや低速
信頼性 低い(挙動の違いあり) 高い

本番環境でPostgreSQLを使用している場合、テストでもPostgreSQLを使用することで、SQL方言やデータ型の違いによる問題を未然に防げます。

基本的なTestcontainersの設定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiTestcontainersTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("testuser")
        .withPassword("testpass");

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldWorkWithRealPostgreSQL() {
        // PostgreSQLコンテナが自動的に起動し、テスト後に停止される
        assertThat(postgres.isRunning()).isTrue();
    }
}

@Testcontainersアノテーションを付与し、@Containerアノテーションを付けたstaticフィールドでコンテナを定義します。テスト実行前にコンテナが自動的に起動し、テスト終了後に停止します。

@DynamicPropertySourceによる接続情報の動的注入

Testcontainersは起動のたびにランダムなポートを使用するため、Spring Bootの設定ファイルにはあらかじめ接続情報を記述できません。@DynamicPropertySourceを使用して、実行時にコンテナから取得した接続情報をSpring Environmentに注入します。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package com.example.demo;

import com.example.demo.entity.User;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

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

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("testuser")
        .withPassword("testpass");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
    }

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUserInPostgreSQL() {
        // Given
        User newUser = new User("PostgreSQLテスト", "postgres@example.com");

        // When
        ResponseEntity<User> response = restTemplate.postForEntity(
            "/api/users",
            newUser,
            User.class
        );

        // Then
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody().getId()).isNotNull();
    }
}

@DynamicPropertySourceメソッドはstaticで宣言し、DynamicPropertyRegistryを引数に取ります。registry.add()で設定キーとその値を提供するラムダを登録します。

動作の仕組み

sequenceDiagram
    participant JUnit as JUnit 5
    participant TC as Testcontainers
    participant Docker as Docker
    participant Spring as Spring Boot
    participant Test as テストクラス

    JUnit->>TC: @Container フィールドを検出
    TC->>Docker: PostgreSQLコンテナを起動
    Docker-->>TC: コンテナ起動完了(ランダムポート割当)
    TC->>Spring: @DynamicPropertySource を実行
    Note over Spring: spring.datasource.url等を動的に設定
    Spring->>Spring: ApplicationContext を起動
    Spring-->>Test: テスト実行開始
    Test->>Test: テストメソッド実行
    Test-->>JUnit: テスト完了
    JUnit->>TC: テスト終了通知
    TC->>Docker: コンテナ停止・削除

@ServiceConnectionによるシンプルな接続設定

Spring Boot 3.1以降では、@ServiceConnectionアノテーションを使用することで、@DynamicPropertySourceを使わずにより簡潔に接続設定ができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.example.demo;

import com.example.demo.entity.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

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

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiServiceConnectionTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUserWithServiceConnection() {
        User newUser = new User("ServiceConnectionテスト", "service@example.com");

        ResponseEntity<User> response = restTemplate.postForEntity(
            "/api/users",
            newUser,
            User.class
        );

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
    }
}

@ServiceConnectionを付与するだけで、Spring Bootが自動的に適切な接続プロパティを設定します。サポートされているコンテナタイプには、PostgreSQL、MySQL、MongoDB、Redis、Kafkaなどがあります。

完全な統合テストの実装例

ここまでの内容を組み合わせた完全な統合テストクラスの例を示します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
package com.example.demo;

import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.List;

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

@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("ユーザーAPI統合テスト")
class UserApiFullIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Nested
    @DisplayName("GET /api/users")
    class GetAllUsers {

        @Test
        @DisplayName("ユーザーが存在しない場合は空のリストを返す")
        void shouldReturnEmptyList() {
            ResponseEntity<List<User>> response = restTemplate.exchange(
                "/api/users",
                HttpMethod.GET,
                null,
                new ParameterizedTypeReference<>() {}
            );

            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(response.getBody()).isEmpty();
        }

        @Test
        @DisplayName("登録済みユーザーの一覧を返す")
        void shouldReturnAllUsers() {
            // Given
            userRepository.save(new User("ユーザー1", "user1@example.com"));
            userRepository.save(new User("ユーザー2", "user2@example.com"));

            // When
            ResponseEntity<List<User>> response = restTemplate.exchange(
                "/api/users",
                HttpMethod.GET,
                null,
                new ParameterizedTypeReference<>() {}
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(response.getBody()).hasSize(2);
        }
    }

    @Nested
    @DisplayName("GET /api/users/{id}")
    class GetUserById {

        @Test
        @DisplayName("指定したIDのユーザーを返す")
        void shouldReturnUser() {
            // Given
            User saved = userRepository.save(new User("取得テスト", "get@example.com"));

            // When
            ResponseEntity<User> response = restTemplate.getForEntity(
                "/api/users/" + saved.getId(),
                User.class
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
            assertThat(response.getBody().getName()).isEqualTo("取得テスト");
        }
    }

    @Nested
    @DisplayName("POST /api/users")
    class CreateUser {

        @Test
        @DisplayName("新しいユーザーを作成する")
        void shouldCreateUser() {
            // Given
            User newUser = new User("新規ユーザー", "new@example.com");

            // When
            ResponseEntity<User> response = restTemplate.postForEntity(
                "/api/users",
                newUser,
                User.class
            );

            // Then
            assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
            assertThat(response.getBody().getId()).isNotNull();
            assertThat(response.getBody().getName()).isEqualTo("新規ユーザー");

            // DBにも保存されていることを確認
            assertThat(userRepository.findAll()).hasSize(1);
        }
    }

    @Nested
    @DisplayName("PUT /api/users/{id}")
    class UpdateUser {

        @Test
        @DisplayName("既存ユーザーを更新する")
        void shouldUpdateUser() {
            // Given
            User saved = userRepository.save(new User("更新前", "before@example.com"));
            User updateData = new User("更新後", "after@example.com");

            // When
            restTemplate.put("/api/users/" + saved.getId(), updateData);

            // Then
            User updated = userRepository.findById(saved.getId()).orElseThrow();
            assertThat(updated.getName()).isEqualTo("更新後");
            assertThat(updated.getEmail()).isEqualTo("after@example.com");
        }
    }

    @Nested
    @DisplayName("DELETE /api/users/{id}")
    class DeleteUser {

        @Test
        @DisplayName("指定したIDのユーザーを削除する")
        void shouldDeleteUser() {
            // Given
            User saved = userRepository.save(new User("削除対象", "delete@example.com"));
            assertThat(userRepository.findById(saved.getId())).isPresent();

            // When
            restTemplate.delete("/api/users/" + saved.getId());

            // Then
            assertThat(userRepository.findById(saved.getId())).isEmpty();
        }
    }
}

テスト設計のポイント

  1. @BeforeEachでデータをクリア: 各テストの独立性を保つために、テスト前にデータベースをクリアします
  2. @Nestedでテストをグループ化: エンドポイントごとにネストクラスを作成し、テストを整理します
  3. @DisplayNameで可読性向上: 日本語でテストの意図を明確にします
  4. Given-When-Thenパターン: テストの構造を明確にし、可読性を高めます

テスト実行時の注意点

Docker環境の確認

Testcontainersを使用するには、Docker(またはDocker互換の環境)が実行されている必要があります。

1
2
# Dockerが起動しているか確認
docker info

テスト実行コマンド

1
2
3
4
5
# Mavenの場合
./mvnw test

# Gradleの場合
./gradlew test

期待される結果

テストが成功すると、以下のような出力が得られます。

[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] 
[INFO] BUILD SUCCESS

Testcontainersのログには、コンテナの起動・停止情報が表示されます。

INFO  [docker.testcontainers.org] Starting container with ID: abc123...
INFO  [docker.testcontainers.org] Container postgres:16-alpine started in PT2.345S

トラブルシューティング

コンテナが起動しない

Docker Desktopが起動しているか確認してください。また、リソース制限(メモリ、CPU)が原因でコンテナが起動できない場合があります。

ポートの競合

Testcontainersはランダムポートを使用するため、通常はポートの競合は発生しません。もし問題が発生した場合は、他のDockerコンテナが同じポートを使用していないか確認してください。

テストが遅い

Testcontainersはコンテナの起動に時間がかかります。以下の対策で高速化できます。

  1. Ryuk Containerの無効化(CI環境向け)
  2. コンテナの再利用(開発時向け)
1
2
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withReuse(true);  // コンテナを再利用

まとめ

Spring Bootの統合テストでは、@SpringBootTestTestRestTemplateを使用して全レイヤーを対象としたテストを実装できます。Testcontainersを組み合わせることで、本番環境と同じデータベースを使用した高信頼性のテストが可能になります。

@DynamicPropertySourceまたは@ServiceConnectionを使用して、動的に接続情報を注入することで、コンテナのランダムポートにも対応できます。

コントローラ層のユニットテストと組み合わせることで、効果的なテストピラミッドを構築し、REST APIの品質を担保できます。

参考リンク