Testcontainers: test với PostgreSQL thật

4 câu hỏi~25 phútTừ khóa: H2 compatibility, Testcontainers, PostgreSQLContainer, @ServiceConnection, @Testcontainers, @Container, singleton container, reuse, KafkaContainer, Redis, Ryuk, CI, Docker

Tiếp đi: Testcontainers. Repository test của mình xanh hết trên H2, deploy lên thì query ON CONFLICT lỗi syntax. H2 có mode PostgreSQL rồi mà, sao vẫn lỗi?

MODE=PostgreSQL của H2 chỉ giả lập một phần cú pháp. Nó vẫn là H2: engine khác, optimizer khác, kiểu dữ liệu khác, lock khác. Test xanh trên H2 chỉ chứng minh code chạy được trên H2.

1. Bài toán

shop-app dùng nhiều thứ đặc thù PostgreSQL:

-- Giữ hàng: tránh hai request trừ cùng một tồn kho
SELECT * FROM product WHERE id = :id FOR UPDATE;

-- Idempotency khi tạo order
INSERT INTO orders (id, idempotency_key, ...) VALUES (...)
ON CONFLICT (idempotency_key) DO NOTHING;

-- Lưu thông tin thanh toán dạng jsonb, có partial index
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'PENDING';

Cộng thêm migration Flyway (bài 4.3.12) viết bằng SQL của PostgreSQL.

2. Vấn đề: H2 "nói dối"

3. Giải pháp: Testcontainers

4. Code từng bước

Bước 1: Dependency

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<!-- version do BOM của Spring Boot quản lý -->

Bước 2: Config dùng chung

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection              // tự cấu hình spring.datasource.* từ container
    PostgreSQLContainer<?> postgres() {
        return new PostgreSQLContainer<>("postgres:16-alpine");   // cùng major version với prod
    }
}

Container khai báo là bean nên sống theo ApplicationContext: context được cache (bài 5.4) thì container cũng được dùng lại giữa các test class.

Bước 3: Code test

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)   // QUAN TRỌNG: không thay bằng H2
@Import(TestcontainersConfig.class)
class OrderRepositoryIT {

    @Autowired OrderRepository orderRepository;

    @Test
    void insertIfAbsent_isIdempotentOnSameKey() {
        int first  = orderRepository.insertIfAbsent(7L, "key-abc", 42L);
        int second = orderRepository.insertIfAbsent(8L, "key-abc", 42L);   // ON CONFLICT DO NOTHING

        assertThat(first).isEqualTo(1);
        assertThat(second).isZero();
    }
}

Cách khác, không qua bean (static field, chia sẻ trong class):

@SpringBootTest
@Testcontainers
class CreateOrderFlowIT {
    @Container @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
}

Thêm Kafka và Redis cho luồng publish event / cache:

@Bean @ServiceConnection
KafkaContainer kafka() {                     // org.testcontainers.kafka.KafkaContainer (KRaft)
    return new KafkaContainer("apache/kafka-native:3.8.0");
}

@Bean @ServiceConnection(name = "redis")     // GenericContainer cần name để Boot biết là Redis
GenericContainer<?> redis() {
    return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
}

Dùng luôn khi dev local, không cần cài PostgreSQL:

// src/test/java/.../TestShopApplication.java
public class TestShopApplication {
    public static void main(String[] args) {
        SpringApplication.from(ShopApplication::main).with(TestcontainersConfig.class).run(args);
    }
}

Bước 4: Chạy

docker info                          # cần Docker (hoặc Podman/Colima/Testcontainers Cloud)
./mvnw verify -Dit.test=OrderRepositoryIT
# log:
#   Creating container for image: postgres:16-alpine
#   Container postgres:16-alpine started in PT1.8S
#   Flyway: Successfully applied 12 migrations
#   Tests run: 1, Failures: 0

5. Cách hoạt động

Test JVM
  │ 1. Testcontainers gọi Docker API: pull image, run container
  │    port 5432 trong container → port ngẫu nhiên trên host (vd 54871)
  │ 2. Wait strategy: chờ log "ready to accept connections"
  │ 3. @ServiceConnection → JdbcConnectionDetails(url=jdbc:postgresql://localhost:54871/test, ...)
  │ 4. Spring tạo DataSource → Flyway migrate → chạy test
  │
  └─ Ryuk container theo dõi JVM; JVM tắt → xóa container

Vòng đời:
  static @Container trong class   → 1 container / test class
  @Bean trong @TestConfiguration  → 1 container / ApplicationContext (cache dùng lại)
  singleton (static init chung)   → 1 container / cả JVM
  withReuse(true) + config        → giữ container qua nhiều lần chạy (chỉ local)

6. So sánh

H2 in-memoryTestcontainersDB dùng chung (dev/staging)
Độ giống prodThấpCao (cùng image)Cao
Tốc độNhanh nhấtKhởi động 1–3s, sau đó nhanhMạng chậm
Cô lậpTốtTốtKém: test dẫm lên nhau
Cần gìKhôngDockerHạ tầng, quyền truy cập
Migration thậtKhôngCóCó

7. Khi nào nên dùng / không nên

Mỗi lần chạy test ở local đợi container khởi động mấy giây, hơi phiền. Có cách giữ container sống giữa các lần chạy không?

Có, đó là reusable container, nhưng chỉ nên bật ở máy dev:

// code
new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);

# ~/.testcontainers.properties  (máy dev, KHÔNG commit, KHÔNG bật trên CI)
testcontainers.reuse.enable=true

Trong cùng một lần chạy thì dùng singleton container (bean trong config chung, hoặc static field ở base class) là đủ, không cần reuse.

Còn chạy trên CI thì sao? Pipeline của mình chạy trong container, có chạy Docker trong đó được không?

Được, nhưng cần chuẩn bị vài thứ:

# .github/workflows/ci.yml (rút gọn)
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: temurin, java-version: '21', cache: maven }
      - run: ./mvnw -B verify          # Docker có sẵn trên runner

Pipeline và quality gate đầy đủ ở bài 5.8.

Phỏng vấn hỏi Testcontainers thì trả lời sao?

Q1. Vì sao dùng Testcontainers thay vì H2?

🇻🇳 Tiếng Việt:

H2 kể cả ở mode PostgreSQL chỉ giả lập một phần cú pháp; jsonb, ON CONFLICT, SKIP LOCKED, kiểu thời gian, lock và MVCC đều khác. Test xanh trên H2 không chứng minh chạy được trên PostgreSQL, và phải viết migration riêng. Testcontainers chạy đúng image PostgreSQL như production trong Docker, nên test được cả SQL đặc thù và migration Flyway thật.

🇬🇧 English:

H2, even in PostgreSQL mode, only emulates part of the syntax; jsonb, ON CONFLICT, SKIP LOCKED, time types, locking and MVCC all behave differently. A green test on H2 does not prove it works on PostgreSQL, and you end up maintaining separate migrations. Testcontainers runs the same PostgreSQL image as production in Docker, so I can test vendor-specific SQL and the real Flyway migrations.

Q2. @ServiceConnection làm gì?

🇻🇳 Tiếng Việt:

Từ Spring Boot 3.1, đánh dấu container bằng @ServiceConnection thì Boot tự tạo ConnectionDetails từ host, port ngẫu nhiên và credential của container, thay cho việc viết @DynamicPropertySource để set spring.datasource.url. Hỗ trợ PostgreSQL, Kafka, Redis, RabbitMQ và nhiều loại khác.

🇬🇧 English:

Since Spring Boot 3.1, annotating a container with @ServiceConnection makes Boot create ConnectionDetails from the container's host, random port and credentials, instead of writing @DynamicPropertySource to set spring.datasource.url. It supports PostgreSQL, Kafka, Redis, RabbitMQ and many others.

Q3. Làm sao để test với Testcontainers không quá chậm?

🇻🇳 Tiếng Việt:

Chia sẻ container: khai báo là bean trong một TestConfiguration dùng chung để nó sống theo context được cache, hoặc singleton container cho cả JVM. Giữ cấu hình test giống nhau để context cache trúng. Ở local có thể bật reuse. Trên CI tách unit và integration test, cache image và dùng registry mirror.

🇬🇧 English:

Share containers: declare them as beans in a common TestConfiguration so they live with the cached context, or use a singleton container for the whole JVM. Keep test configuration consistent so the context cache hits. Locally I can enable reuse. On CI I separate unit and integration tests, cache images and use a registry mirror.

Cách nhớ ngắn gọn


Tự kiểm tra

Thiếu @AutoConfigureTestDatabase(replace = NONE) trong @DataJpaTest thì sao?

Với Boot mới, khi có @ServiceConnection nó tự bỏ qua thay thế; nhưng với cấu hình kiểu cũ, Boot sẽ thay DataSource bằng DB nhúng (H2). Ghi rõ Replace.NONE để chắc chắn dùng PostgreSQL.

Container khai báo là @Bean sống bao lâu?

Theo ApplicationContext; context được cache thì container được dùng lại giữa các test class.

Ryuk dùng để làm gì?

Dọn container khi JVM test kết thúc, kể cả khi bị kill.

Vì sao không bật reuse trên CI?

Container và dữ liệu còn lại giữa các lần chạy, CI cần môi trường sạch và dọn tài nguyên.

Image nên chọn version thế nào?

Pin cùng major version với production, không dùng latest.