Flaky tests: nguyên nhân, phát hiện, cách ly và sửa

4 câu hỏi~20 phútTừ khóa: flaky test, Clock injection, test order dependence, shared state, Thread.sleep, Awaitility, random port, WireMock, rerunFailingTestsCount, quarantine, @Tag

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ânVí dụ trong shop-app
Thời gianLocalDate.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 stateBiến static, cache Spring, DB chung giữa các test, Spring context bị @DirtiesContext lẫn lộn
Async + sleepThread.sleep(2000) chờ consumer Kafka; CI chậm thì không đủ
Port cố địnhTest dùng port 8080/5432, hai job song song trên cùng runner đụng nhau
Dịch vụ ngoàiGọ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

  1. 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.
  2. Cách ly (quarantine): gắn tag, đưa ra job không chặn merge, mở ticket có owner và hạn sửa.
  3. 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ýƯuNhược
Bấm re-run tayNhanhMất niềm tin vào CI, che bug thật
Auto retry (rerunFailingTestsCount)Pipeline ổn định, có báo FlakyDễ thành cách che vĩnh viễn
Quarantine có ownerJob chính sạch, vẫn theo dõiCần kỷ luật để sửa
Sửa tận gốcHết hẳnTốn thời gian điều tra
@DisabledĐơn giảnThường bị quên mãi mãi

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

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


Tổng kết chương Testing

BàiChủ đềÝ chính
5.1Chiến lược testTest pyramid, chọn loại test theo rủi ro
5.2JUnit 5Lifecycle, assertion, parameterized test
5.3MockitoMock, stub, verify; mock đúng ranh giới
5.4Spring test slices@WebMvcTest, @DataJpaTest, @SpringBootTest
5.5TestcontainersDB, broker thật trong test, @ServiceConnection
5.6Transaction & concurrencyRollback giấu bug, optimistic lock, latch, Awaitility
5.7Messaging, contract, E2EKafka/RabbitMQ IT, idempotency, contract, ít E2E
5.8Build quality gateSurefire/Failsafe, JaCoCo, Sonar, CI gate, PIT
5.9Flaky testsNguyê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.