Testcontainers: test với PostgreSQL thật
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"
- Cú pháp:
jsonb,ON CONFLICT ... DO UPDATE,SKIP LOCKED, array, CTE ghi, extension (pg_trgm) không có hoặc khác. - Hành vi: so sánh chuỗi, collation, kiểu
timestamptz, sequence/identity, độ chính xác numeric khác nhau. - Concurrency: lock và MVCC khác hẳn, test race condition trên H2 gần như vô nghĩa.
- Hai bộ migration: phải viết riêng script cho H2 → không test migration thật.
- Kết quả: test xanh, production đỏ. Hoặc ngược lại, phải né tính năng PostgreSQL chỉ để test chạy được.
3. Giải pháp: Testcontainers
- Thư viện Java khởi động Docker container thật (PostgreSQL, Kafka, Redis...) từ trong test, cổng ngẫu nhiên, xóa khi xong.
- Cùng image với production (
postgres:16-alpine) → cùng SQL, cùng migration. @ServiceConnection(Boot 3.1+): Spring Boot tự đọc host/port/user/password từ container và tạoConnectionDetails, không cần@DynamicPropertySource.- Ryuk: container phụ tự dọn container khi JVM test chết.
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-memory | Testcontainers | DB dùng chung (dev/staging) | |
|---|---|---|---|
| Độ giống prod | Thấp | Cao (cùng image) | Cao |
| Tốc độ | Nhanh nhất | Khởi động 1–3s, sau đó nhanh | Mạng chậm |
| Cô lập | Tốt | Tốt | Kém: test dẫm lên nhau |
| Cần gì | Không | Docker | Hạ tầng, quyền truy cập |
| Migration thật | Không | Có | Có |
7. Khi nào nên dùng / không nên
- Nên: repository test, migration, query native, locking/concurrency, luồng tích hợp có Kafka/Redis.
- Không cần: unit test logic thuần (bài 5.2, 5.3),
@JsonTest,@WebMvcTest. - Mỗi test class một container mới: chậm, hãy chia sẻ qua bean/singleton.
- Đừng dùng tag
latest: pin version trùng production.
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
- Testcontainers tính hash cấu hình container; lần chạy sau thấy container cùng hash đang chạy thì dùng lại, không tạo mới, Ryuk không xóa nó.
- Hệ quả: dữ liệu còn lại từ lần trước. Test phải tự dọn dữ liệu hoặc không giả định DB trống.
- Trên CI không bật: mỗi job cần môi trường sạch, và container phải được dọn.
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ứ:
- Docker có sẵn: GitHub Actions
ubuntu-latestcó Docker sẵn. Runner chạy trong container (GitLab, Jenkins agent) thì cần Docker-in-Docker hoặc mount/var/run/docker.sock; khi đó có thể phải setTESTCONTAINERS_HOST_OVERRIDE. - Pull image: dễ dính rate limit Docker Hub. Dùng registry mirror nội bộ (
hub.image.name.prefixtrong.testcontainers.properties) hoặc cache image. - Tách unit và integration: unit qua surefire (
mvn test),*ITqua failsafe (mvn verify) để lỗi nhanh hiện sớm. - Tài nguyên: chạy song song nhiều fork, mỗi fork một PostgreSQL → coi chừng RAM của runner.
- Không có Docker (một số môi trường bị chặn): Testcontainers Cloud hoặc chạy Docker remote.
# .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
- H2 chỉ chứng minh code chạy trên H2.
- Testcontainers = cùng image prod, cổng ngẫu nhiên, Ryuk dọn.
- @ServiceConnection thay @DynamicPropertySource (Boot 3.1+).
- Chia sẻ container qua bean/singleton; reuse chỉ ở local.
@DataJpaTestnhớReplace.NONE.
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.