Spring BootAPI Testing
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));
    }
}