Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Async / Concurrent Testing Strategies

Mở đầu — vì sao bài này quan trọng

Nếu bạn đã đi làm QA automation được vài tháng, chắc chắn bạn từng gặp cảnh này: một test case chạy tay thì luôn pass, nhưng khi bấm nút chạy trên CI thì lúc xanh lúc đỏ, không theo quy luật nào cả. Bạn chạy lại lần nữa — nó pass. Bạn nhún vai, merge code. Ba ngày sau nó lại đỏ. Đó gần như luôn là dấu hiệu của một vấn đề: bạn đang test một đoạn code bất đồng bộ (async) hoặc chạy song song (concurrent) mà chưa hiểu rõ cách kiểm soát thời gian và thứ tự thực thi.

Async và concurrency có mặt ở khắp nơi trong phần mềm hiện đại. Một app đặt xe gọi API tính giá, đồng thời tải bản đồ, đồng thời kiểm tra ví — ba luồng chạy chồng lên nhau. Một backend nhận webhook thanh toán trong khi vẫn đang xử lý đơn hàng gốc. Một job xử lý hàng loạt (batch) chạy 50 worker cùng lúc ghi vào một bảng database. Tất cả những thứ này đều "khó test" theo một cách rất đặc trưng — và nếu bạn không có chiến lược, test của bạn sẽ trở thành nguồn flaky lớn nhất trong dự án.

Bài này không dạy bạn về công cụ cụ thể (những bài khác đã lo phần đó), mà dạy bạn tư duy và chiến lược để test code async/concurrent một cách tin cậy: hiểu vì sao nó khó, biết các kỹ thuật kiểm soát thời gian và thứ tự, và biết cách biến những bug "không tái hiện được" thành test tái hiện được 100%.

Khái niệm cốt lõi

Async khác Concurrent như thế nào?

Hai từ này thường bị dùng lẫn lộn, nhưng phân biệt rõ sẽ giúp bạn chọn đúng chiến lược test.

  • Async (bất đồng bộ): Một tác vụ được khởi động rồi trả quyền điều khiển lại ngay, kết quả đến "sau đó" thông qua callback, Promise (JavaScript), Future/CompletableFuture (Java), hay async/await (Python, JS, C#). Bản chất async không nhất thiết chạy nhiều luồng — nó chỉ nghĩa là "kết quả không có ngay tại dòng lệnh này".
  • Concurrent (đồng thời): Nhiều đơn vị thực thi (thread, process, coroutine) cùng tiến triển và có thể chạm vào cùng một tài nguyên chung. Đây là nơi sinh ra race condition — bug phụ thuộc vào ai chạm dữ liệu trước.
Một hệ thống có thể async mà không concurrent (một event loop đơn luồng như Node.js), hoặc concurrent thật sự (Java thread pool). Cách test khác nhau khá nhiều.

Vì sao async/concurrent code khó test?

Bốn lý do gốc rễ mà bạn phải nằm lòng:

  • Thứ tự không xác định (non-deterministic order). Khi hai tác vụ chạy song song, không có gì đảm bảo tác vụ A xong trước tác vụ B. Test viết theo giả định "A xong trước" sẽ pass 90% lần và fail 10% lần khi lịch chạy đổi.
  • Race condition khó tái hiện. Bug chỉ xuất hiện khi hai thread chạm dữ liệu trong một khoảng thời gian cực ngắn — có khi vài microsecond. Trên máy bạn không bao giờ trúng cửa sổ đó; trên server production tải cao thì trúng liên tục.
  • Nhạy với thời gian (timing-sensitive). Nhiều test async được "chữa cháy" bằng sleep(2000) rồi kiểm tra kết quả. Nếu máy CI chậm hơn dự kiến, 2 giây không đủ — test fail. Nếu nhanh hơn, test lãng phí thời gian. Đây là nguồn flaky kinh điển.
  • Promise/Future chưa resolve khi assert. Bạn assert kết quả trong khi tác vụ async còn đang chạy dở. Test kết thúc, kết quả về sau đó chẳng ai nghe — thậm chí exception bị nuốt mất, test vẫn "pass giả".

Ba nguyên lý vàng để test tin cậy

  • Kiểm soát thay vì chờ đợi mù quáng. Đừng sleep. Hãy dùng cơ chế await kết quả cụ thể: chờ Promise resolve, await future.get(), hoặc polling có điều kiện (poll cho tới khi điều kiện đúng, có timeout tối đa). Trong hệ sinh thái Java có Awaitility; trong JS có waitFor của Testing Library; trong Python có asyncio primitives.
  • Làm cho thời gian trở nên xác định. Thay vì để test phụ thuộc đồng hồ thật, hãy tiêm (inject) một clock giả (fake clock / virtual time) để bạn "tua" thời gian theo ý muốn. Test một retry sau 30 giây không nên mất 30 giây thật.
  • Ép race condition xảy ra, đừng chờ may rủi. Dùng barrier, latch, hoặc semaphore để dàn xếp cho nhiều thread cùng đến một điểm rồi mới thả ra cùng lúc — biến bug hiếm gặp thành bug chắc chắn tái hiện.

Tình huống thực tế

Tình huống 1 — Ví điện tử và cú "double spend" ở một fintech Việt Nam

Một startup ví điện tử tại TP.HCM (giả định gọi là PayZen) có tính năng rút tiền. Logic: kiểm tra số dư ≥ số tiền rút, nếu đủ thì trừ tiền. Test đơn luồng của họ pass hết. Nhưng khi lên production, một user tinh ranh dùng script gửi hai request rút 500.000đ cùng lúc trên tài khoản chỉ có 500.000đ. Cả hai request đọc số dư = 500.000, cả hai thấy "đủ", cả hai cùng trừ — user rút được 1.000.000đ từ 500.000đ. Thiệt hại thực tế vài chục triệu trước khi bị phát hiện.

Diễn giải: Đây là race condition kinh điển (check-then-act không atomic). Suite test cũ không hề có test concurrent nên không bao giờ bắt được. Đội QA sau đó viết một test dùng CountDownLatch: khởi tạo tài khoản 500.000đ, tạo 2 thread cùng gọi rút 500.000đ, cho cả hai chờ ở một latch, rồi thả cùng lúc. Assert: đúng một request thành công, tổng tiền rút không vượt số dư. Test này fail với code cũ (chứng minh bug) và pass sau khi họ thêm lock database (SELECT ... FOR UPDATE).

Bài học: Với logic tiền bạc/tồn kho, test concurrent không phải "nice to have" mà là bắt buộc. Và test tốt phải fail được với code có bug — nếu nó pass cả trước lẫn sau khi sửa, nó vô dụng.

Tình huống 2 — Màn hình đặt món chớp nhoáng ở một app giao đồ ăn

Một team làm app giao đồ ăn ở Đông Nam Á có màn hình chi tiết nhà hàng. Khi mở, app gọi song song 3 API: thông tin quán, menu, và trạng thái mở/đóng cửa. UI chỉ hiện nút "Đặt món" khi cả 3 về xong. Test E2E của họ hay flaky: khoảng 1/8 lần chạy trên CI báo "không tìm thấy nút Đặt món".

Diễn giải: Test cũ viết kiểu sleep(1500) rồi tìm nút. Trên CI runner chia sẻ tài nguyên, đôi khi API menu về sau 1.6 giây — quá 1.5 giây chờ, nút chưa render, test fail. Họ sửa bằng cách bỏ hết sleep, thay bằng explicit wait có điều kiện: chờ tối đa 10 giây cho tới khi nút "Đặt món" hiển thị và click được. Tỷ lệ flaky rơi từ 12% xuống gần 0, mà thời gian chạy trung bình còn giảm vì phần lớn trường hợp nút hiện sau 0.8 giây và test đi tiếp ngay.

Bài học: sleep là kẻ thù. Nó vừa gây flaky (chờ thiếu) vừa làm chậm suite (chờ thừa). Luôn thay bằng "chờ cho tới khi điều kiện X đúng, tối đa N giây".

Tình huống 3 — Retry và timeout ở một hệ thống tích hợp ngân hàng

Một công ty cung cấp cổng thanh toán tích hợp với nhiều ngân hàng có cơ chế: nếu gọi API ngân hàng bị timeout, hệ thống tự retry sau 5 giây, tối đa 3 lần, dùng exponential backoff (5s, 10s, 20s). Team muốn test rằng sau 3 lần thất bại thì giao dịch chuyển sang trạng thái FAILED và gửi cảnh báo.

Diễn giải: Nếu test dùng thời gian thật, mỗi lần chạy mất 5 + 10 + 20 = 35 giây chỉ để chờ. Với hàng chục kịch bản retry, suite phình lên vài chục phút — không ai muốn chạy. Giải pháp: họ tiêm một fake clock vào scheduler. Trong test, sau khi kích hoạt lần gọi đầu tiên thất bại, họ gọi clock.advance(5s) để "tua" tới lần retry, rồi advance(10s), advance(20s). Toàn bộ kịch bản 35 giây "logic" chạy xong trong vài mili-giây thật, và họ kiểm soát chính xác từng mốc thời gian.

Bài học: Đừng để test phụ thuộc đồng hồ thật. Thiết kế code sao cho thời gian là một dependency tiêm được (injectable clock), bạn sẽ test được mọi kịch bản timing mà không phải chờ một giây thật nào.

Hướng dẫn từng bước

Đây là quy trình bạn có thể áp dụng khi đứng trước một đoạn code async/concurrent cần test.

Bước 1 — Xác định bạn đang test loại nào. Hỏi: đây là async đơn luồng (chờ một kết quả về muộn) hay concurrent thật (nhiều thread chạm tài nguyên chung)? Câu trả lời quyết định chiến lược: async → tập trung vào "chờ đúng kết quả"; concurrent → tập trung vào "ép race condition và kiểm tra tính đúng đắn dưới tải".

Bước 2 — Loại bỏ mọi sleep cứng. Rà soát test hiện có, thay từng sleep(n) bằng một điều kiện chờ rõ ràng. Mẫu chung:

// Thay vì:
sleep(2000);
assertThat(order.getStatus()).isEqualTo("PAID");

// Hãy dùng polling có điều kiện (ví dụ Awaitility - Java): await().atMost(5, SECONDS) .until(() -> order.getStatus().equals("PAID"));

Điều kiện chờ nên trỏ vào kết quả bạn thực sự quan tâm, không phải một mốc thời gian tùy tiện.

Bước 3 — Với code async, hãy await đúng cách trong test. Đừng để test kết thúc trước khi tác vụ async xong. Trong Python asyncio dùng await; trong JS dùng async test và await Promise; trong Java dùng future.get(timeout). Nếu framework test hỗ trợ, dùng cơ chế done/waitFor để test chỉ kết thúc khi callback đã chạy.

Python - pytest-asyncio

async def test_fetch_price(): result = await price_service.fetch(order_id="A123") assert result.amount == 50000

Bước 4 — Với race condition, ép nó xảy ra bằng đồng bộ hóa. Dùng latch/barrier để dàn nhiều thread cùng xuất phát:

int threads = 2;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int i = 0; i < threads; i++) {
    new Thread(() -> {
        start.await();            // tất cả cùng chờ ở vạch xuất phát
        wallet.withdraw(500_000); // rồi thả ra cùng lúc
        done.countDown();
    }).start();
}
start.countDown();               // bắn phát súng lệnh
done.await(5, SECONDS);
assertThat(wallet.balance()).isGreaterThanOrEqualTo(0);

Bước 5 — Tiêm clock giả cho các kịch bản timing. Đừng để code gọi trực tiếp System.currentTimeMillis() hay time.time(). Nhận Clock qua constructor; trong test truyền một fake clock mà bạn tua được. Điều này biến test retry/timeout từ hàng chục giây xuống mili-giây.

Bước 6 — Kiểm tra cả nhánh lỗi và tính idempotent. Với async, đừng chỉ test happy path. Test: tác vụ bị timeout, Promise bị reject, exception trong callback có được lan ra không. Với concurrent, test rằng thao tác lặp lại (ví dụ nhận webhook hai lần) không gây tác dụng phụ kép.

Bước 7 — Chạy lặp và chạy dưới tải để soi lỗi ẩn. Một test concurrent chạy đúng 1 lần chưa chứng minh gì. Cấu hình chạy nó 100–1000 lần (nhiều framework có annotation lặp), hoặc dùng công cụ stress. Nếu 1/500 lần fail, bạn vẫn còn race condition.

Lỗi thường gặp & mẹo

  • Dùng sleep để "chờ cho chắc". Đây là lỗi số một. Nó tạo flaky và làm chậm suite. Luôn thay bằng chờ có điều kiện + timeout. Nếu bắt buộc phải có độ trễ, hãy đặt timeout tối đa rộng rãi nhưng thoát ngay khi điều kiện đúng.
  • Assert khi Promise/Future chưa resolve. Test kết thúc quá sớm, assertion không bao giờ chạy, hoặc exception async bị nuốt. Mẹo: luôn await/.get() tác vụ trước khi assert; đảm bảo test runner của bạn thực sự chờ code async (dùng đúng annotation như @pytest.mark.asyncio, async test trong JS).
  • Test concurrent nhưng thực chất chạy tuần tự. Nếu bạn start thread rồi join ngay từng cái một, chúng chạy nối đuôi chứ không đè lên nhau — race condition không bao giờ lộ. Phải dùng latch để chúng cùng xuất phát.
  • Test race pass ngẫu nhiên nên tin là hết bug. Race condition mang tính xác suất. Chạy một lần pass không có nghĩa gì. Chạy lặp hàng trăm lần, và quan trọng hơn: viết test theo hướng ép bug xảy ra chắc chắn (dùng latch), đừng phó mặc lịch chạy.
  • Chia sẻ trạng thái giữa các test chạy song song. Khi bạn bật parallel execution cho cả suite, hai test dùng chung một biến static hay chung một record database sẽ gây nhiễu nhau. Mẹo: mỗi test tự tạo dữ liệu riêng (unique ID), tránh trạng thái toàn cục, đảm bảo test độc lập.
  • Bỏ qua thread pool bị bỏ đói (starvation). Nếu code test dùng chung thread pool giới hạn với code bị block, test có thể treo tới timeout. Mẹo: dành pool riêng cho test, và luôn đặt timeout cho await/get để test fail nhanh thay vì treo vô hạn.
  • Mẹo quan trọng về thiết kế: Code dễ test async/concurrent bắt đầu từ thiết kế tốt — tách logic thuần khỏi phần điều phối thời gian, tiêm clock và executor thay vì hardcode. Nếu test quá khó viết, thường đó là tín hiệu code cần refactor chứ không phải test cần phức tạp hơn.

Bài tập thực hành

  • Săn sleep. Mở suite test hiện tại của dự án bạn (hoặc một repo mẫu), tìm tất cả chỗ dùng sleep/Thread.sleep/setTimeout trong test. Với mỗi chỗ, viết lại thành chờ có điều kiện + timeout. Ghi lại thời gian chạy suite trước và sau — bạn sẽ bất ngờ.
  • Tái hiện double-spend. Viết một hàm withdraw đơn giản kiểu check-then-act không có lock. Viết một test dùng CountDownLatch (hoặc tương đương trong ngôn ngữ bạn dùng) cho 10 thread cùng rút từ một tài khoản chỉ đủ cho 1 lần. Chứng minh test fail với code không lock, rồi thêm lock/atomic và chứng minh test pass.
  • Fake clock cho retry. Viết một service retry với backoff 1s → 2s → 4s. Refactor để nhận Clock qua constructor. Viết test tiêm fake clock, tua thời gian qua từng mốc, và assert số lần gọi cùng trạng thái cuối cùng — tất cả phải chạy dưới 100ms thật.
  • Chạy lặp để soi flaky. Lấy một test concurrent bất kỳ bạn vừa viết, cấu hình chạy nó 500 lần liên tục. Nếu có lần fail, phân tích: đó là bug thật trong code, hay test của bạn giả định sai về thứ tự?

Tóm tắt

Async và concurrent code khó test vì bốn lý do cốt lõi: thứ tự thực thi không xác định, race condition khó tái hiện, độ nhạy với thời gian, và việc assert khi tác vụ chưa hoàn tất. Chiến lược để thắng chúng cũng gói gọn trong vài nguyên tắc: thay sleep bằng chờ có điều kiện, await đúng cách để không assert quá sớm, ép race condition bằng latch/barrier thay vì chờ may rủi, tiêm clock giả để kiểm soát thời gian, và chạy lặp nhiều lần để lộ lỗi ẩn. Ba câu chuyện thực tế — double-spend ở ví điện tử, màn hình flaky ở app giao đồ ăn, và retry 35 giây rút xuống mili-giây — cho thấy cùng một bài học: đừng phó mặc cho thời gian và lịch chạy, hãy chủ động kiểm soát chúng. Một test tốt cho code async không phải là test chạy chậm rồi hy vọng mọi thứ kịp xong, mà là test chạy nhanh, xác định, và fail được đúng lúc code có bug.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi