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, language-agnostic style), or contract tests with Spring Cloud Contract (producer-consumer contract verification).
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.
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} β idempotency: ββββββββββββββββββββββββ
@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());
}
}RestAssured
RestAssured is a fluent BDD-style API testing library that reads like plain English. It requires a running HTTP server (use @SpringBootTest with RANDOM_PORT) and is especially readable for complex request/response assertions. It supports JSON Schema validation, multipart uploads, and cookie-based auth out of the box.
Java
<!-- pom.xml: -->
<!-- <dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>spring-mock-mvc</artifactId>
<scope>test</scope>
</dependency> -->
@SpringBootTest(webEnvironment =
SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserApiRestAssuredTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@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 β get β delete: βββββββββββββββββββββββββββββββ
@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/YAML DSL files, generates stubs from them, and publishes the stubs. The consumer runs tests against the stubs. If the producer's real API breaks the contract, the producer's own tests fail.
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>
<version>4.1.3</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;
@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"
// Downloads published stub JAR and starts a WireMock server on 8081
)
class OrderServiceContractTest {
@Autowired
private OrderService orderService;
@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 total, size=1), and invalid inputs (negative page, invalid sort field). 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
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@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: βββββββββββββββββββββββββββββββββββββββββββ
@Test
@WithMockUser
void getProducts_invalidSortField_returns400() throws Exception {
mockMvc.perform(get("/api/products")
.param("sort", "nonExistentField,asc"))
.andExpect(status().isBadRequest());
}
// ββ 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));
}
}