はじめに#
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();
}
}
}
|
テスト設計のポイント#
@BeforeEachでデータをクリア: 各テストの独立性を保つために、テスト前にデータベースをクリアします
@Nestedでテストをグループ化: エンドポイントごとにネストクラスを作成し、テストを整理します
@DisplayNameで可読性向上: 日本語でテストの意図を明確にします
- 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はコンテナの起動に時間がかかります。以下の対策で高速化できます。
- Ryuk Containerの無効化(CI環境向け)
- コンテナの再利用(開発時向け)
1
2
|
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // コンテナを再利用
|
まとめ#
Spring Bootの統合テストでは、@SpringBootTestとTestRestTemplateを使用して全レイヤーを対象としたテストを実装できます。Testcontainersを組み合わせることで、本番環境と同じデータベースを使用した高信頼性のテストが可能になります。
@DynamicPropertySourceまたは@ServiceConnectionを使用して、動的に接続情報を注入することで、コンテナのランダムポートにも対応できます。
コントローラ層のユニットテストと組み合わせることで、効果的なテストピラミッドを構築し、REST APIの品質を担保できます。
参考リンク#