Java has one of the richest testing ecosystems of any language. It also has some of the most elaborate test suites that test almost nothing important. Understanding the difference between tests that provide confidence and tests that provide coverage numbers is the most important skill in Java testing.
The Testing Pyramid (and Why Most Java Projects Invert It)
The testing pyramid describes the ideal distribution of test types:
- Unit tests: Many, fast, test one thing in isolation
- Integration tests: Fewer, slower, test component interactions
- End-to-end tests: Few, slowest, test user flows
Most Java projects, especially Spring Boot applications, invert this pyramid. They have:
- Hundreds of
@SpringBootTestintegration tests that spin up the entire application context for every test - Heavy mocking of everything except the specific class being tested
- A handful of actual unit tests
This results in slow test suites, high maintenance overhead, and tests that still miss bugs in the interactions between components.
Unit Testing with JUnit 5 and Mockito
JUnit 5 is the current standard. If you’re still on JUnit 4, migrate. The extension model, parameterized tests, and nested test classes alone are worth the effort.
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
@Test
@DisplayName("should process order and charge payment")
void shouldProcessOrder() {
// given
var order = new Order(UUID.randomUUID(), List.of(
new LineItem("product-1", 2, BigDecimal.valueOf(9.99))
));
when(paymentService.charge(any())).thenReturn(PaymentResult.success("tx-123"));
// when
var result = orderService.processOrder(order);
// then
assertThat(result.isSuccessful()).isTrue();
verify(orderRepository).save(argThat(saved ->
saved.getStatus() == OrderStatus.CONFIRMED
));
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void shouldRejectBlankProductIds(String productId) {
var order = new Order(UUID.randomUUID(), List.of(
new LineItem(productId, 1, BigDecimal.ONE)
));
assertThrows(ValidationException.class,
() -> orderService.processOrder(order));
}
}
Key practices:
- Use
@DisplayNamefor human-readable test names - Follow the Given/When/Then structure (Arrange/Act/Assert)
- Use
@ParameterizedTestto avoid copy-paste test proliferation - Prefer AssertJ over JUnit assertions for readability
What to Mock and What Not To
The most important and most contested decision in Java unit testing is what to mock.
Mock external dependencies: Third-party APIs, external services, anything not in your control.
Mock expensive infrastructure: Databases, message queues, caches—in unit tests.
Don’t mock your own domain objects: If you’re mocking Order to test OrderService, you’re hiding behavior, not testing it. Use real domain objects.
Don’t mock things just because they exist: The value of a test is proportional to how closely it resembles real production behavior. Every mock is a lie about how the system actually behaves.
The instinct to mock everything to achieve isolation and speed is understandable, but it produces tests that don’t catch the bugs that actually happen in production—which are usually about how components interact.
Integration Testing with Testcontainers
Testcontainers is the most important development in Java testing in the last several years. It lets you spin up real Docker containers for your test dependencies—actual PostgreSQL, actual Redis, actual Kafka—in your tests.
This eliminates the “it works in tests but breaks in production” problem caused by using H2 as an in-memory substitute for PostgreSQL and then wondering why your native queries don’t work.
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@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 OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrder() {
var order = new Order(UUID.randomUUID(), OrderStatus.PENDING, LocalDateTime.now());
orderRepository.save(order);
var retrieved = orderRepository.findById(order.getId());
assertThat(retrieved).isPresent();
assertThat(retrieved.get().getStatus()).isEqualTo(OrderStatus.PENDING);
}
}
Use @Container with static for containers shared across the test class (much faster than spinning up a container per test). Use @DynamicPropertySource to override Spring Boot configuration with the container’s connection details.
Testing Spring Boot Slices
@SpringBootTest loads the entire application context. For testing specific layers, use slice tests:
@WebMvcTest for controllers:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrderById() throws Exception {
var order = new Order(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
OrderStatus.CONFIRMED, LocalDateTime.now());
when(orderService.findById(any())).thenReturn(Optional.of(order));
mockMvc.perform(get("/api/orders/123e4567-e89b-12d3-a456-426614174000"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("CONFIRMED"));
}
}
@DataJpaTest for repositories (use with Testcontainers instead of H2).
@JsonTest for JSON serialization/deserialization.
These slice tests are faster than full @SpringBootTest and more focused.
Architecture Testing with ArchUnit
One of my favorite underused tools. ArchUnit lets you write tests that enforce architectural constraints:
@AnalyzeClasses(packages = "com.example.myapp")
class ArchitectureTest {
@ArchTest
static final ArchRule servicesShouldNotDependOnControllers =
noClasses()
.that().resideInAPackage("..service..")
.should().dependOnClassesThat()
.resideInAPackage("..controller..");
@ArchTest
static final ArchRule repositoriesShouldNotBeUsedInControllers =
noClasses()
.that().resideInAPackage("..controller..")
.should().dependOnClassesThat()
.resideInAPackage("..repository..");
@ArchTest
static final ArchRule servicesShouldBeAnnotated =
classes()
.that().resideInAPackage("..service..")
.should().beAnnotatedWith(Service.class);
}
Architecture tests run in milliseconds and catch the drift that happens over time when teams are moving fast. Put them in your pipeline and enforce your layered architecture automatically.
Contract Testing with Spring Cloud Contract
If you have microservices, contract testing prevents the “consumer expects field X, producer removed field X” class of bugs:
// Contract defined by producer
Contract.make {
description "should return order by id"
request {
method GET()
url "/api/orders/123"
}
response {
status OK()
body([
id: "123",
status: "CONFIRMED"
])
headers {
contentType(applicationJson())
}
}
}
Spring Cloud Contract generates tests from these contracts that run on the producer side, and stubs that run on the consumer side. If the producer breaks the contract, the test fails before it ever reaches the consumer’s pipeline.
Test Data Management
Random test data is a code smell. Use builders:
public class OrderTestBuilder {
private UUID id = UUID.randomUUID();
private OrderStatus status = OrderStatus.PENDING;
private List<LineItem> items = List.of(LineItemTestBuilder.defaults().build());
public static OrderTestBuilder defaults() {
return new OrderTestBuilder();
}
public OrderTestBuilder withStatus(OrderStatus status) {
this.status = status;
return this;
}
public Order build() {
return new Order(id, status, items);
}
}
// In tests:
var order = OrderTestBuilder.defaults()
.withStatus(OrderStatus.CONFIRMED)
.build();
Libraries like Instancio can generate realistic random data while letting you control the fields that matter for a specific test.
The 10-Minute Rule
If your full test suite takes more than 10 minutes, you need to fix it. Developers stop running tests that take 10 minutes. They run them before merging and then don’t catch issues until CI runs.
Profile your test suite. Find the slow tests (almost certainly @SpringBootTest integration tests). Refactor to slices, use Testcontainers with shared containers, and parallelize with @TestMethodOrder and JUnit 5’s parallel execution support.
Good test suites are fast, informative, and trustworthy. If your tests are slow, ignored, or frequently produce false positives, they’re not providing value—they’re providing coverage metrics that fool management into thinking the codebase is well-tested.