0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot入門⑨】テストの書き方 ― JUnit 5 + MockMvc で品質を守る

0
Posted at

株式会社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回で導入したTodoNotFoundExceptiondescriptionフィールド追加前)をベースにしています。第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 MockMvcTestRestTemplate@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) を自動的に使用する
  • @EntityJpaRepository がスキャン対象になる
  • 各テストメソッドはトランザクション内で実行され、終了後に自動ロールバックされる
  • 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ではTestEntityManagerTodoRepositoryの両方が使えます。

  • テストデータの準備にはTestEntityManagerを使う(テスト対象のRepositoryで準備すると、準備自体のバグとテスト対象のバグが区別できない)
  • テスト対象の操作にはTodoRepositoryを使う
  • 結果の検証にはTestEntityManagerを使う(テスト対象のRepositoryで検証すると同様の理由)

4. コントローラーテスト(@WebMvcTest + MockMvc)

@WebMvcTest の特徴

@WebMvcTestは、Spring MVCのWeb層だけをロードする軽量なテストです。

  • @Controller@ControllerAdviceFilter等がスキャン対象
  • @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();
    }
}

以下のテストケースを書いてください。

  1. findByEmail - 存在するメールアドレスで検索した場合、該当ユーザーを返す
  2. findByEmail - 存在しないメールアドレスで検索した場合、IllegalArgumentExceptionを投げる
  3. 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));
    }
}

以下のテストケースを書いてください。

  1. GET /api/users/email/test@example.com - 200 OKでユーザー情報をJSONで返す
  2. GET /api/users/email/unknown@example.com - 存在しないメールの場合エラー
  3. 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つの統合テストとして作成してください。

  1. TODOを3件作成する
  2. 一覧取得で3件返ることを確認する
  3. 1件削除する
  4. 一覧取得で2件返ることを確認する
  5. 存在しない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 全レイヤー なし

テストを書く際の指針:

  1. テストピラミッドを意識する ― ユニットテストを多く、統合テストは少なく
  2. 各テストは独立させる ― 他のテストに依存しない、実行順序に依存しない
  3. AAAパターンで構造化する ― Arrange(準備)→ Act(実行)→ Assert(検証)
  4. 正常系だけでなく異常系もテストする ― 存在しないID、不正な入力、境界値
  5. テストは仕様書である@DisplayNameで何をテストしているか明確にする

次回予告

次回(第10回)は総合演習:掲示板アプリをSpring Bootで再構築です。第1回から第9回までに学んだすべての要素(Controller、Thymeleaf、バリデーション、JPA、REST API、Security、エラーハンドリング、テスト)を総動員し、Servlet/JSP入門シリーズで作った掲示板アプリをSpring Bootで再構築します。


Spring Boot入門シリーズ 全10回(予定):

  1. Servlet/JSPからの移行と環境構築
  2. コントローラとルーティング
  3. Thymeleafによるビュー
  4. フォーム処理とバリデーション
  5. Spring Data JPA(データベース連携)
  6. RESTful API設計
  7. Spring Security(認証・認可)
  8. 例外処理とエラーハンドリング
  9. :point_right: テストの書き方(JUnit + MockMvc)(本記事)
  10. 総合演習:掲示板アプリをSpring Bootで再構築

参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?