Spring Boot
API Testing
API testing verifies the HTTP contract of a service — status codes, response bodies, headers, error formats, and pagination. In Spring Boot, API tests are written with MockMvc (in-process, fast), TestRestTemplate (real embedded server), RestAssured (fluent DSL), or contract tests with Spring Cloud Contract (producer-consumer contract verification). This entry covers all four tools, how to test error responses and validation, pagination and sorting edge cases, and how to choose between them.
MockMvc — Fluent API Testing
MockMvc is Spring's built-in in-process HTTP testing framework. It exercises the full Spring MVC pipeline — filters, interceptors, controllers, exception handlers — without starting a real HTTP server. It is faster than starting an embedded server and provides rich assertion helpers via jsonPath, header matchers, and content-type checks. To use @WithMockUser you need spring-security-test on the test classpath. Because these tests use a real application context and repository, clean the data before each test so tests do not depend on each other.
Java
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class UserApiTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@Autowired private UserRepository userRepository;
@BeforeEach
void cleanUp() { userRepository.deleteAll(); }
// ── GET /api/users — paginated list: ─────────────────────────────
@Test
@WithMockUser(roles = "ADMIN")
void getUsers_returnsPagedResult() throws Exception {
userRepository.saveAll(List.of(
User.builder().name("Alice").email("alice@x.com").build(),
User.builder().name("Bob").email("bob@x.com").build()
));
mockMvc.perform(get("/api/users")
.param("page", "0")
.param("size", "10")
.param("sort", "name,asc"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.content[0].name").value("Alice"))
.andExpect(jsonPath("$.totalElements").value(2))
.andExpect(jsonPath("$.totalPages").value(1))
.andDo(print()); // print request/response to console
}
// ── POST /api/users — create and verify Location header: ──────────
@Test
@WithMockUser(roles = "ADMIN")
void createUser_returns201WithLocation() throws Exception {
CreateUserRequest request = new CreateUserRequest(
"Charlie", "charlie@x.com", "password123");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(header().string("Location",
matchesPattern(".*/api/users/\\d+")))
.andExpect(jsonPath("$.id").isNumber())
.andExpect(jsonPath("$.name").value("Charlie"))
.andExpect(jsonPath("$.password").doesNotExist());
// password must NOT be in response
}
// ── PUT /api/users/{id} — update: ────────────────────────────────
@Test
@WithMockUser(roles = "ADMIN")
void updateUser_returns200WithUpdatedData() throws Exception {
User saved = userRepository.save(
User.builder().name("Dave").email("dave@x.com").build());
mockMvc.perform(put("/api/users/" + saved.getId())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "name": "David" }
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("David"))
.andExpect(jsonPath("$.email").value("dave@x.com"));
}
// ── DELETE /api/users/{id} — repeated delete: ────────────────────
// The server state after the second call is the same as after the
// first (the user is gone), which is what idempotent means. The
// status code may still differ: 204 first, 404 afterwards.
@Test
@WithMockUser(roles = "ADMIN")
void deleteUser_returns204_andSecondDeleteReturns404() throws Exception {
User saved = userRepository.save(
User.builder().name("Eve").email("eve@x.com").build());
mockMvc.perform(delete("/api/users/" + saved.getId()))
.andExpect(status().isNoContent());
mockMvc.perform(delete("/api/users/" + saved.getId()))
.andExpect(status().isNotFound());
}
}Testing Error Responses and Validation
Clients depend on the shape of your errors as much as on the success responses, so test them explicitly: a missing resource, an invalid request body, an unauthenticated call and a forbidden call. Spring Boot can return errors in the standard RFC 9457 Problem Details format (enable it with spring.mvc.problemdetails.enabled=true or your own @ControllerAdvice). Assert the status, the content type and the stable fields of the error body, not the exact message text, which tends to change. Adjust the field names below to match your own exception handler.
Java
@SpringBootTest
@AutoConfigureMockMvc
class UserApiErrorTest {
@Autowired private MockMvc mockMvc;
// ── 404: resource does not exist ─────────────────────────────────
@Test
@WithMockUser(roles = "ADMIN")
void getUser_returns404_whenNotFound() throws Exception {
mockMvc.perform(get("/api/users/99999"))
.andExpect(status().isNotFound())
.andExpect(content().contentTypeCompatibleWith(
MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.status").value(404));
}
// ── 400: bean validation failure ─────────────────────────────────
@Test
@WithMockUser(roles = "ADMIN")
void createUser_returns400_whenEmailInvalid() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{ "name": "", "email": "not-an-email",
"password": "123" }
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400));
}
// ── 400: malformed JSON ──────────────────────────────────────────
@Test
@WithMockUser(roles = "ADMIN")
void createUser_returns400_whenBodyMalformed() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{ this is not json"))
.andExpect(status().isBadRequest());
}
// ── 401: no authentication (stateless JWT setup) ─────────────────
@Test
void getUsers_returns401_whenNotAuthenticated() throws Exception {
mockMvc.perform(get("/api/users"))
.andExpect(status().isUnauthorized());
}
// ── 403: authenticated but missing the required role ─────────────
@Test
@WithMockUser(roles = "USER")
void deleteUser_returns403_whenNotAdmin() throws Exception {
mockMvc.perform(delete("/api/users/1"))
.andExpect(status().isForbidden());
}
// ── 415: wrong content type ──────────────────────────────────────
@Test
@WithMockUser(roles = "ADMIN")
void createUser_returns415_whenContentTypeNotJson() throws Exception {
mockMvc.perform(post("/api/users")
.contentType(MediaType.TEXT_PLAIN)
.content("name=Alice"))
.andExpect(status().isUnsupportedMediaType());
}
}TestRestTemplate — Real Server Tests
TestRestTemplate sends real HTTP requests to an embedded server started on a random port. Use it when the behaviour you care about depends on the real server: serialisation over the wire, servlet filters, compression, cookies, or the exact HTTP status and headers a client would see. It is slower than MockMvc because it starts a real server, so keep these tests for end-to-end checks and use MockMvc for the bulk of your controller tests. Spring Framework 7 also adds RestTestClient, a fluent client for testing REST endpoints that can be used against both mock and real servers.
Java
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserApiTestRestTemplateTest {
@Autowired
private TestRestTemplate restTemplate;
// ── Simple GET with a typed response: ────────────────────────────
@Test
void getUser_returnsUser() {
ResponseEntity<UserResponse> response = restTemplate
.withBasicAuth("admin", "admin-password")
.getForEntity("/api/users/1", UserResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).isNotNull();
assertThat(response.getBody().name()).isEqualTo("Alice");
}
// ── POST with a custom header (for example a bearer token): ──────
@Test
void createUser_returns201() {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(JwtTestUtil.generateToken("admin", "ROLE_ADMIN"));
headers.setContentType(MediaType.APPLICATION_JSON);
CreateUserRequest body = new CreateUserRequest(
"Frank", "frank@x.com", "password123");
ResponseEntity<UserResponse> response = restTemplate.exchange(
"/api/users", HttpMethod.POST,
new HttpEntity<>(body, headers), UserResponse.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(response.getHeaders().getLocation()).isNotNull();
}
// ── Error responses do not throw: check the status instead ───────
@Test
void getUser_returns404_forUnknownId() {
ResponseEntity<String> response = restTemplate
.withBasicAuth("admin", "admin-password")
.getForEntity("/api/users/99999", String.class);
assertThat(response.getStatusCode())
.isEqualTo(HttpStatus.NOT_FOUND);
}
}RestAssured
RestAssured is a fluent BDD-style API testing library that reads like plain English. Against a real server it needs @SpringBootTest with RANDOM_PORT and only the rest-assured dependency (Spring Boot manages its version). It is especially readable for complex request and response assertions and supports JSON Schema validation, multipart uploads and cookie-based auth. The example uses Testcontainers with @ServiceConnection (Spring Boot 3.1+), which wires the datasource to the container automatically so you do not need @DynamicPropertySource. This needs the spring-boot-testcontainers dependency plus the Testcontainers junit-jupiter and postgresql modules.
Java
<!-- pom.xml (test scope): rest-assured, spring-boot-testcontainers,
org.testcontainers:junit-jupiter, org.testcontainers:postgresql -->
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.*;
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserApiRestAssuredTest {
@Container
@ServiceConnection // configures the datasource automatically
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@LocalServerPort
private int port;
@Autowired
private UserRepository userRepository;
@BeforeEach
void setUp() {
RestAssured.port = port;
RestAssured.basePath = "/api";
userRepository.deleteAll();
}
// ── GET — BDD given/when/then style: ─────────────────────────────
@Test
void getUsers_returnsEmptyList() {
given()
.header("Authorization", "Bearer " + adminToken())
.contentType(ContentType.JSON)
.when()
.get("/users")
.then()
.statusCode(200)
.body("content", hasSize(0))
.body("totalElements", equalTo(0));
}
// ── POST — create and assert response body: ───────────────────────
@Test
void createUser_returnsCreatedUser() {
given()
.header("Authorization", "Bearer " + adminToken())
.contentType(ContentType.JSON)
.body("""
{
"name": "Alice",
"email": "alice@example.com",
"password": "password123"
}
""")
.when()
.post("/users")
.then()
.statusCode(201)
.header("Location", matchesPattern(".*/api/users/\\d+"))
.body("name", equalTo("Alice"))
.body("email", equalTo("alice@example.com"))
.body("id", notNullValue())
.body("password", nullValue()); // never return password
}
// ── Chained create → delete → get: ───────────────────────────────
@Test
void createThenDelete_userNoLongerExists() {
int userId =
given()
.header("Authorization", "Bearer " + adminToken())
.contentType(ContentType.JSON)
.body("""
{ "name": "Bob", "email": "bob@x.com",
"password": "pass1234" }""")
.when()
.post("/users")
.then()
.statusCode(201)
.extract().path("id");
given()
.header("Authorization", "Bearer " + adminToken())
.when()
.delete("/users/" + userId)
.then()
.statusCode(204);
given()
.header("Authorization", "Bearer " + adminToken())
.when()
.get("/users/" + userId)
.then()
.statusCode(404);
}
private String adminToken() {
// Generate or fetch a test JWT:
return JwtTestUtil.generateToken("admin", "ROLE_ADMIN");
}
}Contract Testing with Spring Cloud Contract
Contract testing verifies that a producer service (the one exposing an API) and a consumer service (the one calling it) agree on the API contract — without deploying both at the same time. The producer defines contracts as Groovy or YAML DSL files, generates tests and stubs from them, and publishes the stubs (mvn install publishes them to your local repository). The consumer runs its tests against the stubs. If the producer's real API breaks the contract, the producer's own generated tests fail, so the breaking change is caught before it is released. Spring Cloud Contract is producer-driven and fits Spring-to-Spring communication well. Pact is a popular consumer-driven alternative that works across languages. In the examples, @MockitoBean replaces the old @MockBean, which was deprecated in Spring Boot 3.4 and removed in Spring Boot 4.
Java
<!-- Producer pom.xml: -->
<!-- <dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-contract-verifier</artifactId>
<scope>test</scope>
</dependency>
<plugin>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-contract-maven-plugin</artifactId>
<!-- use the version that matches your Spring Cloud release train -->
<version>${spring-cloud-contract.version}</version>
<extensions>true</extensions>
<configuration>
<baseClassForTests>
com.example.BaseContractTest
</baseClassForTests>
</configuration>
</plugin> -->
// ── Contract file (src/test/resources/contracts/user/get_user.groovy): ─
// Contract.make {
// description "should return user by id"
// request {
// method GET()
// url "/api/users/1"
// headers { header("Authorization", matching("Bearer .*")) }
// }
// response {
// status OK()
// headers { contentType(applicationJson()) }
// body([
// id: 1,
// name: "Alice",
// email: "alice@example.com"
// ])
// }
// }
// ── Producer base test class: ─────────────────────────────────────────
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.MOCK)
@AutoConfigureMockMvc
public abstract class BaseContractTest {
@Autowired
protected MockMvc mockMvc;
@MockitoBean // replaces the removed @MockBean
protected UserService userService;
@BeforeEach
void setUp() {
// Set up the data the contract requires:
when(userService.findById(1L))
.thenReturn(UserResponse.builder()
.id(1L)
.name("Alice")
.email("alice@example.com")
.build());
RestAssuredMockMvc.mockMvc(mockMvc);
}
}
// Spring Cloud Contract generates a test from the Groovy DSL and runs it
// against BaseContractTest — if the real controller breaks the contract
// this generated test fails, stopping the producer from publishing.
// ── Consumer stub test: ───────────────────────────────────────────────
// Consumer pom.xml:
// <dependency>
// <groupId>org.springframework.cloud</groupId>
// <artifactId>spring-cloud-starter-contract-stub-runner</artifactId>
// <scope>test</scope>
// </dependency>
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureStubRunner(
stubsMode = StubRunnerProperties.StubsMode.LOCAL,
ids = "com.example:user-service:+:stubs:8081"
// groupId:artifactId:version:classifier:port
// Downloads the stub JAR and starts a WireMock server on 8081
)
class OrderServiceContractTest {
@Autowired
private OrderService orderService;
// The test configuration must point the user-service URL at
// http://localhost:8081 so that the call reaches the stub.
@Test
void placeOrder_callsUserService_usingContractStub() {
// WireMock serves the contract stub on port 8081.
// OrderService calls http://localhost:8081/api/users/1
// and gets the contracted response — no real UserService needed.
OrderResponse order = orderService.placeOrder(
CreateOrderRequest.builder().userId(1L).productId(5L).build());
assertThat(order).isNotNull();
assertThat(order.getUserName()).isEqualTo("Alice");
}
}Testing Pagination, Sorting, and Filtering
Pagination, sorting and filtering endpoints have many parameter combinations and edge cases. Test the normal path (first page, last page, sorted), boundary conditions (page beyond the total, size=1) and invalid inputs (an unknown sort field). Spring Data's default pageable resolver quietly falls back to page 0 for a negative page number and caps very large page sizes, so test what your API actually does. An unknown sort field raises a PropertyReferenceException, which returns 500 unless your exception handler maps it to 400, so the invalid-sort test below only passes once that mapping exists. These tests catch off-by-one errors and missing parameter validation that are easy to miss in manual testing.
Java
@SpringBootTest
@AutoConfigureMockMvc
@Testcontainers
class PaginationApiTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired private MockMvc mockMvc;
@Autowired private ProductRepository productRepository;
@BeforeEach
void setUp() {
productRepository.deleteAll();
// Insert 25 products with sequential names and prices:
IntStream.rangeClosed(1, 25).forEach(i ->
productRepository.save(Product.builder()
.name("Product " + String.format("%02d", i))
.price(new BigDecimal(i * 10))
.category("Electronics")
.build()));
}
// ── First page: ───────────────────────────────────────────────────
@Test
@WithMockUser
void getProducts_firstPage_returns10Items() throws Exception {
mockMvc.perform(get("/api/products")
.param("page", "0").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(10))
.andExpect(jsonPath("$.totalElements").value(25))
.andExpect(jsonPath("$.totalPages").value(3))
.andExpect(jsonPath("$.first").value(true))
.andExpect(jsonPath("$.last").value(false));
}
// ── Last page (partial): ──────────────────────────────────────────
@Test
@WithMockUser
void getProducts_lastPage_returnsRemainder() throws Exception {
mockMvc.perform(get("/api/products")
.param("page", "2").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content.length()").value(5))
.andExpect(jsonPath("$.last").value(true));
}
// ── Sort ascending: ───────────────────────────────────────────────
@Test
@WithMockUser
void getProducts_sortedByPriceAsc_returnsInOrder() throws Exception {
mockMvc.perform(get("/api/products")
.param("page", "0").param("size", "5")
.param("sort", "price,asc"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].price").value(10))
.andExpect(jsonPath("$.content[4].price").value(50));
}
// ── Filter by category: ───────────────────────────────────────────
@Test
@WithMockUser
void getProducts_filteredByCategory_returnsMatchingOnly() throws Exception {
productRepository.save(Product.builder()
.name("Chair").price(new BigDecimal("99"))
.category("Furniture").build());
mockMvc.perform(get("/api/products")
.param("category", "Furniture"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.content[0].name").value("Chair"));
}
// ── Invalid sort field (needs a handler that maps to 400): ────────
// @ExceptionHandler(PropertyReferenceException.class) → 400
@Test
@WithMockUser
void getProducts_invalidSortField_returns400() throws Exception {
mockMvc.perform(get("/api/products")
.param("sort", "nonExistentField,asc"))
.andExpect(status().isBadRequest());
}
// ── Negative page: Spring Data falls back to page 0 ───────────────
@Test
@WithMockUser
void getProducts_negativePage_isTreatedAsFirstPage() throws Exception {
mockMvc.perform(get("/api/products")
.param("page", "-1").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.first").value(true));
}
// ── Beyond last page — returns empty: ─────────────────────────────
@Test
@WithMockUser
void getProducts_pageBeyondTotal_returnsEmpty() throws Exception {
mockMvc.perform(get("/api/products")
.param("page", "999").param("size", "10"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isEmpty())
.andExpect(jsonPath("$.totalElements").value(25));
}
}Choosing the Right API Testing Tool
Use MockMvc for most controller and API tests: it is fast, runs the real MVC pipeline and handles security with @WithMockUser. Use TestRestTemplate or RestAssured with RANDOM_PORT for a smaller set of end-to-end checks where real HTTP behaviour matters. Use contract tests between services that are developed and deployed by different teams, so that one team cannot silently break another. Keep the number of slow tests small, test error responses as carefully as success responses, and never rely on test order: each test should create the data it needs and clean up afterwards.
Java
// ── Quick comparison ──────────────────────────────────────────────────
// Tool Real server? Speed Best for
// MockMvc No (in-process) Fast Controller + MVC pipeline,
// validation, security rules
// TestRestTemplate Yes Medium End-to-end HTTP behaviour
// RestAssured Yes (or mock) Medium Readable, complex assertions,
// JSON Schema validation
// Spring Cloud Contract Stubs Fast Producer/consumer agreement
// between services
// ── A sensible split for one service ──────────────────────────────────
// 70% MockMvc / slice tests (@WebMvcTest or @SpringBootTest + MockMvc)
// 20% Repository tests (@DataJpaTest + Testcontainers)
// 10% End-to-end + contract (RANDOM_PORT, Spring Cloud Contract)
// ── Lighter alternative for controller-only tests ─────────────────────
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired private MockMvc mockMvc;
@MockitoBean private UserService userService; // only the web layer loads
@Test
@WithMockUser
void getUser_returnsUser() throws Exception {
when(userService.findById(1L)).thenReturn(
new UserResponse(1L, "Alice", "alice@example.com"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Alice"));
}
}