A Testing Strategy for APIs

Decide which tests to write for a Spring Boot API: fast unit tests, focused slice tests and a few full-stack tests against a real database.

SyntaxUnit: plain JUnit class, new the object, mock its collaborators Slice: @WebMvcTest(OrderController.class) / @DataJpaTest Integration: @SpringBootTest + @ServiceConnection container

A Spring Boot API has three kinds of code worth testing: business rules in services, the HTTP contract in controllers, and queries against the database. Each one has a cheapest test that still catches its real bugs. A good strategy picks that test for each layer instead of starting the whole application for everything.

Three levels

LevelToolCatchesCost
UnitJUnit, AssertJ, MockitoWrong business rules, edge casesMilliseconds, no Spring
Slice@WebMvcTest, @DataJpaTestMapping, status codes, JSON shape, validation, queriesA small context, about a second
Integration@SpringBootTest with TestcontainersWiring, configuration, real SQL, security rules end to endSeconds, needs Docker

Most tests should be unit tests, because they run in milliseconds and point straight at the broken rule. Slice tests start only the beans for one layer: @WebMvcTest loads controllers and MVC infrastructure, @DataJpaTest loads JPA and repositories. A handful of integration tests then prove the pieces fit together against the same database engine you run in production.

What to assert in an API

Clients depend on the HTTP contract, not on your classes. Assert on status codes, headers such as Location, the JSON field names and the error body. A renamed record component silently renames a JSON field; only a test that reads the JSON notices.

Keep the suite fast

The Spring TestContext framework caches application contexts and reuses one when the next test class asks for the same configuration. Every difference in configuration creates a new context: a different set of @MockitoBean fields, another active profile, an extra properties entry. Twenty test classes with twenty slightly different setups means twenty context startups. Share a base class or a small number of standard setups so the cache does its job.

Mistakes that cost you later

  • Everything is a @SpringBootTest. The suite takes minutes, so people stop running it before pushing.
  • Testing against H2 when production runs PostgreSQL. Native queries, JSON columns, locking and constraint names behave differently, and the bug appears only after deployment.
  • Mocking what you own and testing nothing. A controller test that mocks the service and then verifies the mock was called proves very little; assert on the HTTP response instead.

All the tools in this stage come from spring-boot-starter-test plus the technology test starters that Spring Boot 4 splits out, such as spring-boot-starter-webmvc-test and spring-boot-starter-data-jpa-test.

Example

Example · java
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.api.Test;

record ShippingQuote(BigDecimal orderTotal, int itemCount) {
}

class ShippingPolicy {

    private static final BigDecimal FREE_FROM = new BigDecimal("50.00");
    private static final BigDecimal FLAT_FEE = new BigDecimal("4.95");

    BigDecimal feeFor(ShippingQuote quote) {
        if (quote.itemCount() <= 0) {
            throw new IllegalArgumentException("An order needs at least one item");
        }
        return quote.orderTotal().compareTo(FREE_FROM) >= 0 ? BigDecimal.ZERO : FLAT_FEE;
    }
}

class ShippingPolicyTest {

    private final ShippingPolicy policy = new ShippingPolicy();

    @ParameterizedTest
    @CsvSource({
            "49.99, 4.95",
            "50.00, 0",
            "120.00, 0"
    })
    void chargesTheFlatFeeBelowTheThreshold(BigDecimal total, BigDecimal expectedFee) {
        BigDecimal fee = policy.feeFor(new ShippingQuote(total, 1));

        assertThat(fee).isEqualByComparingTo(expectedFee);
    }

    @Test
    void rejectsAnEmptyOrder() {
        assertThatThrownBy(() -> policy.feeFor(new ShippingQuote(BigDecimal.TEN, 0)))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("at least one item");
    }
}

When to use it

  • Plan the tests for a new orders API so that most run in milliseconds and only a few start the full application.
  • Cut a twelve-minute CI build down by finding test classes that each start their own application context.
  • Replace an H2-only test suite with a few PostgreSQL integration tests after a native query failed in production.

More examples

Test dependencies for a typical API

Spring Boot 4 split the old all-in-one test starter by technology, so a web API typically adds the webmvc, data JPA and security test starters next to <code>spring-boot-starter-test</code>. The Spring Boot parent manages every version here, including Testcontainers, so none of these entries needs a <code>version</code>. Leaving out a technology test starter is the usual reason an annotation such as <code>@WebMvcTest</code> fails to import after an upgrade.

Example · xml
<dependencies>
    <!-- JUnit Jupiter, AssertJ, Mockito, JSONassert, Awaitility -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- @WebMvcTest, MockMvcTester, RestTestClient -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- @DataJpaTest, TestEntityManager -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- @WithMockUser, jwt(), csrf() -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security-test</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- @ServiceConnection and real databases in Docker -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

One shared base class for integration tests

Every class that extends <code>ApiIntegrationTest</code> has an identical configuration, so the TestContext framework starts the application once and reuses it for all of them. If one subclass added its own <code>@MockitoBean</code> or a different profile, it would get a context of its own and pay the full startup again. Putting the shared annotations in one place is the simplest way to keep that number low as the suite grows.

Example · java
import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.assertj.MockMvcTester;

@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
abstract class ApiIntegrationTest {

    @Autowired
    protected MockMvcTester mvc;
}

class HealthEndpointIT extends ApiIntegrationTest {

    @Test
    void reportsUp() {
        assertThat(mvc.get().uri("/actuator/health"))
                .hasStatusOk()
                .bodyJson()
                .extractingPath("$.status")
                .asString()
                .isEqualTo("UP");
    }
}

class OrderListIT extends ApiIntegrationTest {

    @Test
    void listsOrdersAsJson() {
        assertThat(mvc.get().uri("/orders"))
                .hasStatusOk()
                .hasContentTypeCompatibleWith("application/json");
    }
}

Running unit tests and integration tests separately

Naming integration tests <code>*IT</code> and running them with Failsafe gives you a quick <code>test</code> loop for everyday work and a complete <code>verify</code> run in CI. The Spring Boot parent configures Failsafe in its plugin management, but you still declare the plugin in your own <code>build</code> section for it to run. Keep the split honest: a test that needs Docker belongs in the <code>IT</code> group.

Example · bash
# Surefire runs *Test classes in the test phase: unit and slice tests, no Docker needed.
./mvnw test

# With the maven-failsafe-plugin declared in the build, *IT classes run in
# the integration-test phase and the build fails in verify if any of them failed.
./mvnw verify

# Run one class or one method while working on it.
./mvnw test -Dtest=OrderControllerTest
./mvnw test -Dtest='OrderControllerTest#returnsNotFoundForUnknownOrder'

Discussion

  • Be the first to comment on this lesson.