Mở đầu — vì sao bài này quan trọng
Nếu bạn đã theo học đến Bài 9 và viết được test login flow đầu tiên bằng Appium, chắc chắn bạn đã gặp một hiện tượng khó chịu: cùng một test, chạy lần thứ nhất PASS, chạy lần thứ hai FAIL, chạy lần thứ ba lại PASS. Không sửa gì cả nhưng kết quả cứ nhảy loạn xạ. Đó chính là flaky test — con quái vật ám ảnh mọi kỹ sư QA tự động hóa mobile.
Trong thế giới web, flaky test đã khó chịu rồi. Nhưng trên mobile, mọi thứ tệ hơn gấp bội. Vì sao? Vì giữa dòng test của bạn và ứng dụng thật sự có tới ba, bốn lớp trung gian: script Appium → Appium server → driver (UiAutomator2 hoặc XCUITest) → hệ điều hành → ứng dụng. Mỗi lớp đều có độ trễ riêng. Thêm vào đó, thiết bị thật có thể lag do CPU nóng, animation chưa chạy xong, mạng chậm, hay đơn giản là con điện thoại đang bận cập nhật ngầm. Kết quả là element mà bạn tìm "đáng lẽ phải có" lại chưa kịp xuất hiện tại thời điểm dòng code chạy tới.
Nghệ thuật chống flaky test nằm gần như trọn vẹn ở một chủ đề: wait và đồng bộ (sync). Nắm vững wait strategies không chỉ giúp test của bạn ổn định, mà còn quyết định việc bộ test tự động của bạn có được cả team tin dùng hay không. Một bộ test flaky 20% là bộ test không ai dám tin — người ta sẽ re-run cho tới khi xanh, và như thế nó mất hoàn toàn giá trị. Bài này sẽ giúp bạn xây nền tảng để test của mình xanh một cách trung thực.
Khái niệm cốt lõi
Vì sao cần wait: bất đồng bộ giữa test và app
Test của bạn chạy tuần tự, dòng nào xong thì sang dòng kế tiếp — cực kỳ nhanh, tính bằng mili giây. Nhưng ứng dụng mobile thì không phản hồi tức thì. Khi bạn bấm nút "Đăng nhập", app phải gọi API, chờ server trả về, cập nhật UI, chạy animation chuyển màn hình... quá trình này có thể mất từ vài trăm mili giây đến vài giây. Nếu dòng code kế tiếp của bạn tìm element trên màn hình mới ngay lập tức, nó sẽ báo lỗi NoSuchElementException vì màn hình đó chưa render xong.
Wait là cách bạn nói với Appium: "Đừng vội, hãy chờ điều kiện này đúng đã rồi mới đi tiếp." Có ba loại wait chính, và hiểu rõ khác biệt giữa chúng là chìa khóa.
1. Implicit wait — chờ ngầm định
Implicit wait là thiết lập toàn cục (global). Bạn khai báo một lần, và từ đó về sau mọi lệnh findElement sẽ tự động retry trong khoảng thời gian N giây trước khi chịu ném lỗi. Ví dụ với client Python:
driver.implicitly_wait(10) # đơn vị: giây
Với dòng này, khi bạn gọi driver.find_element(...) mà element chưa xuất hiện, Appium sẽ âm thầm thử lại liên tục trong tối đa 10 giây. Nếu element xuất hiện ở giây thứ 3, nó trả về ngay lập tức. Nếu hết 10 giây vẫn không có, nó mới ném NoSuchElementException.
Nghe rất tiện, nhưng implicit wait có những cạm bẫy nghiêm trọng:
- Nó chỉ chờ element tồn tại trong cây UI (present), không chờ element hiển thị được (visible), không chờ element bấm được (clickable). Một element có thể present nhưng bị che, mờ, hoặc animation chưa xong.
- Nó áp dụng cho tất cả lệnh find, kể cả những chỗ bạn cố tình muốn kiểm tra element KHÔNG tồn tại. Lúc đó bạn phải chờ đủ 10 giây một cách vô nghĩa mới biết element không có — làm test chậm đi.
- Nó dễ xung đột với explicit wait nếu dùng lẫn lộn, tạo ra thời gian chờ cộng dồn khó lường.
2. Explicit wait — chờ tường minh một điều kiện cụ thể
Explicit wait là công cụ mạnh nhất và nên là lựa chọn mặc định của bạn. Thay vì chờ chung chung, bạn chờ một điều kiện cụ thể trên một element cụ thể: chờ nó visible, chờ nó clickable, chờ text của nó thay đổi, chờ nó biến mất...
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from appium.webdriver.common.appiumby import AppiumBy
wait = WebDriverWait(driver, 15) # chờ tối đa 15 giây
login_btn = wait.until(
EC.element_to_be_clickable((AppiumBy.ACCESSIBILITY_ID, "btn_login"))
)
login_btn.click()
WebDriverWait sẽ poll (thăm dò) điều kiện đó theo chu kỳ (mặc định 500ms) cho đến khi điều kiện đúng hoặc hết timeout. Ưu điểm là bạn diễn đạt được chính xác ý định: "chờ nút login BẤM ĐƯỢC" khác hoàn toàn với "chờ nút login TỒN TẠI". Những expected_conditions hay dùng nhất:
presence_of_element_located— element có mặt trong cây UI.visibility_of_element_located— element hiển thị (visible, kích thước > 0).element_to_be_clickable— element vừa visible vừa enabled.invisibility_of_element_located— element biến mất (rất hữu ích để chờ spinner loading tắt đi).text_to_be_present_in_element— chờ nội dung text xuất hiện.
3. Fluent wait — explicit wait có tùy biến sâu
Fluent wait là biến thể của explicit wait cho phép bạn tinh chỉnh chu kỳ poll và bỏ qua (ignore) một số exception nhất định. Ở Java rõ nhất:
Wait<AppiumDriver> wait = new FluentWait<>(driver)
.withTimeout(Duration.ofSeconds(20))
.pollingEvery(Duration.ofMillis(300))
.ignoring(StaleElementReferenceException.class);
Fluent wait hữu ích khi UI có phần tử chập chờn (ví dụ danh sách đang được vẽ lại liên tục) khiến element bị "stale". Bạn bảo Appium bỏ qua lỗi stale và cứ tiếp tục thử.
Nguyên tắc vàng: KHÔNG BAO GIỜ dùng sleep cứng
Cạm bẫy lớn nhất của người mới là dùng time.sleep(5). Nó "hoạt động" nên rất cám dỗ. Nhưng nó là thuốc độc:
- Nếu màn hình load trong 1 giây, bạn vẫn phí 4 giây. Nhân với hàng trăm bước, bộ test của bạn chậm đến mức không ai đợi nổi.
- Nếu vào ngày mạng chậm màn hình load mất 6 giây,
sleep(5)vẫn fail. Bạn lại tăng lênsleep(8), rồisleep(10)... một vòng xoáy vô tận.
Tình huống thực tế
Tình huống 1 — Ví MoMo và cái spinner "vô hình"
Một team QA tại một ví điện tử lớn ở TP.HCM (gọi là ví X cho gần với MoMo) tự động hóa luồng nạp tiền. Test của họ: mở app → chọn "Nạp tiền" → nhập số tiền → bấm "Xác nhận" → kiểm tra màn hình "Nạp thành công". Bộ test này flaky khoảng 30%, khiến pipeline CI của họ đêm nào cũng đỏ vài lần.
Điều tra kỹ, họ phát hiện: sau khi bấm "Xác nhận", app hiển thị một spinner loading trong lúc gọi cổng thanh toán. Test dùng implicit wait 10 giây và tìm ngay text "Nạp thành công". Vấn đề là màn hình kết quả có sẵn một TextView với id txt_result được render trước cả khi API trả về — ban đầu nó rỗng, sau đó mới được điền text. Implicit wait thấy txt_result present ngay lập tức nên trả về, test đọc text rỗng và assert fail.
Bài học: implicit wait chỉ chờ present, không chờ nội dung đúng. Họ thay bằng explicit wait với text_to_be_present_in_element chờ đúng chữ "Nạp thành công", đồng thời thêm một bước invisibility_of_element_located chờ spinner biến mất. Tỉ lệ flaky tụt từ 30% xuống dưới 2%.
Tình huống 2 — Shopee-like app và animation chuyển tab
Một công ty thương mại điện tử ở Hà Nội tự động test app mua sắm. Test bấm vào tab "Giỏ hàng" ở bottom navigation rồi kiểm tra danh sách sản phẩm. Trên máy Samsung đời cao chạy ngon, nhưng trên con Oppo A15 giá rẻ trong device farm thì fail liên tục.
Nguyên nhân: khi chuyển tab, app chạy một animation trượt kéo dài ~350ms. Trên máy mạnh animation gần như tức thì; trên máy yếu nó chậm và giật. Test bấm tab xong tìm sản phẩm ngay, nhưng ở máy yếu, tại thời điểm đó danh sách vẫn đang trong quá trình animate — element "present" nhưng tọa độ chưa ổn định, click() bấm trượt ra ngoài.
Bài học: khác biệt phần cứng khiến cùng một test hành xử khác nhau — đây là lý do flaky test hay "chỉ xảy ra trên CI". Họ giải quyết bằng explicit wait element_to_be_clickable cho item đầu tiên trong danh sách (đảm bảo element ổn định và bấm được), thay vì presence. Ngoài ra họ tắt animation ở cấp hệ thống bằng lệnh adb shell settings put global window_animation_scale 0 trước khi chạy test — một mẹo cực mạnh giảm flaky trên Android.
Tình huống 3 — Ngân hàng số và bài học "đừng trộn wait"
Một team QA ở một ngân hàng số Đông Nam Á kế thừa một bộ test cũ. Test rất chậm — mỗi ca chạy mất gần 2 tiếng dù chỉ có 80 test case. Khi rà lại, họ phát hiện code vừa set implicitly_wait(15) toàn cục, vừa dùng WebDriverWait(driver, 20) ở khắp nơi.
Hậu quả: mỗi lần explicit wait poll một element chưa có, bản thân lệnh find bên trong nó lại bị implicit wait "giữ" tới 15 giây trước khi trả về để explicit wait poll lần kế tiếp. Thời gian chờ bị nhân lên khủng khiếp, và ở những chỗ chờ element biến mất thì càng thảm họa.
Bài học: đừng bao giờ trộn implicit và explicit wait. Chọn một chiến lược. Khuyến nghị của hầu hết chuyên gia: đặt implicit wait = 0 và dùng explicit wait ở mọi nơi. Sau khi họ gỡ implicit wait, thời gian chạy giảm từ 2 tiếng xuống còn 40 phút mà không đổi logic nào khác.
Hướng dẫn từng bước
Đây là quy trình chuẩn để xử lý wait một cách bài bản trong dự án Appium của bạn:
Bước 1 — Vô hiệu hóa implicit wait. Ngay khi khởi tạo driver, đặt driver.implicitly_wait(0). Cam kết dùng explicit wait cho mọi thứ. Điều này loại bỏ nguồn xung đột lớn nhất.
Bước 2 — Tạo một helper wait dùng chung. Đừng viết WebDriverWait(...) lặp lại khắp nơi. Gói nó vào hàm tiện ích:
def wait_visible(driver, locator, timeout=15):
return WebDriverWait(driver, timeout).until(
EC.visibility_of_element_located(locator)
)
def wait_clickable(driver, locator, timeout=15):
return WebDriverWait(driver, timeout).until(
EC.element_to_be_clickable(locator)
)
Nhờ vậy khi cần đổi timeout mặc định hay thêm log, bạn chỉ sửa một chỗ.
Bước 3 — Chọn đúng điều kiện cho từng thao tác. Trước khi click, dùng wait_clickable. Trước khi đọc/assert text, dùng wait_visible. Sau khi submit form, chờ spinner invisibility rồi mới kiểm tra kết quả. Ánh xạ hành động → điều kiện là kỹ năng quan trọng nhất.
Bước 4 — Đặt timeout hợp lý theo bối cảnh. Element UI thông thường: 10–15 giây. Thao tác chờ mạng (gọi API, đăng nhập): 20–30 giây. Đừng đặt 60 giây "cho chắc" — timeout dài che giấu bug thật và làm test fail chậm.
Bước 5 — Tắt animation ở cấp thiết bị (Android). Trong bước setup, chạy:
adb shell settings put global window_animation_scale 0
adb shell settings put global transition_animation_scale 0
adb shell settings put global animator_duration_scale 0
Điều này loại bỏ một nguồn flaky khổng lồ mà không cần thay đổi test.
Bước 6 — Thêm cơ chế retry ở cấp test (không phải cấp bước). Với những test vẫn thi thoảng flaky do yếu tố ngoài tầm kiểm soát (mạng thật), dùng plugin như pytest-rerunfailures để retry cả test 1–2 lần. Nhưng nhớ: retry là lưới an toàn cuối cùng, không phải cách sửa flaky. Nếu một test cần retry thường xuyên, hãy điều tra nguyên nhân gốc.
Bước 7 — Log lại mỗi lần wait timeout. Khi một wait fail, ghi lại element nào, chờ điều kiện gì, ảnh chụp màn hình tại thời điểm đó. Dữ liệu này giúp bạn phân biệt "app thật sự chậm" với "locator sai".
Lỗi thường gặp & mẹo
- Trộn implicit và explicit wait. Đã phân tích ở Tình huống 3. Nguyên tắc: chỉ dùng một, ưu tiên explicit với implicit = 0.
- Dùng
presencekhi cầnvisibilityhoặcclickable. Element present không có nghĩa là dùng được. Đây là nguyên nhân số một của flaky click. Luôn hỏi: "Thao tác kế tiếp của mình cần element ở trạng thái nào?"
StaleElementReferenceException. Xảy ra khi bạn giữ tham chiếu tới một element rồi UI vẽ lại, tham chiếu cũ trở nên "hết hạn". Mẹo: đừng lưu element vào biến rồi dùng lại sau nhiều thao tác. Hãy find lại element ngay trước khi tương tác, hoặc dùng fluent wait ignore stale.
- Timeout quá dài để "giấu" bug. Nếu bạn phải để timeout 40 giây thì thường app đang có vấn đề hiệu năng thật, hoặc locator của bạn sai. Timeout dài chỉ làm CI chậm và che vấn đề.
- Chờ nhầm điều kiện phủ định. Muốn khẳng định element KHÔNG xuất hiện? Đừng chờ
presencerồi bắt exception (chậm và mơ hồ). Dùnginvisibility_of_element_locatedvới timeout ngắn, hoặc kiểm tra danh sáchfind_elementsrỗng.
- Mẹo chờ theo trạng thái thay vì thời gian. Triết lý cốt lõi: đừng bao giờ hỏi "bao lâu là đủ", hãy hỏi "điều kiện nào cho biết đã sẵn sàng". Chuyển tư duy từ chờ thời gian sang chờ trạng thái là bước trưởng thành lớn nhất của một QA automation.
- Mẹo với WebView/hybrid app. Trong context WebView, wait của native đôi khi không đủ vì nội dung web load riêng. Bạn cần chờ thêm điều kiện trong context web. (Chủ đề này sẽ được đào sâu ở bài về Webview & Hybrid App Testing.)
Bài tập thực hành
- Chuyển hóa sleep. Lấy một test cũ của bạn (hoặc test login ở Bài 9) có dùng
time.sleep(). Thay từngsleepbằng explicit wait với điều kiện phù hợp. Đo thời gian chạy trước và sau — ghi lại con số bạn tiết kiệm được.
- Đo tác động của implicit wait xung đột. Viết một test đơn giản, đầu tiên set
implicitly_wait(10)kết hợp mộtWebDriverWaitchờ element không tồn tại (timeout 10s). Ghi lại thời gian test fail. Sau đó đặtimplicitly_wait(0)và chạy lại. So sánh — bạn sẽ tận mắt thấy hiệu ứng cộng dồn.
- Chờ spinner biến mất. Chọn một luồng trong app có màn hình loading (đăng nhập, tải danh sách). Viết wait dùng
invisibility_of_element_locatedđể chờ spinner tắt trước khi kiểm tra kết quả. Xác nhận test không còn đọc nhầm trạng thái trung gian.
- Săn flaky. Chạy một test bất kỳ 20 lần liên tiếp bằng vòng lặp shell. Đếm số lần fail. Nếu > 0, điều tra: đó là do wait sai điều kiện, animation, hay stale element? Sửa và chạy lại 20 lần cho tới khi xanh 20/20.
- Tắt animation. Thêm ba lệnh
adb shell settings put global ...animation_scale 0vào bước setup và chạy lại bài tập 4. Ghi nhận flaky có giảm không.
Tóm tắt
Wait và đồng bộ là trái tim của việc chống flaky test trong Appium. Hãy nhớ những điểm cốt lõi: có ba loại wait — implicit (chờ ngầm toàn cục, chỉ chờ present, nhiều cạm bẫy), explicit (chờ tường minh một điều kiện cụ thể, lựa chọn mặc định của bạn), và fluent (explicit có tùy biến poll và ignore exception). Nguyên tắc bất di bất dịch: không bao giờ dùng sleep cứng, và không trộn implicit với explicit wait — hãy đặt implicit = 0 rồi dùng explicit ở mọi nơi.
Chìa khóa để chọn đúng là ánh xạ hành động với trạng thái element: chờ clickable trước khi bấm, chờ visible trước khi đọc text, chờ invisible để biết spinner đã tắt. Ba tình huống thực tế — ví điện tử với spinner vô hình, app thương mại điện tử với animation chuyển tab, và ngân hàng số trộn wait — cho thấy phần lớn flaky test đến từ việc chờ sai điều kiện, khác biệt phần cứng, và xung đột wait. Cộng thêm mẹo tắt animation cấp hệ thống và log lại mọi timeout, bạn đã có đủ vũ khí để biến bộ test chập chờn thành bộ test xanh một cách trung thực — thứ mà cả team mới dám tin dùng.