home / skills / giuseppe-trisciuoglio / developer-kit / unit-test-controller-layer

This skill helps you unit test REST controllers with MockMvc by isolating the web layer and validating requests, responses, and errors.

npx playbooks add skill giuseppe-trisciuoglio/developer-kit --skill unit-test-controller-layer

Review the files below or copy the command above to add this skill to your agents.

Files (1)
SKILL.md
11.6 KB
---
name: unit-test-controller-layer
description: Provides patterns for unit testing REST controllers using MockMvc and @WebMvcTest. Validates request/response mapping, validation, and exception handling. Use when testing web layer endpoints in isolation.
category: testing
tags: [junit-5, mockito, unit-testing, controller, rest, mockmvc]
version: 1.0.1
allowed-tools: Read, Write, Bash, Glob, Grep
---

# Unit Testing REST Controllers with MockMvc

## Overview

This skill provides patterns for unit testing @RestController and @Controller classes using MockMvc. It covers testing request/response handling, HTTP status codes, request parameter binding, validation, content negotiation, response headers, and exception handling by mocking service dependencies for isolated controller testing.

## When to Use

Use this skill when:
- Testing REST controller request/response handling
- Verifying HTTP status codes and response formats
- Testing request parameter binding and validation
- Mocking service layer for isolated controller tests
- Testing content negotiation and response headers
- Want fast controller tests without integration test overhead

## Instructions

1. **Use standalone MockMvc setup**: Use `MockMvcBuilders.standaloneSetup(controller)` for isolated testing
2. **Mock all service dependencies**: Use @Mock for all services injected into the controller
3. **Test all HTTP methods**: Verify GET, POST, PUT, PATCH, DELETE with appropriate status codes
4. **Verify request/response formats**: Use JsonPath assertions for JSON response validation
5. **Test validation errors**: Send invalid requests and verify 400 status with error details
6. **Test error scenarios**: Verify 404, 400, 401, 403, 500 status codes for appropriate conditions
7. **Test headers**: Verify both request headers (Authorization) and response headers
8. **Use content negotiation**: Test with different Accept and Content-Type headers

## Examples

## Setup: MockMvc + Mockito

### Maven
```xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.mockito</groupId>
  <artifactId>mockito-core</artifactId>
  <scope>test</scope>
</dependency>
```

### Gradle
```kotlin
dependencies {
  testImplementation("org.springframework.boot:spring-boot-starter-test")
  testImplementation("org.mockito:mockito-core")
}
```

## Basic Pattern: Testing GET Endpoint

### Simple GET Endpoint Test

```java
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 org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
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;

  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("$").isArray())
      .andExpect(jsonPath("$[0].id").value(1))
      .andExpect(jsonPath("$[0].name").value("Alice"))
      .andExpect(jsonPath("$[1].id").value(2));

    verify(userService, times(1)).getAllUsers();
  }

  @Test
  void shouldReturnUserById() throws Exception {
    UserDto user = new UserDto(1L, "Alice");
    when(userService.getUserById(1L)).thenReturn(user);

    mockMvc.perform(get("/api/users/1"))
      .andExpect(status().isOk())
      .andExpect(jsonPath("$.id").value(1))
      .andExpect(jsonPath("$.name").value("Alice"));

    verify(userService).getUserById(1L);
  }
}
```

## Testing POST Endpoint

### Create Resource with Request Body

```java
@Test
void shouldCreateUserAndReturn201() throws Exception {
  UserCreateRequest request = new UserCreateRequest("Alice", "[email protected]");
  UserDto createdUser = new UserDto(1L, "Alice", "[email protected]");
  
  when(userService.createUser(any(UserCreateRequest.class)))
    .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"))
    .andExpect(jsonPath("$.email").value("[email protected]"));

  verify(userService).createUser(any(UserCreateRequest.class));
}
```

## Testing Error Scenarios

### Handle 404 Not Found

```java
@Test
void shouldReturn404WhenUserNotFound() throws Exception {
  when(userService.getUserById(999L))
    .thenThrow(new UserNotFoundException("User not found"));

  mockMvc.perform(get("/api/users/999"))
    .andExpect(status().isNotFound())
    .andExpect(jsonPath("$.error").value("User not found"));

  verify(userService).getUserById(999L);
}
```

### Handle 400 Bad Request

```java
@Test
void shouldReturn400WhenRequestBodyInvalid() throws Exception {
  mockMvc.perform(post("/api/users")
      .contentType("application/json")
      .content("{\"name\":\"\"}")) // Empty name
    .andExpect(status().isBadRequest())
    .andExpect(jsonPath("$.errors").isArray());
}
```

## Testing PUT/PATCH Endpoints

### Update Resource

```java
@Test
void shouldUpdateUserAndReturn200() throws Exception {
  UserUpdateRequest request = new UserUpdateRequest("Alice Updated");
  UserDto updatedUser = new UserDto(1L, "Alice Updated");
  
  when(userService.updateUser(eq(1L), any(UserUpdateRequest.class)))
    .thenReturn(updatedUser);

  mockMvc.perform(put("/api/users/1")
      .contentType("application/json")
      .content("{\"name\":\"Alice Updated\"}"))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$.id").value(1))
    .andExpect(jsonPath("$.name").value("Alice Updated"));

  verify(userService).updateUser(eq(1L), any(UserUpdateRequest.class));
}
```

## Testing DELETE Endpoint

### Delete Resource

```java
@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 shouldReturn404WhenDeletingNonExistentUser() throws Exception {
  doThrow(new UserNotFoundException("User not found"))
    .when(userService).deleteUser(999L);

  mockMvc.perform(delete("/api/users/999"))
    .andExpect(status().isNotFound());
}
```

## Testing Request Parameters

### Query Parameters

```java
@Test
void shouldFilterUsersByName() throws Exception {
  List<UserDto> users = List.of(new UserDto(1L, "Alice"));
  when(userService.searchUsers("Alice")).thenReturn(users);

  mockMvc.perform(get("/api/users/search?name=Alice"))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$").isArray())
    .andExpect(jsonPath("$[0].name").value("Alice"));

  verify(userService).searchUsers("Alice");
}
```

### Path Variables

```java
@Test
void shouldGetUserByIdFromPath() throws Exception {
  UserDto user = new UserDto(123L, "Alice");
  when(userService.getUserById(123L)).thenReturn(user);

  mockMvc.perform(get("/api/users/{id}", 123L))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$.id").value(123));
}
```

## Testing Response Headers

### Verify Response Headers

```java
@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"))
    .andExpect(header().string("Content-Type", containsString("application/json")));
}
```

## Testing Request Headers

### Send Request Headers

```java
@Test
void shouldRequireAuthorizationHeader() throws Exception {
  mockMvc.perform(get("/api/users"))
    .andExpect(status().isUnauthorized());

  mockMvc.perform(get("/api/users")
      .header("Authorization", "Bearer token123"))
    .andExpect(status().isOk());
}
```

## Content Negotiation

### Test Different Accept Headers

```java
@Test
void shouldReturnJsonWhenAcceptHeaderIsJson() throws Exception {
  UserDto user = new UserDto(1L, "Alice");
  when(userService.getUserById(1L)).thenReturn(user);

  mockMvc.perform(get("/api/users/1")
      .accept("application/json"))
    .andExpect(status().isOk())
    .andExpect(content().contentType("application/json"));
}
```

## Advanced: Testing Multiple Status Codes

```java
@Test
void shouldReturnDifferentStatusCodesForDifferentScenarios() throws Exception {
  // Successful response
  when(userService.getUserById(1L)).thenReturn(new UserDto(1L, "Alice"));
  mockMvc.perform(get("/api/users/1"))
    .andExpect(status().isOk());

  // Not found
  when(userService.getUserById(999L))
    .thenThrow(new UserNotFoundException("Not found"));
  mockMvc.perform(get("/api/users/999"))
    .andExpect(status().isNotFound());

  // Unauthorized
  mockMvc.perform(get("/api/admin/users"))
    .andExpect(status().isUnauthorized());
}
```

## Best Practices

- **Use standalone setup** when testing single controller: `MockMvcBuilders.standaloneSetup()`
- **Mock service layer** - controllers should focus on HTTP handling
- **Test happy path and error paths** thoroughly
- **Verify service method calls** to ensure controller delegates correctly
- **Use content() matchers** for response body validation
- **Keep tests focused** on one endpoint behavior per test
- **Use JsonPath** for fluent JSON response assertions

## Common Pitfalls

- **Testing business logic in controller**: Move to service tests
- **Not mocking service layer**: Always mock service dependencies
- **Testing framework behavior**: Focus on your code, not Spring code
- **Hardcoding URLs**: Use MockMvcRequestBuilders helpers
- **Not verifying mock interactions**: Always verify service was called correctly

## Constraints and Warnings

- **Controller tests are not integration tests**: They verify HTTP handling, not full request flow
- **MockMvc standalone setup limitations**: Some features like @Validated may not work without full context
- **JsonPath requires proper JSON structure**: Ensure response body is valid JSON for JsonPath assertions
- **Don't test framework behavior**: Spring's request mapping is tested by Spring; test your code only
- **Avoid hardcoding URLs**: Extract URL paths to constants or use the controller's @RequestMapping values
- **Security annotations**: @PreAuthorize and @Secured require additional setup; consider separate security tests
- **File uploads**: Use MockMultipartFile for testing multipart file uploads

## Troubleshooting

**Content type mismatch**: Ensure `contentType()` matches controller's `@PostMapping(consumes=...)` or use default.

**JsonPath not matching**: Use `mockMvc.perform(...).andDo(print())` to see actual response content.

**Status code assertions fail**: Check controller `@RequestMapping`, `@PostMapping` status codes and error handling.

## References

- [Spring MockMvc Documentation](https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/test/web/servlet/MockMvc.html)
- [JsonPath for REST Assertions](https://goessner.net/articles/JsonPath/)
- [Spring Testing Best Practices](https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.testing)

Overview

This skill provides practical patterns for unit testing Spring REST controllers using MockMvc and @WebMvcTest. It focuses on isolating the web layer by mocking service dependencies to validate request/response mapping, validation, headers, content negotiation, and exception handling. Use it to build fast, focused controller tests without running a full integration context.

How this skill works

The patterns show a standalone MockMvc setup (MockMvcBuilders.standaloneSetup) or lightweight @WebMvcTest configuration, with all injected services mocked via Mockito. Tests perform HTTP requests (GET/POST/PUT/PATCH/DELETE) against controller endpoints and assert status codes, response bodies using JsonPath, headers, and validation error payloads. Error scenarios are simulated by configuring mocked services to throw exceptions.

When to use it

  • Verifying request/response mapping and JSON output for a controller
  • Testing request parameter binding, path variables, and query filters
  • Validating input constraints and asserting 400 validation responses
  • Confirming exception handling and mapping to appropriate HTTP statuses
  • Testing content negotiation and response/request headers in isolation

Best practices

  • Use standalone MockMvc for single-controller tests to keep tests fast
  • Mock all service and repository dependencies; controllers should only handle HTTP concerns
  • Test both happy paths and error paths (404, 400, 401/403, 500) for each endpoint
  • Assert JSON responses with JsonPath and verify response headers explicitly
  • Keep each test focused on one behavior and verify mock interactions

Example use cases

  • Unit test GET /api/users to assert JSON array structure and that service.getAllUsers() is called
  • POST /api/users validation test that sends invalid JSON and expects 400 with errors array
  • Simulate service throwing UserNotFoundException and assert controller returns 404 with error message
  • PUT /api/users/{id} test that verifies update returns 200 and the updated fields
  • Test Authorization header handling by asserting endpoints return 401 without a token and 200 with a valid header

FAQ

Should I mock the service layer or use real beans?

Mock the service layer for controller unit tests. That keeps tests focused on HTTP mapping and behavior. Business logic belongs in service-layer tests.

When should I use standalone MockMvc vs @WebMvcTest?

Use standalone MockMvc for single-controller, fast unit tests. Use @WebMvcTest when you need Spring MVC configuration or controller advice applied.