Product Management
Đăng nhập
ESC

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

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

Bài 11 — XCUITest — Native iOS Testing

Đã đọc 0/14 mục · còn khoảng 12 phút
Cách chúng tôi ghi nhận

Một mục được tính là đã đọc khi nó nằm giữa màn hình đủ lâu so với độ dài của nó (bảng và sơ đồ tính một nửa), bạn vẫn đang cuộn hoặc bấm phím, và bạn đã thấy tới cuối mục. Lướt nhanh hoặc để tab mở không được tính. Chúng tôi chỉ lưu thời gian đọc từng mục — không lưu gì khác.

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

Nếu bạn đã theo dõi từ Bài 7 đến Bài 10, bạn đã dành khá nhiều thời gian với Appium — một framework cross-platform mạnh mẽ, chạy được cả iOS lẫn Android bằng cùng một bộ test. Đó là lựa chọn tuyệt vời khi đội của bạn cần một codebase test duy nhất. Nhưng sẽ có lúc bạn chạm trần: test iOS chạy chậm, hay flaky một cách khó hiểu, và mỗi khi Apple ra iOS mới thì bạn lại phải chờ Appium/WebDriverAgent cập nhật theo. Đó chính là lúc bạn cần biết đến XCUITest.

XCUITest là framework kiểm thử UI native của chính Apple, tích hợp thẳng vào Xcode. Nó không phải đối thủ để thay thế hoàn toàn Appium, mà là một công cụ bạn nên có trong túi đồ nghề — đặc biệt khi làm việc với những app iOS thuần, đội ngũ có lập trình viên iOS, hoặc khi tốc độ và độ ổn định của test suite trở thành nút thắt cổ chai.

Trong bài này chúng ta sẽ tập trung riêng vào XCUITest: nó là gì, kiến trúc chạy ra sao, cấu trúc một test viết bằng Swift trông thế nào, khi nào nên chọn nó thay vì Appium, và những cạm bẫy thực tế mà một QA Việt Nam hay gặp khi lần đầu chạm vào hệ sinh thái Apple. Những chủ đề như locator strategy chi tiết của Appium (Bài 8) hay so sánh Espresso cho Android (Bài 12) sẽ được để riêng cho các bài tương ứng — ở đây chúng ta ở lại trong thế giới iOS native.

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

XCUITest là gì

XCUITest (đầy đủ là XCUITest framework, một phần của XCTest) là bộ công cụ do Apple phát hành để viết UI test tự động cho ứng dụng iOS, iPadOS, watchOS và tvOS. Bạn viết test bằng Swift hoặc Objective-C, ngay trong Xcode, và chúng chạy như một phần tự nhiên của quy trình build.

Điểm mấu chốt khiến XCUITest hấp dẫn: vì nó là công cụ "cây nhà lá vườn" của Apple, nó có quyền truy cập trực tiếp vào Accessibility layer của hệ điều hành — cùng lớp mà Appium phải đi vòng qua để tương tác. Kết quả là XCUITest thường chạy nhanh hơn Appium khoảng 3–5 lần cho cùng một kịch bản, vì không phải qua trung gian (middleman) là WebDriverAgent và HTTP server.

Kiến trúc — vì sao nó nhanh hơn Appium

Để hiểu vì sao nhanh, hãy so sánh đường đi của một lệnh "tap vào nút Login":

  • Với Appium: Test script (Python/Java/JS) → gửi HTTP request theo giao thức WebDriver → Appium Server → WebDriverAgent (một app phụ chạy trên thiết bị) → XCUITest API → app thật. Mỗi lệnh phải nhảy qua nhiều tầng, mỗi tầng thêm độ trễ vài chục đến vài trăm mili-giây.
  • Với XCUITest: Test code (Swift) được đóng gói thành một test bundle, chạy trong cùng process hoặc process song song ngay trên thiết bị, gọi thẳng XCUITest API → app thật. Không HTTP, không middleman.
XCUITest hoạt động theo mô hình hai tiến trình (two-process): một tiến trình chạy app đang được test (AUT — App Under Test), một tiến trình chạy test runner. Test runner điều khiển AUT thông qua accessibility. Điều quan trọng cần nhớ: XCUITest không cho phép bạn gọi trực tiếp vào code nội bộ của app (không truy cập biến, hàm private) — nó chỉ "nhìn" và "chạm" app từ bên ngoài như một người dùng thật, thông qua các phần tử accessibility. Đây vừa là giới hạn, vừa là triết lý: test phản ánh đúng trải nghiệm người dùng.

Cấu trúc một test XCUITest

Một test XCUITest điển hình dựa trên ba lớp đối tượng cốt lõi:

  • XCUIApplication — đại diện cho app bạn đang test. Bạn .launch() nó để khởi động.
  • XCUIElement — một phần tử UI đơn lẻ: nút, ô text, label, cell...
  • XCUIElementQuery — cách bạn tìm ra element, ví dụ app.buttons["Login"].
Đây là một test đăng nhập tối giản viết bằng Swift:

import XCTest

final class LoginUITests: XCTestCase {

    var app: XCUIApplication!

    override func setUpWithError() throws {
        continueAfterFailure = false
        app = XCUIApplication()
        app.launch()
    }

    func testLoginThanhCong() throws {
        // Nhập email
        let emailField = app.textFields["email_field"]
        XCTAssertTrue(emailField.waitForExistence(timeout: 5))
        emailField.tap()
        emailField.typeText("khang@vietnamcos.com")

        // Nhập mật khẩu
        let passwordField = app.secureTextFields["password_field"]
        passwordField.tap()
        passwordField.typeText("MatKhau123!")

        // Nhấn nút đăng nhập
        app.buttons["login_button"].tap()

        // Kiểm chứng: màn hình chính hiện ra
        let homeTitle = app.staticTexts["Trang chủ"]
        XCTAssertTrue(homeTitle.waitForExistence(timeout: 10),
                      "Không thấy màn hình chính sau khi đăng nhập")
    }
}

Vài điểm đáng chú ý trong đoạn code trên, vì chúng phản ánh triết lý làm việc của XCUITest:

  • setUpWithError() chạy trước mỗi hàm test — nơi khởi tạo và launch app sạch sẽ.
  • continueAfterFailure = false báo cho framework dừng ngay khi một assertion đầu tiên thất bại, tránh chuỗi lỗi giả sau đó.
  • waitForExistence(timeout:) là cơ chế chờ tường minh (explicit wait) built-in — bạn không cần cài thư viện ngoài. Ta sẽ nói kỹ hơn ở phần lỗi thường gặp.
  • "email_field", "login_button" là accessibility identifier do lập trình viên gán trong code app. Đây là "locator" bền vững nhất trong XCUITest — không đổi khi UI thay đổi màu sắc hay vị trí.

Khi nào chọn XCUITest thay vì Appium

Đây là quyết định kiến trúc, không phải chuyện "cái nào tốt hơn tuyệt đối":

  • Chọn XCUITest khi: app chỉ có iOS (hoặc iOS là ưu tiên số 1), đội có lập trình viên iOS sẵn sàng viết Swift, bạn cần test suite chạy nhanh và ổn định, bạn muốn tích hợp chặt với Xcode Cloud / CI của Apple, hoặc bạn cần chạy test ngay trong pipeline mà lập trình viên iOS đang dùng.
  • Vẫn ưu tiên Appium khi: bạn cần một bộ test dùng chung cho cả iOS và Android, đội QA viết Python/Java/JS và không rành Swift, hoặc bạn cần chạy test trên device farm (BrowserStack/Sauce Labs) với giao thức WebDriver chuẩn.
Nhiều đội trưởng thành cuối cùng dùng cả hai: XCUITest cho các luồng iOS quan trọng cần tốc độ, Appium cho lớp cross-platform mỏng hơn.

Tình huống thực tế

Ví dụ 1 — Ví điện tử ở TP.HCM cắt thời gian regression từ 40 phút xuống 9 phút

Một công ty fintech giả định tên PayViet tại Quận 1, TP.HCM, có app ví điện tử iOS với khoảng 120 test case tự động viết bằng Appium. Vấn đề: bộ regression chạy mất 40 phút trên CI, và tệ hơn, cứ mỗi lần Apple phát hành iOS beta mới trước mùa Tết (thời điểm giao dịch cao điểm), WebDriverAgent lại chưa tương thích, khiến toàn bộ suite đỏ lòm dù app hoàn toàn ổn.

Đội QA quyết định port 35 luồng quan trọng nhất (đăng nhập, chuyển tiền, quét QR thanh toán) sang XCUITest, phối hợp với 2 lập trình viên iOS để gán đầy đủ accessibility identifier. Kết quả sau 3 tuần: 35 luồng đó chạy trong 9 phút, nhanh hơn gần 4,5 lần. Quan trọng hơn, khi iOS 18 beta ra mắt, các test XCUITest chạy thẳng trên simulator iOS 18 mà không cần chờ bất kỳ bản vá bên thứ ba nào.

Bài học: XCUITest tỏa sáng nhất ở những luồng lõi, chạy thường xuyên, nơi mỗi giây tiết kiệm được nhân lên hàng trăm lần mỗi ngày. Bạn không cần port toàn bộ — hãy port phần "nóng" nhất.

Ví dụ 2 — App đọc báo ở Hà Nội và bài học về accessibility identifier

Một startup media giả định ở Hà Nội, BaoNhanh, thuê một QA fresher viết test XCUITest cho app đọc tin. Bạn QA này ban đầu định vị element bằng label hiển thị, ví dụ app.buttons["Đăng nhập"]. Test chạy ngon trên máy dev. Nhưng khi team bật localization để thử giao diện tiếng Anh (app.buttons["Login"]), toàn bộ test gãy. Tệ hơn, khi designer đổi chữ nút từ "Đăng nhập" thành "Đăng nhập ngay", lại gãy tiếp.

Team ngồi lại và thống nhất quy tắc: mọi element được test bắt buộc phải có accessibilityIdentifier cố định (ví dụ "btn_login"), tách biệt hoàn toàn khỏi text hiển thị. Identifier này không bao giờ dịch, không bao giờ đổi theo copywriting. Sau khi refactor, test suite bền vững qua cả hai ngôn ngữ và nhiều lần đổi nội dung nút.

Bài học: Trong XCUITest, accessibilityIdentifier là bạn thân nhất của QA. Đừng bao giờ định vị element bằng text người dùng nhìn thấy nếu app có localization hoặc content hay thay đổi. Đây là điểm QA phải chủ động đàm phán với lập trình viên ngay từ đầu dự án.

Ví dụ 3 — Grab-style super app và giới hạn "hộp đen"

Một đội QA làm cho một super app gọi xe kiểu Grab tại Đông Nam Á muốn viết test kiểm tra rằng sau khi tài xế nhận cuốc, một biến trạng thái nội bộ tripState chuyển sang ACCEPTED. Bạn QA quen với unit test nên định "đọc thẳng" biến đó. Nhưng XCUITest không cho phép — nó chỉ thao tác app từ bên ngoài qua accessibility, không truy cập được nội tại code.

Giải pháp đúng: thay vì kiểm biến, đội kiểm chứng hệ quả nhìn thấy được — nút "Bắt đầu chuyến đi" xuất hiện, thanh trạng thái đổi thành "Đang đón khách". Ngoài ra, lập trình viên thêm một cơ chế truyền dữ liệu qua launch arguments/environment (app.launchArguments = ["-uiTestingMode"]) để đặt app vào trạng thái mong muốn nhanh chóng mà không phải bấm qua 5 màn hình.

Bài học: XCUITest là kiểm thử hộp đen theo góc nhìn người dùng. Nếu bạn cần kiểm trạng thái nội bộ, hãy tìm biểu hiện UI tương ứng, hoặc dùng launch arguments để thiết lập tiền đề — đừng cố "chọc" vào ruột app.

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

Dưới đây là quy trình để có test XCUITest đầu tiên chạy được. Giả định bạn có máy macOS với Xcode đã cài (XCUITest chỉ chạy trên macOS — đây là ràng buộc bắt buộc).

  • Mở project trong Xcode và thêm UI Testing Target. Vào File → New → Target → UI Testing Bundle. Xcode tạo sẵn một thư mục test với một file mẫu. Nếu project đã có target này rồi thì bỏ qua.
  • Phối hợp gán accessibility identifier. Đây là bước QA hay quên. Cùng lập trình viên, đảm bảo mọi element cần test đều có element.accessibilityIdentifier = "ten_dinh_danh" trong code app (SwiftUI dùng .accessibilityIdentifier("...")). Không có bước này, test của bạn sẽ mong manh.
  • Dùng Recording để dò element nhanh. Đặt con trỏ trong một hàm test, bấm nút Record (hình tròn đỏ) ở dưới cửa sổ Xcode, rồi thao tác trên simulator. Xcode sinh code tương ứng. Lưu ý: code sinh ra chỉ để tham khảo — hãy dọn dẹp, thay locator mong manh bằng accessibility identifier, và thêm assertion thủ công.
  • Viết assertion tường minh. Đừng chỉ thao tác rồi kết thúc. Mỗi test phải có ít nhất một XCTAssert... kiểm chứng kết quả mong đợi (ví dụ màn hình đích xuất hiện). Test không có assertion là test giả.
  • Dùng waitForExistence thay vì sleep. Luôn chờ element bằng waitForExistence(timeout:), không bao giờ dùng thời gian chờ cứng. UI mobile bất định về thời gian tải.
  • Chạy test. Bấm Cmd + U để chạy toàn bộ, hoặc bấm hình thoi cạnh từng hàm test để chạy lẻ. Xem kết quả xanh/đỏ trong Test Navigator.
  • Chạy từ dòng lệnh để chuẩn bị cho CI. Dùng xcodebuild test -scheme YourScheme -destination 'platform=iOS Simulator,name=iPhone 15'. Đây là lệnh mà pipeline CI/CD sẽ gọi (chi tiết CI/CD mobile sẽ có ở Bài 48).

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

  • Định vị element bằng text hiển thị. Như ví dụ BaoNhanh, đây là lỗi số một. Luôn ưu tiên accessibilityIdentifier. Text người dùng nhìn thấy là thứ dễ thay đổi nhất trong app.
  • Dùng sleep(3) để chờ. Cực kỳ phổ biến ở người mới. Nó làm test vừa chậm vừa flaky: máy nhanh thì lãng phí, máy chậm hoặc mạng lag thì vẫn trượt. Luôn dùng waitForExistence(timeout:). Cơ chế flaky test và đồng bộ đã được nói kỹ ở Bài 10 với Appium — triết lý y hệt áp dụng ở đây.
  • Quên continueAfterFailure = false. Nếu để mặc định true, một lỗi giữa chừng khiến các assertion sau chạy trên trạng thái sai, sinh ra một đống lỗi giả gây rối khi đọc báo cáo.
  • Test phụ thuộc lẫn nhau. Mỗi hàm test phải độc lập, tự launch app sạch trong setUp. Đừng để test B chỉ chạy được khi test A đã đăng nhập trước. Thứ tự chạy test không được đảm bảo.
  • Nhầm loại field. app.textFields[...] không tìm được ô mật khẩu — ô mật khẩu là app.secureTextFields[...]. Đây là bẫy khiến nhiều bạn mất cả buổi.
  • Bàn phím che nút. Trên iOS, bàn phím ảo có thể che element cần tap, gây lỗi "not hittable". Mẹo: gọi bàn phím ẩn đi (tap ra ngoài, hoặc dismiss) trước khi tương tác element bên dưới.
  • Chỉ test được trên macOS. Nếu đội của bạn dùng Windows/Linux, đây là rào cản thật. Cân nhắc máy Mac dùng chung, Mac mini trên cloud, hoặc quay lại Appium chạy qua device farm.
  • Mẹo Page Object. Ngay cả trong Swift, hãy tổ chức test theo mô hình Page Object (một struct cho mỗi màn hình chứa các element và hành động). Điều này giúp bảo trì khi UI đổi — kiến trúc automation sẽ được đào sâu ở Bài 59.

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

  • Cài đặt và chạy. Trên một máy Mac có Xcode, tạo một app SwiftUI đơn giản (hoặc dùng project mẫu) có màn hình đăng nhập với 2 ô nhập và 1 nút. Thêm UI Testing Target và viết lại đúng đoạn testLoginThanhCong ở trên cho khớp identifier của bạn. Chạy Cmd + U và đạt màu xanh.
  • Gán identifier. Cố tình định vị nút bằng text (app.buttons["Đăng nhập"]), chạy test cho xanh, rồi đổi text nút trong app thành "Đăng nhập ngay". Quan sát test gãy. Sau đó chuyển sang accessibilityIdentifier và chứng minh test không còn gãy khi đổi text.
  • Thay sleep bằng wait. Viết một test cố tình dùng sleep(2) để chờ màn hình chính, sau đó viết lại bằng waitForExistence(timeout: 10). Đo và ghi lại thời gian chạy của cả hai — bạn sẽ thấy phiên bản dùng wait vừa nhanh hơn vừa ổn định hơn.
  • Viết mini báo cáo so sánh. Nếu đội bạn đang dùng Appium, chọn 1 luồng, viết nó bằng cả Appium và XCUITest, đo thời gian chạy mỗi bên 5 lần, và viết 1 đoạn ngắn khuyến nghị nên dùng cái nào cho luồng đó và vì sao.

Tóm tắt

XCUITest là framework UI testing native của Apple, viết bằng Swift, tích hợp thẳng vào Xcode. Vì gọi trực tiếp accessibility layer mà không qua middleman như Appium, nó thường chạy nhanh hơn 3–5 lần và ổn định hơn khi iOS cập nhật. Kiến trúc hai tiến trình của nó điều khiển app từ bên ngoài như người dùng thật — bạn không truy cập được nội tại code, nên hãy kiểm chứng qua biểu hiện UI và dùng launch arguments để thiết lập tiền đề.

Ba lớp cốt lõi cần nắm là XCUIApplication, XCUIElement và XCUIElementQuery, cùng cấu trúc setUp → thao tác → XCTAssert. Locator bền vững nhất là accessibilityIdentifier — không bao giờ định vị bằng text hiển thị nếu app có localization hoặc content hay đổi. Luôn dùng waitForExistence thay cho sleep, và giữ mỗi test độc lập.

Chọn XCUITest cho app iOS-first cần tốc độ và độ ổn định; giữ Appium cho nhu cầu cross-platform. Nhiều đội trưởng thành dùng cả hai. Ràng buộc lớn nhất cần nhớ: XCUITest chỉ chạy trên macOS. Sang Bài 12, chúng ta sẽ khám phá người anh em bên Android — Espresso — để thấy triết lý native testing lặp lại thế nào ở phía Google.

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