unit-test-controller-layer について
Spring MVC 開発者向けに、MockMvc とモックしたサービスを使ってコントローラーを分離してテストするスキルです。リクエスト、レスポンス、検証、ヘッダー、コンテンツネゴシエーション、エラー処理を扱います。
主な機能
- 01MockMvc による単独コントローラー設定
- 02サービス依存関係のモック化
- 03HTTP メソッドとステータスの検証
- 04JSON とレスポンス本文の確認
- 05検証、ヘッダー、エラーケースのテスト
- 06コンテンツネゴシエーションのテスト
活用シーン
- 01REST コントローラーのエンドポイントテスト
- 02リクエストとレスポンス処理の確認
- 03検証結果とエラーレスポンスの確認
- 04ヘッダーとコンテンツタイプのテスト
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ giuseppe-trisciuoglio/ developer-kit --skill unit-test-controller-layerインストール後、Claude Code で/unit-test-controller-layerを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/advanced.mdcommon-pitfalls.mdtroubleshooting.md詳細
MockMvc を使用した REST コントローラーのユニットテスト
概要
MockMvc を使用して、@RestController および @Controller クラスをユニットテストするパターンを紹介します。リクエスト/レスポンスの処理、HTTP ステータスコード、リクエストパラメーターのバインド、バリデーション、コンテンツネゴシエーション、レスポンスヘッダー、モックしたサービス依存関係を使用した例外処理を扱います。
使用する場面
次の場合に使用します:コントローラーテスト、API エンドポイントのテスト、Spring MVC テスト、HTTP リクエストのモック、Web レイヤーのエンドポイントのユニットテスト、REST コントローラーを分離して検証する場合。
手順
- スタンドアロンの MockMvc をセットアップする:分離してテストするには
MockMvcBuilders.standaloneSetup(controller)を使用します - サービス依存関係をモックする:すべてのサービスに
@Mockを使用し、コントローラーには@InjectMocksを使用します - HTTP メソッドをテストする:GET、POST、PUT、PATCH、DELETE と適切なステータスコードをテストします
- レスポンスを検証する:JSON には JsonPath アサーションを、本文にはコンテンツマッチャーを使用します
- バリデーションをテストする:無効な入力を送信し、エラーの詳細を含む 400 ステータスを確認します
- エラーをテストする:適切な条件で 404、400、401、403、500 を確認します
- ヘッダーを検証する:リクエスト(Authorization)ヘッダーとレスポンスヘッダーの両方を検証します
- コンテンツネゴシエーションをテストする:異なる Accept ヘッダーと Content-Type ヘッダーを使用します
バリデーションのワークフロー
Run test → If fails: add .andDo(print()) → Check actual vs expected → Fix assertion
例
Maven / Gradle の依存関係
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
基本パターン:GET エンドポイント
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
@Mock
private UserService userService;
@InjectMocks
private UserController userController;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(userController).build();
}
@Test
void shouldReturnAllUsers() throws Exception {
List<UserDto> users = List.of(new UserDto(1L, "Alice"), new UserDto(2L, "Bob"));
when(userService.getAllUsers()).thenReturn(users);
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].id").value(1))
.andExpect(jsonPath("$[0].name").value("Alice"));
verify(userService, times(1)).getAllUsers();
}
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
when(userService.getUserById(999L))
.thenThrow(new UserNotFoundException("User not found"));
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
verify(userService).getUserById(999L);
}
}
POST:リソースの作成
@Test
void shouldCreateUserAndReturn201() throws Exception {
UserDto createdUser = new UserDto(1L, "Alice", "[email protected]");
when(userService.createUser(any())).thenReturn(createdUser);
mockMvc.perform(post("/api/users")
.contentType("application/json")
.content("{\"name\":\"Alice\",\"email\":\"[email protected]\"}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Alice"));
verify(userService).createUser(any(UserCreateRequest.class));
}
PUT:リソースの更新
@Test
void shouldUpdateUserAndReturn200() throws Exception {
UserDto updatedUser = new UserDto(1L, "Updated");
when(userService.updateUser(eq(1L), any())).thenReturn(updatedUser);
mockMvc.perform(put("/api/users/1")
.contentType("application/json")
.content("{\"name\":\"Updated\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Updated"));
verify(userService).updateUser(eq(1L), any());
}
DELETE:リソースの削除
@Test
void shouldDeleteUserAndReturn204() throws Exception {
doNothing().when(userService).deleteUser(1L);
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isNoContent());
verify(userService).deleteUser(1L);
}
クエリパラメーター
@Test
void shouldFilterUsersByName() throws Exception {
when(userService.searchUsers("Alice")).thenReturn(List.of(new UserDto(1L, "Alice")));
mockMvc.perform(get("/api/users/search").param("name", "Alice"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("Alice"));
verify(userService).searchUsers("Alice");
}
パス変数
@Test
void shouldGetUserByIdFromPath() throws Exception {
when(userService.getUserById(123L)).thenReturn(new UserDto(123L, "Alice"));
mockMvc.perform(get("/api/users/{id}", 123L))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(123));
}
バリデーションエラー(400)
@Test
void shouldReturn400WhenRequestBodyInvalid() throws Exception {
mockMvc.perform(post("/api/users")
.contentType("application/json")
.content("{\"name\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
レスポンスヘッダー
@Test
void shouldReturnCustomHeaders() throws Exception {
when(userService.getAllUsers()).thenReturn(List.of());
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(header().exists("X-Total-Count"))
.andExpect(header().string("X-Total-Count", "0"));
}
Authorization ヘッダー
@Test
void shouldRequireAuthorizationHeader() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
mockMvc.perform(get("/api/users").header("Authorization", "Bearer token"))
.andExpect(status().isOk());
}
コンテンツネゴシエーション
@Test
void shouldReturnJsonWhenAcceptHeaderIsJson() throws Exception {
when(userService.getUserById(1L)).thenReturn(new UserDto(1L, "Alice"));
mockMvc.perform(get("/api/users/1").accept("application/json"))
.andExpect(status().isOk())
.andExpect(content().contentType("application/json"));
}
ベストプラクティス
- コントローラーを分離してテストするには
standaloneSetup()を使用します - サービスレイヤーをモックします。コントローラーは HTTP を処理し、サービスはビジネスロジックを処理します
- モックの呼び出しを検証します:
verify(service).method(args) - 正常系とエラーのシナリオ(404、400、500)の両方をテストします
- 簡潔な JSON アサーションには
jsonPath()を使用します - 各テストメソッドでは、焦点を絞ったアサーションを 1 つ使用します
制約と警告
- コントローラーテストで検証するのは HTTP の処理のみであり、リクエストフロー全体ではありません
standaloneSetup()は、完全なコンテキストなしでは@Validatedをサポートしない場合があります- JsonPath には、レスポンス本文に有効な JSON が必要です
@PreAuthorize/@Securedには追加のセットアップが必要です。セキュリティテストを別途実施することを検討してください- ファイルのアップロードには
MockMultipartFileが必要です
参考資料
コメント
Markdown 対応