Writing Tests in Java
JUnit 5, Mockito, AssertJ and Testcontainers — the stack that has been stable for a decade, plus Spring's test slices and what they cost.
1 min read · updated 19 September 2026
Java's testing stack has been stable for a decade, which is unusual and useful: the answers you find are generally still correct.
#The stack
// build.gradle.kts
dependencies {
testImplementation(platform("org.junit:junit-bom:5.11.0"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.assertj:assertj-core:3.26.3")
testImplementation("org.mockito:mockito-junit-jupiter:5.14.0")
testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.3"))
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.wiremock:wiremock-standalone:3.9.1")
}
tasks.test {
useJUnitPlatform()
systemProperty("junit.jupiter.execution.parallel.enabled", "true")
}#Unit tests
class PricingTest {
@ParameterizedTest(name = "an order of {0} is discounted by {1}")
@CsvSource({ "12000, 2400", "10000, 2000", "9999, 0" })
void discountAppliesAtOrAboveTheThreshold(int subtotal, int expected) {
var result = Pricing.applyDiscount(new Order(subtotal), POLICY);
assertThat(result.discountCents()).isEqualTo(expected);
}
@Test
void reservingMoreThanStockNamesTheSku() {
var service = new OrderService(new StubInventory(1), FIXED_CLOCK);
assertThatThrownBy(() -> service.reserve("book-1", 2))
.isInstanceOf(InsufficientStockException.class)
.hasMessageContaining("book-1");
}
}AssertJ carries most of the readability. Its collection assertions in particular are worth learning properly:
assertThat(order.lines())
.hasSize(2)
.extracting(OrderLine::sku, OrderLine::quantity)
.containsExactlyInAnyOrder(tuple("book-1", 2), tuple("pen-2", 1));#java.time and the Clock
// Take a Clock. It is in the standard library precisely for this.
public record TokenService(Clock clock) {
public boolean isExpired(Token token) {
return !token.expiresAt().isAfter(clock.instant());
}
}
@Test
void aTokenExpiresExactlyAtItsExpiry() {
var at = Instant.parse("2026-01-01T13:00:00Z");
var service = new TokenService(Clock.fixed(at, ZoneOffset.UTC));
assertThat(service.isExpired(new Token(at))).isTrue();
assertThat(service.isExpired(new Token(at.plusSeconds(1)))).isFalse();
}#Mockito
@ExtendWith(MockitoExtension.class)
class CheckoutTest {
@Mock PaymentGateway gateway;
@Captor ArgumentCaptor<ChargeRequest> request;
@Test
void chargesTheOrderTotalWithAnIdempotencyKey() {
when(gateway.charge(any())).thenReturn(ChargeResult.succeeded("pi_1"));
new Checkout(gateway).pay(order);
verify(gateway).charge(request.capture());
assertThat(request.getValue().cents()).isEqualTo(4_000);
assertThat(request.getValue().idempotencyKey()).startsWith("ORD-");
}
}ArgumentCaptor plus AssertJ beats a complicated argument matcher every
time: the failure message names the field that was wrong rather than saying
"wanted but not invoked".
#Testcontainers
@Testcontainers
class OrderRepositoryTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
static DataSource dataSource;
@BeforeAll
static void migrate() {
dataSource = dataSourceFor(POSTGRES);
Flyway.configure().dataSource(dataSource).load().migrate();
}
@Test
void rejectsADuplicateReference() {
var repository = new OrderRepository(dataSource);
repository.save(new Order("REF-1", 4_000));
assertThatThrownBy(() -> repository.save(new Order("REF-1", 5_000)))
.isInstanceOf(DuplicateReferenceException.class);
}
}Testcontainers was born in this ecosystem and is best supported here. See Testcontainers.
#Spring: slices versus the full context
// A slice: the web layer only. Starts in ~1s.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service; // @MockBean in older Spring Boot
@Test
void returnsA404ForAnUnknownOrder() throws Exception {
when(service.find("nope")).thenReturn(Optional.empty());
mvc.perform(get("/orders/nope"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("not_found"));
}
}// The full application, on a real port, with a real database.
// This is a component test — see /testing-levels/component-testing.
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class OrdersApiTest {
@Container
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
@Autowired TestRestTemplate rest;
@MockitoBean PaymentGateway gateway; // the only thing faked
@Test
void aDeclinedCardReturns402AndLeavesNoOrder() {
when(gateway.charge(any())).thenReturn(ChargeResult.declined());
var response = rest.postForEntity("/orders", new OrderRequest("book-1", 1), Problem.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.PAYMENT_REQUIRED);
assertThat(rest.getForObject("/orders", OrderView[].class)).isEmpty();
}
}#The context caching trap
Spring caches the application context between test classes — but keyed on the configuration. Any difference creates a new context and another startup.
// These three produce three separate contexts, and three startups.
@SpringBootTest class A { }
@SpringBootTest @MockitoBean(PaymentGateway.class) class B { }
@SpringBootTest @TestPropertySource(properties = "feature.x=true") class C { }A suite with thirty distinct configurations starts Spring thirty times, and that is almost always why a Spring test suite is slow. The fix is to standardise: a small number of shared test configurations, applied via a composed annotation.
@Target(TYPE) @Retention(RUNTIME)
@SpringBootTest(webEnvironment = RANDOM_PORT)
@ActiveProfiles("test")
@Testcontainers
public @interface ComponentTest { } // one context, shared by every user#Running it
./gradlew test # unit
./gradlew test -PexcludeTags=integration # fast subset
./gradlew jacocoTestReport # coverage
mvn test -Dgroups='!integration'Tag-based splitting — @Tag("integration") — is how you keep the inner loop
fast while running everything in CI. See
JUnit 5 for the tagging and parallelism configuration.
Common questions
- What is the standard Java testing stack?
- JUnit 5 for the runner, Mockito for doubles, AssertJ for assertions, Testcontainers for real dependencies, and WireMock for HTTP stubbing. It has been stable for years and there is little reason to deviate.
- Should I use @SpringBootTest or a test slice?
- A slice where one exists — @WebMvcTest, @DataJpaTest, @JsonTest — because they start a fraction of the context and run in a fraction of the time. Use the full @SpringBootTest for component tests where the whole wiring is the point.
- Why are my Spring tests slow?
- Usually context caching being defeated. Spring caches the application context between test classes, but any difference in configuration — a different @MockBean, a different property — creates a new one. A suite with thirty distinct context configurations starts Spring thirty times.
Runnable samples for this page
last test results ↗- Java
java/src/test/java/languages/java
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- JUnit 5The Java standard — Jupiter's extension model, parameterised tests, nested classes, assertions with AssertJ, and running it all in CI.
- TestcontainersRunning real databases, brokers and services as disposable containers from inside your test suite — the pattern that made integration testing cheap.
- Mocking FrameworksMoq, NSubstitute, Mockito, unittest.mock, Sinon and the rest — what each ecosystem's mocking library does well, and the failure modes they share.
- WireMockA programmable HTTP server for tests — stubbing responses, injecting failures, verifying requests, and recording real traffic to replay.
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
- Writing Tests in C#The .NET testing stack — xUnit, Moq or NSubstitute, WebApplicationFactory, Testcontainers — and the dependency injection story that makes it the most testable of the four.
- Testing in TeamCityBuild chains, real-time test reporting and the best flaky-test detection of any CI platform — configured as Kotlin DSL rather than clicked together.