Integration Test và Testcontainers
Xem thửMột đội dùng H2 cho mọi kiểm thử cơ sở dữ liệu và tự tin vì 600 bài đều xanh. Ngày triển khai, migration chạy lỗi trên PostgreSQL thật vì dùng kiểu dữ liệu chỉ có ở H2. Họ chuyển sang Testcontainers, và bộ kiểm thử từ 4 phút thành 38 phút vì mỗi lớp kiểm thử khởi động một container PostgreSQL riêng và một ngữ cảnh Spring riêng. Họ tối ưu một phần, và gặp rắc rối mới: các bài ghi dữ liệu vào cùng một cơ sở dữ liệu nên bài này thấy dữ liệu của bài kia, thỉnh thoảng đỏ rồi tự xanh khi chạy lại. Họ thêm @Transactional lên lớp kiểm thử để hoàn tác dữ liệu cho gọn, nhưng các bài gọi API qua cổng mạng thật vẫn để lại dữ liệu, vì giao dịch của bài kiểm thử và giao dịch của máy chủ nằm trên hai thread khác nhau.
Kiểm thử tích hợp là tầng đỉnh của kim tự tháp: ít, chậm, nhưng chứng minh các phần ghép đúng với phần mềm thật. Dùng sai thì chúng vừa chậm vừa mong manh. Bài này đo, trên Spring Boot 4.1.1 và Testcontainers 2.0.5 với PostgreSQL 16 thật, ba thứ quyết định chi phí và độ tin cậy: cách chia sẻ container, cách Spring lưu đệm ngữ cảnh, và hành vi giao dịch của kiểm thử. Kèm một kiểm thử đầu cuối qua máy chủ thật với RestTestClient.
Sau bài này, bạn có thể
- Dựng kiểm thử tích hợp với PostgreSQL thật qua Testcontainers và
@ServiceConnection, chạy toàn bộ migration của ứng dụng. - Chọn một trong ba cách chia sẻ container và giải thích khác biệt đã đo.
- Giải thích bộ nhớ đệm ngữ cảnh của Spring TestContext, cái gì làm vỡ nó, và chi phí của từng lần vỡ.
- Viết kiểm thử đầu cuối với máy chủ thật bằng
RestTestClientvà biết nó khác MockMvc ở đâu. - Tránh bẫy
@Transactionalcủa lớp kiểm thử khi gọi qua mạng, và chọn chiến lược cô lập dữ liệu.
Kiểm thử tích hợp có mục tiêu rõ ràng
Cách đọc hình
Một kiểm thử tích hợp khởi động ngữ cảnh Spring thật của ứng dụng, kết nối tới một PostgreSQL chạy trong container (thay vì mô phỏng), chạy chính các migration Flyway như production, rồi gọi API từ ngoài vào. Ngữ cảnh được lưu đệm và dùng lại giữa các lớp kiểm thử có cấu hình giống nhau; container cũng nên được chia sẻ. Hai thứ đó (ngữ cảnh và container) là phần đắt nhất, nên toàn bộ nghệ thuật là khởi động chúng ít lần nhất có thể mà vẫn giữ các bài độc lập.
Mục tiêu của kiểm thử tích hợp không phải kiểm tra mọi nhánh logic (kiểm thử đơn vị làm việc đó rẻ hơn) mà là chứng minh những thứ chỉ có khi ghép: migration khớp entity (ddl-auto=validate), truy vấn chạy trên engine thật, giao dịch và ràng buộc hoạt động, hợp đồng JSON qua máy chủ thật, cấu hình bảo mật và bộ lọc thật.
Ứng dụng của lab: bảng customers với tên duy nhất, một endpoint đăng ký và một endpoint đếm.
CREATE TABLE customers (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE
);
@RestController
@RequestMapping("/api/customers")
class CustomerController {
private final CustomerService service;
private final CustomerRepository repository;
CustomerController(CustomerService service, CustomerRepository repository) {
this.service = service;
this.repository = repository;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
Map<String, Object> register(@RequestBody Map<String, String> body) {
Customer saved = service.register(body.get("name"));
return Map.of("id", saved.getId(), "name", saved.getName());
}
@GetMapping("/count")
Map<String, Long> count() {
return Map.of("count", repository.count());
}
@GetMapping("/boom")
String boom() {
throw new IllegalStateException("unexpected failure");
}
}
Kiểm thử đầu cuối qua máy chủ thật
@SpringBootTest(webEnvironment = RANDOM_PORT) khởi động máy chủ servlet thật trên cổng ngẫu nhiên. RestTestClient của Spring Framework 7 gọi nó qua HTTP thật:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FullStackIT {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine");
@LocalServerPort int port;
@Autowired CustomerRepository customers;
@Test
void realServerEndToEnd() {
RestTestClient client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
client.post().uri("/api/customers").body(Map.of("name", "Lan")).exchange().expectStatus().isCreated();
client.post().uri("/api/customers").body(Map.of("name", "Lan")).exchange().expectStatus().is5xxServerError();
var boom = client.get().uri("/api/customers/boom").exchange();
boom.expectStatus().is5xxServerError();
assertThat(customers.count()).isEqualTo(1);
}
}
@ServiceConnection trên container tĩnh làm Spring Boot tự điền địa chỉ, tên đăng nhập, mật khẩu của nguồn dữ liệu từ container, không cần @DynamicPropertySource; Flyway chạy migration trên container ngay khi ngữ cảnh khởi động. Kiểm thử chứng minh ba điều: đăng ký đầu tiên thành công (201), đăng ký trùng tên thất bại ở ràng buộc UNIQUE của cơ sở dữ liệu (một lỗi 5xx vì lab chưa có advice, bài trước đã chỉ cách ánh xạ sang 409), và sau cùng cơ sở dữ liệu chỉ có một hàng.
Khác biệt với MockMvc, nhìn qua endpoint /boom ném IllegalStateException. Với máy chủ thật, máy chủ servlet dựng phản hồi lỗi qua cơ chế /error của Spring Boot:
{"timestamp":"2026-10-15T23:42:26.916Z","status":500,"error":"Internal Server Error","path":"/api/customers/boom"}
Phản hồi 500 có cấu trúc, và không lộ thông điệp ngoại lệ hay stack trace (đúng như mặc định an toàn của Spring Boot). Trong MockMvc, cùng ngoại lệ lọt ra thành ServletException mà không có phản hồi nào (bài trước). Đây là loại hành vi chỉ kiểm thử với máy chủ thật mới khẳng định được.
Chia sẻ container: ba cách, ba chi phí
Khởi động một container PostgreSQL tốn vài giây; khởi động cho mỗi lớp là nguyên nhân bộ kiểm thử 38 phút. Lab thử ba cách và in mã container thực của từng lớp:
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:16-alpine");
Cách 1, container tĩnh trong từng lớp: mỗi lớp một container (vòng đời theo lớp). Đơn giản nhất nhưng đắt nhất khi có nhiều lớp.
@TestConfiguration(proxyBeanMethods = false)
class PerContextContainerConfig {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
return new PostgreSQLContainer("postgres:16-alpine");
}
}
Cách 2, container là bean trong cấu hình kiểm thử: vòng đời gắn với ngữ cảnh Spring. Các lớp dùng chung ngữ cảnh thì chung container, nhưng mỗi ngữ cảnh khác nhau có container riêng.
interface ContainersInterface {
@ServiceConnection
PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine");
}
@SpringBootTest
@ImportTestcontainers(ContainersInterface.class)
class SharedImportedTestA {
}
Cách 3, container tĩnh trong một interface dùng chung, nhập bằng @ImportTestcontainers: một container cho cả JVM, mọi ngữ cảnh đều dùng lại.
Kết quả đo (mã container và số ngữ cảnh):
| Lớp | Cấu hình | Số ngữ cảnh đã khởi động | Mã container |
|---|---|---|---|
SharedImportedTestA và B | cách 3, cùng cấu hình | 1 cho hai lớp | a5958… |
DifferentConfigTest | cách 3, thêm một thuộc tính | thêm 1 ngữ cảnh | a5958… (cùng container) |
PerContextTestA | cách 2, thuộc tính lab.variant=A | thêm 1 ngữ cảnh | cc8b… (mới) |
PerContextTestB | cách 2, thuộc tính lab.variant=B | thêm 1 ngữ cảnh | 7c92… (mới) |
Thời gian chạy từng lớp trong lab (gồm khởi động ngữ cảnh và container nếu có):
| Lớp | Thời gian |
|---|---|
SharedImportedTestB (ngữ cảnh đầu tiên, khởi động container) | 10,6 s |
DifferentConfigTest (ngữ cảnh mới, container đã chạy) | 1,8 s |
SharedImportedTestA (ngữ cảnh lưu đệm) | 0,07 s |
PerContextTestA, PerContextTestB (ngữ cảnh mới và container mới) | khoảng 4,4 và 4,6 s |
Khuyến nghị: dùng cách 3 cho một cơ sở dữ liệu duy nhất dùng chung; mỗi ngữ cảnh mới chỉ tốn khoảng hai giây thay vì mười. Đổi lại, vì mọi bài dùng chung một cơ sở dữ liệu, bạn phải cô lập dữ liệu (phần cuối).
Bộ nhớ đệm ngữ cảnh
Đang tải Animation…
Thử và quan sát
Dự đoán trước: hai lớp kiểm thử chỉ khác nhau một thuộc tính lab.variant, chúng có dùng chung ngữ cảnh không? Chạy biến thể "mỗi lớp một cấu hình" và đếm số ngữ cảnh.
Spring TestContext lưu đệm ngữ cảnh theo một khóa gồm: các lớp cấu hình, thuộc tính (properties, @TestPropertySource, @DynamicPropertySource), profile hoạt động, các bean ghi đè (@MockitoBean), initializer, và loại môi trường web. Hai lớp có khóa giống nhau dùng chung một ngữ cảnh (lab: SharedImportedTestA chỉ mất 0,07 giây). Chỉ cần một thành phần khác, như một thuộc tính (DifferentConfigTest, PerContextTest), là ngữ cảnh mới.
Quyết định của Spring khi một lớp kiểm thử cần ngữ cảnh:
Đang tải sơ đồ…
Những thứ hay làm vỡ bộ nhớ đệm một cách vô tình:
- Mỗi lớp có một tập
@MockitoBeankhác nhau. Giải pháp: gom mock vào cấu hình chung hoặc lớp cha, hoặc dùng@Importcho một cấu hình test chung. - Thuộc tính truyền theo từng lớp (
properties = {...}khác nhau). Giải pháp: đặt ởapplication-test.propertieshoặc một lớp cha. @DirtiesContext: xóa ngữ cảnh khỏi đệm sau bài, bắt buộc khởi động lại. Chỉ dùng khi bài thật sự làm hỏng ngữ cảnh.@ActiveProfileskhác nhau.
Quy tắc thực hành: đặt một lớp cha (hoặc một nhóm chú thích tổ hợp) chứa cấu hình chung của các kiểm thử tích hợp, và để mọi lớp kế thừa nó; theo dõi số ngữ cảnh được khởi động (như ContextCountProbe của lab, đếm sự kiện ContextRefreshedEvent) để phát hiện hồi quy. Ngữ cảnh cũng dùng chung cho cả các lớp chạy tuần tự; khi bật chạy song song (bài JUnit), mỗi luồng cần cô lập dữ liệu tốt hơn.
Giao dịch trong kiểm thử: bẫy qua mạng
@Transactional trên lớp hoặc phương thức kiểm thử mở một giao dịch quanh bài và hoàn tác ở cuối: cách gọn để dọn dữ liệu. Nhưng nó chỉ bao được những gì chạy trên cùng thread với bài. Lab chứng minh bằng hai bài, mỗi bài hoàn tác thủ công bằng TestTransaction.flagForRollback() và end():
@Test
@Transactional
void serviceCallJoinsTestTransaction() {
service.register("InTestTx");
long during = jdbc.queryForObject("SELECT count(*) FROM customers WHERE name = 'InTestTx'", Long.class);
TestTransaction.flagForRollback();
TestTransaction.end();
long after = jdbc.queryForObject("SELECT count(*) FROM customers WHERE name = 'InTestTx'", Long.class);
}
@Test
@Transactional
void httpCallRunsInServerThreadTransaction() {
RestTestClient client = RestTestClient.bindToServer().baseUrl("http://localhost:" + port).build();
client.post().uri("/api/customers").body(Map.of("name", "ViaHttp")).exchange().expectStatus().isCreated();
long during = jdbc.queryForObject("SELECT count(*) FROM customers WHERE name = 'ViaHttp'", Long.class);
TestTransaction.flagForRollback();
TestTransaction.end();
long after = jdbc.queryForObject("SELECT count(*) FROM customers WHERE name = 'ViaHttp'", Long.class);
}
TX same thread: during=1 afterRollback=0
TX over HTTP: visibleInsideTestTx=1 afterTestRollback=1
Gọi dịch vụ trực tiếp trong bài tham gia giao dịch của bài: dữ liệu thấy trong bài (1) rồi bị hoàn tác (0). Gọi qua HTTP thì yêu cầu chạy trên thread của máy chủ với giao dịch riêng đã commit: dữ liệu vẫn còn (1) sau khi giao dịch của bài hoàn tác. Hệ quả: @Transactional cho dọn dẹp chỉ đáng tin với kiểm thử không qua mạng (MOCK hoặc gọi bean trực tiếp); với RANDOM_PORT phải dọn dữ liệu chủ động. (Ghi chú: dữ liệu ViaHttp được xóa tay cuối bài trong lab.)
Cô lập dữ liệu khi dùng chung cơ sở dữ liệu
Các cách, từ rẻ tới an toàn:
- Dữ liệu duy nhất cho mỗi bài: tạo tên, email, mã ngẫu nhiên (
"customer-" + UUID.randomUUID()) để các bài không đụng nhau; bài chỉ khẳng định dữ liệu của mình. Hiệu quả nhất, cho phép chạy song song. - Dọn trước hoặc sau mỗi bài:
JdbcTestUtils.deleteFromTables(jdbc, "customers")trong@BeforeEach, hoặc@Sql(scripts = "/cleanup.sql", executionPhase = BEFORE_TEST_METHOD). Đơn giản, nhưng phải chạy tuần tự. @Transactionalhoàn tác: chỉ cho bài không qua mạng.- Cơ sở dữ liệu hoặc schema riêng cho mỗi lớp: cô lập tuyệt đối, đắt hơn.
Đừng viết bài phụ thuộc vào hàng "có sẵn" do bài khác tạo; khẳng định theo tập hợp (contains), không theo tổng số hàng toàn bảng, nếu dùng chung dữ liệu. Với bài phải kiểm tra tác vụ bất đồng bộ (sự kiện, xử lý nền), dùng Awaitility (đã có trong spring-boot-starter-test khi thêm phụ thuộc) với thời gian chờ tối đa thay cho Thread.sleep.
Kiểm thử chính các migration
Một lợi ích lớn nhất của container thật: chạy toàn bộ migration Flyway từ đầu trên PostgreSQL thật và để Hibernate validate đối chiếu entity với schema. Chỉ cần một bài @SpringBootTest khởi động được ngữ cảnh là đã chứng minh: mọi migration chạy trơn tru, và schema khớp entity (nếu lệch, ngữ cảnh không khởi động, như bài MySQL đã chỉ ra). Với những thay đổi migration quan trọng, thêm bài kiểm thử riêng nạp dữ liệu ở phiên bản trước rồi chạy migration và kiểm tra dữ liệu sau khi di chuyển.
Quyết định thiết kế và đánh đổi
| Nhu cầu | Khuyến nghị | Đánh đổi |
|---|---|---|
| Cơ sở dữ liệu thật trong kiểm thử | Testcontainers với đúng phiên bản chính của production | Cần Docker, vài giây khởi động |
| Chia sẻ container | @ImportTestcontainers với interface tĩnh | Mọi bài dùng chung dữ liệu |
| Ít ngữ cảnh | Lớp cha chung, gom mock và thuộc tính | Cấu hình dùng chung phải đủ cho mọi bài |
| Hành vi máy chủ thật | RANDOM_PORT và RestTestClient | Chậm hơn MockMvc |
| Dọn dữ liệu | Dữ liệu duy nhất mỗi bài, hoặc dọn tường minh | @Transactional không đủ cho bài qua mạng |
| Tác vụ bất đồng bộ | Awaitility | Cần chọn thời gian chờ hợp lý |
| Thay đổi migration | Bài kiểm thử dữ liệu trước và sau | Thêm công việc bảo trì |
Sổ tay xử lý sự cố
- Triệu chứng: bộ kiểm thử chậm dần theo số lớp. Nguyên nhân: mỗi lớp một ngữ cảnh hoặc một container. Kiểm tra: đếm
ContextRefreshedEvent, đếm container Docker. Sửa: lớp cha chung, container chung. - Triệu chứng: bài đỏ ngẫu nhiên, xanh khi chạy lại. Nguyên nhân: dữ liệu dùng chung giữa các bài. Sửa: dữ liệu duy nhất mỗi bài hoặc dọn tường minh.
- Triệu chứng:
@Transactionalnhưng dữ liệu vẫn còn sau bài. Nguyên nhân: gọi qua HTTP thật (thread khác). Sửa: dọn chủ động. - Triệu chứng: bài xanh với mock, đỏ ở production. Nguyên nhân: không có kiểm thử tích hợp cho đường chính. Sửa: một bộ nhỏ bài đầu cuối.
- Triệu chứng: ngữ cảnh khởi động mỗi lớp dù cấu hình trông giống nhau. Nguyên nhân: khác
@MockitoBean, thuộc tính, profile hay@DirtiesContext. Sửa: thống nhất. - Triệu chứng: không kết nối được cơ sở dữ liệu trong CI. Nguyên nhân: không có Docker hoặc quyền. Sửa: bảo đảm Docker khả dụng, hoặc dùng dịch vụ cơ sở dữ liệu của CI.
- Triệu chứng: container còn sót sau khi chạy. Nguyên nhân: tiến trình bị giết. Sửa: để Ryuk dọn (mặc định) và kiểm tra
docker ps. - Triệu chứng: bài đầu cuối thấy
500mà MockMvc thì không. Nguyên nhân: khác biệt xử lý lỗi/error. Sửa: khẳng định hợp đồng lỗi trong cả hai tầng.
Hiệu năng, bảo mật và vận hành
- Ghim phiên bản ảnh (
postgres:16-alpine) khớp production; bật bộ nhớ đệm ảnh Docker trong CI. - Giữ số bài tích hợp ít và có mục đích; mỗi bài chứng minh một đường đi, không lặp lại logic đã có kiểm thử đơn vị.
- Chia bộ kiểm thử:
*Testchạy nhanh ở mọi lần commit,*ITchạy ở phaverifyhoặc theo lịch, như bài JUnit đã nói. - Không đưa dữ liệu thật vào kiểm thử; dùng dữ liệu tổng hợp.
- Theo dõi thời gian từng lớp kiểm thử để phát hiện lớp làm vỡ bộ nhớ đệm.
Bài thực hành
- Dựng
CustomerController,CustomerService, migration và bàiFullStackITvớiRANDOM_PORTvàRestTestClient; xác nhận đăng ký trùng thất bại và cơ sở dữ liệu có một hàng. - Gọi endpoint
/boomqua máy chủ thật, ghi lại phản hồi500, rồi gọi cùng endpoint bằng MockMvc và so sánh. - Viết ba kiểu chia sẻ container và in mã container của từng lớp.
- Thêm bộ đếm
ContextRefreshedEventvà chứng minh hai lớp cùng cấu hình dùng chung ngữ cảnh, còn khác một thuộc tính thì tạo ngữ cảnh mới. - Viết hai bài
@Transactional, một gọi dịch vụ trực tiếp và một gọi qua HTTP, và ghi lại số hàng sau hoàn tác. - Dùng dữ liệu duy nhất mỗi bài và chạy hai bài song song.
- (Mở rộng) Viết bài kiểm thử nạp dữ liệu ở phiên bản migration trước, chạy migration mới và kiểm tra dữ liệu.
Tiêu chí hoàn thành: cơ sở dữ liệu chỉ có 1 hàng sau bước 1; phản hồi 500 có timestamp, status, error, path và không có thông điệp ngoại lệ; hai lớp cùng cấu hình in cùng số ngữ cảnh; hai bài giao dịch cho afterRollback=0 và afterTestRollback=1; bộ kiểm thử không còn phụ thuộc thứ tự chạy. Gợi ý nếu @ImportTestcontainers không có hiệu lực: bảo đảm interface có trường tĩnh chú thích @ServiceConnection và lớp kiểm thử khai báo @ImportTestcontainers(ContainersInterface.class).
Tự kiểm tra
- Vì sao dùng H2 cho kiểm thử cơ sở dữ liệu có thể dẫn tới lỗi migration ở production?
- Ba cách chia sẻ container Testcontainers khác nhau ở vòng đời nào, và cách nào cho một container duy nhất cả JVM?
- Cái gì tạo nên khóa bộ nhớ đệm ngữ cảnh, và ba cách phổ biến làm vỡ nó?
- Vì sao
@Transactionaltrên lớp kiểm thử không hoàn tác dữ liệu do gọi qua HTTP? - MockMvc và
RANDOM_PORTkhác nhau thế nào với ngoại lệ chưa xử lý? - Nêu hai cách cô lập dữ liệu giữa các bài dùng chung cơ sở dữ liệu.
- Một bài tích hợp thật sự cần chứng minh điều gì mà kiểm thử đơn vị không chứng minh được?
Gợi ý đáp án. (1) H2 chạy khác engine (kiểu dữ liệu, cú pháp, DDL giao dịch) nên lỗi chỉ lộ ra trên engine thật. (2) Container tĩnh theo lớp (vòng đời lớp), bean trong cấu hình (vòng đời ngữ cảnh), và container tĩnh trong interface nhập bằng @ImportTestcontainers (một container cho cả JVM). (3) Lớp cấu hình, thuộc tính, profile, bean ghi đè (@MockitoBean), initializer, loại môi trường web; vỡ bởi mock khác nhau theo lớp, thuộc tính khác nhau, @DirtiesContext. (4) Yêu cầu chạy trên thread máy chủ với giao dịch riêng đã commit, ngoài giao dịch của bài. (5) MockMvc để ngoại lệ lọt ra (ServletException); máy chủ thật trả 500 có cấu trúc qua /error. (6) Dữ liệu duy nhất mỗi bài, hoặc dọn tường minh trước và sau bài (cũng có @Transactional cho bài không qua mạng). (7) Các phần ghép đúng với phần mềm thật: migration và schema, truy vấn trên engine thật, giao dịch và ràng buộc, hợp đồng qua máy chủ thật.
Điều cần nhớ
- Dùng đúng engine cơ sở dữ liệu của production trong kiểm thử tích hợp; Testcontainers với
@ServiceConnectionlà cách gọn. - Chia sẻ container (một cho cả JVM) và ngữ cảnh (cấu hình chung) là chìa khóa tốc độ.
@Transactionalcủa kiểm thử không bao được lời gọi qua mạng.- Máy chủ thật khác MockMvc ở xử lý lỗi và cấu hình container; giữ một ít bài đầu cuối.
- Cô lập dữ liệu bằng dữ liệu duy nhất hoặc dọn tường minh; tránh phụ thuộc thứ tự.
Học liệu chọn lọc
- Spring Boot Reference: Testcontainers Support
- Spring Boot Reference: Testing Spring Boot Applications
- Spring Framework Reference: TestContext Framework, Context Caching
- Spring Framework Reference: Test-managed Transactions
- Testcontainers for Java: PostgreSQL module
- Awaitility Documentation
- Flyway Documentation
- The Java Tutorials (Oracle): JDBC Basics
- Sách: Testing Java Microservices (Alex Soto Bueno và cộng sự); Unit Testing Principles, Practices, and Patterns (Vladimir Khorikov).