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.
Unit: plain JUnit class, new the object, mock its collaborators
Slice: @WebMvcTest(OrderController.class) / @DataJpaTest
Integration: @SpringBootTest + @ServiceConnection containerA 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
| Level | Tool | Catches | Cost |
|---|---|---|---|
| Unit | JUnit, AssertJ, Mockito | Wrong business rules, edge cases | Milliseconds, no Spring |
| Slice | @WebMvcTest, @DataJpaTest | Mapping, status codes, JSON shape, validation, queries | A small context, about a second |
| Integration | @SpringBootTest with Testcontainers | Wiring, configuration, real SQL, security rules end to end | Seconds, 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
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();
({
"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);
}
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.
<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.
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.
# 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