株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
はじめに
前回(第8回)では、例外処理とエラーハンドリングを学び、APIとしてもWebアプリケーションとしても堅牢なエラー応答を返せるようになりました。
しかし、ここまで作ってきたコードが本当に正しく動いていることをどうやって保証しますか?
「ブラウザで画面を開いて確認した」「curlでAPIを叩いた」――これらは手動テストです。手動テストには大きな問題があります。
- 時間がかかる: 機能が増えるほど、確認箇所が増える
- 漏れが出る: 修正のたびに全機能を手動で確認するのは現実的でない
- 回帰バグ: Aを直したらBが壊れた、に気づけない
自動テストはこれらの問題を解決します。一度書けば、コマンド1つで何度でも実行でき、変更のたびに全テストを走らせてデグレード(機能後退)を即座に検知できます。
第9回では、Spring Bootのテスト基盤を使い、サービス層のユニットテスト、リポジトリ層のテスト、コントローラー層のテスト、統合テストの書き方を学びます。
今回学ぶこと
-
spring-boot-starter-testに含まれるライブラリ - テストアノテーションの使い分け(
@SpringBootTest/@WebMvcTest/@DataJpaTest) - JUnit 5 + Mockitoによるサービス層のユニットテスト
-
@DataJpaTestによるリポジトリ層のテスト -
@WebMvcTest+ MockMvcによるコントローラー層のテスト -
@SpringBootTestによる統合テスト - 実践例:TODO APIのテスト一式
本記事のコードはすべて第1回で作成したhello-springプロジェクト(com.example.hellospringパッケージ)上で動作します。テストの説明をシンプルにするため、本記事では第5回時点のTodoService(第8回で導入したTodoNotFoundExceptionやdescriptionフィールド追加前)をベースにしています。第8回の変更を反映したプロジェクトでテストを書く場合は、例外クラスやメソッドシグネチャを適宜置き換えてください。
1. Spring Bootのテスト基盤
spring-boot-starter-test
Spring Bootプロジェクトを作成すると、pom.xmlに最初から以下の依存関係が含まれています。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
この1行で、テストに必要な主要ライブラリがすべてインポートされます。
| ライブラリ | 役割 |
|---|---|
| JUnit 5(Jupiter) | テストの実行基盤。@Test、@BeforeEach、@DisplayName など |
| Mockito | モックオブジェクトの作成。依存クラスの振る舞いを模倣する |
| AssertJ | 流暢なアサーション。assertThat(actual).isEqualTo(expected)
|
| Spring Test |
MockMvc、TestRestTemplate、@MockitoBean など |
| Hamcrest | マッチャーライブラリ。jsonPath の検証で使用 |
| JSONassert | JSONレスポンスの検証 |
| JsonPath | JSONからの値抽出(XPathのJSON版) |
| Awaitility | 非同期処理のテスト用ライブラリ |
テストクラスの配置
テストコードは src/test/java に配置します。テスト対象クラスと同じパッケージ構造にするのが慣例です。
hello-spring/
├── src/
│ ├── main/java/com/example/hellospring/
│ │ ├── entity/
│ │ │ └── Todo.java
│ │ ├── repository/
│ │ │ └── TodoRepository.java
│ │ ├── service/
│ │ │ └── TodoService.java
│ │ └── controller/
│ │ └── TodoApiController.java
│ └── test/java/com/example/hellospring/
│ ├── service/
│ │ └── TodoServiceTest.java ← ユニットテスト
│ ├── repository/
│ │ └── TodoRepositoryTest.java ← リポジトリテスト
│ └── controller/
│ └── TodoApiControllerTest.java ← コントローラーテスト
テストアノテーションの使い分け
Spring Bootには、テストの範囲に応じたアノテーションが用意されています。
| アノテーション | ロードする範囲 | 用途 | 速度 |
|---|---|---|---|
| なし(素のJUnit 5) | Springコンテキストなし | 純粋なユニットテスト | 最速 |
@DataJpaTest |
JPA関連のみ(Entity, Repository) | リポジトリのテスト | 速い |
@WebMvcTest |
Web層のみ(Controller, Filter等) | コントローラーのテスト | 速い |
@SpringBootTest |
アプリケーション全体 | 統合テスト | 遅い |
原則:テストの範囲は必要最小限にする。 速度が速いほど開発中にこまめに実行でき、フィードバックサイクルが短くなります。
速い ←───────────────────────────→ 遅い
ユニットテスト → @DataJpaTest → @WebMvcTest → @SpringBootTest
(Spring不要) (JPA層のみ) (Web層のみ) (全体)
2. ユニットテスト(Service層)
テスト対象の確認
まず、テスト対象となるTodoServiceを確認します。第5回で作成したものです。
package com.example.hellospring.service;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.repository.TodoRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@Transactional
public class TodoService {
private final TodoRepository todoRepository;
public TodoService(TodoRepository todoRepository) {
this.todoRepository = todoRepository;
}
public List<Todo> findAll() {
return todoRepository.findAll();
}
public Todo findById(Long id) {
return todoRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException(
"Todo not found: id=" + id));
}
public Todo create(Todo todo) {
return todoRepository.save(todo);
}
public Todo update(Long id, Todo updated) {
Todo todo = findById(id);
todo.setTitle(updated.getTitle());
todo.setCompleted(updated.isCompleted());
return todoRepository.save(todo);
}
public void delete(Long id) {
Todo todo = findById(id);
todoRepository.delete(todo);
}
}
JUnit 5 + Mockitoの基本
サービス層のテストでは、依存先であるリポジトリをモック化し、サービスのロジックだけを検証します。Springコンテキストは起動しません。
package com.example.hellospring.service;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.repository.TodoRepository;
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.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("TodoService のテスト")
class TodoServiceTest {
@Mock
private TodoRepository todoRepository;
@InjectMocks
private TodoService todoService;
private Todo sampleTodo;
@BeforeEach
void setUp() {
sampleTodo = new Todo();
sampleTodo.setId(1L);
sampleTodo.setTitle("テスト用TODO");
sampleTodo.setCompleted(false);
}
@Nested
@DisplayName("findAll")
class FindAllTests {
@Test
@DisplayName("全件取得 - TODOが2件ある場合、2件返す")
void 全件取得_2件() {
// Arrange(準備)
Todo todo2 = new Todo();
todo2.setId(2L);
todo2.setTitle("2番目のTODO");
todo2.setCompleted(true);
when(todoRepository.findAll())
.thenReturn(Arrays.asList(sampleTodo, todo2));
// Act(実行)
List<Todo> result = todoService.findAll();
// Assert(検証)
assertThat(result).hasSize(2);
assertThat(result.get(0).getTitle()).isEqualTo("テスト用TODO");
assertThat(result.get(1).getTitle()).isEqualTo("2番目のTODO");
// リポジトリのfindAllが1回呼ばれたことを検証
verify(todoRepository, times(1)).findAll();
}
@Test
@DisplayName("全件取得 - TODOが0件の場合、空リストを返す")
void 全件取得_0件() {
when(todoRepository.findAll()).thenReturn(List.of());
List<Todo> result = todoService.findAll();
assertThat(result).isEmpty();
}
}
@Nested
@DisplayName("findById")
class FindByIdTests {
@Test
@DisplayName("ID検索 - 存在するIDの場合、該当のTODOを返す")
void ID検索_存在する() {
when(todoRepository.findById(1L))
.thenReturn(Optional.of(sampleTodo));
Todo result = todoService.findById(1L);
assertThat(result.getTitle()).isEqualTo("テスト用TODO");
assertThat(result.isCompleted()).isFalse();
}
@Test
@DisplayName("ID検索 - 存在しないIDの場合、例外を投げる")
void ID検索_存在しない() {
when(todoRepository.findById(999L))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> todoService.findById(999L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Todo not found: id=999");
}
}
@Nested
@DisplayName("create")
class CreateTests {
@Test
@DisplayName("新規作成 - TODOを保存し、保存後のオブジェクトを返す")
void 新規作成() {
Todo newTodo = new Todo();
newTodo.setTitle("新しいTODO");
Todo savedTodo = new Todo();
savedTodo.setId(1L);
savedTodo.setTitle("新しいTODO");
savedTodo.setCompleted(false);
when(todoRepository.save(newTodo)).thenReturn(savedTodo);
Todo result = todoService.create(newTodo);
assertThat(result.getId()).isEqualTo(1L);
assertThat(result.getTitle()).isEqualTo("新しいTODO");
verify(todoRepository).save(newTodo);
}
}
@Nested
@DisplayName("update")
class UpdateTests {
@Test
@DisplayName("更新 - 既存TODOのタイトルと完了状態を更新する")
void 更新_正常() {
Todo updated = new Todo();
updated.setTitle("更新後のタイトル");
updated.setCompleted(true);
when(todoRepository.findById(1L))
.thenReturn(Optional.of(sampleTodo));
when(todoRepository.save(any(Todo.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
Todo result = todoService.update(1L, updated);
assertThat(result.getTitle()).isEqualTo("更新後のタイトル");
assertThat(result.isCompleted()).isTrue();
verify(todoRepository).save(sampleTodo);
}
@Test
@DisplayName("更新 - 存在しないIDの場合、例外を投げる")
void 更新_存在しないID() {
Todo updated = new Todo();
updated.setTitle("更新後");
when(todoRepository.findById(999L))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> todoService.update(999L, updated))
.isInstanceOf(IllegalArgumentException.class);
// saveが呼ばれていないことを検証
verify(todoRepository, never()).save(any());
}
}
@Nested
@DisplayName("delete")
class DeleteTests {
@Test
@DisplayName("削除 - 存在するTODOを削除する")
void 削除_正常() {
when(todoRepository.findById(1L))
.thenReturn(Optional.of(sampleTodo));
todoService.delete(1L);
verify(todoRepository).delete(sampleTodo);
}
@Test
@DisplayName("削除 - 存在しないIDの場合、例外を投げる")
void 削除_存在しないID() {
when(todoRepository.findById(999L))
.thenReturn(Optional.empty());
assertThatThrownBy(() -> todoService.delete(999L))
.isInstanceOf(IllegalArgumentException.class);
verify(todoRepository, never()).delete(any());
}
}
}
主要なアノテーションと機能
テストクラスの設定
| アノテーション | 役割 |
|---|---|
@ExtendWith(MockitoExtension.class) |
Mockitoの機能をJUnit 5で使えるようにする |
@Mock |
モックオブジェクトを生成する |
@InjectMocks |
@Mockで生成したモックを注入してテスト対象を生成する |
@DisplayName("...") |
テスト名を日本語で分かりやすく表示する |
@Nested |
テストクラスをグループ化する |
@BeforeEach |
各テストメソッドの前に実行する準備処理 |
Mockitoの主要メソッド
| メソッド | 役割 | 例 |
|---|---|---|
when(...).thenReturn(...) |
モックの戻り値を設定 | when(repo.findById(1L)).thenReturn(Optional.of(todo)) |
when(...).thenThrow(...) |
モックに例外を投げさせる | when(repo.findById(1L)).thenThrow(new RuntimeException()) |
verify(mock).method(...) |
メソッドが呼ばれたことを検証 | verify(repo).save(todo) |
verify(mock, times(n)) |
呼ばれた回数を検証 | verify(repo, times(1)).findAll() |
verify(mock, never()) |
呼ばれていないことを検証 | verify(repo, never()).delete(any()) |
any() / any(Class)
|
任意の引数にマッチ | when(repo.save(any(Todo.class))) |
テストの構造(AAAパターン)
各テストメソッドはAAA(Arrange-Act-Assert)パターンで構成します。
@Test
void テスト名() {
// Arrange(準備): テストデータやモックの設定
when(repository.findById(1L)).thenReturn(Optional.of(todo));
// Act(実行): テスト対象メソッドの呼び出し
Todo result = service.findById(1L);
// Assert(検証): 結果の確認
assertThat(result.getTitle()).isEqualTo("テスト用TODO");
}
3. リポジトリテスト(@DataJpaTest)
@DataJpaTest の特徴
@DataJpaTestは、JPA関連のコンポーネントだけをロードする軽量なテストです。
- インメモリデータベース(H2) を自動的に使用する
-
@Entity、JpaRepositoryがスキャン対象になる - 各テストメソッドはトランザクション内で実行され、終了後に自動ロールバックされる
-
TestEntityManagerが自動注入可能
テスト対象の確認
package com.example.hellospring.repository;
import com.example.hellospring.entity.Todo;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TodoRepository extends JpaRepository<Todo, Long> {
List<Todo> findByCompleted(boolean completed);
List<Todo> findByTitleContaining(String keyword);
}
リポジトリテストの実装
package com.example.hellospring.repository;
import com.example.hellospring.entity.Todo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@DisplayName("TodoRepository のテスト")
class TodoRepositoryTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private TodoRepository todoRepository;
@BeforeEach
void setUp() {
// テストデータを投入
Todo todo1 = new Todo();
todo1.setTitle("買い物に行く");
todo1.setCompleted(false);
entityManager.persist(todo1);
Todo todo2 = new Todo();
todo2.setTitle("レポートを書く");
todo2.setCompleted(true);
entityManager.persist(todo2);
Todo todo3 = new Todo();
todo3.setTitle("買い物リストを作る");
todo3.setCompleted(false);
entityManager.persist(todo3);
entityManager.flush();
}
@Test
@DisplayName("findAll - 全件取得で3件返る")
void findAll() {
List<Todo> todos = todoRepository.findAll();
assertThat(todos).hasSize(3);
}
@Test
@DisplayName("findById - 存在するIDで該当のTODOを取得できる")
void findById_存在する() {
// persistで返されるエンティティからIDを取得
Todo persisted = new Todo();
persisted.setTitle("テスト");
persisted.setCompleted(false);
Todo saved = entityManager.persistAndFlush(persisted);
Optional<Todo> found = todoRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("テスト");
}
@Test
@DisplayName("findByCompleted - 未完了のTODOだけを取得できる")
void findByCompleted_未完了() {
List<Todo> incompleteTodos = todoRepository.findByCompleted(false);
assertThat(incompleteTodos).hasSize(2);
assertThat(incompleteTodos)
.allMatch(todo -> !todo.isCompleted());
}
@Test
@DisplayName("findByCompleted - 完了済みのTODOだけを取得できる")
void findByCompleted_完了済み() {
List<Todo> completedTodos = todoRepository.findByCompleted(true);
assertThat(completedTodos).hasSize(1);
assertThat(completedTodos.get(0).getTitle())
.isEqualTo("レポートを書く");
}
@Test
@DisplayName("findByTitleContaining - タイトルにキーワードを含むTODOを取得できる")
void findByTitleContaining() {
List<Todo> results = todoRepository.findByTitleContaining("買い物");
assertThat(results).hasSize(2);
assertThat(results)
.extracting(Todo::getTitle)
.allMatch(title -> title.contains("買い物"));
}
@Test
@DisplayName("save - 新しいTODOを保存できる")
void save() {
Todo newTodo = new Todo();
newTodo.setTitle("新規TODO");
newTodo.setCompleted(false);
Todo saved = todoRepository.save(newTodo);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getTitle()).isEqualTo("新規TODO");
// DBから再取得して確認
Todo found = entityManager.find(Todo.class, saved.getId());
assertThat(found.getTitle()).isEqualTo("新規TODO");
}
@Test
@DisplayName("delete - TODOを削除できる")
void delete() {
Todo todo = new Todo();
todo.setTitle("削除対象");
todo.setCompleted(false);
Todo saved = entityManager.persistAndFlush(todo);
todoRepository.deleteById(saved.getId());
entityManager.flush();
Todo found = entityManager.find(Todo.class, saved.getId());
assertThat(found).isNull();
}
}
TestEntityManager と Repository の使い分け
@DataJpaTestではTestEntityManagerとTodoRepositoryの両方が使えます。
-
テストデータの準備には
TestEntityManagerを使う(テスト対象のRepositoryで準備すると、準備自体のバグとテスト対象のバグが区別できない) -
テスト対象の操作には
TodoRepositoryを使う -
結果の検証には
TestEntityManagerを使う(テスト対象のRepositoryで検証すると同様の理由)
4. コントローラーテスト(@WebMvcTest + MockMvc)
@WebMvcTest の特徴
@WebMvcTestは、Spring MVCのWeb層だけをロードする軽量なテストです。
-
@Controller、@ControllerAdvice、Filter等がスキャン対象 -
@Service、@Repositoryはスキャンされない(モックが必要) -
MockMvcが自動的に設定され、HTTPリクエストをシミュレートできる
テスト対象の確認
package com.example.hellospring.controller;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.service.TodoService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/todos")
public class TodoApiController {
private final TodoService todoService;
public TodoApiController(TodoService todoService) {
this.todoService = todoService;
}
@GetMapping
public List<Todo> findAll() {
return todoService.findAll();
}
@GetMapping("/{id}")
public Todo findById(@PathVariable Long id) {
return todoService.findById(id);
}
@PostMapping
public ResponseEntity<Todo> create(@RequestBody Todo todo) {
Todo created = todoService.create(todo);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public Todo update(@PathVariable Long id, @RequestBody Todo todo) {
return todoService.update(id, todo);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
todoService.delete(id);
return ResponseEntity.noContent().build();
}
}
@MockBean と @MockitoBean
コントローラーテストではService層をモック化する必要があります。Spring Bootでは2つのアノテーションが使えます。
| アノテーション | パッケージ | 状態 |
|---|---|---|
@MockBean |
org.springframework.boot.test.mock.mockito |
Spring Boot 3.4で非推奨(4.0で削除予定) |
@MockitoBean |
org.springframework.test.context.bean.override.mockito |
Spring Framework 6.2で導入。推奨 |
本記事では推奨の@MockitoBeanを使用します。
Spring Boot 3.3以前を使用している場合は、@MockitoBeanの代わりに@MockBeanを使用してください。インポート先が異なるだけで、基本的な使い方は同じです。
コントローラーテストの実装
package com.example.hellospring.controller;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.service.TodoService;
import com.fasterxml.jackson.databind.ObjectMapper;
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.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.Arrays;
import java.util.List;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TodoApiController.class)
@DisplayName("TodoApiController のテスト")
class TodoApiControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private TodoService todoService;
@Autowired
private ObjectMapper objectMapper;
// ── テストデータ作成用ヘルパーメソッド ──
private Todo createTodo(Long id, String title, boolean completed) {
Todo todo = new Todo();
todo.setId(id);
todo.setTitle(title);
todo.setCompleted(completed);
return todo;
}
// ── GET /api/todos ──
@Nested
@DisplayName("GET /api/todos")
class GetAllTests {
@Test
@DisplayName("200 OK - TODO一覧をJSON配列で返す")
void 全件取得() throws Exception {
List<Todo> todos = Arrays.asList(
createTodo(1L, "買い物", false),
createTodo(2L, "勉強", true)
);
when(todoService.findAll()).thenReturn(todos);
mockMvc.perform(get("/api/todos"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(2)))
.andExpect(jsonPath("$[0].id", is(1)))
.andExpect(jsonPath("$[0].title", is("買い物")))
.andExpect(jsonPath("$[0].completed", is(false)))
.andExpect(jsonPath("$[1].id", is(2)))
.andExpect(jsonPath("$[1].title", is("勉強")))
.andExpect(jsonPath("$[1].completed", is(true)));
}
@Test
@DisplayName("200 OK - TODOが0件の場合、空配列を返す")
void 全件取得_0件() throws Exception {
when(todoService.findAll()).thenReturn(List.of());
mockMvc.perform(get("/api/todos"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", hasSize(0)));
}
}
// ── GET /api/todos/{id} ──
@Nested
@DisplayName("GET /api/todos/{id}")
class GetByIdTests {
@Test
@DisplayName("200 OK - 指定IDのTODOをJSONで返す")
void ID検索_存在する() throws Exception {
Todo todo = createTodo(1L, "買い物", false);
when(todoService.findById(1L)).thenReturn(todo);
mockMvc.perform(get("/api/todos/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.title", is("買い物")))
.andExpect(jsonPath("$.completed", is(false)));
}
@Test
@DisplayName("404/500 - 存在しないIDの場合、エラーレスポンスを返す")
void ID検索_存在しない() throws Exception {
when(todoService.findById(999L))
.thenThrow(new IllegalArgumentException(
"Todo not found: id=999"));
mockMvc.perform(get("/api/todos/999"))
.andExpect(status().is4xxClientError());
}
}
// ── POST /api/todos ──
@Nested
@DisplayName("POST /api/todos")
class CreateTests {
@Test
@DisplayName("201 Created - 新しいTODOを作成してJSONで返す")
void 新規作成() throws Exception {
Todo input = new Todo();
input.setTitle("新しいTODO");
Todo saved = createTodo(1L, "新しいTODO", false);
when(todoService.create(any(Todo.class))).thenReturn(saved);
mockMvc.perform(post("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.title", is("新しいTODO")))
.andExpect(jsonPath("$.completed", is(false)));
verify(todoService).create(any(Todo.class));
}
}
// ── PUT /api/todos/{id} ──
@Nested
@DisplayName("PUT /api/todos/{id}")
class UpdateTests {
@Test
@DisplayName("200 OK - TODOを更新してJSONで返す")
void 更新() throws Exception {
Todo input = new Todo();
input.setTitle("更新後のタイトル");
input.setCompleted(true);
Todo updated = createTodo(1L, "更新後のタイトル", true);
when(todoService.update(eq(1L), any(Todo.class)))
.thenReturn(updated);
mockMvc.perform(put("/api/todos/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.title", is("更新後のタイトル")))
.andExpect(jsonPath("$.completed", is(true)));
}
}
// ── DELETE /api/todos/{id} ──
@Nested
@DisplayName("DELETE /api/todos/{id}")
class DeleteTests {
@Test
@DisplayName("204 No Content - TODOを削除する")
void 削除() throws Exception {
doNothing().when(todoService).delete(1L);
mockMvc.perform(delete("/api/todos/1"))
.andExpect(status().isNoContent());
verify(todoService).delete(1L);
}
@Test
@DisplayName("404/500 - 存在しないIDを削除しようとするとエラー")
void 削除_存在しないID() throws Exception {
doThrow(new IllegalArgumentException("Todo not found: id=999"))
.when(todoService).delete(999L);
mockMvc.perform(delete("/api/todos/999"))
.andExpect(status().is4xxClientError());
}
}
}
MockMvcの主要メソッド
リクエストの組み立て
// GETリクエスト
mockMvc.perform(get("/api/todos"))
// POSTリクエスト(JSONボディ付き)
mockMvc.perform(post("/api/todos")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"title\":\"テスト\"}"))
// PUTリクエスト
mockMvc.perform(put("/api/todos/1")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(todo)))
// DELETEリクエスト
mockMvc.perform(delete("/api/todos/1"))
// クエリパラメータ付き
mockMvc.perform(get("/api/todos").param("completed", "true"))
レスポンスの検証
// ステータスコード
.andExpect(status().isOk()) // 200
.andExpect(status().isCreated()) // 201
.andExpect(status().isNoContent()) // 204
.andExpect(status().isBadRequest()) // 400
.andExpect(status().isNotFound()) // 404
// JSONの値
.andExpect(jsonPath("$.title", is("買い物"))) // ルート要素
.andExpect(jsonPath("$[0].title", is("買い物"))) // 配列の要素
.andExpect(jsonPath("$", hasSize(3))) // 配列のサイズ
.andExpect(jsonPath("$.id").isNumber()) // 型の検証
.andExpect(jsonPath("$.title").isString()) // 型の検証
.andExpect(jsonPath("$.description").doesNotExist()) // 存在しない
// レスポンスヘッダー
.andExpect(header().string("Content-Type", "application/json"))
// レスポンスボディ全体(文字列として)
.andExpect(content().string("Hello World"))
5. 統合テスト(@SpringBootTest)
@SpringBootTest の特徴
@SpringBootTestはアプリケーション全体のコンテキストを起動します。全レイヤー(Controller → Service → Repository → DB)を通したエンドツーエンドのテストが可能です。
- 実際のSpring Bootアプリケーションと同じ環境でテストできる
- DBを含めた処理全体の整合性を確認できる
- 起動に時間がかかるため、数を絞って使う
統合テストの実装
package com.example.hellospring;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.repository.TodoRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("TODO API 統合テスト")
class TodoApiIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TodoRepository todoRepository;
@Autowired
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
todoRepository.deleteAll();
}
@Test
@DisplayName("TODO作成 → 一覧取得の一連の流れが正しく動作する")
void 作成から一覧取得まで() {
// 1. TODOを作成
Todo newTodo = new Todo();
newTodo.setTitle("統合テスト用TODO");
newTodo.setCompleted(false);
ResponseEntity<Todo> createResponse = restTemplate.postForEntity(
"/api/todos", newTodo, Todo.class);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(createResponse.getBody()).isNotNull();
assertThat(createResponse.getBody().getId()).isNotNull();
assertThat(createResponse.getBody().getTitle())
.isEqualTo("統合テスト用TODO");
Long createdId = createResponse.getBody().getId();
// 2. 一覧取得で作成したTODOが含まれることを確認
ResponseEntity<List<Todo>> listResponse = restTemplate.exchange(
"/api/todos",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<Todo>>() {});
assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(listResponse.getBody()).hasSize(1);
assertThat(listResponse.getBody().get(0).getTitle())
.isEqualTo("統合テスト用TODO");
// 3. ID指定で取得
ResponseEntity<Todo> getResponse = restTemplate.getForEntity(
"/api/todos/" + createdId, Todo.class);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().getTitle())
.isEqualTo("統合テスト用TODO");
}
@Test
@DisplayName("TODO更新が正しく動作する")
void 更新() {
// 準備: TODOを作成
Todo todo = new Todo();
todo.setTitle("更新前");
todo.setCompleted(false);
ResponseEntity<Todo> createResponse = restTemplate.postForEntity(
"/api/todos", todo, Todo.class);
Long id = createResponse.getBody().getId();
// 実行: 更新
Todo updateData = new Todo();
updateData.setTitle("更新後");
updateData.setCompleted(true);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Todo> request = new HttpEntity<>(updateData, headers);
ResponseEntity<Todo> updateResponse = restTemplate.exchange(
"/api/todos/" + id,
HttpMethod.PUT,
request,
Todo.class);
// 検証
assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(updateResponse.getBody().getTitle()).isEqualTo("更新後");
assertThat(updateResponse.getBody().isCompleted()).isTrue();
// DBから再取得して確認
ResponseEntity<Todo> getResponse = restTemplate.getForEntity(
"/api/todos/" + id, Todo.class);
assertThat(getResponse.getBody().getTitle()).isEqualTo("更新後");
}
@Test
@DisplayName("TODO削除が正しく動作する")
void 削除() {
// 準備: TODOを作成
Todo todo = new Todo();
todo.setTitle("削除対象");
todo.setCompleted(false);
ResponseEntity<Todo> createResponse = restTemplate.postForEntity(
"/api/todos", todo, Todo.class);
Long id = createResponse.getBody().getId();
// 実行: 削除
restTemplate.delete("/api/todos/" + id);
// 検証: 一覧が空になっている
ResponseEntity<List<Todo>> listResponse = restTemplate.exchange(
"/api/todos",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<Todo>>() {});
assertThat(listResponse.getBody()).isEmpty();
}
}
TestRestTemplate の主要メソッド
| メソッド | HTTPメソッド | 用途 |
|---|---|---|
getForEntity(url, Class) |
GET | レスポンスをResponseEntityで取得 |
postForEntity(url, body, Class) |
POST | リクエストボディ付きでPOST |
exchange(url, method, entity, Class) |
任意 | HTTPメソッドを自由に指定 |
delete(url) |
DELETE | 削除リクエスト |
put(url, body) |
PUT | 更新リクエスト(レスポンスなし) |
exchangeメソッドはHttpMethodを自由に指定でき、リクエストヘッダーやボディも設定できる汎用メソッドです。PUTでレスポンスボディを受け取りたい場合や、ジェネリクス型(List<Todo>等)を指定したい場合に使います。
6. テスト戦略のまとめ
テストピラミッド
テストは以下のピラミッド型で構成するのが理想です。下に行くほど数が多く、上に行くほど数が少ないのが望ましい構成です。
/\
/ \
/ 統合 \ ← 少数: 主要なシナリオのみ
/ テスト \ @SpringBootTest
/──────────\
/ コントローラー \ ← 中程度: 各エンドポイント
/ テスト \ @WebMvcTest
/──────────────────\
/ ユニットテスト \ ← 多数: 各メソッドの各パターン
/ リポジトリテスト \ JUnit 5 + Mockito / @DataJpaTest
/────────────────────────\
アノテーション選択フローチャート
テスト対象は何?
├── Service / ビジネスロジック
│ └── JUnit 5 + Mockito(Springコンテキスト不要)
├── Repository / クエリメソッド
│ └── @DataJpaTest
├── Controller / APIエンドポイント
│ └── @WebMvcTest + MockMvc
└── 複数レイヤーにまたがるシナリオ
└── @SpringBootTest
テスト実行コマンド
# 全テスト実行
./mvnw test
# 特定のテストクラスだけ実行
./mvnw test -Dtest=TodoServiceTest
# 特定のテストメソッドだけ実行
./mvnw test -Dtest="TodoServiceTest#全件取得_2件"
# テスト結果のレポート(target/surefire-reports/)
./mvnw surefire-report:report
練習問題
問題1: UserServiceのユニットテスト ⭐
以下のUserServiceクラスに対するユニットテストを作成してください。
package com.example.hellospring.service;
import com.example.hellospring.entity.User;
import com.example.hellospring.repository.UserRepository;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public User findByEmail(String email) {
return userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException(
"User not found: email=" + email));
}
public boolean existsByEmail(String email) {
return userRepository.findByEmail(email).isPresent();
}
}
以下のテストケースを書いてください。
-
findByEmail- 存在するメールアドレスで検索した場合、該当ユーザーを返す -
findByEmail- 存在しないメールアドレスで検索した場合、IllegalArgumentExceptionを投げる -
existsByEmail- 存在するメールアドレスの場合はtrue、存在しない場合はfalseを返す
模範解答
package com.example.hellospring.service;
import com.example.hellospring.entity.User;
import com.example.hellospring.repository.UserRepository;
import org.junit.jupiter.api.DisplayName;
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 java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@DisplayName("UserService のテスト")
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
@DisplayName("findByEmail - 存在するメールアドレスで該当ユーザーを返す")
void findByEmail_存在する() {
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
user.setName("テストユーザー");
when(userRepository.findByEmail("test@example.com"))
.thenReturn(Optional.of(user));
User result = userService.findByEmail("test@example.com");
assertThat(result.getEmail()).isEqualTo("test@example.com");
assertThat(result.getName()).isEqualTo("テストユーザー");
}
@Test
@DisplayName("findByEmail - 存在しないメールアドレスで例外を投げる")
void findByEmail_存在しない() {
when(userRepository.findByEmail("unknown@example.com"))
.thenReturn(Optional.empty());
assertThatThrownBy(() ->
userService.findByEmail("unknown@example.com"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("User not found: email=unknown@example.com");
}
@Test
@DisplayName("existsByEmail - 存在するメールアドレスの場合trueを返す")
void existsByEmail_存在する() {
User user = new User();
user.setEmail("test@example.com");
when(userRepository.findByEmail("test@example.com"))
.thenReturn(Optional.of(user));
assertThat(userService.existsByEmail("test@example.com")).isTrue();
}
@Test
@DisplayName("existsByEmail - 存在しないメールアドレスの場合falseを返す")
void existsByEmail_存在しない() {
when(userRepository.findByEmail("unknown@example.com"))
.thenReturn(Optional.empty());
assertThat(userService.existsByEmail("unknown@example.com")).isFalse();
}
}
問題2: MockMvcでのAPIテスト ⭐⭐
以下のUserApiControllerに対する@WebMvcTestテストを作成してください。
package com.example.hellospring.controller;
import com.example.hellospring.entity.User;
import com.example.hellospring.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/users")
public class UserApiController {
private final UserService userService;
public UserApiController(UserService userService) {
this.userService = userService;
}
@GetMapping("/email/{email}")
public User findByEmail(@PathVariable String email) {
return userService.findByEmail(email);
}
@GetMapping("/email/{email}/exists")
public ResponseEntity<Boolean> existsByEmail(@PathVariable String email) {
return ResponseEntity.ok(userService.existsByEmail(email));
}
}
以下のテストケースを書いてください。
-
GET /api/users/email/test@example.com- 200 OKでユーザー情報をJSONで返す -
GET /api/users/email/unknown@example.com- 存在しないメールの場合エラー -
GET /api/users/email/test@example.com/exists- 200 OKでtrueを返す
模範解答
package com.example.hellospring.controller;
import com.example.hellospring.entity.User;
import com.example.hellospring.service.UserService;
import org.junit.jupiter.api.DisplayName;
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.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.hamcrest.Matchers.is;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(UserApiController.class)
@DisplayName("UserApiController のテスト")
class UserApiControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Test
@DisplayName("GET /api/users/email/{email} - ユーザー情報をJSONで返す")
void findByEmail_存在する() throws Exception {
User user = new User();
user.setId(1L);
user.setEmail("test@example.com");
user.setName("テストユーザー");
when(userService.findByEmail("test@example.com"))
.thenReturn(user);
mockMvc.perform(get("/api/users/email/test@example.com"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id", is(1)))
.andExpect(jsonPath("$.email", is("test@example.com")))
.andExpect(jsonPath("$.name", is("テストユーザー")));
}
@Test
@DisplayName("GET /api/users/email/{email} - 存在しない場合エラー")
void findByEmail_存在しない() throws Exception {
when(userService.findByEmail("unknown@example.com"))
.thenThrow(new IllegalArgumentException(
"User not found: email=unknown@example.com"));
mockMvc.perform(get("/api/users/email/unknown@example.com"))
.andExpect(status().is4xxClientError());
}
@Test
@DisplayName("GET /api/users/email/{email}/exists - trueを返す")
void existsByEmail_存在する() throws Exception {
when(userService.existsByEmail("test@example.com"))
.thenReturn(true);
mockMvc.perform(
get("/api/users/email/test@example.com/exists"))
.andExpect(status().isOk())
.andExpect(jsonPath("$", is(true)));
}
}
問題3: エラーケースを含む統合テスト ⭐⭐⭐
@SpringBootTestを使い、以下のシナリオを1つの統合テストとして作成してください。
- TODOを3件作成する
- 一覧取得で3件返ることを確認する
- 1件削除する
- 一覧取得で2件返ることを確認する
- 存在しないID(9999)で取得を試み、エラーになることを確認する
模範解答
package com.example.hellospring;
import com.example.hellospring.entity.Todo;
import com.example.hellospring.repository.TodoRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
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.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@DisplayName("TODO API シナリオテスト")
class TodoApiScenarioTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TodoRepository todoRepository;
@BeforeEach
void setUp() {
todoRepository.deleteAll();
}
@Test
@DisplayName("作成→一覧→削除→一覧→存在しないID取得のシナリオ")
void 一連のシナリオ() {
// 1. TODOを3件作成
Long[] ids = new Long[3];
String[] titles = {"TODO-A", "TODO-B", "TODO-C"};
for (int i = 0; i < 3; i++) {
Todo todo = new Todo();
todo.setTitle(titles[i]);
todo.setCompleted(false);
ResponseEntity<Todo> response = restTemplate.postForEntity(
"/api/todos", todo, Todo.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
ids[i] = response.getBody().getId();
}
// 2. 一覧取得で3件返ることを確認
ResponseEntity<List<Todo>> listResponse = restTemplate.exchange(
"/api/todos",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<Todo>>() {});
assertThat(listResponse.getBody()).hasSize(3);
// 3. 1件削除(TODO-Bを削除)
restTemplate.delete("/api/todos/" + ids[1]);
// 4. 一覧取得で2件返ることを確認
listResponse = restTemplate.exchange(
"/api/todos",
HttpMethod.GET,
null,
new ParameterizedTypeReference<List<Todo>>() {});
assertThat(listResponse.getBody()).hasSize(2);
assertThat(listResponse.getBody())
.extracting(Todo::getTitle)
.containsExactlyInAnyOrder("TODO-A", "TODO-C");
// 5. 存在しないIDで取得を試みる
ResponseEntity<String> errorResponse = restTemplate.getForEntity(
"/api/todos/9999", String.class);
assertThat(errorResponse.getStatusCode().is4xxClientError()
|| errorResponse.getStatusCode().is5xxServerError())
.isTrue();
}
}
まとめ
本記事では、Spring Bootにおけるテストの書き方を学びました。
| テストの種類 | アノテーション | テスト対象 | モック化する層 |
|---|---|---|---|
| ユニットテスト | @ExtendWith(MockitoExtension.class) |
Service | Repository(@Mock) |
| リポジトリテスト | @DataJpaTest |
Repository | なし(H2を使用) |
| コントローラーテスト | @WebMvcTest |
Controller | Service(@MockitoBean) |
| 統合テスト | @SpringBootTest |
全レイヤー | なし |
テストを書く際の指針:
- テストピラミッドを意識する ― ユニットテストを多く、統合テストは少なく
- 各テストは独立させる ― 他のテストに依存しない、実行順序に依存しない
- AAAパターンで構造化する ― Arrange(準備)→ Act(実行)→ Assert(検証)
- 正常系だけでなく異常系もテストする ― 存在しないID、不正な入力、境界値
-
テストは仕様書である ―
@DisplayNameで何をテストしているか明確にする
次回予告
次回(第10回)は総合演習:掲示板アプリをSpring Bootで再構築です。第1回から第9回までに学んだすべての要素(Controller、Thymeleaf、バリデーション、JPA、REST API、Security、エラーハンドリング、テスト)を総動員し、Servlet/JSP入門シリーズで作った掲示板アプリをSpring Bootで再構築します。
Spring Boot入門シリーズ 全10回(予定):
- Servlet/JSPからの移行と環境構築
- コントローラとルーティング
- Thymeleafによるビュー
- フォーム処理とバリデーション
- Spring Data JPA(データベース連携)
- RESTful API設計
- Spring Security(認証・認可)
- 例外処理とエラーハンドリング
-
テストの書き方(JUnit + MockMvc)(本記事) - 総合演習:掲示板アプリをSpring Bootで再構築
参考
- Spring Boot 公式ドキュメント - Testing
- Spring Boot 公式ドキュメント - Test Scope Dependencies
- Spring Boot 公式ドキュメント - Testing Spring Boot Applications
- Spring Framework 公式ドキュメント - Testing
- Spring 公式ガイド - Testing the Web Layer
- JUnit 5 User Guide
- Mockito 公式ドキュメント
- AssertJ 公式ドキュメント
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!