DEV Community

Cover image for Spring Boot Testing: A Comprehensive Best Practices Guide
AnkitDevCode
AnkitDevCode

Posted on

Spring Boot Testing: A Comprehensive Best Practices Guide

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"));
    }
}
Enter fullscreen mode Exit fullscreen mode

The class under testUserService
Its dependencyUserRepository

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

You can let Mockito handle it:

@Mock
private UserRepository userRepository;

@InjectMocks
private UserService userService;
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
Enter fullscreen mode Exit fullscreen mode

Verify interactions with mocks

@Test
void shouldDeleteUser() {
    userService.deleteUser(1L);
    verify(userRepository, times(1)).deleteById(1L);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
 @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
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    });
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Activate Test Profile in Component Tests

Use @ActiveProfiles on your test class:

@ExtendWith(MockitoExtension.class)
@ActiveProfiles("test")
@WebMvcTest(UserController.class)
class UserControllerTest {
}
Enter fullscreen mode Exit fullscreen mode

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 put application-test.yml only under src/test/resources, and not under src/main/resources, then in test runs only the test file exists and Since there’s no application.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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

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"))));
    }
}

Enter fullscreen mode Exit fullscreen mode

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"));
    }
}

Enter fullscreen mode Exit fullscreen mode

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();
    }
}

Enter fullscreen mode Exit fullscreen mode

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)