Spring Boot Testing: Unit to Integration Tests That Actually Work
Test Spring Boot apps properly. Unit tests, @WebMvcTest, @DataJpaTest, Testcontainers, and test slices explained with real examples.
Moshiour Rahman
Advertisement
The Problem: Your Tests Are Lying to You
You have 90% test coverage. CI is green. Then production breaks.
Why? Your tests:
- Mock everything → don’t catch integration issues
- Use H2 → misses Postgres-specific behavior
- Test implementation → break on refactoring
- Are slow → developers skip running them
Good tests catch bugs before production. Here’s how to write them.
Quick Answer (TL;DR)
// Unit test - fast, isolated
@Test
void shouldCalculateTotal() {
var service = new OrderService(mock(OrderRepository.class));
assertEquals(100, service.calculateTotal(order));
}
// Integration test - real database
@DataJpaTest
@Testcontainers
class OrderRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Test
void shouldFindByStatus() {
var orders = repository.findByStatus(PENDING);
assertThat(orders).hasSize(2);
}
}
// Web test - HTTP layer only
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Test
void shouldReturn404WhenNotFound() {
mockMvc.perform(get("/orders/999"))
.andExpect(status().isNotFound());
}
}
The Testing Pyramid

| Layer | Speed | Scope | Tools |
|---|---|---|---|
| Unit | ~1ms | Single class | JUnit, Mockito |
| Integration | ~100ms | Multiple classes + DB | @DataJpaTest, Testcontainers |
| E2E | ~1s+ | Full application | @SpringBootTest, MockMvc |
Unit Tests: Fast and Focused
Service Layer
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orderRepository;
@Mock
private PaymentService paymentService;
@InjectMocks
private OrderService orderService;
@Test
void shouldCreateOrder() {
// Given
var request = new CreateOrderRequest("user-123", List.of(
new OrderItem("SKU-1", 2),
new OrderItem("SKU-2", 1)
));
when(orderRepository.save(any())).thenAnswer(inv -> {
Order order = inv.getArgument(0);
order.setId("order-456");
return order;
});
// When
Order result = orderService.createOrder(request);
// Then
assertThat(result.getId()).isEqualTo("order-456");
assertThat(result.getItems()).hasSize(2);
verify(orderRepository).save(any(Order.class));
}
@Test
void shouldThrowWhenPaymentFails() {
// Given
var order = new Order("order-123", List.of());
when(paymentService.charge(any())).thenThrow(new PaymentException("Declined"));
// When/Then
assertThrows(PaymentException.class,
() -> orderService.processPayment(order));
verify(orderRepository, never()).save(any());
}
}
Testing Edge Cases
@ParameterizedTest
@CsvSource({
"0, 0, 0",
"100, 0, 100",
"100, 10, 90",
"100, 100, 0"
})
void shouldCalculateDiscount(int price, int discountPercent, int expected) {
var calculator = new PriceCalculator();
assertEquals(expected, calculator.applyDiscount(price, discountPercent));
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldRejectBlankInput(String input) {
assertThrows(IllegalArgumentException.class,
() -> validator.validate(input));
}
@WebMvcTest: Controller Layer Only
Tests HTTP handling without starting full application.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService;
@Test
void shouldReturnOrder() throws Exception {
// Given
var order = new Order("order-123", "user-1", List.of());
when(orderService.findById("order-123")).thenReturn(Optional.of(order));
// When/Then
mockMvc.perform(get("/api/orders/order-123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value("order-123"))
.andExpect(jsonPath("$.userId").value("user-1"));
}
@Test
void shouldReturn404WhenNotFound() throws Exception {
when(orderService.findById("missing")).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders/missing"))
.andExpect(status().isNotFound());
}
@Test
void shouldValidateRequest() throws Exception {
var invalidRequest = """
{
"userId": "",
"items": []
}
""";
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(invalidRequest))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").isArray());
}
@Test
void shouldCreateOrder() throws Exception {
var request = """
{
"userId": "user-123",
"items": [{"sku": "SKU-1", "quantity": 2}]
}
""";
var created = new Order("order-456", "user-123", List.of());
when(orderService.createOrder(any())).thenReturn(created);
mockMvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "/api/orders/order-456"));
}
}
@DataJpaTest: Repository Layer
Tests JPA repositories with real database operations.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository repository;
@Autowired
private TestEntityManager entityManager;
@Test
void shouldFindByUserId() {
// Given
var order1 = entityManager.persist(new Order(null, "user-1", PENDING));
var order2 = entityManager.persist(new Order(null, "user-1", SHIPPED));
entityManager.persist(new Order(null, "user-2", PENDING));
entityManager.flush();
// When
var orders = repository.findByUserId("user-1");
// Then
assertThat(orders).hasSize(2)
.extracting(Order::getId)
.containsExactlyInAnyOrder(order1.getId(), order2.getId());
}
@Test
void shouldFindPendingOrdersOlderThan() {
// Given
var oldOrder = new Order(null, "user-1", PENDING);
oldOrder.setCreatedAt(LocalDateTime.now().minusDays(7));
entityManager.persist(oldOrder);
var newOrder = new Order(null, "user-1", PENDING);
newOrder.setCreatedAt(LocalDateTime.now());
entityManager.persist(newOrder);
entityManager.flush();
// When
var staleOrders = repository.findPendingOrdersOlderThan(
LocalDateTime.now().minusDays(3)
);
// Then
assertThat(staleOrders).hasSize(1)
.extracting(Order::getId)
.containsOnly(oldOrder.getId());
}
}
Testcontainers: Real Databases
@SpringBootTest
@Testcontainers
class IntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withInitScript("init.sql");
@Container
@ServiceConnection
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
);
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void shouldProcessOrderEndToEnd() {
// Full integration test with real Postgres, Redis, Kafka
}
}
Reusable Container Configuration
// src/test/java/io/techyowls/TestcontainersConfig.java
@TestConfiguration
public class TestcontainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // Reuse across tests
}
@Bean
@ServiceConnection
GenericContainer<?> redisContainer() {
return new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379)
.withReuse(true);
}
}
// Use in tests
@SpringBootTest
@Import(TestcontainersConfig.class)
class OrderServiceIntegrationTest {
// Containers are injected and reused
}
@SpringBootTest: Full Application
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private OrderRepository orderRepository;
@BeforeEach
void setUp() {
orderRepository.deleteAll();
}
@Test
void shouldCreateAndRetrieveOrder() {
// Create
var request = new CreateOrderRequest("user-123", List.of(
new OrderItem("SKU-1", 2)
));
var createResponse = restTemplate.postForEntity(
"/api/orders", request, Order.class
);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
var createdOrder = createResponse.getBody();
assertThat(createdOrder.getId()).isNotNull();
// Retrieve
var getResponse = restTemplate.getForEntity(
"/api/orders/{id}", Order.class, createdOrder.getId()
);
assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getResponse.getBody().getUserId()).isEqualTo("user-123");
}
@Test
void shouldReturnPaginatedResults() {
// Given - 25 orders
IntStream.range(0, 25).forEach(i ->
orderRepository.save(new Order(null, "user-" + i, PENDING))
);
// When
var response = restTemplate.getForEntity(
"/api/orders?page=0&size=10", PagedResponse.class
);
// Then
assertThat(response.getBody().getContent()).hasSize(10);
assertThat(response.getBody().getTotalElements()).isEqualTo(25);
assertThat(response.getBody().getTotalPages()).isEqualTo(3);
}
}
Test Slices Reference
| Annotation | What It Loads | Use For |
|---|---|---|
@WebMvcTest | Controllers, filters, converters | REST API tests |
@DataJpaTest | JPA repositories, EntityManager | Database tests |
@DataMongoTest | MongoDB repositories | MongoDB tests |
@JsonTest | Jackson ObjectMapper | JSON serialization |
@WebFluxTest | WebFlux controllers | Reactive endpoints |
@JdbcTest | JdbcTemplate, DataSource | Raw JDBC tests |
Testing Security
@WebMvcTest(SecureController.class)
@Import(SecurityConfig.class)
class SecureControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldRejectUnauthenticatedRequest() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(roles = "USER")
void shouldRejectUnauthorizedUser() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser(roles = "ADMIN")
void shouldAllowAdmin() throws Exception {
mockMvc.perform(get("/api/admin/users"))
.andExpect(status().isOk());
}
@Test
void shouldAuthenticateWithJwt() throws Exception {
var token = jwtService.generateToken("admin", Set.of("ROLE_ADMIN"));
mockMvc.perform(get("/api/admin/users")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
}
Test Configuration
application-test.yml
spring:
datasource:
url: jdbc:tc:postgresql:16-alpine:///testdb
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
logging:
level:
org.springframework.test: DEBUG
org.testcontainers: INFO
Faster Tests
// Parallel execution
// src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent
Common Pitfalls
| Pitfall | Problem | Fix |
|---|---|---|
| Testing implementation | Tests break on refactor | Test behavior, not internals |
| H2 in tests | Misses DB-specific bugs | Use Testcontainers |
| No test isolation | Tests affect each other | @BeforeEach cleanup, @Transactional |
| Slow full tests | Developers skip them | Use test slices |
| Mocking too much | False confidence | Integration tests for critical paths |
Code Repository
Complete test examples:
GitHub: techyowls/techyowls-io-blog-public/spring-boot-testing
git clone https://github.com/techyowls/techyowls-io-blog-public.git
cd techyowls-io-blog-public/spring-boot-testing
./mvnw test
Further Reading
- Docker Compose for Spring Boot - Test in containers
- Kafka Message Ordering - Test async flows
- Spring Testing Docs
Write tests that catch bugs, not tests that pass. Follow TechyOwls for more practical guides.
Advertisement
Moshiour Rahman
Software Architect & AI Engineer
Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.
Related Articles
Spring Boot Testing with JUnit 5 and Mockito: Complete Guide
Master Spring Boot testing with JUnit 5 and Mockito. Learn unit testing, integration testing, mocking dependencies, and test-driven development.
Spring BootTestcontainers: Why Your Integration Tests Have Been Lying to You
Stop using H2 for tests. Learn how Testcontainers eliminates the 'works on my machine' problem by running real databases in Docker during tests.
Spring BootSpring Boot 3 Virtual Threads: Complete Guide to Java 21 Concurrency
Master virtual threads in Spring Boot 3. Learn configuration, performance benchmarks, when to use them, common pitfalls, and production-ready patterns for high-throughput applications.
Comments
Comments are powered by GitHub Discussions.
Configure Giscus at giscus.app to enable comments.