Flaky tests: nguyên nhân, phát hiện, cách ly và sửa
Tiếp đi: flaky test. Pipeline shop-app hôm nay đỏ, bấm re-run lại xanh, không ai sửa gì. Team bắt đầu quen bấm re-run rồi.
"Quen bấm re-run" là dấu hiệu nguy hiểm nhất: khi mọi người không tin build đỏ nữa, bug thật cũng sẽ bị re-run cho qua.
1. Bài toán: các test lúc xanh lúc đỏ trong shop-app
OrderExpiryTest đỏ khi chạy lúc 23:59:59
OrderServiceTest đỏ khi chạy sau WalletServiceTest
OrderEventPublisherIT đỏ trên CI chậm (sleep 2s)
PaymentClientIT đỏ khi sandbox của cổng thanh toán bảo trì
2. Vấn đề: 6 nguyên nhân phổ biến
| Nguyên nhân | Ví dụ trong shop-app |
|---|---|
| Thời gian | LocalDate.now() trong code; test chạy qua nửa đêm, khác timezone CI |
| Phụ thuộc thứ tự | Test B dựa vào dữ liệu test A tạo; JUnit không đảm bảo thứ tự |
| Shared state | Biến static, cache Spring, DB chung giữa các test, Spring context bị @DirtiesContext lẫn lộn |
| Async + sleep | Thread.sleep(2000) chờ consumer Kafka; CI chậm thì không đủ |
| Port cố định | Test dùng port 8080/5432, hai job song song trên cùng runner đụng nhau |
| Dịch vụ ngoài | Gọi sandbox cổng thanh toán thật, mạng chập chờn |
Thêm: thứ tự không xác định (HashMap, findAll() không ORDER BY), random không seed, concurrency thật sự có bug (bài 5.6).
3. Giải pháp
- Phát hiện: thống kê kết quả test qua nhiều lần chạy; chạy lặp; chạy random order.
- Cách ly (quarantine): gắn tag, đưa ra job không chặn merge, mở ticket có owner và hạn sửa.
- Sửa tận gốc: inject
Clock, dữ liệu riêng mỗi test, Awaitility, port ngẫu nhiên, WireMock/Testcontainers thay dịch vụ ngoài.
4. Code từng bước
Bước 1: Phát hiện
# Chạy lặp một test nghi ngờ
@RepeatedTest(100)
void expireOrder() { ... }
# Random thứ tự để lộ phụ thuộc thứ tự
# src/test/resources/junit-platform.properties
junit.jupiter.testmethod.order.default=org.junit.jupiter.api.MethodOrderer$Random
junit.jupiter.testclass.order.default=org.junit.jupiter.api.ClassOrderer$Random
Surefire có -Dsurefire.rerunFailingTestsCount=2: test fail rồi pass khi chạy lại được báo là Flaky trong report. Dùng để đo, không dùng để che lâu dài.
Bước 2: Cách ly
@Tag("quarantine") // JIRA SHOP-812, owner: team-payment, hạn 2 tuần
@Test
void paymentClientHandlesTimeout() { ... }
<!-- job chính -->
<excludedGroups>quarantine</excludedGroups>
<!-- job phụ, không chặn merge: -Dgroups=quarantine -->
Bước 3: Sửa
3a. Thời gian: inject Clock
@Configuration
class TimeConfig {
@Bean Clock clock() { return Clock.systemUTC(); }
}
@Service
class OrderExpiryService {
private final Clock clock;
OrderExpiryService(Clock clock) { this.clock = clock; }
boolean isExpired(Order o) {
return o.getCreatedAt().plus(Duration.ofMinutes(30)).isBefore(Instant.now(clock));
}
}
class OrderExpiryServiceTest {
@Test
void expiresAfter30Minutes() {
Clock fixed = Clock.fixed(Instant.parse("2026-01-01T23:59:59Z"), ZoneOffset.UTC);
var service = new OrderExpiryService(fixed);
Order o = orderCreatedAt(Instant.parse("2026-01-01T23:29:58Z"));
assertThat(service.isExpired(o)).isTrue();
}
}
3b. Thứ tự và shared state
@BeforeEach
void setUp() {
jdbc.execute("TRUNCATE orders, wallet, processed_event RESTART IDENTITY CASCADE");
userId = seedUser(); // mỗi test tự tạo dữ liệu của mình
}
// Assert danh sách không phụ thuộc thứ tự:
assertThat(orders).extracting(Order::getId).containsExactlyInAnyOrder(1L, 2L);
3c. Async: bỏ sleep
// ✘ Thread.sleep(2000); assertThat(paymentRepo.count()).isEqualTo(1);
await().atMost(Duration.ofSeconds(10))
.untilAsserted(() -> assertThat(paymentRepo.count()).isEqualTo(1));
3d. Port ngẫu nhiên và dịch vụ ngoài
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class OrderApiIT {
@LocalServerPort int port; // không hardcode 8080
}
@WireMockTest // WireMock chạy port ngẫu nhiên
class PaymentClientIT {
@Test
void timeoutIsHandled(WireMockRuntimeInfo wm) {
stubFor(post("/charge").willReturn(ok().withFixedDelay(5_000)));
var client = new PaymentClient(wm.getHttpBaseUrl(), Duration.ofSeconds(1));
assertThatThrownBy(() -> client.charge(req)).isInstanceOf(PaymentTimeoutException.class);
}
}
// DB, Kafka: Testcontainers + @ServiceConnection (port ngẫu nhiên, bài 5.5)
Bước 4: Kiểm lại
./mvnw verify -Dsurefire.rerunFailingTestsCount=2
# Tests run: 412, Failures: 0, Flakes: 0
# Theo dõi chỉ số "flaky rate" theo tuần trên dashboard CI
5. Cách hoạt động
Vòng đời một flaky test
phát hiện (report "Flaky", thống kê CI)
│
▼
quarantine (@Tag, ticket, owner, hạn) ──► job chính lại đáng tin
│
▼
tìm nguyên nhân: time? order? state? async? port? external?
│
▼
sửa (Clock, dữ liệu riêng, Awaitility, random port, WireMock)
│
▼
@RepeatedTest(100) xanh ──► bỏ tag, đưa lại job chính
(quá hạn mà không sửa ──► xóa test, viết lại)
6. So sánh
| Cách xử lý | Ưu | Nhược |
|---|---|---|
| Bấm re-run tay | Nhanh | Mất niềm tin vào CI, che bug thật |
| Auto retry (rerunFailingTestsCount) | Pipeline ổn định, có báo Flaky | Dễ thành cách che vĩnh viễn |
| Quarantine có owner | Job chính sạch, vẫn theo dõi | Cần kỷ luật để sửa |
| Sửa tận gốc | Hết hẳn | Tốn thời gian điều tra |
| @Disabled | Đơn giản | Thường bị quên mãi mãi |
7. Khi nào nên dùng / không nên dùng
- Retry tự động: chấp nhận được cho IT/E2E kèm báo cáo flaky; không cho unit test (unit flaky = code hoặc test sai).
- Quarantine phải có hạn; test quá hạn thì sửa hoặc xóa.
- Không gọi dịch vụ ngoài thật trong pipeline chặn merge.
- Không dùng
Thread.sleeptrong test, khôngInstant.now()trực tiếp trong code nghiệp vụ.
Thêm @TestMethodOrder cố định thứ tự cho test chạy ổn định có được không?
Chạy được nhưng là che lỗi. Bạn hiểu đúng là thứ tự gây lỗi, chỉ cần chỉnh một điểm: cách sửa là làm cho mỗi test độc lập, không phải khóa thứ tự. Khóa thứ tự thì chạy một test riêng lẻ (khi debug) vẫn đỏ, và chạy song song sẽ vỡ. @TestMethodOrder chỉ hợp lý cho kịch bản cố ý nhiều bước (ví dụ E2E tạo → thanh toán → hủy), và khi đó nên gộp thành một test.
Test chạy song song (junit.jupiter.execution.parallel.enabled) cho nhanh thì có làm flaky nhiều hơn không?
Có, nó sẽ lộ mọi shared state đang ẩn: biến static, cùng một dòng DB, cùng một queue. Đó vừa là rủi ro vừa là cách kiểm tra tốt. Nên bật dần: unit test thuần trước; IT dùng chung DB thì để @Execution(SAME_THREAD) hoặc @ResourceLock("db"), hoặc cho mỗi test dữ liệu riêng (userId, orderId ngẫu nhiên) để không cần truncate.
Phỏng vấn hỏi flaky test thì trả lời sao?
Q1. Nguyên nhân flaky test thường gặp?
🇻🇳 Tiếng Việt:
Phụ thuộc thời gian hệ thống, phụ thuộc thứ tự chạy test, shared state như biến static hay dữ liệu DB chung, chờ code async bằng sleep, dùng port cố định, và gọi dịch vụ ngoài thật. Ngoài ra là thứ tự không xác định của collection và bug concurrency thật trong code.
🇬🇧 English:
Dependence on system time, on test execution order, shared state such as static fields or shared database rows, waiting for async code with sleep, fixed ports, and calls to real external services. Also non-deterministic collection ordering and genuine concurrency bugs in the code.
Q2. Team bạn xử lý flaky test thế nào?
🇻🇳 Tiếng Việt:
Phát hiện qua báo cáo flaky của Surefire và thống kê CI. Test flaky được quarantine bằng tag, chuyển sang job không chặn merge, có ticket, owner và hạn sửa. Sau đó sửa tận gốc và chạy lặp nhiều lần trước khi đưa lại. Không để mọi người bấm re-run cho qua vì sẽ mất niềm tin vào CI.
🇬🇧 English:
We detect them through Surefire's flaky reports and CI statistics. A flaky test is quarantined with a tag and moved to a non-blocking job, with a ticket, an owner and a deadline. Then we fix the root cause and run it repeatedly before bringing it back. We do not let people just re-run the pipeline, because that destroys trust in CI.
Q3. Làm sao test code phụ thuộc thời gian?
🇻🇳 Tiếng Việt:
Inject java.time.Clock làm bean, code nghiệp vụ dùng Instant.now(clock). Trong test truyền Clock.fixed hoặc Clock.offset để thời gian xác định, kể cả các mốc khó như nửa đêm hay cuối tháng.
🇬🇧 English:
I inject java.time.Clock as a bean and business code calls Instant.now(clock). In tests I pass Clock.fixed or Clock.offset so time is deterministic, including tricky moments such as midnight or month end.
Cách nhớ ngắn gọn
- 6 nguồn: time, order, shared state, sleep, port, external.
- Phát hiện → quarantine (owner + hạn) → sửa → chạy lặp → đưa lại.
- Clock inject, dữ liệu riêng, Awaitility, random port, WireMock/Testcontainers.
- Re-run tay là thói quen nguy hiểm.
Tổng kết chương Testing
| Bài | Chủ đề | Ý chính |
|---|---|---|
| 5.1 | Chiến lược test | Test pyramid, chọn loại test theo rủi ro |
| 5.2 | JUnit 5 | Lifecycle, assertion, parameterized test |
| 5.3 | Mockito | Mock, stub, verify; mock đúng ranh giới |
| 5.4 | Spring test slices | @WebMvcTest, @DataJpaTest, @SpringBootTest |
| 5.5 | Testcontainers | DB, broker thật trong test, @ServiceConnection |
| 5.6 | Transaction & concurrency | Rollback giấu bug, optimistic lock, latch, Awaitility |
| 5.7 | Messaging, contract, E2E | Kafka/RabbitMQ IT, idempotency, contract, ít E2E |
| 5.8 | Build quality gate | Surefire/Failsafe, JaCoCo, Sonar, CI gate, PIT |
| 5.9 | Flaky tests | Nguyên nhân, quarantine, Clock, Awaitility |
Tự kiểm tra
Test đỏ lúc 23:59:59 mà ban ngày xanh, nghi gì đầu tiên?
Code dùng thời gian hệ thống trực tiếp; inject Clock.
Làm sao lộ test phụ thuộc thứ tự?
Bật MethodOrderer$Random/ClassOrderer$Random, chạy riêng lẻ từng test.
rerunFailingTestsCount nên dùng thế nào?
Để đo và báo "Flaky", kèm quarantine và sửa; không dùng để che vĩnh viễn.
Quarantine cần những gì để không thành "nghĩa địa test"?
Ticket, owner, hạn sửa; quá hạn thì sửa hoặc xóa.
Thay gọi sandbox cổng thanh toán thật bằng gì?
WireMock (port ngẫu nhiên) hoặc contract stub; sandbox thật chỉ dùng ở job không chặn merge.