Spring Boot Testing Guide: Proven Strategies for Bulletproof Applications From Unit to Integration Tests
Master comprehensive Spring Boot testing strategies from unit tests to full integration. Learn MockMvc, @DataJpaTest, security testing & more. Build reliable apps with confidence.
When I build a Spring Boot application that others will depend on, I think of testing not as a separate task, but as the foundation the entire project rests on. It’s the difference between hoping something works and knowing it does. Over time, I’ve learned that a scattered approach leads to fragile software. A structured plan, however, builds something that can withstand change, grow over time, and be trusted in a real production environment.
Let me share the specific methods I use to create that trust. These aren’t just theories; they are practical steps I take in every project to ensure quality from the very first line of code to the final deployment.
The fastest and most fundamental tests are unit tests. Their purpose is simple: verify that a single piece of your business logic works in isolation. I don’t start the whole Spring application for these. I focus purely on the Java class and its rules. This forces me to write code that is easier to test and, consequently, better designed.
Here’s a common example. Imagine a service that calculates a discount. I want to test the calculation logic itself, without worrying about databases or web calls.
class DiscountCalculatorUnitTest {
private DiscountCalculator calculator = new DiscountCalculator();
@Test
void shouldApplyTenPercentDiscountForOverOneHundred() {
BigDecimal total = new BigDecimal("150.00");
BigDecimal discounted = calculator.applyDiscount(total);
assertThat(discounted).isEqualByComparingTo("135.00"); // 10% off
}
@Test
void shouldApplyNoDiscountBelowThreshold() {
BigDecimal total = new BigDecimal("80.00");
BigDecimal discounted = calculator.applyDiscount(total);
assertThat(discounted).isEqualByComparingTo("80.00");
}
}
These tests run in a few milliseconds. When I make a change to the discount rules, I get immediate feedback. I use mocks for any dependencies, like a repository or an email client. This lets me control the test environment completely, simulating exactly what I need for each scenario.
Once my business logic is sound, I shift to the web layer—the controllers that handle HTTP requests and responses. Starting a full server for every controller test is slow. Instead, I use @WebMvcTest. This is a “slice” test; Spring Boot only starts the components related to web handling, making it very fast.
I can test URL mappings, JSON serialization, status codes, and security filters all in this focused environment.
@WebMvcTest(ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc; // This object simulates HTTP calls
@MockBean
private ProductService productService; // The real service is replaced with a mock
@Test
void getProduct_ShouldReturnProduct_WhenProductExists() throws Exception {
// 1. Arrange: Define what the mocked service will return
Product mockProduct = new Product("P123", "Spring Boot Guide", 29.99);
given(productService.findById("P123")).willReturn(mockProduct);
// 2. Act & Assert: Perform the HTTP call and check the result
mockMvc.perform(get("/api/products/P123")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("Spring Boot Guide"))
.andExpect(jsonPath("$.price").value(29.99));
}
@Test
void getProduct_ShouldReturn404_WhenProductDoesNotExist() throws Exception {
given(productService.findById("INVALID_ID"))
.willThrow(new ProductNotFoundException());
mockMvc.perform(get("/api/products/INVALID_ID"))
.andExpect(status().isNotFound());
}
}
The MockMvc object is powerful. It lets me add headers, check response content type, and verify error handlers. It’s my go-to tool for ensuring my API contracts are stable and correct.
If the web layer defines how data comes in and out, the data layer defines how it’s stored. For this, I use @DataJpaTest. It sets up an in-memory database (like H2) and configures only JPA-related beans. It’s perfect for testing repositories, entity mappings, and simple query methods.
@DataJpaTest
class CustomerRepositoryTest {
@Autowired
private TestEntityManager entityManager; // A helper for manual persistence
@Autowired
private CustomerRepository customerRepository;
@Test
void findByEmail_ShouldReturnCustomer_WhenEmailExists() {
// Persist a customer directly using TestEntityManager
Customer savedCustomer = new Customer("[email protected]", "John", "Doe");
entityManager.persist(savedCustomer);
entityManager.flush();
// Query using the repository method we want to test
Customer foundCustomer = customerRepository.findByEmail("[email protected]").orElseThrow();
assertThat(foundCustomer.getId()).isNotNull();
assertThat(foundCustomer.getFirstName()).isEqualTo("John");
}
@Test
void findByEmail_ShouldReturnEmpty_WhenEmailDoesNotExist() {
Optional<Customer> result = customerRepository.findByEmail("[email protected]");
assertThat(result).isEmpty(); // Important to test the "not found" case
}
}
By default, each test runs in a transaction that rolls back at the end, so tests don’t interfere with each other. It’s a clean, fast way to validate that my database interactions are working as I expect.
Modern applications communicate via JSON. A small mistake in a Jackson annotation can break an API for all your clients. To catch these issues early, I write dedicated JSON serialization tests using @JsonTest. These tests verify that my objects convert to and from JSON exactly as intended.
@JsonTest
class OrderRequestJsonTest {
@Autowired
private JacksonTester<OrderRequest> jsonTester;
@Test
void shouldSerializeOrderRequestToJson() throws Exception {
OrderRequest request = new OrderRequest("ORD-789", List.of("item1", "item2"), Instant.parse("2023-12-01T10:00:00Z"));
// This checks against a predefined JSON file or a JSON string
assertThat(jsonTester.write(request)).isEqualToJson("/expected/order-request.json");
}
@Test
void shouldDeserializeJsonToOrderRequest() throws Exception {
String jsonPayload = """
{
"orderId": "ORD-789",
"items": ["item1", "item2"],
"orderDate": "2023-12-01T10:00:00Z"
}
""";
assertThat(jsonTester.parse(jsonPayload))
.isEqualTo(new OrderRequest("ORD-789", List.of("item1", "item2"), Instant.parse("2023-12-01T10:00:00Z")));
}
}
This is especially crucial when using custom date formats, enums, or when a field should be ignored during serialization. A failing test here tells me immediately that I’ve changed my public API unintentionally.
Spring Boot’s configuration property binding is powerful, but misconfigured properties cause hard-to-find runtime errors. I test my @ConfigurationProperties classes directly to ensure they read values from application.yml or environment variables correctly.
@ConfigurationPropertiesTest
@EnableConfigurationProperties(AppConfigProperties.class)
@TestPropertySource(properties = {
"app.config.api-endpoint=https://test-api.example.com",
"app.config.retry-attempts=5"
})
class AppConfigPropertiesTest {
@Autowired
private AppConfigProperties properties;
@Test
void shouldBindPropertiesFromTestSource() {
assertThat(properties.getApiEndpoint()).isEqualTo("https://test-api.example.com");
assertThat(properties.getRetryAttempts()).isEqualTo(5);
}
@Test
void shouldUseDefaultValueWhenPropertyNotSet() {
// The 'timeout' property isn't set in @TestPropertySource, so it should use the default
assertThat(properties.getTimeout()).isEqualTo(Duration.ofSeconds(10));
}
}
This type of test gives me confidence that my application will start correctly with different configuration profiles, like dev, staging, and prod.
For reactive applications using WebFlux, or even for testing any REST endpoint in a more fluent style, I use WebTestClient. It can bind to a running server or mock the web layer, and its API is very expressive for testing responses.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReactiveUserEndpointTest {
@Autowired
private WebTestClient webTestClient; // Injects a client connected to the running server
@Test
void shouldGetStreamOfUsers() {
webTestClient.get().uri("/api/users/stream")
.accept(MediaType.TEXT_EVENT_STREAM)
.exchange()
.expectStatus().isOk()
.expectHeader().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM)
.expectBodyList(User.class).hasSize(10); // Expect a stream of 10 users
}
}
It’s also excellent for testing standard REST endpoints, offering a nice alternative to TestRestTemplate with a more modern, chainable API.
Managing the state of a database for tests can be messy. While @DataJpaTest often uses transactional rollbacks, sometimes you need a specific, complex dataset. For these cases, I use @Sql to run initialization and cleanup scripts. This keeps the test setup clear and separate from the Java code.
@SpringBootTest
@Sql("/scripts/setup-test-orders.sql") // Runs before each test method
@Sql(scripts = "/scripts/cleanup-test-orders.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldCalculateTotalRevenueForActiveOrders() {
// The 'setup-test-orders.sql' script has inserted 5 active orders with specific amounts
BigDecimal revenue = orderService.calculateActiveOrderRevenue();
assertThat(revenue).isEqualByComparingTo("1250.75");
}
}
The SQL scripts live in src/test/resources/scripts/. This approach is very straightforward for anyone to understand what data the test expects.
Applications rarely live in isolation. They call other services—payment gateways, weather APIs, notification services. Testing these integrations without making real network calls is essential. I use @MockBean to replace the HTTP client bean with a mock.
@SpringBootTest
class PaymentIntegrationServiceTest {
@Autowired
private PaymentIntegrationService paymentService;
@MockBean
private RestTemplate restTemplate; // The real RestTemplate is swapped out
@Test
void shouldProcessPayment_WhenGatewayReturnsSuccess() {
// Mock the response from the external payment gateway
String gatewayResponse = "{\"status\":\"SUCCESS\",\"transactionId\":\"TX98765\"}";
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenReturn(new ResponseEntity<>(gatewayResponse, HttpStatus.OK));
PaymentResult result = paymentService.charge(new PaymentDetails("100.00", "USD"));
assertThat(result.isSuccess()).isTrue();
assertThat(result.getTransactionId()).isEqualTo("TX98765");
}
@Test
void shouldHandlePaymentFailure_Gracefully() {
when(restTemplate.postForEntity(anyString(), any(), eq(String.class)))
.thenThrow(new RestClientException("Gateway timeout"));
assertThatThrownBy(() -> paymentService.charge(new PaymentDetails("100.00", "USD")))
.isInstanceOf(PaymentGatewayException.class)
.hasMessageContaining("unavailable");
}
}
This way, I can test how my service behaves when the external service is slow, returns an error, or sends an unexpected response. It builds resilience into the code.
Security is non-negotiable, and its configuration must be tested. Spring Security provides excellent test utilities. I can simulate authenticated users with different roles directly in my @WebMvcTest or @SpringBootTest.
@WebMvcTest(AdminController.class)
class AdminControllerSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
@WithMockUser(roles = "USER") // Simulates a user with the role "USER"
void regularUser_ShouldBeDeniedAccessToAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isForbidden()); // Expect a 403 Forbidden
}
@Test
@WithMockUser(roles = "ADMIN") // Simulates a user with the role "ADMIN"
void adminUser_ShouldAccessAdminEndpoint() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().isOk());
}
@Test
@WithAnonymousUser // Simulates an unauthenticated request
void anonymousUser_ShouldBeRedirectedOrDenied() throws Exception {
mockMvc.perform(get("/api/admin/dashboard"))
.andExpect(status().is3xxRedirection()); // Expect a redirect to login
}
}
These annotations make it trivial to test complex authorization rules. For more specific scenarios, like testing with a custom authentication token, I can use RequestPostProcessor methods.
Finally, we have the broadest tests: full integration tests annotated with @SpringBootTest. These start the entire application context, just as it would in production (but with test-specific properties). They are slower, so I use them for the most critical user journeys.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(locations = "classpath:application-integrationtest.properties")
class UserRegistrationIntegrationTest {
@LocalServerPort
private int port; // The random port the server started on
@Autowired
private TestRestTemplate restTemplate; // A convenient REST client for tests
@Test
void completeUserRegistrationFlow() {
// 1. Create a user
UserRegistrationRequest request = new UserRegistrationRequest("[email protected]", "password123");
ResponseEntity<Void> createResponse = restTemplate.postForEntity(
"http://localhost:" + port + "/api/register",
request,
Void.class
);
assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
String locationHeader = createResponse.getHeaders().getFirst("Location");
assertThat(locationHeader).isNotEmpty();
// 2. Retrieve the created user using the Location header
ResponseEntity<User> getUserResponse = restTemplate.getForEntity(
"http://localhost:" + port + locationHeader,
User.class
);
assertThat(getUserResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(getUserResponse.getBody().getEmail()).isEqualTo("[email protected]");
}
}
These tests give me the highest level of confidence. I configure them to use in-memory databases and mocked external services to keep them reasonably fast and reliable for my continuous integration pipeline.
Putting it all together, the goal is a balanced testing strategy. I run hundreds of fast unit and slice tests every time I save a file. They are my first line of defense. The integration tests run on every pull request, verifying the major workflows. This layered approach means most bugs are caught quickly by the fast tests, while the slower tests ensure all the pieces fit together perfectly. It’s this combination that lets me deploy with the certainty that my Spring Boot application is ready for production.