Flaky Test: Vì sao automation test lúc pass lúc fail?

Bạn vừa chạy một testcase automation bằng Cypress.

Lần đầu: Fail.

Code không thay đổi, dữ liệu không thay đổi, chỉ chạy lại đúng testcase đó.

Lần thứ hai: Pass.

Chạy thêm một lần nữa: Pass.

Vậy testcase này đang đúng hay sai?

Đây là một trong những tình huống dễ làm bạn và cả team mất niềm tin vào automation nhất: flaky test — một testcase có thể lúc pass, lúc fail dù code và điều kiện chạy gần như không thay đổi.

Ban đầu, bạn có thể nghĩ đơn giản:

“Chắc hệ thống phản hồi chậm, chạy lại là được.”

Nhưng nếu điều này xảy ra thường xuyên, vấn đề không còn nằm ở một testcase nữa.

Một automated test lúc đỏ lúc xanh đôi khi còn nguy hiểm hơn một test chưa được automate. Khi team đã quen với việc “fail thì chạy lại”, đến lúc xuất hiện bug thật, mọi người rất dễ nghĩ đó chỉ là một flaky test khác.




Flaky test là gì và vì sao đây là một vấn đề?

Mục đích của automated test không đơn giản là chạy được thật nhiều testcase. Giá trị quan trọng nhất của automation là giúp Tester nhanh chóng trả lời câu hỏi:

“Sau khi hệ thống có thay đổi, chức năng này còn hoạt động đúng hay không?”

Ví dụ, bạn có một testcase kiểm tra flow đăng ký lớp học:

1. Parent đăng ký tài khoản.
2. Chọn event.
3. Thêm children.
4. Chọn lớp học.
5. Thanh toán.
6. Kiểm tra thông tin vừa thanh toán.

Nếu code không thay đổi và dữ liệu test giống nhau, chúng ta kỳ vọng kết quả cũng phải giống nhau.

Nhưng thực tế có thể xảy ra:

Run 1 → PASS
Run 2 → FAIL
Run 3 → PASS
Run 4 → PASS
Run 5 → FAIL





Đó chính là flaky test.

Một test đáng tin cần có tính ổn định và lặp lại được. Có thể hiểu đơn giản:

Cùng một điều kiện đầu vào thì testcase nên cho cùng một kết quả.

Nếu cùng một testcase nhưng lần này pass, vài phút sau lại fail mà không có lý do rõ ràng, test đang tạo ra nhiễu thay vì cung cấp thông tin hữu ích cho Tester.


Rủi ro lớn nhất: Tester không còn tin vào kết quả chạy test tự động

Một flaky test đơn lẻ có vẻ chỉ gây phiền.

Nhưng khi số lượng flaky test tăng lên, vấn đề nghiêm trọng hơn nhiều: Bạn và team bắt đầu không còn tin vào kết quả chạy automation nữa.

Giả sử bạn chạy một bộ 200 automated testcases.

Một testcase fail.

Bạn nhìn lịch sử và nhận ra:

“Test này hay fail lắm.”

Sau đó bạn chạy lại testcase đó.

Lần này pass → Lần sau lại fail → Chạy lại →Lại pass.

Dần dần, một phản xạ rất nguy hiểm hình thành: Kết quả fail không còn đồng nghĩa với “có vấn đề cần kiểm tra”.

Nó trở thành:

“Chắc flaky thôi, chạy lại trước đã.”

Đến một ngày, testcase fail vì bug thật.

Nhưng vì bạn đã quá quen với những lần fail giả, bug đó cũng có thể bị bỏ qua.

Đây là lúc flaky test làm mất đi thứ quan trọng nhất của automation: kết quả đáng tin để bạn biết khi nào hệ thống thật sự có vấn đề.

Một vòng lặp thường gặp:

Test fail
   ↓
Tester nghi flaky
   ↓
Chạy lại testcase
   ↓
Test pass
   ↓
Không điều tra nguyên nhân
   ↓
Flaky test tiếp tục tồn tại
   ↓
Tester ngày càng ít tin vào automation

Team có thể có hàng trăm hoặc hàng nghìn automated testcases, nhưng nếu kết quả không đáng tin thì số lượng đó cũng không mang lại nhiều giá trị.


Khi test flaky, nên nghĩ gì trước khi sửa?

Khi gặp một testcase lúc pass lúc fail, phản ứng đầu tiên không nên là:

“Thêm cy.wait(5000) thử xem.”

Thay vào đó, bạn nên bắt đầu bằng câu hỏi:

Điều gì đang khiến kết quả của testcase thay đổi dù logic test không đổi?

Với Cypress, nguyên nhân thường nằm trong một vài nhóm phổ biến dưới đây.

Timing: Test chạy nhanh hơn hệ thống

Đây là một trong những nguyên nhân thường gặp nhất khi test UI.

Ví dụ Parent chọn một lớp học và bấm Pay Now.

Sau đó:

  • Frontend gửi request thanh toán.

  • Backend xử lý payment.

  • Hệ thống lưu thông tin đăng ký lớp.

  • Trạng thái thanh toán được cập nhật.

  • Trang confirmation mới hiển thị thông tin vừa thanh toán.

Nếu Cypress kiểm tra confirmation page quá sớm, testcase có thể fail.

Không nhất thiết là application bị lỗi.

Đơn giản là hệ thống chưa xử lý xong.

Ví dụ:

cy.get('[data-testid="pay-now"]').click()

cy.get('[data-testid="payment-success"]')
  .should('be.visible')

Nếu success message hoặc thông tin thanh toán xuất hiện sau một khoảng thời gian không cố định, test có thể:

  • Pass khi hệ thống phản hồi nhanh.

  • Fail khi hệ thống chậm hơn.

  • Pass trên máy bạn nhưng fail ở môi trường test khác.

Đây là dấu hiệu đầu tiên cho thấy testcase đang có vấn đề về timing.

Shared test data: Nhiều testcase dùng chung dữ liệu

Giả sử nhiều testcase cùng sử dụng một Parent account:

parent_test@gmail.com

Testcase A thêm một child mới.

Testcase B kiểm tra danh sách children.

Testcase C đăng ký một lớp học.

Nếu các testcase chạy riêng, mọi thứ có thể vẫn ổn.

Nhưng khi chạy cùng một bộ test, testcase trước có thể đã làm thay đổi dữ liệu mà testcase sau đang cần.

Ví dụ:

  • Một child đã được thêm trước đó.

  • Một lớp đã được đăng ký rồi.

  • Slot của lớp đã thay đổi.

  • Payment trước đó làm trạng thái event khác đi.

Kết quả có thể là:

Run riêng → PASS

Run full suite → FAIL

Đây là một dấu hiệu rất đáng chú ý.

Nếu testcase chạy riêng ổn định nhưng chạy cùng toàn bộ bộ test lại fail, hãy kiểm tra khả năng các test đang dùng chung hoặc thay đổi cùng một dữ liệu.

Environment: Môi trường chạy không phải lúc nào cũng giống nhau

Máy dùng để chạy test và môi trường test dùng chung có thể rất khác nhau.

Ví dụ:

  • Máy chạy test chạy nhanh/chậm hơn.

  • Server test đang có nhiều người sử dụng.

  • Network chậm hơn.

  • Service vừa restart.

  • Database đang xử lý nhiều request.

Vì vậy, một testcase chạy tốt trên máy cá nhân vẫn có thể fail ở môi trường test khác.

Automation cần ổn định trong môi trường thực tế mà bạn sử dụng để chạy testcase, không chỉ trên một máy cụ thể.

External dependency: Phụ thuộc vào hệ thống bên ngoài

Flow đăng ký lớp học và thanh toán thường có thể phụ thuộc vào các hệ thống khác, ví dụ:

  • Payment gateway.

  • Email service.

  • SMS service.

  • Third-party API.

Nếu payment sandbox phản hồi chậm hoặc tạm thời không hoạt động, testcase có thể fail dù chức năng đăng ký lớp học của hệ thống mình không có lỗi.

Lúc này bạn cần đặt câu hỏi:

Testcase này đang kiểm tra chức năng của hệ thống mình, hay đang vô tình phụ thuộc quá nhiều vào độ ổn định của hệ thống bên ngoài?

Không phải testcase nào cũng cần gọi service thật.

Với một số automated test chạy thường xuyên, có thể dùng mock hoặc stub cho những dependency bên ngoài.

Các testcase thật sự cần kiểm tra toàn bộ flow tích hợp với payment gateway có thể được tách thành một nhóm riêng.

Selector không ổn định

Ví dụ:

cy.get('.container > div:nth-child(3) > button')

Selector này phụ thuộc rất nhiều vào cấu trúc HTML.

Chỉ cần developer thêm một <div> mới hoặc thay đổi layout, testcase có thể fail dù chức năng chọn lớp hoặc thanh toán vẫn hoạt động bình thường.

Một cách ổn định hơn:

cy.get('[data-testid="select-class-button"]')

hoặc:

cy.get('[data-testid="pay-now"]')

Selector càng gắn với ý nghĩa của element, testcase càng ít bị ảnh hưởng khi giao diện thay đổi.

Test phụ thuộc vào thứ tự chạy

Một automated testcase tốt nên có khả năng chạy độc lập.

Ví dụ:

Test A → Parent đăng ký tài khoản
Test B → Thêm child
Test C → Đăng ký lớp học

Nhưng nếu Test B chỉ hoạt động khi Test A đã chạy trước, hoặc Test C chỉ hoạt động khi Test B đã tạo child, thì các testcase đang phụ thuộc vào nhau.

Nếu chạy theo thứ tự:

A → B → C

mọi thứ có vẻ ổn.

Nhưng nếu Tester chỉ chạy Test C, testcase có thể fail ngay.

Nguyên tắc nên hướng tới là:

Một testcase không nên cần testcase khác chạy trước để tạo điều kiện cho mình.

Nếu Test C cần một Parent và child tồn tại, testcase đó nên tự chuẩn bị dữ liệu cần thiết trước khi chạy.


Vì sao cy.wait(5000) thường không phải giải pháp?

Đây là một cách sửa rất dễ xuất hiện khi bạn gặp timing issue.

Ví dụ sau khi Parent bấm Pay Now, hệ thống cần một khoảng thời gian để xử lý payment.

Giải pháp nhanh:

cy.wait(5000)

Sau đó testcase pass.

Có vẻ vấn đề đã được giải quyết.

Nhưng thực tế, bạn chỉ đang nói với Cypress:

“Đợi 5 giây rồi chạy bước tiếp theo.”

Chúng ta không thực sự biết payment đã xử lý xong hay chưa.

Nếu payment hoàn tất sau 500ms:

Payment hoàn tất sau 500ms
→ Test vẫn phải chờ đủ 5 giây

Nếu payment mất 7 giây:

Payment hoàn tất sau 7 giây
→ Test vẫn fail

Vậy tăng thành 10 giây?

Testcase có thể pass nhiều hơn, nhưng toàn bộ bộ test cũng chạy chậm hơn trong khi nguyên nhân thật vẫn chưa được giải quyết.

Chờ đúng điều kiện thay vì chờ một khoảng thời gian

Ví dụ frontend gọi API thanh toán:

cy.intercept('POST', '/api/payments').as('payment')

cy.get('[data-testid="pay-now"]').click()

cy.wait('@payment')
  .its('response.statusCode')
  .should('eq', 200)

cy.get('[data-testid="payment-success"]')
  .should('be.visible')

Điểm khác biệt nằm ở cách suy nghĩ.

Thay vì:

“Có lẽ 5 giây là đủ.”

Test đang nói:

“Chỉ tiếp tục khi request thanh toán đã hoàn tất thành công.”

Điều kiện có thể là:

  • Payment API đã trả response.

  • Trạng thái thanh toán đã thành COMPLETED.

  • Confirmation page đã hiển thị.

  • Lớp học vừa đăng ký đã xuất hiện trong danh sách.

  • Transaction record đã được tạo.

Cypress vốn có cơ chế tự retry cho nhiều command và assertion. Vì vậy, trong nhiều trường hợp, bạn nên tận dụng cơ chế này thay vì thêm thời gian chờ cố định.


Data isolation: Đừng để các testcase giẫm chân nhau

Giả sử bạn viết testcase đăng ký lớp học bằng một Parent account cố định:

const email = 'parent_test@gmail.com'

Nếu testcase được chạy nhiều lần, account đó có thể đã:

  • Có child từ lần chạy trước.

  • Đăng ký lớp đó rồi.

  • Có payment cũ.

  • Không còn ở trạng thái giống lần đầu.

Kết quả là testcase bắt đầu lúc pass, lúc fail.

Một hướng tốt hơn là tạo data riêng cho mỗi lần chạy.

Ví dụ:

const email = `parent_${Date.now()}@example.com`

Cách tương tự có thể áp dụng cho:

  • Parent.

  • Child.

  • Registration ID.

  • Payment reference.

  • Transaction ID.

  • Idempotency key.

Mục tiêu là giảm khả năng:

Testcase A thay đổi dữ liệu mà testcase B đang sử dụng.

Nếu hệ thống có API hỗ trợ tạo test data, bạn cũng có thể chuẩn bị dữ liệu trước khi testcase chạy thay vì tạo tất cả mọi thứ qua UI.

Ví dụ:

beforeEach(() => {
  cy.request('POST', '/api/test/parents', {
    email: `parent_${Date.now()}@example.com`
  })
})

Lợi ích là:

  • Mỗi testcase có dữ liệu riêng.

  • Setup nhanh hơn.

  • Ít phụ thuộc UI.

  • Testcase dễ chạy độc lập hơn.


Khi flaky test xuất hiện, đừng vội đổ lỗi cho Cypress

Khi automation fail ngẫu nhiên, rất dễ kết luận:

“Cypress flaky.”

Nhưng Cypress chỉ là công cụ mà bạn đang dùng để chạy testcase.

Một testcase không ổn định có thể đến từ nhiều nơi:

  • Test code.

  • Application.

  • Test data.

  • Môi trường test.

  • API.

  • Network.

  • Payment service.

  • Third-party service.

Trước khi sửa, bạn nên xác định:

Flakiness thực sự nằm ở đâu?

Ví dụ:

Nếu payment API lúc trả 200, lúc trả 500, đây có thể là vấn đề của application hoặc môi trường.

Nếu API luôn trả 200 nhưng Cypress không thấy confirmation message, hãy kiểm tra timing hoặc selector.

Nếu test chỉ fail khi chạy cùng nhiều testcase khác, hãy kiểm tra shared data.

Nếu test chỉ fail ở môi trường test dùng chung, hãy kiểm tra tốc độ server và network.

Phân loại đúng nguyên nhân giúp tránh tình trạng sửa triệu chứng nhưng không giải quyết vấn đề thật.


Quarantine: Giải pháp tạm thời, không phải nơi bỏ quên testcase

Có những trường hợp flaky test xuất hiện nhưng bạn chưa thể sửa ngay.

Nếu testcase liên tục làm kết quả chạy automation bị fail, team có thể tạm thời đưa testcase đó ra khỏi bộ test chính.

Cách này thường được gọi là quarantine.

Quarantine không phải lúc nào cũng xấu.

Trong một số trường hợp, nó giúp giữ cho kết quả chạy test của team dễ đọc hơn trong lúc bạn điều tra nguyên nhân.

Vấn đề bắt đầu khi cách xử lý trở thành:

“Bỏ testcase này ra trước rồi tính sau.”

Và cuối cùng không ai quay lại xử lý nữa.

Một flaky testcase được quarantine nên có ít nhất:

  • Bạn hoặc owner chịu trách nhiệm.

  • Bug hoặc task để tracking.

  • Evidence của các lần fail.

  • Nguyên nhân nếu đã xác định được.

  • Deadline hoặc mức ưu tiên xử lý.

Quarantine nên là nơi testcase chờ được sửa, không phải nơi testcase bị bỏ quên.


Đừng chỉ đếm số automated testcase, hãy theo dõi test nào thường xuyên fail ngẫu nhiên

Một team có thể có 2.000 automated testcases.

Nhưng nếu mỗi lần bạn chạy đều có vài chục testcase fail không ổn định, con số 2.000 không còn nhiều ý nghĩa.

Ngoài pass rate, bạn có thể theo dõi thêm một số thông tin đơn giản.

Fail rồi pass sau khi chạy lại

Ví dụ:

Lần đầu → FAIL
Chạy lại → PASS

Nếu hành vi này thường xuyên lặp lại với cùng một testcase, đó là ứng viên rõ ràng để điều tra.

Danh sách testcase flaky nhiều nhất

Không nhất thiết phải sửa tất cả cùng lúc.

Có thể ưu tiên 5–10 testcase gây ra nhiều lần fail nhất.

Flaky theo môi trường

Ví dụ:

Chrome → ổn định
Firefox → flaky

Máy Tester → ổn định
Test environment → flaky

Pattern này giúp thu hẹp phạm vi điều tra.

Thời gian xử lý flaky test

Một flaky testcase thường tồn tại bao lâu trước khi được sửa?

Nếu nhiều testcase bị bỏ riêng ra trong nhiều tháng, đây có thể là dấu hiệu việc maintain automation chưa được ưu tiên đúng mức.

Một testcase flaky 20% số lần chạy thường đáng được chú ý hơn nhiều testcase chỉ fail cực kỳ hiếm.


Checklist xử lý một Cypress testcase lúc pass lúc fail

Khi gặp flaky test, bạn có thể đi theo quy trình sau:

1. Chạy lại testcase nhiều lần

Xác nhận testcase có thực sự flaky hay chỉ là một lỗi xảy ra một lần.

Run 1 → PASS
Run 2 → FAIL
Run 3 → PASS
Run 4 → FAIL

2. So sánh chạy riêng và chạy cùng toàn bộ bộ test

Nếu chạy riêng ổn định nhưng chạy cùng các testcase khác lại fail, hãy ưu tiên kiểm tra:

  • Shared data.

  • Test ordering.

  • State còn lại từ testcase trước.

  • Các testcase chạy cùng lúc.

3. Kiểm tra timing

Hỏi:

  • Testcase có đang dùng cy.wait() với thời gian cố định không?

  • Có API async nào chưa hoàn tất không?

  • Payment đã xử lý xong chưa?

  • Confirmation page đã thực sự ready chưa?

4. Kiểm tra selector

Tránh selector phụ thuộc quá nhiều vào layout.

Ưu tiên những selector ổn định như:

[data-testid="..."]

5. Thu thập evidence

Khi testcase fail, nên giữ lại:

  • Screenshot.

  • Video.

  • Log.

  • API request và response.

  • Error message.

  • Test data đang sử dụng.

  • Payment reference nếu có.

Một dòng như:

Expected payment success message to be visible

thường chưa đủ để tìm nguyên nhân.

6. Tìm nguồn gây ra sự không ổn định

Kiểm tra xem testcase có đang phụ thuộc vào:

  • Timing.

  • Data.

  • Environment.

  • Payment service.

  • External service.

  • Một testcase khác.

  • Random value.

7. Fix nguyên nhân thật

Mục tiêu không phải chỉ là:

“Làm cho testcase pass.”

Mục tiêu nên là:

Làm cho testcase ổn định.

Hai điều này không hoàn toàn giống nhau.

8. Chạy lặp lại sau khi sửa

Đừng chạy một lần thấy pass rồi đóng bug.

Tùy mức độ quan trọng của testcase, bạn có thể chạy:

20 lần
50 lần
100 lần

Nếu testcase ổn định qua nhiều lần chạy, chúng ta mới có thêm cơ sở để tin rằng nguyên nhân đã thực sự được xử lý.


Solution: Xây Cypress testcase ổn định ngay từ đầu

Sửa flaky test là cần thiết. Nhưng tốt hơn nữa là hạn chế tạo ra flaky test ngay từ lúc bạnviết automation.

Có một số nguyên tắc đơn giản nên ưu tiên.

Chờ condition, không chờ thời gian

Hạn chế:

cy.wait(5000)

Thay vào đó, hãy chờ đúng điều kiện mà testcase cần:

  • Payment API hoàn tất.

  • Element xuất hiện.

  • Trạng thái payment thay đổi.

  • Registration được tạo.

  • Thông tin thanh toán xuất hiện trên confirmation page.

Mỗi testcase nên có dữ liệu riêng

Khi có thể, hãy tạo unique:

  • Parent account.

  • Child.

  • Registration.

  • Payment reference.

  • Transaction ID.

Điều này đặc biệt quan trọng khi nhiều testcase có thể chạy cùng nhau.

Testcase phải có khả năng chạy độc lập

Bạn nên tự hỏi:

“Nếu tôi chỉ chạy đúng testcase này trên một môi trường sạch, nó có pass không?”

Nếu câu trả lời là “không”, testcase đang có dependency cần được xem lại.

Dùng selector ổn định

Nếu có thể, thống nhất với Developer về attribute dành riêng cho automation, ví dụ:

data-testid="pay-now"

hoặc:

data-testid="select-class-button"

Điều này giúp testcase ít bị ảnh hưởng bởi những thay đổi chỉ liên quan đến giao diện.

Kiểm soát external dependency

Không phải testcase nào cũng cần gọi payment service thật.

Tùy mục tiêu, có thể sử dụng:

  • Mock.

  • Stub.

  • Test environment riêng.

  • Payment sandbox ổn định.

Treat flaky test như một bug

Đừng coi câu:

“Test này lâu lâu fail.”

là trạng thái bình thường.

Nếu một testcase không đáng tin, đó là một vấn đề chất lượng của test suite.

Quy trình nên là:

Detect → Track → Investigate → Fix → Verify

Takeaway

Một automated testcase tốt không phải là testcase pass nhiều lần nhất.

Một automated testcase tốt là testcase cho bạn một kết quả đáng tin.

Nếu cùng một đoạn code, cùng một điều kiện nhưng Cypress lúc báo pass, lúc báo fail, mục tiêu không nên là thêm vài giây wait để testcase xanh trở lại.

Câu hỏi quan trọng hơn là:

Điều gì đang khiến kết quả của testcase không ổn định?

Có thể là timing.

Có thể là shared data.

Có thể là selector.

Có thể là môi trường test.

Có thể là payment service.

Cũng có thể chính application đang có một lỗi chỉ xuất hiện trong một số thời điểm mà automation vô tình phát hiện ra.

Flaky test vì vậy không chỉ là chuyện “automation hơi khó chịu”.

Nó ảnh hưởng trực tiếp đến khả năng bạn phân biệt giữa bug thật và lỗi giả do testcase không ổn định.

Khi một testcase fail, bạn nên có đủ niềm tin để nghĩ:

“Có thứ gì đó cần được kiểm tra.”

Thay vì:

“Chạy lại thử xem, chắc lần sau sẽ pass.”

Đó mới là lúc automation thực sự tạo ra giá trị.

Đăng nhận xét

Mới hơn Cũ hơn