はじめに

Spring Boot REST APIの開発において、コントローラ層のユニットテストは品質保証の要となります。@WebMvcTestMockMvcを組み合わせることで、サーバーを起動せずに高速かつ効率的なコントローラテストを実現できます。

本記事では、Spring Bootにおけるコントローラのユニットテスト手法として、@WebMvcTestによるスライステスト、MockMvcを使ったHTTPリクエストの検証、@MockitoBeanによるサービス層のモック化、そしてjsonPath()によるレスポンスJSON検証について実践的なサンプルコードとともに解説します。

実行環境と前提条件

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

項目 バージョン
Java 21
Spring Boot 3.4.x
JUnit 5.x
Mockito 5.x

必要な依存関係

spring-boot-starter-testには、MockMvc、JUnit 5、Mockito、AssertJ、JSONPathなど、テストに必要なライブラリがすべて含まれています。

1
2
3
4
5
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

@WebMvcTestの仕組みと設定

@WebMvcTestとは

@WebMvcTestはSpring Bootが提供するスライステスト用のアノテーションです。アプリケーション全体ではなく、Spring MVCのWeb層(コントローラ層)のみを対象としたApplicationContextを構築します。

1
2
3
4
@WebMvcTest(UserController.class)
class UserControllerTest {
    // テストコード
}

@WebMvcTestが自動設定するコンポーネント

@WebMvcTestを使用すると、以下のコンポーネントのみがスキャン対象となります。

  • @Controller@RestController
  • @ControllerAdvice@RestControllerAdvice
  • @JsonComponent
  • ConverterGenericConverter
  • Filter
  • WebMvcConfigurer
  • HandlerMethodArgumentResolver

一方で、@Service@Repository@Componentといったビジネスロジックやデータアクセス層のBeanはスキャン対象外となります。これにより、テスト対象をコントローラ層に限定した高速なテスト実行が可能になります。

テスト対象コントローラの指定

@WebMvcTestのvalue属性で、テスト対象のコントローラクラスを明示的に指定できます。これにより、不要なコントローラのロードを防ぎ、テストの実行速度がさらに向上します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 単一のコントローラを指定
@WebMvcTest(UserController.class)
class UserControllerTest {
    // ...
}

// 複数のコントローラを指定
@WebMvcTest({UserController.class, ProductController.class})
class MultipleControllerTest {
    // ...
}

テスト対象のコントローラとサービス

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

Userエンティティ

1
2
3
4
5
public record User(
    Long id,
    String name,
    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
@Service
public class UserService {
    
    public User findById(Long id) {
        // データベースからユーザーを取得する処理
        return new User(id, "山田太郎", "yamada@example.com");
    }
    
    public List<User> findAll() {
        // 全ユーザーを取得する処理
        return List.of(
            new User(1L, "山田太郎", "yamada@example.com"),
            new User(2L, "佐藤花子", "sato@example.com")
        );
    }
    
    public User create(User user) {
        // ユーザーを作成する処理
        return new User(1L, user.name(), user.email());
    }
    
    public void deleteById(Long 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
@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        if (user == null) {
            return ResponseEntity.notFound().build();
        }
        return ResponseEntity.ok(user);
    }
    
    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        return ResponseEntity.ok(userService.findAll());
    }
    
    @PostMapping
    public ResponseEntity<User> createUser(@Valid @RequestBody User user) {
        User createdUser = userService.create(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
    }
    
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteById(id);
        return ResponseEntity.noContent().build();
    }
}

MockMvcによるHTTPリクエスト送信

MockMvcの基本構造

MockMvcは、サーバーを起動することなくSpring MVCのリクエスト処理をシミュレートするテストフレームワークです。@WebMvcTestを使用すると、MockMvcが自動的に設定されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockitoBean
    private UserService userService;
    
    @Test
    void ユーザーを取得できる() throws Exception {
        // テストコード
    }
}

GETリクエストのテスト

MockMvcを使ってGETリクエストを送信し、レスポンスを検証する基本的なパターンを示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
void ユーザーIDを指定してユーザーを取得できる() throws Exception {
    // Arrange: モックの振る舞いを定義
    User expectedUser = new User(1L, "山田太郎", "yamada@example.com");
    given(userService.findById(1L)).willReturn(expectedUser);
    
    // Act & Assert: リクエストを実行し結果を検証
    mockMvc.perform(get("/api/users/{id}", 1L)
            .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.name").value("山田太郎"))
        .andExpect(jsonPath("$.email").value("yamada@example.com"));
}

POSTリクエストのテスト

リクエストボディを含むPOSTリクエストのテスト方法を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
void 新規ユーザーを作成できる() throws Exception {
    // Arrange
    User inputUser = new User(null, "山田太郎", "yamada@example.com");
    User createdUser = new User(1L, "山田太郎", "yamada@example.com");
    given(userService.create(any(User.class))).willReturn(createdUser);
    
    String requestBody = """
        {
            "name": "山田太郎",
            "email": "yamada@example.com"
        }
        """;
    
    // Act & Assert
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(requestBody))
        .andExpect(status().isCreated())
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.name").value("山田太郎"));
}

DELETEリクエストのテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@Test
void ユーザーを削除できる() throws Exception {
    // Arrange
    doNothing().when(userService).deleteById(1L);
    
    // Act & Assert
    mockMvc.perform(delete("/api/users/{id}", 1L))
        .andExpect(status().isNoContent());
    
    // サービスメソッドが呼び出されたことを検証
    verify(userService, times(1)).deleteById(1L);
}

クエリパラメータを使ったテスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Test
void クエリパラメータで検索条件を指定できる() throws Exception {
    // Arrange
    List<User> users = List.of(new User(1L, "山田太郎", "yamada@example.com"));
    given(userService.searchByName("山田")).willReturn(users);
    
    // Act & Assert
    mockMvc.perform(get("/api/users/search")
            .param("name", "山田")
            .accept(MediaType.APPLICATION_JSON))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$", hasSize(1)));
}

@MockitoBeanによる依存のモック化

@MockitoBeanとは

@MockitoBeanはSpring Framework 6.2以降で導入されたアノテーションで、テスト対象のコントローラが依存するサービス層のBeanをモックに置き換えます。従来の@MockBeanの後継として位置づけられています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
@WebMvcTest(UserController.class)
class UserControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockitoBean
    private UserService userService;
    
    // テストメソッド
}

モックの振る舞いを定義する

Mockitoのgiven()メソッドを使用して、モックオブジェクトの振る舞いを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Test
void モックの振る舞いを定義する例() throws Exception {
    // 戻り値を持つメソッドのモック
    given(userService.findById(1L))
        .willReturn(new User(1L, "山田太郎", "yamada@example.com"));
    
    // 例外をスローするモック
    given(userService.findById(999L))
        .willThrow(new UserNotFoundException("ユーザーが見つかりません"));
    
    // 引数に応じて異なる値を返すモック
    given(userService.findById(anyLong()))
        .willAnswer(invocation -> {
            Long id = invocation.getArgument(0);
            return new User(id, "ユーザー" + id, "user" + id + "@example.com");
        });
}

メソッド呼び出しの検証

verify()を使用して、モックメソッドが正しく呼び出されたことを検証できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
@Test
void サービスメソッドの呼び出しを検証する() throws Exception {
    // Arrange
    given(userService.findById(1L))
        .willReturn(new User(1L, "山田太郎", "yamada@example.com"));
    
    // Act
    mockMvc.perform(get("/api/users/{id}", 1L));
    
    // Assert: メソッドが1回呼び出されたことを検証
    verify(userService, times(1)).findById(1L);
    
    // 呼び出されなかったことを検証
    verify(userService, never()).deleteById(anyLong());
}

jsonPath()によるレスポンスJSON検証

JsonPathの基本構文

jsonPath()はJSONレスポンスの特定の値を抽出・検証するためのDSLです。JSONPathの構文を使用してJSONドキュメント内の要素にアクセスできます。

構文 説明
$ ルート要素
. 子要素へのアクセス
[] 配列のインデックスアクセス
[*] 配列の全要素
.. 再帰的な検索

単一オブジェクトの検証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Test
void 単一オブジェクトのJSON構造を検証する() throws Exception {
    // Arrange
    User user = new User(1L, "山田太郎", "yamada@example.com");
    given(userService.findById(1L)).willReturn(user);
    
    // Act & Assert
    mockMvc.perform(get("/api/users/{id}", 1L))
        .andExpect(status().isOk())
        // 値の完全一致
        .andExpect(jsonPath("$.id").value(1))
        .andExpect(jsonPath("$.name").value("山田太郎"))
        // 値の存在確認
        .andExpect(jsonPath("$.email").exists())
        // 値が存在しないことの確認
        .andExpect(jsonPath("$.password").doesNotExist())
        // 型の検証
        .andExpect(jsonPath("$.id").isNumber())
        .andExpect(jsonPath("$.name").isString());
}

配列の検証

 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 配列レスポンスを検証する() throws Exception {
    // Arrange
    List<User> users = List.of(
        new User(1L, "山田太郎", "yamada@example.com"),
        new User(2L, "佐藤花子", "sato@example.com")
    );
    given(userService.findAll()).willReturn(users);
    
    // Act & Assert
    mockMvc.perform(get("/api/users"))
        .andExpect(status().isOk())
        // 配列であることを確認
        .andExpect(jsonPath("$").isArray())
        // 配列のサイズを検証
        .andExpect(jsonPath("$", hasSize(2)))
        // 配列の特定要素にアクセス
        .andExpect(jsonPath("$[0].id").value(1))
        .andExpect(jsonPath("$[0].name").value("山田太郎"))
        .andExpect(jsonPath("$[1].id").value(2))
        // 配列内の全要素のプロパティを検証
        .andExpect(jsonPath("$[*].email").isNotEmpty());
}

Hamcrestマッチャーとの組み合わせ

jsonPath()はHamcrestマッチャーと組み合わせることで、より柔軟な検証が可能になります。

 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
import static org.hamcrest.Matchers.*;

@Test
void Hamcrestマッチャーを使った高度な検証() throws Exception {
    // Arrange
    List<User> users = List.of(
        new User(1L, "山田太郎", "yamada@example.com"),
        new User(2L, "佐藤花子", "sato@example.com")
    );
    given(userService.findAll()).willReturn(users);
    
    // Act & Assert
    mockMvc.perform(get("/api/users"))
        .andExpect(status().isOk())
        // サイズの検証
        .andExpect(jsonPath("$", hasSize(greaterThan(0))))
        // 値が特定の条件を満たすことを検証
        .andExpect(jsonPath("$[0].id", is(1)))
        .andExpect(jsonPath("$[*].name", hasItem("山田太郎")))
        // 文字列パターンの検証
        .andExpect(jsonPath("$[0].email", containsString("@")))
        // 複数条件の組み合わせ
        .andExpect(jsonPath("$[0].name", allOf(
            startsWith("山田"),
            hasLength(4)
        )));
}

MockMvcTesterによるAssertJスタイルのテスト

Spring Boot 3.4以降では、AssertJと統合されたMockMvcTesterも利用可能です。従来のMockMvcに比べて、より流暢なアサーション記述が可能になります。

 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
@WebMvcTest(UserController.class)
class UserControllerAssertJTest {
    
    @Autowired
    private MockMvcTester mvc;
    
    @MockitoBean
    private UserService userService;
    
    @Test
    void AssertJスタイルでユーザー取得をテストする() {
        // Arrange
        User user = new User(1L, "山田太郎", "yamada@example.com");
        given(userService.findById(1L)).willReturn(user);
        
        // Act & Assert
        assertThat(mvc.get().uri("/api/users/{id}", 1L)
                .accept(MediaType.APPLICATION_JSON))
            .hasStatusOk()
            .hasContentType(MediaType.APPLICATION_JSON)
            .bodyJson()
            .extractingPath("$.name")
            .isEqualTo("山田太郎");
    }
}

エラーケースのテスト

404 Not Foundの検証

1
2
3
4
5
6
7
8
9
@Test
void 存在しないユーザーを取得すると404を返す() throws Exception {
    // Arrange
    given(userService.findById(999L)).willReturn(null);
    
    // Act & Assert
    mockMvc.perform(get("/api/users/{id}", 999L))
        .andExpect(status().isNotFound());
}

バリデーションエラーの検証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@Test
void 不正なリクエストボディで400を返す() throws Exception {
    // Arrange
    String invalidRequestBody = """
        {
            "name": "",
            "email": "invalid-email"
        }
        """;
    
    // Act & Assert
    mockMvc.perform(post("/api/users")
            .contentType(MediaType.APPLICATION_JSON)
            .content(invalidRequestBody))
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.errors").isArray())
        .andExpect(jsonPath("$.errors", hasSize(greaterThan(0))));
}

例外ハンドリングの検証

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
@Test
void サービス層で例外が発生した場合のエラーレスポンスを検証する() throws Exception {
    // Arrange
    given(userService.findById(1L))
        .willThrow(new RuntimeException("データベース接続エラー"));
    
    // Act & Assert
    mockMvc.perform(get("/api/users/{id}", 1L))
        .andExpect(status().isInternalServerError());
}

完全なテストクラスの例

以下に、これまでの内容を統合した完全なテストクラスの例を示します。

  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
package com.example.api.controller;

import com.example.api.model.User;
import com.example.api.service.UserService;
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.List;

import static org.hamcrest.Matchers.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
@DisplayName("UserController のテスト")
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private UserService userService;

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

        @Test
        @DisplayName("存在するユーザーIDを指定すると、ユーザー情報を返す")
        void returnsUserWhenExists() throws Exception {
            // Arrange
            User user = new User(1L, "山田太郎", "yamada@example.com");
            given(userService.findById(1L)).willReturn(user);

            // Act & Assert
            mockMvc.perform(get("/api/users/{id}", 1L)
                    .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("山田太郎"))
                .andExpect(jsonPath("$.email").value("yamada@example.com"));
        }

        @Test
        @DisplayName("存在しないユーザーIDを指定すると、404を返す")
        void returns404WhenNotExists() throws Exception {
            // Arrange
            given(userService.findById(999L)).willReturn(null);

            // Act & Assert
            mockMvc.perform(get("/api/users/{id}", 999L))
                .andExpect(status().isNotFound());
        }
    }

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

        @Test
        @DisplayName("全ユーザーの一覧を返す")
        void returnsAllUsers() throws Exception {
            // Arrange
            List<User> users = List.of(
                new User(1L, "山田太郎", "yamada@example.com"),
                new User(2L, "佐藤花子", "sato@example.com")
            );
            given(userService.findAll()).willReturn(users);

            // Act & Assert
            mockMvc.perform(get("/api/users")
                    .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$", hasSize(2)))
                .andExpect(jsonPath("$[0].name").value("山田太郎"))
                .andExpect(jsonPath("$[1].name").value("佐藤花子"));
        }
    }

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

        @Test
        @DisplayName("有効なリクエストで新規ユーザーを作成できる")
        void createsUserWithValidRequest() throws Exception {
            // Arrange
            User createdUser = new User(1L, "山田太郎", "yamada@example.com");
            given(userService.create(any(User.class))).willReturn(createdUser);

            String requestBody = """
                {
                    "name": "山田太郎",
                    "email": "yamada@example.com"
                }
                """;

            // Act & Assert
            mockMvc.perform(post("/api/users")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(requestBody))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("山田太郎"));

            verify(userService, times(1)).create(any(User.class));
        }
    }

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

        @Test
        @DisplayName("ユーザーを削除すると204を返す")
        void deletesUserAndReturns204() throws Exception {
            // Arrange
            doNothing().when(userService).deleteById(1L);

            // Act & Assert
            mockMvc.perform(delete("/api/users/{id}", 1L))
                .andExpect(status().isNoContent());

            verify(userService, times(1)).deleteById(1L);
        }
    }
}

テスト実行と期待される結果

上記のテストクラスをIDEまたはMaven/Gradleで実行すると、以下のような結果が期待されます。

Mavenでの実行

1
mvn test -Dtest=UserControllerTest

Gradleでの実行

1
./gradlew test --tests UserControllerTest

期待される出力

UserController のテスト
  GET /api/users/{id}
    [OK] 存在するユーザーIDを指定すると、ユーザー情報を返す
    [OK] 存在しないユーザーIDを指定すると、404を返す
  GET /api/users
    [OK] 全ユーザーの一覧を返す
  POST /api/users
    [OK] 有効なリクエストで新規ユーザーを作成できる
  DELETE /api/users/{id}
    [OK] ユーザーを削除すると204を返す

5 tests completed, 0 failed

まとめ

本記事では、Spring Boot REST APIにおけるコントローラのユニットテスト手法について解説しました。

  • @WebMvcTestによるスライステストで、コントローラ層のみを対象とした高速なテストが実現できます
  • MockMvcを使用することで、サーバーを起動せずにHTTPリクエスト・レスポンスをシミュレートできます
  • @MockitoBeanでサービス層の依存をモック化し、コントローラ層に集中したテストが可能になります
  • jsonPath()とHamcrestマッチャーの組み合わせにより、JSONレスポンスの詳細な検証ができます

コントローラのユニットテストを適切に実装することで、REST APIの品質を効率的に担保できます。次のステップとして、@SpringBootTestとTestcontainersを使用した統合テストの導入を検討してください。

参考リンク