Chuyển đến nội dung chính

Lesson 18: Integration Testing — @SpringBootTest & Testcontainers

@SpringBootTest, @DataJpaTest for integration testing. Testcontainers — test with real PostgreSQL, Redis in Docker. Test slices and best practices.

💻 Programming — Lesson 17 Lesson 18: Integration Testing — @SpringBootTest & Testcontainers

Spring Boot 4: From Basics to Advanced

Part 5: Testing & Code Quality

xdev.asia

Introduction

Integration tests test multiple components working together with real database, real configuration. Testcontainers allow running PostgreSQL, Redis, Kafka in Docker containers automatically, ensuring a production-like test environment.


1. @SpringBootTest

1.1 Full Application Context

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

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldCreateAndRetrieveOrder() {
        // Create
        var request = new CreateOrderRequest(/* ... */);
        ResponseEntity<OrderResponse> createResponse = restTemplate
            .postForEntity("/api/v1/orders", request, OrderResponse.class);

        assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long orderId = createResponse.getBody().id();

        // Retrieve
        ResponseEntity<OrderResponse> getResponse = restTemplate
            .getForEntity("/api/v1/orders/" + orderId, OrderResponse.class);

        assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(getResponse.getBody().id()).isEqualTo(orderId);
    }
}

2. Test Slices — Load only the necessary part

2.1 @DataJpaTest — Test Repository Layer

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private TestEntityManager entityManager;

    @Test
    void shouldFindProductsByCategory() {
        // Given
        Product p1 = Product.builder()
            .name("Spring Boot Book").price(new BigDecimal("450000"))
            .category("Education").build();
        Product p2 = Product.builder()
            .name("Java Mug").price(new BigDecimal("150000"))
            .category("Merchandise").build();
        entityManager.persist(p1);
        entityManager.persist(p2);
        entityManager.flush();

        // When
        List<Product> result = productRepository.findByCategory("Education");

        // Then
        assertThat(result).hasSize(1);
        assertThat(result.getFirst().getName()).isEqualTo("Spring Boot Book");
    }

    @Test
    void shouldSearchByKeyword() {
        entityManager.persist(Product.builder()
            .name("Spring Boot Guide").price(BigDecimal.TEN)
            .category("Book").build());
        entityManager.flush();

        Page<Product> results = productRepository
            .searchByKeyword("spring", Pageable.ofSize(10));

        assertThat(results.getContent()).hasSize(1);
    }
}

2.2 @WebMvcTest — Test Controller Layer

@WebMvcTest(ProductController.class)
class ProductControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockitoBean
    private ProductService productService;

    @Test
    void shouldReturnProduct() throws Exception {
        var product = new ProductResponse(1L, "Book", new BigDecimal("450000"));
        when(productService.getProduct(1L)).thenReturn(product);

        mockMvc.perform(get("/api/v1/products/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.name").value("Book"))
            .andExpect(jsonPath("$.price").value(450000));
    }
}

3. Testcontainers

3.1 Setup

// build.gradle.kts
testImplementation("org.springframework.boot:spring-boot-testcontainers")
testImplementation("org.testcontainers:postgresql")
testImplementation("org.testcontainers:junit-jupiter")

3.2 PostgreSQL Containers

@SpringBootTest
@Testcontainers
class ProductRepositoryIntegrationTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:17-alpine");

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldSaveAndRetrieveProduct() {
        Product product = Product.builder()
            .name("Test Product")
            .price(new BigDecimal("100000"))
            .category("Test")
            .build();

        Product saved = productRepository.save(product);

        assertThat(saved.getId()).isNotNull();
        assertThat(productRepository.findById(saved.getId()))
            .isPresent()
            .hasValueSatisfying(p ->
                assertThat(p.getName()).isEqualTo("Test Product"));
    }
}

3.3 Shared Container Pattern (optimized performance)

// Base class cho tất cả integration tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class BaseIntegrationTest {

    @Container
    @ServiceConnection
    static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:17-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");

    @Autowired
    protected TestRestTemplate restTemplate;
}
// Test kế thừa, reuse container
class OrderIntegrationTest extends BaseIntegrationTest {

    @Autowired
    private OrderRepository orderRepository;

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

    @Test
    void shouldCreateOrder() {
        // Test with real PostgreSQL
    }
}

3.4 Multiple containers

@SpringBootTest
@Testcontainers
class FullStackIntegrationTest {

    @Container
    @ServiceConnection
    static final PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:17-alpine");

    @Container
    @ServiceConnection
    static final GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);
}

4. Test Data Management

4.1 @Sql — Load test data from file

@DataJpaTest
@Sql("/test-data/products.sql")
class ProductRepositoryTest {

    @Test
    @Sql(statements = "DELETE FROM products", executionPhase = AFTER_TEST_METHOD)
    void shouldFindAllProducts() {
        List<Product> products = productRepository.findAll();
        assertThat(products).hasSize(5); // Từ products.sql
    }
}

4.2 TestFixtures — Helper methods

public class TestFixtures {

    public static Product aProduct() {
        return Product.builder()
            .name("Test Product")
            .price(new BigDecimal("100000"))
            .category("Test")
            .build();
    }

    public static CreateProductRequest aCreateProductRequest() {
        return new CreateProductRequest("Test Product",
            new BigDecimal("100000"), "Test");
    }
}

Summary

  • Test Slices (@DataJpaTest, @WebMvcTest) loads only the necessary parts, runs faster than @SpringBootTest
  • Testcontainers automatically spin up PostgreSQL, Redis containers — test with real infrastructure
  • @ServiceConnection automatically configures connection properties, no manual config required
  • Shared Container Pattern reuses containers between test classes, reducing startup time

Exercises

  1. Write @DataJpaTest for ProductRepository with PostgreSQL Testcontainers: test save, findAll, custom query methods
  2. Create BaseIntegrationTest class with shared PostgreSQL container, write full integration test for Order CRUD flow
  3. Test caching: use Testcontainers Redis, verify data is cached and evict correctly