Introduction
In modern software development, testing is not a one-size-fits-all activity. Different kinds of tests serve different purposes — from quickly verifying small units of code to validating entire system flows under real-world conditions.
To design an efficient and maintainable test strategy, it’s important to understand the various types of tests, their scope, and their trade-offs in terms of speed, reliability, and coverage
The table below provides an overview to helps teams balance their testing strategy according to the Test Pyramid principle — having more fast, cheap unit tests at the base, fewer integration tests in the middle, and only a handful of slow, expensive tests (E2E, performance) at the top.
The golden rule is to write lots of unit tests, some integration tests, and few end-to-end tests.
A typical ratio:
- 70–80% unit tests (fast, isolated, cover edge cases)
- 15–20% integration tests (cross-layer correctness)
- 5–10% end-to-end (E2E) (real dependencies, full workflow)
Testing Strategy Guidelines
When to Use Each Test Type
- Unit Tests: Use for business logic, algorithms, utility functions, and any code that can be tested in isolation. Aim for high coverage of your core business logic.
- Integration Tests: Use for testing interactions between your application components, database operations, and internal service communication.
- Component Tests: Use when you need to test a specific layer or module with some real dependencies but want faster execution than full integration tests.
- E2E Tests: Use sparingly for critical user workflows that span multiple systems. Focus on high-value scenarios that would cause significant business impact if broken.
- Functional Tests: Use to verify that features meet business requirements. Often written in collaboration with product owners and business analysts.
- Contract Tests: Use when your application communicates with other services, especially in microservices architectures where service boundaries are critical.
- Performance Tests: Use when you have specific performance requirements or when you need to understand system behavior under load.
- Smoke Tests: Use for quick validation after deployments or major changes. Include in your deployment pipeline for immediate feedback.
- Regression Tests: Maintain as part of your overall test suite to prevent reintroduction of fixed bugs and ensure stable functionality.
- Security Tests: Integrate into your development and deployment process to identify vulnerabilities early and ensure security controls function correctly.
Note: This article highlights a few important test types, rather than covering every possible category.
Unit Testing
Purpose
Unit tests verify that individual classes and methods work correctly in isolation, without any external dependencies like databases, web services, or file systems.
Best Practices
- Mock External Dependencies: Replace all external dependencies with mocks or stubs to ensure tests run fast and reliably. This includes databases, HTTP clients, file systems, and third-party services.
- Follow the AAA Pattern: Structure tests with clear Arrange, Act, and Assert sections. Set up test data and mocks, execute the method under test, then verify the results and interactions.
- Test Edge Cases: Don't just test the happy path. Include tests for null inputs, empty collections, boundary values, and error conditions. These edge cases often reveal bugs that normal usage might miss.
- Use Descriptive Test Names: Test names should clearly describe what scenario is being tested and what outcome is expected. Avoid generic names like "testMethod1" or "shouldWork".
- Keep Tests Independent: Each test should be able to run independently and in any order. Avoid shared state between tests and ensure proper setup and teardown.
- Test One Thing at a Time: Each test should verify a single behavior or scenario. If you need multiple assertions, make sure they're all related to the same logical outcome.
- Use Test Data Builders: Create builder patterns for test data to make tests more readable and maintainable. This approach makes it easy to create variations of test objects.
Let’s assume we want to write a unit test (using JUnit 5 + Mockito) for the following UserService
class.
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String getUserName(Long id) {
return userRepository.findById(id)
.map(User::getName)
.orElseThrow(() -> new RuntimeException("User not found"));
}
}
The class under test→ UserService
Its dependency → UserRepository
Without Annotations (Manual Mocks, No Extension)
No @ExtendWith needed because we’re manually creating mocks with mock().
class UserServiceTest {
private final UserRepository userRepository = mock(UserRepository.class);
private final UserService userService = new UserService(userRepository);
@Test
void shouldReturnUserNameWhenUserExists() {
when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));
String result = userService.getUserName(1L);
assertEquals("Alice", result);
}
}
When to Use @ExtendWith(MockitoExtension.class)
If you want to use Mockito annotations like:
- @Mock → creates mock objects
- @InjectMocks → injects mocks into the class under test then you need @ExtendWith(MockitoExtension.class) on your test class.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
@Test
void shouldReturnUserNameWhenUserExists() {
when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));
String result = userService.getUserName(1L);
assertEquals("Alice", result);
}
}
What is @InjectMocks?
@InjectMocks
is a Mockito annotation that creates an instance of the class under test and automatically injects the required dependencies (the ones you marked with @Mock) into it.
So instead of you manually writing:
UserRepository userRepository = mock(UserRepository.class);
UserService userService = new UserService(userRepository);
You can let Mockito handle it:
@Mock
private UserRepository userRepository;
@InjectMocks
private UserService userService;
Why use @InjectMocks?
- Less boilerplate — no need to manually new-up UserService.
- Clearer intent — it’s obvious which class is being tested (userService).
- Works with constructor, setter, or field injection.
👉 Follow the method naming pattern:
should<ExpectedBehavior>When<Condition>
shouldReturnUnknownWhenUserDoesNotExist
shouldThrowExceptionWhenUserAlreadyExists
Use @ParameterizedTest for multiple inputs
Example without ParameterizedTest
@Test
void shouldReturnAliceWhenIdIs1() {
when(userRepository.findById(1L)).thenReturn(Optional.of(new User(1L, "Alice")));
assertEquals("Alice", userService.getUserName(1L));
}
@Test
void shouldReturnUnknownWhenIdDoesNotExist() {
when(userRepository.findById(99L)).thenReturn(Optional.empty());
assertEquals("Unknown", userService.getUserName(99L));
}
Example with ParameterizedTest
@ParameterizedTest
@CsvSource({
"1, Alice", // user exists
"2, Bob", // user exists
"3, Unknown" // user does not exist
})
void shouldReturnUserNameOrUnknownBasedOnExistence(Long id, String expectedName) {
// Arrange
if (!expectedName.equals("Unknown")) {
when(userRepository.findById(id))
.thenReturn(Optional.of(new User(id, expectedName)));
} else {
when(userRepository.findById(id)).thenReturn(Optional.empty());
}
// Act
String result = userService.getUserName(id);
// Assert
assertEquals(expectedName, result);
}
Verify interactions with mocks
@Test
void shouldDeleteUser() {
userService.deleteUser(1L);
verify(userRepository, times(1)).deleteById(1L);
}
Test with ArgumentCaptor
The use of ArgumentCaptor in unit testing (with Mockito) is to capture arguments that are passed to mocked methods, so you can make assertions on them.
- Useful when you want to inspect properties of the passed object.
- Can capture multiple values if the method is called multiple times.
- Makes assertions more fine-grained than just verifying method calls.
Capture argument for verification
Simple Example: please note createUser
doesn't return anything here.
Without ArgumentCaptor, you can only check if save() was called.But you don’t know what object was passed. ArgumentCaptor lets you inspect the actual argument (User) and assert its fields.
class UserService {
private final UserRepository repo;
UserService(UserRepository repo) {
this.repo = repo;
}
void createUser(String name) {
User user = new User(null, name);
repo.save(user);
}
}
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository repo;
@InjectMocks
private UserService service;
@Test
void shouldCaptureUserPassedToRepository() {
// Given
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
// When
service.createUser("Alice");
// Then
Mockito.verify(repo).save(captor.capture());
assertEquals("Alice", captor.getValue().getName()); // we can check input
}
}
Answer / custom return behavior
This is very useful when the stubbed method needs to do something dynamic based on the input, not just return a fixed value.
Basic Example (Fixed Return)
when(repo.save(any(User.class))).thenReturn(new User(1L, "Alice"));
Here the save() method always returns the same user, regardless of what was passed.
- thenReturn() → good for simple, static returns.
- thenAnswer() → powerful when return depends on input or custom logic.
Using Answer for Dynamic Return
Sometimes you want the return value to depend on the argument passed. That’s where Answer comes in.
when(repo.findById(anyLong()))
.thenAnswer(invocation -> {
Long id = invocation.getArgument(0);
if (id == 0) throw new IllegalArgumentException("Invalid ID");
return new User(id, "User" + id);
});
When To Use Answer
- When you want to simulate DB behavior (e.g., assigning IDs).
- When the return should depend on input values.
- When you need to throw exceptions for specific inputs.
Void Methods (doNothing / doThrow
Since void methods can’t be stubbed with when(...).thenReturn(...), Mockito gives us:
- doNothing() → clarify intention when void method should just pass.
- doThrow() → test error handling for void methods.
- doAnswer() → add side-effects like logging, counters, or capturing arguments.
Snnipet:
@Test
void shouldRegisterUserAndSendEmail() {
// void method -> doNothing
doNothing().when(emailService).sendWelcomeEmail(any(User.class));
service.register("Alice");
verify(emailService).sendWelcomeEmail(any(User.class));
}
@Test
void shouldHandleEmailFailure() {
doThrow(new RuntimeException("SMTP error"))
.when(emailService).sendWelcomeEmail(any(User.class));
assertThrows(RuntimeException.class, () -> service.register("Bob"));
}
What is BDDMockito?
- BDD = Behavior-Driven Development
- Mockito provides BDD style methods: given(), willReturn(), willThrow(), then().
- The behavior is the same as standard Mockito, but it improves readability and aligns with BDD conventions.
Test in BDD Style
@Test
void shouldRegisterUserAndSendEmail() {
// Given
User user = new User(null, "Alice", "alice@example.com");
given(repo.save(any(User.class))).willAnswer(inv -> {
User u = inv.getArgument(0);
u.setId(101L); // mimic DB
return u;
});
// When
User saved = service.register("Alice", "alice@example.com");
// Then
then(repo).should().save(any(User.class));
then(email).should().sendWelcomeEmail("alice@example.com");
assertEquals(101L, saved.getId());
assertEquals("Alice", saved.getName());
}
Advantages of BDDMockito
- Improves readability: clearly separates Given, When, Then.
- Aligns tests with BDD style requirements.
- Makes test intentions obvious for new team members.
Parallel Test Execution
JUnit 5 supports junit.jupiter.execution.parallel.enabled=true.
- Combine with Stateless test design (no shared mutable state).
- Be careful with shared resources
Controlling Test Lifecycle with @TestInstance
By default, JUnit creates a new test class instance per test method (PER_METHOD).
With @TestInstance(PER_CLASS), JUnit creates only one instance of the test class for all methods.
import org.junit.jupiter.api.*;
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class UserServiceIntegrationTest {
private UserService userService;
@BeforeAll
void setUpAll() {
System.out.println("Init once for the whole class");
userService = new UserService();
}
@BeforeEach
void setUpEach() {
System.out.println("Runs before each test");
}
@Test
void testCreateUser() {
System.out.println("Test 1");
// uses same UserService instance
}
@Test
void testFindUser() {
System.out.println("Test 2");
// still same UserService instance
}
}
Why Use PER_CLASS
- Performance optimization
- If your test class has expensive setup (e.g., loading Spring beans manually, external resource initialization), using a single test instance avoids redoing that work for every test method.
- Example: Large @BeforeEach → becomes @BeforeAll.
- State sharing between tests
- With PER_CLASS, you can keep shared state across test methods (e.g., Testcontainers instance, mock server, expensive cached objects).
- Useful when multiple test methods need to reuse the same resource.
- Cleaner lifecycle management
- You can use non-static @BeforeAll and @AfterAll methods (normally they must be static in JUnit)..
Caveats
- Shared state across tests can lead to test pollution if not carefully managed.
- Best paired with clear teardown logic or immutable test data to avoid flaky tests.
Follow FIRST principles
- Fast → runs quickly
- Isolated → doesn’t depend on DB/network
- Repeatable → same result every run
- Self-validating → uses assertions, not console output
- Timely → written close to when code is written
Component Testing
Purpose
Component testing (sometimes called slice testing) is:
Testing a single Spring-managed component or a set of components together without starting the full application.
Best Practices
- Use Spring Boot Test Slices: Leverage annotations like WebMvcTest, DataJpaTest, and JsonTest to load only the relevant parts of the Spring context for faster test execution.
- Test Layer Boundaries: Focus on testing how different layers of your application interact. For example, test how controllers handle requests and responses, or how repositories interact with the database.
- Mock Collaborators: Mock services and components that aren't part of the slice being tested. This keeps tests focused and fast while still testing real integration points.
- Test Error Handling: Verify that your components properly handle and propagate errors. Test validation, exception handling, and error responses.
- Validate Serialization: Test JSON serialization and deserialization, especially for REST APIs. Ensure that your DTOs and entities are correctly mapped.
Use Slice Annotations When Possible
Spring Boot provides slice testing annotations:
- @WebMvcTest Test controllers with MockMvc, auto-configures MVC beans
- @DataJpaTest Test repositories with in-memory DB (H2)
- @RestClientTest Test REST clients like WebClient
- @JsonTest Test JSON serialization/deserialization
- @SpringBootTest Full context (use sparingly for component integration)
- @TestConfiguration For custom test configurations.
Mock External Dependencies
Choose the Right Test Slice: Use specific annotations like @WebMvcTest
for controllers, @DataJpaTest
for repositories, rather than always using @SpringBootTest
which loads the entire context.
Mock Dependencies Properly: Use @MockBean
for Spring-managed beans and @Mock
for regular dependencies. This keeps tests fast and isolated.
Testing Controller Components
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldCreateUserAndReturnCreated() throws Exception {
// Arrange
CreateUserRequest request = new CreateUserRequest("john@example.com", "John Doe");
UserResponse response = new UserResponse(1L, "john@example.com", "John Doe");
when(userService.createUser(any(CreateUserRequest.class))).thenReturn(response);
// Act & Assert
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.email").value("john@example.com"))
.andExpect(jsonPath("$.name").value("John Doe"));
verify(userService).createUser(any(CreateUserRequest.class));
}
}
Testing Repository Components
For repositories: @DataJpaTest + H2 in-memory DB.
@DataJpaTest
class UserRepositoryTest {
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndFindUser() {
User user = userRepository.save(new User(null, "Alice"));
assertTrue(userRepository.findById(user.getId()).isPresent());
}
}
Testing Service Components
- For service layer, use @ExtendWith(MockitoExtension.class)
- Mock dependencies to test service logic without DB or external calls.
We have already seen a couple of examples in the previous section.
Use Spring Profiles for Tests
Using Spring Profiles for tests is a best practice in Spring applications to isolate test configurations from production configurations.
Spring Boot automatically looks for configuration files in the src/main/resources
and src/test/resources
folders.
Test Configuration (src/test/resources/application-test.yml
)
spring:
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
logging:
level:
org.springframework.web: DEBUG
com.yourpackage: DEBUG
app:
cache:
enabled: false
external-api:
base-url: http://localhost:8080/mock
# Disable unnecessary features in tests
management:
endpoints:
enabled-by-default: false
Activate Test Profile in Component Tests
Use @ActiveProfiles on your test class:
@ExtendWith(MockitoExtension.class)
@ActiveProfiles("test")
@WebMvcTest(UserController.class)
class UserControllerTest {
}
This loads beans from application-test.yml instead of application.yml.
By default, Spring Boot always loads application.yml (and then applies overrides from profile-specific files like application-local.yml).
If you putapplication-test.yml
only undersrc/test/resources
, and not undersrc/main/resources
, then in test runs only the test file exists and Since there’s noapplication.yml
in classpath, Spring won’t load or merge it.
Use Test-Specific Configuration Classes
- Only loaded when @ActiveProfiles("test") is set.
- Keeps test beans separate from production beans.
You should create TestConfig
in the src/test
package.
@Configuration
@Profile("test")
public class TestConfig {
@Bean
public EmailService emailService() {
// return a mock or dummy email service for tests
return Mockito.mock(EmailService.class);
}
}
Can be imported in your test classes with @Import(TestConfig.class)
if needed.
@WebMvcTest(UserController.class)
@Import(TestConfig.class)
class UserControllerTest {
@Autowired
private EmailService emailService; // comes from TestConfig
}
Use @TestConfiguration for Inline Test Beans
Spring Boot provides @TestConfiguration for test-only bean definitions:
This keeps test beans scoped to this test class.
@WebMvcTest(UserController.class)
class UserControllerTest {
@TestConfiguration
static class Config {
@Bean
public EmailService emailService() {
return Mockito.mock(EmailService.class);
}
}
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Autowired
private EmailService emailService;
When to Use Spring Profiles for Tests
- Separate Test-Specific Configuration
- Use a dedicated profile (e.g., test) to load beans, properties, or configurations that are only relevant during testing.
- Example: different database (H2 in-memory DB) for tests instead of production DB.
- Avoid Interference with Production Configuration
- Running tests with a separate profile prevents accidental use of production services or endpoints.
- You can define test-specific beans that mock external services (e.g., payment gateway, email service) under the test profile.
Integration Testing
Purpose
Integration tests verify that multiple components work together correctly, including interactions between services, databases, and external systems.
Why Integration Tests Matter
- Bridging the Gap: Unit tests validate logic, but only integration tests confirm that layers (controller ↔ service ↔ repo ↔ DB) actually work together.
- Catch Environment Gaps: Many bugs in production come from misconfigured beans, serialization issues, or DB quirks (H2 ≠ Postgres!) that unit tests miss.
- CI/CD Gatekeeper: In most mature teams, integration tests run on every pull request → preventing broken builds from merging.
Best Practices
- Use Real Databases When Possible: While H2 in-memory databases are fast, they don't always behave like production databases. Consider using containers with real database engines for more accurate testing.
- Test Actual Data Flow: Verify that data correctly flows through your application layers. Test that controllers properly call services, services interact with repositories, and data is correctly persisted and retrieved.
- Isolate External Dependencies: Use test doubles for external services while keeping internal components real. This provides confidence in your integration while maintaining test reliability.
- Use Transaction Rollback: Configure tests to rollback database transactions after each test to maintain clean state between tests.
- Test Configuration: Verify that your Spring configuration works correctly by testing that beans are properly wired and configurations are applied.
- Focus on Critical Paths: Integration tests are more expensive than unit tests, so focus on testing the most critical business workflows and integration points.
Use @SpringBootTest Wisely
- @SpringBootTest loads the full context (all beans).
- Useful when testing end-to-end within the app.
- But it’s heavy → avoid for every test class.
- Prefer narrower slices (@WebMvcTest, @DataJpaTest) when possible.
** Choose the Right Testing Strategy based on use case**
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private UserRepository userRepository;
@Test
void shouldCreateUserSuccessfully() {
// Test full request flow
CreateUserRequest request = new CreateUserRequest("john@example.com", "John");
ResponseEntity<User> response = restTemplate.postForEntity(
"/api/users", request, User.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
assertThat(userRepository.findByEmail("john@example.com")).isPresent();
}
}
What @SpringBootTest Does
Loads the entire Spring ApplicationContext (all beans, configs, filters, interceptors, DB connections, etc.).
Boots your app in an embedded container (Tomcat/Jetty/Undertow, depending on your setup).
You can then hit your controller endpoints just like a real client would.
Use Profiles for Testing
As discussed in detail in the previous section.
- Keep application-test.yml with test-specific configs and separate profile i.e
@ActiveProfiles("integration-test")
- Example: lower logging level, test DB credentials, disable real integrations (e.g., email).
Performance and Optimization
integration tests in Spring Boot can easily become slow if not optimized. Let’s talk about performance best practices with focus on context reuse and related tricks.
Context Reuse (Spring TestContext Framework)
- By default, Spring caches the application context between test classes, so it doesn’t restart for every test.
- This is one of the biggest optimizations: context startup is the slowest part of integration testing.
How Context Caching Works in Spring Boot Tests
Spring Boot optimizes test execution by caching the application context when possible. This avoids expensive reinitialization and speeds up test suites.
Reusing Application Context using Base class
// Base class to share context
@SpringBootTest
@Testcontainers
abstract class BaseIntegrationTest {
}
// Concrete test classes extend base class
class UserIntegrationTest extends BaseIntegrationTest {
// Test methods
}
class OrderIntegrationTest extends BaseIntegrationTest {
// Test methods
}
Best Practices:
- Group tests by profile to maximize reuse.
- Use @DirtiesContext only when necessary—it forces context reload.
- Avoid unnecessary profile changes unless isolation is required.
Using MockMvc inside @SpringBootTest (mock servlet layer, faster)
You can also inject MockMvc when using @SpringBootTest.
This does not use a real HTTP server but still goes through Spring MVC dispatching.
So with @SpringBootTest, you’re not limited — you can test controllers via MockMvc (mock HTTP) or TestRestTemplate (real HTTP).
Two directions of HTTP in Spring Boot
- Inbound HTTP calls → when clients call your service.
- Example: curl http://my-service/api/users/1 hits your controller.
- Outbound HTTP calls → when your service calls another service.
- Example: Your UserService calls http://orders-service/api/orders/123.
Which tool is for which direction?
Inbound (your service)
- Test /api/users/1 returns JSON.
- Use MockMvc (fast, no real server) OR TestRestTemplate (full stack, real HTTP).
Outbound (your service calls another)
@SpringBootTest
class ExternalServiceIntegrationTest {
private MockWebServer mockWebServer;
@BeforeEach
void setUp() throws IOException {
mockWebServer = new MockWebServer();
mockWebServer.start();
}
@AfterEach
void tearDown() throws IOException {
mockWebServer.shutdown();
}
@Test
void shouldHandleExternalServiceResponse() {
// Given
mockWebServer.enqueue(new MockResponse()
.setBody("{\"status\":\"success\"}")
.addHeader("Content-Type", "application/json"));
String baseUrl = String.format("http://localhost:%s", mockWebServer.getPort());
// When
ExternalServiceResponse response = externalService.callService(baseUrl);
// Then
assertThat(response.getStatus()).isEqualTo("success");
}
}
Here:
- Your service thinks it called a real API.
- But actually, MockWebServer intercepted and returned your fake response
WireMock for Complex External APIs
WireMock is like a "supercharged" version of MockWebServer.
- Testing outbound HTTP calls (your app → external services)
- Just like MockWebServer, you don’t want your tests to depend on real APIs.
- WireMock lets you stub those APIs so your service thinks it’s calling the real thing.
- WireMock shines when the mocked responses are not just static.
@SpringBootTest
class PaymentServiceIntegrationTest {
@RegisterExtension
static WireMockExtension wireMock = WireMockExtension.newInstance()
.options(wireMockConfig().port(8089))
.build();
@Test
void shouldProcessPaymentSuccessfully() {
// Given
wireMock.stubFor(post(urlEqualTo("/api/payments"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("{\"transactionId\":\"123\",\"status\":\"approved\"}")));
// When
PaymentResult result = paymentService.processPayment(100.0);
// Then
assertThat(result.isSuccessful()).isTrue();
assertThat(result.getTransactionId()).isEqualTo("123");
}
}
Features WireMock adds (compared to MockWebServer):
- Request matching (by URL, headers, body, query params).
- Dynamic responses (different responses for same endpoint depending on input).
- Fault injection (timeouts, connection reset → great for resilience testing).
- Scenarios (simulate stateful APIs, e.g., first call = pending, second call = success).
- Recording & playback (record real API responses and replay them).
Rule of thumb:
- Use MockWebServer if you just need lightweight static mocks.
- Use WireMock when your tests need realistic API simulation (matching, state, errors).
Testing Security
@SpringBootTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class SecurityIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void shouldRequireAuthenticationForProtectedEndpoints() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/users/profile", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@WithMockUser(roles = "USER")
void shouldAllowAccessWithValidRole() {
ResponseEntity<UserProfile> response = restTemplate.getForEntity(
"/api/users/profile", UserProfile.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
Testing with TestContainers (Database Integration)
What is Test containers ?
- It uses Docker containers to spin up temporary instances of real services.
- You can use it in JUnit 5, JUnit 4, and Spock tests.
- Containers are started before tests and stopped after tests.
Why use Testcontainers?
- Mocks and in-memory DBs (like H2) are not 100% identical to real services.
- Testcontainers gives you production-like testing environments.
- Makes integration tests reliable and closer to reality.
Test with PostgreSQL Container
@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {
@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 UserService userService;
@Autowired
private UserRepository userRepository;
@Test
void shouldPersistUserToDatabase() {
// Arrange
CreateUserRequest request = new CreateUserRequest("integration@example.com", "Integration Test");
// Act
UserResponse result = userService.createUser(request);
// Assert
assertThat(result.getId()).isNotNull();
Optional<User> savedUser = userRepository.findById(result.getId());
assertThat(savedUser).isPresent();
assertThat(savedUser.get().getEmail()).isEqualTo("integration@example.com");
}
}
Cons:
- Docker Dependency- Docker must be installed and running on your machine (or CI/CD runner).
- Slower Test Execution
- Heavier Resource Usage
Not always necessary (sometimes mocks/in-memory are enough).
When you need confidence that your application works correctly with the real dependencies (DBs, brokers, caches, search engines), Testcontainers is the most reliable way to test.
It hits the sweet spot between:
- Mocks/in-memory (too fake → risk of prod bugs).
- E2E with full infra (too heavy → slow & fragile).
In short:
If your app depends on anything external (Postgres, Kafka, Redis, etc.), Testcontainers is the best balance of speed, realism, and maintainability.
End-to-End Testing
Purpose
E2E (End-to-End) testing is where you validate the entire Spring Boot application stack, often including databases, messaging, external APIs, and even UI (if applicable). These tests are slower but high value because they simulate production usage.
Integration tests ask: "Do my components work together correctly?"
E2E tests ask: "Does the whole system behave correctly from the outside?"
Best Practices
- Test Critical User Journeys: Focus on the most important user workflows that directly impact business value. Don't try to test every possible path through your application.
- Use Real Infrastructure: Test against production-like environments with real databases, message queues, and external services when possible.
- Implement Proper Setup and Teardown: Ensure tests start with a known state and clean up after themselves. Use database migrations, test data scripts, and proper cleanup procedures.
- Handle Asynchronous Operations: Many real applications involve asynchronous processing. Use appropriate waiting strategies and timeouts to handle eventual consistency.
- Keep Tests Stable: E2E tests are prone to flakiness. Use robust waiting strategies, proper timeouts, and retry mechanisms for operations that might be timing-dependent.
- Run in CI/CD Pipeline: Automate E2E tests as part of your deployment pipeline, but consider running them less frequently than unit and integration tests due to their cost.
Test Only Critical Paths
- Don’t aim for 100% E2E coverage — it’s slow, brittle, and expensive.
- Focus on happy paths and business-critical flows (e.g., “user signup → email sent → user can log in”).
- Leave edge cases to unit/integration tests.
Use Real Dependencies Where Feasible
- Prefer Testcontainers for real databases (Postgres, MySQL, Mongo, Kafka, Redis, etc.).
- This ensures tests are realistic and not tied to in-memory substitutes like H2 (which can behave differently).
Run on Random Ports
- Use webEnvironment = RANDOM_PORT with @SpringBootTest so tests don’t conflict in CI/CD when run in parallel.
- Access app endpoints via TestRestTemplate or WebTestClient.
Stub External Services
- Don’t hit real third-party APIs in E2E tests.
- Use WireMock or MockWebServer to mock external APIs.
- This isolates your tests while still simulating realistic HTTP responses.
Scenario
- We’re building a user registration flow:
- User sends a POST /api/register request.
- App stores user in the real database (H2/Postgres via Testcontainers).
- App calls an external email service to send a welcome email.
- Returns 201 Created if everything succeeds.
Technologies in Test
- @SpringBootTest(webEnvironment = RANDOM_PORT) → run full app with real HTTP layer.
- TestRestTemplate → to call our real API.
- WireMock → mock the external email service.
- Testcontainers (optional) → real DB, no mocks.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWireMock(port = 0) // WireMock on random port
@Testcontainers
class UserRegistrationE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@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);
registry.add("email.api.url", () -> "http://localhost:" + WireMockServerRunner.port());
}
@Test
void shouldRegisterUserAndSendWelcomeEmail() {
// Stub external Email API
stubFor(post("/send-email")
.willReturn(aResponse()
.withStatus(200)
.withBody("{\"status\":\"sent\"}")));
// Call real API endpoint
UserRequest newUser = new UserRequest("alice", "password123", "alice@example.com");
ResponseEntity<String> response = restTemplate
.postForEntity("/api/register", newUser, String.class);
// Assert response
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
// Verify Email API was called with expected payload
verify(postRequestedFor(urlEqualTo("/send-email"))
.withRequestBody(matchingJsonPath("$.to", equalTo("alice@example.com"))));
}
}
What This Test Covers
- Real HTTP call → TestRestTemplate hits POST /api/register.
- Real DB interaction → user stored in Postgres (via Testcontainers).
- External dependency → email API is faked by WireMock, so test doesn’t hit the real email server.
- End-to-End flow validated → request travels full stack: controller → service → repo → DB → external call → response.
Alternatives to TestRestTemplate
-
WebTestClient (Reactive-style, but works in Servlet apps too)
- Originally built for Spring WebFlux, but works with Spring MVC too.
- Provides a fluent API that’s easier to chain and assert.
- Can test reactive responses (e.g., streaming JSON).
-
RestAssured
- A third-party library popular for API testing.
- Syntax is very expressive and close to BDD (Given/When/Then).
- Good for contract tests and multi-service E2E testing.
- Downside: needs app running on a real port (not just mock HTTP layer).
Contract Testing
Purpose
Contract tests ensure that services can communicate correctly by verifying that the producer provides what the consumer expects.
Best Practices
- Define Clear Contracts: Establish explicit contracts between services that specify request and response formats, error codes, and behavior expectations.
- Test Producer and Consumer Sides: Verify that the service producing data matches the contract, and that consuming services can properly handle the provided data.
- Version Your Contracts: When contracts change, ensure backward compatibility or coordinate changes between teams. Use semantic versioning for API changes.
- Automate Contract Verification: Run contract tests automatically when either producer or consumer code changes to catch breaking changes early.
- Use Schema Validation: Validate that JSON schemas, API specifications, and message formats are correctly implemented by both sides of the contract.
What is Contract Testing?
A way to ensure compatibility between a service provider (e.g., UserService API) and its consumer (e.g., OrderService that calls UserService).
Instead of running heavy E2E tests across multiple services, contract testing verifies:
- The provider returns responses that match what the consumer expects.
- The consumer sends requests that the provider can understand
Why It’s Needed
- Microservices scale problem – E2E tests get slow and brittle as the number of services grows.
- Independent deployments – teams should be able to deploy services without waiting on each other.
- Shift-left testing – catch breaking API changes early, during CI/CD, before hitting staging or prod.
Use Consumer-Driven Contracts (CDC)
- Consumers define expectations, providers validate against them.
- Ensures consumers never get surprises after provider changes.
Adopt a Contract Testing Framework
- Spring Cloud Contract (most popular in Spring Boot).
- Others: Pact (language agnostic, widely adopted).
Keep Contracts in Version Control
- Store contracts (JSON/YAML/Groovy DSL) in the same repo as the provider OR shared contract repo.
- Use them in CI/CD pipelines.
Version Your Contracts
Treat them like APIs. Breaking changes should follow semantic versioning.
Real example of Pact contract testing with Spring Boot
Imagine a retail platform with microservices:
- Order Service (Consumer) → Places an order, needs user details.
- User Service (Provider) → Manages users, exposes /api/users/{id}.
- Pact Broker → Central hub where contract files are shared
Pact Test (Consumer-Driven Contract)
@ExtendWith(PactConsumerTestExt.class)
public class OrderServicePactTest {
@Pact(consumer = "order-service", provider = "user-service")
public RequestResponsePact createPact(PactDslWithProvider builder) {
return builder
.given("User with ID 100 exists")
.uponReceiving("Request to fetch user 100")
.path("/api/users/100")
.method("GET")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body("{\"id\":100,\"name\":\"Alice\"}")
.toPact();
}
@Test
void verifyUserApi(MockServer mockServer) throws IOException {
URL url = new URL(mockServer.getUrl() + "/api/users/100");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
String response = new BufferedReader(new InputStreamReader(conn.getInputStream()))
.lines().collect(Collectors.joining("\n"));
assertTrue(response.contains("Alice"));
}
}
This generates a Pact contract:
order-service-user-service.json
- Defines request expectations (path, method, headers).
- Defines response expectations (status, JSON body).
Industry Practice
Pact file is automatically uploaded to a Pact Broker via CI (GitHub Actions, Jenkins, GitLab).
Provider Side (User Service)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Provider("user-service")
@PactBroker(host = "pact-broker.mycompany.com", port = "80")
public class UserServiceContractTest {
@LocalServerPort
private int port;
@BeforeEach
void setup(PactVerificationContext context) {
context.setTarget(new HttpTestTarget("localhost", port));
}
@TestTemplate
@ExtendWith(PactVerificationInvocationContextProvider.class)
void verifyPacts(PactVerificationContext context) {
context.verifyInteraction();
}
}
This does not use a mock server. It runs against the real Spring Boot app and verifies:
- API paths
- Request/response schema
- Status codes
If provider response changes (e.g., name → fullName), the verification fails → contract broken.
CI/CD Flow
🔹 Consumer pipeline (Order Service)
- Run Pact tests → generate pact file.
- Publish pact to Pact Broker.
- Tag it with branch/environment (dev, staging, prod).
🔹 Provider pipeline (User Service)
- Fetch contract from Pact Broker.
- Run Pact verification tests against real app.
- If passes ✅ → Provider can be deployed.
- If fails ❌ → Provider cannot break consumer expectations.
References & Credits
AI tools were used to assist in research and writing but final content was reviewed and verified by the author.
Top comments (0)