Skip to content

Spring Boot Testing

Overview

This guide provides a comprehensive approach to testing Spring Boot applications. It covers unit testing, integration testing, test slices, mocking strategies, and more. By the end of this guide, you'll understand how to implement effective testing strategies for Spring Boot applications, ensuring their reliability and robustness.

Prerequisites

  • Basic knowledge of Java and Spring Boot
  • Understanding of software testing concepts
  • Familiarity with dependency injection and inversion of control
  • Development environment with Spring Boot set up

Learning Objectives

  • Understand the Spring Boot testing framework
  • Implement unit tests for Spring components
  • Write integration tests for Spring Boot applications
  • Use Spring Boot test slices for focused testing
  • Apply mocking strategies with Mockito
  • Test web controllers with MockMvc
  • Implement data access layer tests
  • Test security configurations
  • Execute performance and load tests
  • Apply test-driven development practices

Table of Contents

  1. Testing Fundamentals in Spring Boot
  2. Unit Testing Spring Components
  3. Integration Testing
  4. Test Slices
  5. MockMvc for Web Layer Testing
  6. Data Access Layer Testing
  7. Mocking with Mockito
  8. Testing Security
  9. Testing Configurations
  10. Test Containers
  11. Performance and Load Testing
  12. Test-Driven Development
  13. Testing Best Practices

Testing Fundamentals in Spring Boot

Spring Boot provides extensive support for testing through the spring-boot-starter-test dependency, which includes:

  • JUnit 5: The core testing framework
  • Spring Test & Spring Boot Test: Utilities and annotations for testing Spring Boot applications
  • AssertJ: Fluent assertion library
  • Hamcrest: Matchers for test expressions
  • Mockito: Mocking framework
  • JSONassert: JSON assertion library
  • JsonPath: XPath for JSON

Setting Up Test Dependencies

To start testing a Spring Boot application, add the following dependency to your build file:

<!-- Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

Or for Gradle:

// Gradle
testImplementation 'org.springframework.boot:spring-boot-starter-test'

Test Structure and Configuration

A typical Spring Boot test class structure includes:

  • Annotations for test configuration
  • Setup methods for test initialization
  • Test methods that validate specific functionality
  • Teardown methods for cleaning up resources

Example of a basic test class:

@SpringBootTest
class ApplicationTests {

    @Autowired
    private SomeService someService;

    @BeforeEach
    void setUp() {
        // Setup code executed before each test
    }

    @Test
    void contextLoads() {
        // Verify application context loads successfully
        assertThat(someService).isNotNull();
    }

    @Test
    void testServiceOperation() {
        // Test specific functionality
        String result = someService.performOperation("input");
        assertThat(result).isEqualTo("expected output");
    }

    @AfterEach
    void tearDown() {
        // Cleanup code executed after each test
    }
}

Spring Boot Test Annotations

Spring Boot provides several annotations to simplify testing:

  • @SpringBootTest: Loads the full application context. Use when you need the complete Spring context.
  • @WebMvcTest: Focuses on testing the web layer, including controllers.
  • @DataJpaTest: Focuses on the JPA components.
  • @RestClientTest: Tests REST clients.
  • @JsonTest: Tests JSON serialization/deserialization.
  • @AutoConfigureMockMvc: Configures MockMvc for testing web controllers.
  • @MockBean: Creates and injects a Mockito mock.
  • @SpyBean: Creates and injects a Mockito spy.

Testing Profiles

It's common to use different configuration profiles for testing:

# application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop

Activate the profile in your test:

@SpringBootTest
@ActiveProfiles("test")
class ApplicationTests {
    // Test methods
}

Testing Lifecycle

JUnit 5 provides annotations for controlling the test lifecycle:

  • @BeforeAll: Executed once, before all test methods.
  • @AfterAll: Executed once, after all test methods.
  • @BeforeEach: Executed before each test method.
  • @AfterEach: Executed after each test method.
  • @Test: Marks a method as a test case.
  • @Disabled: Temporarily disables a test.
  • @DisplayName: Provides a custom name for the test.
  • @Tag: Tags tests for selective execution.
  • @Timeout: Fails the test if it exceeds the given timeout.
  • @RepeatedTest: Repeats a test a specified number of times.
  • @ParameterizedTest: Runs a test multiple times with different arguments.

Unit Testing Spring Components

Unit tests focus on testing individual components in isolation. In Spring Boot, this often means testing a service or component without loading the entire Spring context.

Testing Services

A service is typically tested by mocking its dependencies:

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class UserServiceTests {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserServiceImpl userService;

    private User testUser;

    @BeforeEach
    void setUp() {
        testUser = new User();
        testUser.setId(1L);
        testUser.setUsername("testuser");
        testUser.setEmail("test@example.com");
    }

    @Test
    void findByIdShouldReturnUser() {
        // Arrange
        when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));

        // Act
        Optional<User> foundUser = userService.findById(1L);

        // Assert
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("testuser");
    }

    @Test
    void findByIdShouldReturnEmptyWhenUserNotFound() {
        // Arrange
        when(userRepository.findById(999L)).thenReturn(Optional.empty());

        // Act
        Optional<User> foundUser = userService.findById(999L);

        // Assert
        assertThat(foundUser).isEmpty();
    }
}

Testing Utilities and Helper Classes

For utility classes and helpers, pure unit tests are appropriate:

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class StringUtilsTests {

    @Test
    void nullOrEmptyShouldReturnTrueForNullString() {
        assertThat(StringUtils.isNullOrEmpty(null)).isTrue();
    }

    @Test
    void nullOrEmptyShouldReturnTrueForEmptyString() {
        assertThat(StringUtils.isNullOrEmpty("")).isTrue();
    }

    @Test
    void nullOrEmptyShouldReturnFalseForNonEmptyString() {
        assertThat(StringUtils.isNullOrEmpty("text")).isFalse();
    }

    @ParameterizedTest
    @CsvSource({
        "test, TEST",
        "Spring, SPRING",
        "java, JAVA"
    })
    void toUpperCaseShouldConvertStringToUpperCase(String input, String expected) {
        assertThat(StringUtils.toUpperCase(input)).isEqualTo(expected);
    }

    @Test
    void toUpperCaseShouldThrowExceptionForNullInput() {
        assertThrows(NullPointerException.class, () -> StringUtils.toUpperCase(null));
    }
}

Using TestNG Instead of JUnit

If you prefer TestNG over JUnit, configure it in your build:

<!-- Maven -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
    <exclusions>
        <exclusion>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<dependency>
    <groupId>org.testng</groupId>
    <artifactId>testng</artifactId>
    <version>7.7.1</version>
    <scope>test</scope>
</dependency>

TestNG test example:

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;

import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;

public class UserServiceTestNG {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserServiceImpl userService;

    private User testUser;

    @BeforeMethod
    public void setUp() {
        MockitoAnnotations.openMocks(this);
        testUser = new User();
        testUser.setId(1L);
        testUser.setUsername("testuser");
    }

    @Test
    public void findByIdShouldReturnUser() {
        when(userRepository.findById(1L)).thenReturn(Optional.of(testUser));

        Optional<User> foundUser = userService.findById(1L);

        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("testuser");
    }
}

Integration Testing

Integration tests verify that different components work together correctly. In Spring Boot, this typically involves loading a subset of the application context.

@SpringBootTest

The @SpringBootTest annotation creates the application context used in tests:

@SpringBootTest
class IntegrationTests {

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void createUserShouldStoreInDatabase() {
        // Create a new user
        User user = new User();
        user.setUsername("integrationtest");
        user.setEmail("integration@example.com");

        // Save using the service
        User savedUser = userService.save(user);

        // Verify it's in the database
        Optional<User> foundUser = userRepository.findById(savedUser.getId());
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getUsername()).isEqualTo("integrationtest");
    }
}

For web applications, you can specify if a real or mock web environment should be used:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class WebIntegrationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void getUserShouldReturnUserDetails() {
        ResponseEntity<User> response = restTemplate.getForEntity("/api/users/1", User.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getId()).isEqualTo(1L);
    }
}

Testing with TestRestTemplate

TestRestTemplate is ideal for integration tests that need to make HTTP requests:

@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class UserControllerIntegrationTests {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();

        User user = new User();
        user.setUsername("testuser");
        user.setEmail("test@example.com");
        userRepository.save(user);
    }

    @Test
    void getAllUsersShouldReturnUsers() {
        ResponseEntity<User[]> response = restTemplate.getForEntity("/api/users", User[].class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody()).hasSize(1);
        assertThat(response.getBody()[0].getUsername()).isEqualTo("testuser");
    }

    @Test
    void createUserShouldReturnNewUser() {
        User newUser = new User();
        newUser.setUsername("newuser");
        newUser.setEmail("new@example.com");

        ResponseEntity<User> response = restTemplate.postForEntity("/api/users", newUser, User.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().getUsername()).isEqualTo("newuser");
        assertThat(response.getBody().getId()).isNotNull();
    }
}

Custom Configuration for Tests

You can customize the configuration for tests using @TestConfiguration:

@SpringBootTest
class CustomConfigIntegrationTests {

    @TestConfiguration
    static class TestConfig {
        @Bean
        public SomeService someServiceMock() {
            return Mockito.mock(SomeService.class);
        }
    }

    @Autowired
    private SomeService someService;

    @Test
    void testWithCustomConfiguration() {
        // The someService is the mock from TestConfig
        Mockito.when(someService.performOperation(any())).thenReturn("mocked result");

        String result = someService.performOperation("test");
        assertThat(result).isEqualTo("mocked result");
    }
}

Using @DirtiesContext

When a test modifies the application context, mark it with @DirtiesContext to reset the context for subsequent tests:

@SpringBootTest
class ContextModifyingTests {

    @Autowired
    private ApplicationContext context;

    @Test
    @DirtiesContext
    void testThatModifiesContext() {
        // Test that changes the application context
    }

    @Test
    void subsequentTest() {
        // This test will get a fresh application context
    }
}

Test Slices

Spring Boot provides test slice annotations that load only a portion of the application context, making tests faster and more focused.

@WebMvcTest

For testing Spring MVC controllers without starting the full application:

@WebMvcTest(UserController.class)
class UserControllerTests {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void getUserByIdShouldReturnUser() throws Exception {
        User user = new User();
        user.setId(1L);
        user.setUsername("testuser");

        when(userService.findById(1L)).thenReturn(Optional.of(user));

        mockMvc.perform(get("/api/users/1"))
               .andExpect(status().isOk())
               .andExpect(jsonPath("$.id").value(1))
               .andExpect(jsonPath("$.username").value("testuser"));
    }

    @Test
    void getUserByIdShouldReturn404WhenNotFound() throws Exception {
        when(userService.findById(999L)).thenReturn(Optional.empty());

        mockMvc.perform(get("/api/users/999"))
               .andExpect(status().isNotFound());
    }
}

@DataJpaTest

For testing JPA repositories:

@DataJpaTest
class UserRepositoryTests {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void findByUsernameShouldReturnUser() {
        // Create and persist a test user
        User user = new User();
        user.setUsername("jpatest");
        user.setEmail("jpa@example.com");
        entityManager.persist(user);
        entityManager.flush();

        // Test the query method
        User found = userRepository.findByUsername("jpatest");

        assertThat(found).isNotNull();
        assertThat(found.getEmail()).isEqualTo("jpa@example.com");
    }

    @Test
    void findByUsernameShouldReturnNullWhenUsernameNotFound() {
        User found = userRepository.findByUsername("nonexistent");

        assertThat(found).isNull();
    }
}

@JsonTest

For testing JSON serialization and deserialization:

@JsonTest
class UserJsonTests {

    @Autowired
    private JacksonTester<User> json;

    @Test
    void serializeUserToJson() throws Exception {
        User user = new User();
        user.setId(1L);
        user.setUsername("jsontest");
        user.setEmail("json@example.com");

        JsonContent<User> jsonContent = json.write(user);

        assertThat(jsonContent).extractingJsonPathNumberValue("$.id").isEqualTo(1);
        assertThat(jsonContent).extractingJsonPathStringValue("$.username").isEqualTo("jsontest");
        assertThat(jsonContent).extractingJsonPathStringValue("$.email").isEqualTo("json@example.com");
    }

    @Test
    void deserializeUserFromJson() throws Exception {
        String jsonContent = "{\"id\":1,\"username\":\"jsontest\",\"email\":\"json@example.com\"}";

        User user = json.parse(jsonContent).getObject();

        assertThat(user.getId()).isEqualTo(1L);
        assertThat(user.getUsername()).isEqualTo("jsontest");
        assertThat(user.getEmail()).isEqualTo("json@example.com");
    }
}

@RestClientTest

For testing REST clients:

@RestClientTest(RemoteUserService.class)
class RemoteUserServiceTests {

    @Autowired
    private RemoteUserService remoteUserService;

    @Autowired
    private MockRestServiceServer server;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    void getRemoteUserShouldReturnUser() throws Exception {
        User user = new User();
        user.setId(1L);
        user.setUsername("remoteuser");

        server.expect(requestTo("/api/remote/users/1"))
              .andRespond(withSuccess(objectMapper.writeValueAsString(user), MediaType.APPLICATION_JSON));

        User result = remoteUserService.getUser(1L);

        assertThat(result).isNotNull();
        assertThat(result.getUsername()).isEqualTo("remoteuser");
    }

    @Test
    void getRemoteUserShouldHandleError() throws Exception {
        server.expect(requestTo("/api/remote/users/999"))
              .andRespond(withStatus(HttpStatus.NOT_FOUND));

        assertThatExceptionOfType(UserNotFoundException.class)
            .isThrownBy(() -> remoteUserService.getUser(999L));
    }
}

@DataMongoTest

For testing MongoDB repositories:

@DataMongoTest
class ProductRepositoryTests {

    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository.deleteAll();
    }

    @Test
    void saveShouldPersistProduct() {
        Product product = new Product();
        product.setName("Test Product");
        product.setPrice(BigDecimal.valueOf(19.99));

        Product savedProduct = productRepository.save(product);

        assertThat(savedProduct.getId()).isNotNull();

        Optional<Product> foundProduct = productRepository.findById(savedProduct.getId());
        assertThat(foundProduct).isPresent();
        assertThat(foundProduct.get().getName()).isEqualTo("Test Product");
    }

    @Test
    void findByNameShouldReturnProducts() {
        // Create and save two products
        Product product1 = new Product();
        product1.setName("Search Product");
        product1.setPrice(BigDecimal.valueOf(29.99));
        productRepository.save(product1);

        Product product2 = new Product();
        product2.setName("Another Product");
        product2.setPrice(BigDecimal.valueOf(39.99));
        productRepository.save(product2);

        // Test the query
        List<Product> foundProducts = productRepository.findByNameContaining("Search");

        assertThat(foundProducts).hasSize(1);
        assertThat(foundProducts.get(0).getName()).isEqualTo("Search Product");
    }
} 

Mocking with Mockito

Mockito is a popular mocking framework bundled with Spring Boot's testing starter. It allows you to create mock objects, specify their behavior, and verify their interactions.

Basic Mocking

@ExtendWith(MockitoExtension.class)
class NotificationServiceTests {

    @Mock
    private EmailSender emailSender;

    @Mock
    private SMSGateway smsGateway;

    @InjectMocks
    private NotificationServiceImpl notificationService;

    @Test
    void notifyUserShouldSendEmailAndSMS() {
        // Arrange
        User user = new User("user@example.com", "1234567890");
        String message = "Test notification";

        // No need to define behavior for void methods like these
        // unless you want them to throw exceptions

        // Act
        notificationService.notifyUser(user, message);

        // Assert - verify the mock interactions
        verify(emailSender).sendEmail(user.getEmail(), message);
        verify(smsGateway).sendSMS(user.getPhoneNumber(), message);
    }
}

Stubbing Method Calls

@Test
void getUserFullNameShouldCombineFirstAndLastName() {
    // Arrange
    UserRepository userRepository = mock(UserRepository.class);
    User user = new User();
    user.setFirstName("John");
    user.setLastName("Doe");

    when(userRepository.findById(1L)).thenReturn(Optional.of(user));

    UserService userService = new UserServiceImpl(userRepository);

    // Act
    String fullName = userService.getUserFullName(1L);

    // Assert
    assertThat(fullName).isEqualTo("John Doe");
}

Advanced Mocking Techniques

Argument Matchers

// Using any() to match any argument
when(userRepository.findByUsername(any())).thenReturn(user);

// Using specific argument matchers
when(userRepository.findByUsernameAndActive(eq("admin"), anyBoolean())).thenReturn(user);

Argument Captor

@Test
void processOrderShouldSendConfirmationEmail() {
    // Arrange
    ArgumentCaptor<String> emailCaptor = ArgumentCaptor.forClass(String.class);
    ArgumentCaptor<String> subjectCaptor = ArgumentCaptor.forClass(String.class);

    Order order = new Order();
    order.setId("ORD-12345");
    order.setCustomerEmail("customer@example.com");

    // Act
    orderService.processOrder(order);

    // Assert - Capture and verify the arguments
    verify(emailService).sendEmail(emailCaptor.capture(), subjectCaptor.capture(), any());

    assertThat(emailCaptor.getValue()).isEqualTo("customer@example.com");
    assertThat(subjectCaptor.getValue()).contains("ORD-12345");
}

Spy on Real Objects

@Test
void calculateTotalWithSpy() {
    // Create a spy on a real object
    List<String> list = new ArrayList<>();
    List<String> spyList = spy(list);

    // Use the spy
    spyList.add("one");
    spyList.add("two");

    // Real method calls work normally
    assertThat(spyList.size()).isEqualTo(2);

    // But we can also stub specific methods
    when(spyList.size()).thenReturn(100);
    assertThat(spyList.size()).isEqualTo(100);

    // Other methods still work with real behavior
    assertThat(spyList.get(0)).isEqualTo("one");
}

Mocking Static Methods (Mockito 3.4.0+)

@Test
void testStaticMethod() {
    try (MockedStatic<UtilityClass> mockedStatic = mockStatic(UtilityClass.class)) {
        // Stub the static method
        mockedStatic.when(() -> UtilityClass.getCurrentDate())
                   .thenReturn(LocalDate.of(2023, 1, 1));

        // Test code that uses the static method
        LocalDate result = myService.processWithDate();

        // Assert
        assertThat(result).isEqualTo(LocalDate.of(2023, 1, 1));
    }
}

Testing Security

Spring Security is a critical component in most applications. Testing security configurations and behavior ensures your application remains protected.

Basic Security Test Setup

@SpringBootTest
@AutoConfigureMockMvc
class SecurityConfigTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void publicEndpointShouldBeAccessible() throws Exception {
        mockMvc.perform(get("/api/public"))
               .andExpect(status().isOk());
    }

    @Test
    void privateEndpointShouldRequireAuthentication() throws Exception {
        mockMvc.perform(get("/api/private"))
               .andExpect(status().isUnauthorized());
    }
}

Testing with Authentication

@SpringBootTest
@AutoConfigureMockMvc
class AuthenticatedEndpointsTests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "user", roles = {"USER"})
    void userCanAccessUserEndpoint() throws Exception {
        mockMvc.perform(get("/api/user"))
               .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "user", roles = {"USER"})
    void userCannotAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin"))
               .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(username = "admin", roles = {"ADMIN"})
    void adminCanAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin"))
               .andExpect(status().isOk());
    }
}

Custom Security Context

@Test
void customSecurityContext() throws Exception {
    List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_CUSTOM"));

    SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
    securityContext.setAuthentication(
        new UsernamePasswordAuthenticationToken("customUser", "password", authorities)
    );
    SecurityContextHolder.setContext(securityContext);

    mockMvc.perform(get("/api/custom").with(SecurityMockMvcRequestPostProcessors.csrf()))
           .andExpect(status().isOk());

    // Always clean up after test
    SecurityContextHolder.clearContext();
}

Testing JWT Authentication

@SpringBootTest
@AutoConfigureMockMvc
class JwtAuthenticationTests {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    @Test
    void endpointShouldBeAccessibleWithValidJwt() throws Exception {
        String token = jwtTokenProvider.createToken("user", List.of("ROLE_USER"));

        mockMvc.perform(get("/api/secured")
               .header("Authorization", "Bearer " + token))
               .andExpect(status().isOk());
    }

    @Test
    void endpointShouldRejectInvalidJwt() throws Exception {
        mockMvc.perform(get("/api/secured")
               .header("Authorization", "Bearer invalidToken"))
               .andExpect(status().isUnauthorized());
    }
}

OAuth2 Testing

@SpringBootTest
@AutoConfigureMockMvc
class OAuth2Tests {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "oauth_user")
    void oauthEndpointWithMockUser() throws Exception {
        mockMvc.perform(get("/api/oauth"))
               .andExpect(status().isOk());
    }

    @Test
    void oauthEndpointWithOAuth2Login() throws Exception {
        mockMvc.perform(get("/api/oauth")
               .with(SecurityMockMvcRequestPostProcessors.oauth2Login()))
               .andExpect(status().isOk());
    }

    @Test
    void oauthEndpointWithCustomOAuth2Login() throws Exception {
        mockMvc.perform(get("/api/oauth")
               .with(SecurityMockMvcRequestPostProcessors.oauth2Login()
                    .attributes(attrs -> {
                        attrs.put("sub", "1234567890");
                        attrs.put("name", "Test User");
                        attrs.put("email", "testuser@example.com");
                    })))
               .andExpect(status().isOk());
    }
}

Testing Configurations

Testing application configurations ensures that your application behaves correctly with different property settings.

Testing Properties Configuration

@SpringBootTest(properties = {"app.feature.enabled=true", "app.max-items=10"})
class ConfigurationTests {

    @Autowired
    private ApplicationProperties appProperties;

    @Test
    void propertiesShouldBeLoaded() {
        assertThat(appProperties.getFeature().isEnabled()).isTrue();
        assertThat(appProperties.getMaxItems()).isEqualTo(10);
    }
}

Testing Profile-Specific Configuration

@SpringBootTest
@ActiveProfiles("test")
class ProfileConfigTests {

    @Autowired
    private Environment environment;

    @Autowired
    private DataSource dataSource;

    @Test
    void testProfileShouldBeActive() {
        assertThat(environment.getActiveProfiles()).contains("test");
    }

    @Test
    void databaseShouldBeH2InTestProfile() {
        try (Connection conn = dataSource.getConnection()) {
            String dbProduct = conn.getMetaData().getDatabaseProductName();
            assertThat(dbProduct.toLowerCase()).contains("h2");
        } catch (SQLException e) {
            fail("Failed to connect to database", e);
        }
    }
}

Testing Configuration Properties Classes

@ConfigurationPropertiesTest
class EmailConfigPropertiesTests {

    @Test
    void validateConfigProperties(
            @ConfigurationProperty(prefix = "app.email") Map<String, Object> properties) {

        assertThat(properties).containsEntry("host", "smtp.example.com");
        assertThat(properties).containsEntry("port", 587);
        assertThat(properties).containsEntry("username", "test@example.com");
    }
}

Testing with External Configuration

@SpringBootTest
@TestPropertySource(locations = "classpath:test-application.properties")
class ExternalConfigTests {

    @Autowired
    private ApplicationProperties appProperties;

    @Test
    void propertiesFromExternalFileShouldBeLoaded() {
        assertThat(appProperties.getApi().getBaseUrl()).isEqualTo("https://test-api.example.com");
        assertThat(appProperties.getApi().getTimeout()).isEqualTo(Duration.ofSeconds(30));
    }
}

Test Containers

Testcontainers is a Java library that provides lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container. It's particularly useful for integration testing.

Getting Started with Testcontainers

First, add the dependencies:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.17.6</version>
    <scope>test</scope>
</dependency>

Testing with a PostgreSQL Container

@SpringBootTest
@Testcontainers
class PostgresIntegrationTests {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14.5")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @DynamicPropertySource
    static void postgresProperties(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 UserRepository userRepository;

    @Test
    void shouldSaveAndRetrieveUser() {
        // Given
        User user = new User();
        user.setUsername("testcontainer");
        user.setEmail("test@container.com");

        // When
        userRepository.save(user);

        // Then
        Optional<User> found = userRepository.findByUsername("testcontainer");
        assertThat(found).isPresent();
        assertThat(found.get().getEmail()).isEqualTo("test@container.com");
    }
}

Multiple Containers and Container Networks

@SpringBootTest
@Testcontainers
class MultiContainerTests {

    static Network network = Network.newNetwork();

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14.5")
            .withNetwork(network)
            .withNetworkAliases("postgres")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:6.2")
            .withNetwork(network)
            .withNetworkAliases("redis")
            .withExposedPorts(6379);

    @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("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", redis::getFirstMappedPort);
    }

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Test
    void shouldInteractWithMultipleContainers() {
        // Test PostgreSQL
        User user = new User();
        user.setUsername("multicontainer");
        userRepository.save(user);

        assertThat(userRepository.findByUsername("multicontainer")).isPresent();

        // Test Redis
        redisTemplate.opsForValue().set("testkey", "testvalue");
        String value = redisTemplate.opsForValue().get("testkey");

        assertThat(value).isEqualTo("testvalue");
    }
}

Custom Container Classes

public class CustomPostgreSQLContainer extends PostgreSQLContainer<CustomPostgreSQLContainer> {

    private static final String IMAGE_VERSION = "postgres:14.5";
    private static CustomPostgreSQLContainer container;

    private CustomPostgreSQLContainer() {
        super(IMAGE_VERSION);
    }

    public static CustomPostgreSQLContainer getInstance() {
        if (container == null) {
            container = new CustomPostgreSQLContainer()
                    .withDatabaseName("testdb")
                    .withUsername("test")
                    .withPassword("test")
                    .withInitScript("init.sql");
        }
        return container;
    }

    @Override
    public void start() {
        super.start();
        System.setProperty("DB_URL", container.getJdbcUrl());
        System.setProperty("DB_USERNAME", container.getUsername());
        System.setProperty("DB_PASSWORD", container.getPassword());
    }

    @Override
    public void stop() {
        // do nothing, JVM handles shutdown
    }
}

Performance and Load Testing

Performance testing ensures your application meets its performance criteria under various conditions.

JMeter Integration

JMeter is a popular tool for load testing. You can integrate it with Spring Boot:

<dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_core</artifactId>
    <version>5.5</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.apache.jmeter</groupId>
    <artifactId>ApacheJMeter_http</artifactId>
    <version>5.5</version>
    <scope>test</scope>
</dependency>

Basic Performance Testing

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PerformanceTests {

    @LocalServerPort
    private int port;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void endpointResponseTimeShouldBeLessThan500ms() {
        // Warm up
        for (int i = 0; i < 5; i++) {
            restTemplate.getForEntity("/api/products", String.class);
        }

        // Test
        long startTime = System.currentTimeMillis();
        ResponseEntity<String> response = restTemplate.getForEntity("/api/products", String.class);
        long endTime = System.currentTimeMillis();
        long duration = endTime - startTime;

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(duration).isLessThan(500);
    }
}

Load Testing with Concurrent Requests

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class LoadTests {

    @LocalServerPort
    private int port;

    @Test
    void shouldHandleConcurrentRequests() throws Exception {
        String url = "http://localhost:" + port + "/api/products";
        int numThreads = 10;
        int requestsPerThread = 100;

        ExecutorService executor = Executors.newFixedThreadPool(numThreads);
        CountDownLatch latch = new CountDownLatch(numThreads);
        AtomicInteger successCounter = new AtomicInteger(0);
        AtomicInteger failCounter = new AtomicInteger(0);

        for (int i = 0; i < numThreads; i++) {
            executor.submit(() -> {
                try {
                    for (int j = 0; j < requestsPerThread; j++) {
                        try {
                            HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection();
                            connection.setRequestMethod("GET");
                            int responseCode = connection.getResponseCode();

                            if (responseCode == 200) {
                                successCounter.incrementAndGet();
                            } else {
                                failCounter.incrementAndGet();
                            }

                            connection.disconnect();
                        } catch (Exception e) {
                            failCounter.incrementAndGet();
                        }
                    }
                } finally {
                    latch.countDown();
                }
            });
        }

        // Wait for all threads to finish
        boolean completed = latch.await(2, TimeUnit.MINUTES);

        assertThat(completed).isTrue();
        assertThat(successCounter.get()).isEqualTo(numThreads * requestsPerThread);
        assertThat(failCounter.get()).isZero();
    }
}

Gatling for Load Testing

Gatling is a more sophisticated load testing tool:

class ProductsSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("http://localhost:8080")
    .acceptHeader("application/json")
    .userAgentHeader("Gatling Load Test")

  val scn = scenario("Products Load Test")
    .exec(http("Get All Products")
      .get("/api/products")
      .check(status.is(200)))
    .pause(1)
    .exec(http("Get Single Product")
      .get("/api/products/1")
      .check(status.is(200)))
    .pause(1)
    .exec(http("Search Products")
      .get("/api/products/search?query=phone")
      .check(status.is(200)))

  setUp(
    scn.inject(
      rampUsers(100).during(10.seconds),
      constantUsersPerSec(10).during(1.minute)
    )
  ).protocols(httpProtocol)
    .assertions(
      global.responseTime.max.lt(500),
      global.successfulRequests.percent.gt(95)
    )
}

Test-Driven Development

Test-Driven Development (TDD) is a software development approach where tests are written before the actual implementation code.

The TDD Cycle

  1. Red: Write a failing test.
  2. Green: Implement the minimum code needed to pass the test.
  3. Refactor: Improve the code while keeping the tests passing.

Example TDD Workflow

Let's implement a calculator service using TDD:

  1. Red Phase - Write a failing test:
@Test
void addShouldReturnSumOfTwoNumbers() {
    // Arrange
    CalculatorService calculator = new CalculatorServiceImpl();

    // Act
    int result = calculator.add(3, 4);

    // Assert
    assertThat(result).isEqualTo(7);
}
  1. Green Phase - Implement the minimal code to pass:
public class CalculatorServiceImpl implements CalculatorService {

    @Override
    public int add(int a, int b) {
        return a + b;
    }

    // Other methods not implemented yet
}
  1. Refactor Phase - Improve code quality:
public class CalculatorServiceImpl implements CalculatorService {

    @Override
    public int add(int a, int b) {
        return a + b;
    }

    // Maybe refactor common validation or logging here
}
  1. Continue the Cycle - Add more tests for new functionality:
@Test
void subtractShouldReturnDifferenceOfTwoNumbers() {
    // Arrange
    CalculatorService calculator = new CalculatorServiceImpl();

    // Act
    int result = calculator.subtract(7, 3);

    // Assert
    assertThat(result).isEqualTo(4);
}
  1. Red-Green-Refactor again:
public class CalculatorServiceImpl implements CalculatorService {

    @Override
    public int add(int a, int b) {
        return a + b;
    }

    @Override
    public int subtract(int a, int b) {
        return a - b;
    }
}

Benefits of TDD

  • Focused Development: You only write code that's needed to pass tests.
  • High Test Coverage: Tests are guaranteed for all features.
  • Better Design: TDD encourages modular, loosely coupled code.
  • Documentation: Tests serve as documentation for how code should behave.
  • Confidence: Changing code is safer with a test suite to catch regressions.

Testing Best Practices

Structure Tests According to AAA Pattern

  • Arrange: Set up test prerequisites.
  • Act: Perform the action being tested.
  • Assert: Verify the expected outcome.
@Test
void userRegistrationShouldCreateNewUser() {
    // Arrange
    UserRegistrationRequest request = new UserRegistrationRequest();
    request.setUsername("newuser");
    request.setEmail("newuser@example.com");
    request.setPassword("password123");

    // Act
    UserResponse response = userService.registerUser(request);

    // Assert
    assertThat(response).isNotNull();
    assertThat(response.getUsername()).isEqualTo("newuser");
    assertThat(userRepository.findByUsername("newuser")).isPresent();
}

Use Meaningful Test Names

Test names should clearly describe what's being tested and expected behavior:

@Test
void shouldReturnNotFoundWhenUserDoesNotExist() { ... }

@Test
void shouldThrowExceptionWhenEmailIsInvalid() { ... }

@Test
void shouldSendConfirmationEmailWhenOrderIsPlaced() { ... }

Avoid Test Interdependencies

Each test should be independent and not rely on other tests:

// BAD - Tests depend on each other
@Test
void test1_createUser() { ... } // Creates a user for test2

@Test
void test2_updateUser() { ... } // Uses the user from test1

// GOOD - Each test is independent
@Test
void shouldCreateUser() {
    // Arrange all prerequisites here
    // Act
    // Assert
}

@Test
void shouldUpdateUser() {
    // Arrange all prerequisites here, including creating a user
    // Act
    // Assert
}

Use Appropriate Assertion Libraries

// JUnit assertions
assertEquals(expected, actual);
assertTrue(condition);

// AssertJ fluent assertions (preferred)
assertThat(actual).isEqualTo(expected);
assertThat(collection).hasSize(3).contains("item1", "item2");
assertThat(exception).isInstanceOf(IllegalArgumentException.class)
                    .hasMessageContaining("Invalid input");

Test Edge Cases and Error Paths

@Test
void shouldHandleEmptyInput() { ... }

@Test
void shouldHandleMaximumSizeInput() { ... }

@Test
void shouldThrowExceptionForInvalidData() { ... }

@Test
void shouldHandleNetworkTimeouts() { ... }

Clean Up After Tests

@AfterEach
void tearDown() {
    // Clean up database
    userRepository.deleteAll();

    // Reset any static data
    TestData.reset();

    // Clear security context
    SecurityContextHolder.clearContext();
}

Use Test Fixtures and Factories

public class UserTestFactory {
    public static User createValidUser() {
        User user = new User();
        user.setId(UUID.randomUUID());
        user.setUsername("testuser");
        user.setEmail("test@example.com");
        user.setCreatedAt(LocalDateTime.now());
        user.setActive(true);
        return user;
    }

    public static User createAdminUser() {
        User user = createValidUser();
        user.setUsername("adminuser");
        user.setEmail("admin@example.com");
        user.setRole(Role.ADMIN);
        return user;
    }
}

Use Test Tags for Categorization

@Tag("unit")
class UserServiceTests { ... }

@Tag("integration")
class UserRepositoryTests { ... }

@Tag("slow")
@Tag("database")
class LargeDatasetTests { ... }

Maintain Test Quality

  • Keep Tests Fast: Slow tests discourage frequent testing.
  • Keep Tests Simple: Complex test logic is prone to bugs.
  • Review Test Code: Test code should meet the same quality standards as production code.
  • Refactor Tests: Continuously improve test code to avoid test maintenance burden.
  • Prioritize Test Reliability: Flaky tests erode confidence in the test suite.

Continuous Integration

  • Run tests automatically on every push.
  • Include test coverage reports.
  • Fail builds when tests fail.
  • Monitor test performance over time.

Summary

Testing is a critical part of Spring Boot application development. With Spring Boot's comprehensive testing support, you can:

  • Write effective unit tests for isolated components.
  • Create integration tests to verify component interactions.
  • Test specific layers using test slices.
  • Mock dependencies for controlled testing.
  • Verify security constraints.
  • Test with realistic environments using test containers.
  • Measure and optimize performance.
  • Apply test-driven development principles.

Following best practices ensures your tests remain valuable, maintainable, and effective at catching bugs before they reach production.

Further Reading