Files
gh-giuseppe-trisciuoglio-de…/skills/spring-boot/spring-boot-test-patterns/references/workflow-patterns.md
2025-11-29 18:28:30 +08:00

9.1 KiB

Spring Boot Testing Workflow Patterns

Complete Database Integration Test Pattern

Scenario: Test a JPA repository with a real PostgreSQL database using Testcontainers.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class UserRepositoryIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldSaveAndRetrieveUserFromDatabase() {
        // Arrange
        User user = new User();
        user.setEmail("test@example.com");
        user.setName("Test User");

        // Act
        User saved = userRepository.save(user);
        userRepository.flush();

        Optional<User> retrieved = userRepository.findByEmail("test@example.com");

        // Assert
        assertThat(retrieved).isPresent();
        assertThat(retrieved.get().getName()).isEqualTo("Test User");
    }

    @Test
    void shouldThrowExceptionForDuplicateEmail() {
        // Arrange
        User user1 = new User();
        user1.setEmail("duplicate@example.com");
        user1.setName("User 1");

        User user2 = new User();
        user2.setEmail("duplicate@example.com");
        user2.setName("User 2");

        userRepository.save(user1);

        // Act & Assert
        assertThatThrownBy(() -> {
            userRepository.save(user2);
            userRepository.flush();
        }).isInstanceOf(DataIntegrityViolationException.class);
    }
}

Complete REST API Integration Test Pattern

Scenario: Test REST controllers with full Spring context using MockMvc.

@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class UserControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Autowired
    private UserRepository userRepository;

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

    @Test
    void shouldCreateUserAndReturn201() throws Exception {
        User user = new User();
        user.setEmail("newuser@example.com");
        user.setName("New User");

        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(user)))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.id").exists())
            .andExpect(jsonPath("$.email").value("newuser@example.com"))
            .andExpect(jsonPath("$.name").value("New User"));
    }

    @Test
    void shouldReturnUserById() throws Exception {
        // Arrange
        User user = new User();
        user.setEmail("existing@example.com");
        user.setName("Existing User");
        User saved = userRepository.save(user);

        // Act & Assert
        mockMvc.perform(get("/api/users/" + saved.getId())
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.email").value("existing@example.com"))
            .andExpect(jsonPath("$.name").value("Existing User"));
    }

    @Test
    void shouldReturnNotFoundForMissingUser() throws Exception {
        mockMvc.perform(get("/api/users/99999")
                .contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isNotFound());
    }

    @Test
    void shouldUpdateUserAndReturn200() throws Exception {
        // Arrange
        User user = new User();
        user.setEmail("update@example.com");
        user.setName("Original Name");
        User saved = userRepository.save(user);

        User updateData = new User();
        updateData.setName("Updated Name");

        // Act & Assert
        mockMvc.perform(put("/api/users/" + saved.getId())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(updateData)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Updated Name"));
    }

    @Test
    void shouldDeleteUserAndReturn204() throws Exception {
        // Arrange
        User user = new User();
        user.setEmail("delete@example.com");
        user.setName("To Delete");
        User saved = userRepository.save(user);

        // Act & Assert
        mockMvc.perform(delete("/api/users/" + saved.getId()))
            .andExpect(status().isNoContent());

        assertThat(userRepository.findById(saved.getId())).isEmpty();
    }
}

Service Layer Integration Test Pattern

Scenario: Test business logic with mocked repository.

class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @InjectMocks
    private UserService userService;

    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }

    @Test
    void shouldFindUserByIdWhenExists() {
        // Arrange
        Long userId = 1L;
        User user = new User();
        user.setId(userId);
        user.setEmail("test@example.com");

        when(userRepository.findById(userId)).thenReturn(Optional.of(user));

        // Act
        Optional<User> result = userService.findById(userId);

        // Assert
        assertThat(result).isPresent();
        assertThat(result.get().getEmail()).isEqualTo("test@example.com");
        verify(userRepository, times(1)).findById(userId);
    }

    @Test
    void shouldReturnEmptyWhenUserNotFound() {
        // Arrange
        Long userId = 999L;
        when(userRepository.findById(userId)).thenReturn(Optional.empty());

        // Act
        Optional<User> result = userService.findById(userId);

        // Assert
        assertThat(result).isEmpty();
        verify(userRepository, times(1)).findById(userId);
    }

    @Test
    void shouldThrowExceptionWhenSavingInvalidUser() {
        // Arrange
        User invalidUser = new User();
        invalidUser.setEmail("invalid-email");

        when(userRepository.save(invalidUser))
            .thenThrow(new DataIntegrityViolationException("Invalid email"));

        // Act & Assert
        assertThatThrownBy(() -> userService.save(invalidUser))
            .isInstanceOf(DataIntegrityViolationException.class);
    }
}

Reactive WebFlux Integration Test Pattern

Scenario: Test WebFlux controllers with WebTestClient.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebTestClient
public class ReactiveUserControllerIntegrationTest {

    @Autowired
    private WebTestClient webTestClient;

    @Autowired
    private UserRepository userRepository;

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

    @Test
    void shouldReturnUserAsJsonReactive() {
        // Arrange
        User user = new User();
        user.setEmail("reactive@example.com");
        user.setName("Reactive User");
        User saved = userRepository.save(user);

        // Act & Assert
        webTestClient.get()
            .uri("/api/users/" + saved.getId())
            .exchange()
            .expectStatus().isOk()
            .expectBody()
            .jsonPath("$.email").isEqualTo("reactive@example.com")
            .jsonPath("$.name").isEqualTo("Reactive User");
    }

    @Test
    void shouldReturnArrayOfUsers() {
        // Arrange
        User user1 = new User();
        user1.setEmail("user1@example.com");
        user1.setName("User 1");

        User user2 = new User();
        user2.setEmail("user2@example.com");
        user2.setName("User 2");

        userRepository.saveAll(List.of(user1, user2));

        // Act & Assert
        webTestClient.get()
            .uri("/api/users")
            .exchange()
            .expectStatus().isOk()
            .expectBodyList(User.class)
            .hasSize(2);
    }
}

Testcontainers Configuration Patterns

@ServiceConnection Pattern (Spring Boot 3.5+)

@TestConfiguration
public class TestContainerConfig {

    @Bean
    @ServiceConnection
    public PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
        // Do not call start(); Spring Boot will manage lifecycle for @ServiceConnection beans
    }
}

@DynamicPropertySource Pattern (Legacy)

public class SharedContainers {
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @BeforeAll
    static void startAll() {
        POSTGRES.start();
    }

    @AfterAll
    static void stopAll() {
        POSTGRES.stop();
    }

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

Slice Tests with Testcontainers

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@TestContainerConfig
public class MyRepositoryIntegrationTest {
    // repository tests
}