Product Management
Đăng nhập
ESC

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

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

Bài 59 — Mobile Test Automation Architecture

Đã đọc 0/14 mục · còn khoảng 11 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

Bạn đã đi qua gần trọn khóa học: viết được test case, dùng Appium, xử lý flaky test, test performance, accessibility, payment, deep link... Nhưng có một câu hỏi mà rất nhiều QA bỏ qua cho đến khi mọi thứ vỡ ra: những đoạn test đó được tổ chức như thế nào để một đội 5-10 người cùng viết, cùng chạy, cùng bảo trì trong hai, ba năm mà không biến thành một mớ hỗn độn không ai dám đụng vào?

Đó chính là câu chuyện của Test Automation Architecture — kiến trúc cho hệ thống kiểm thử tự động. Nó không phải là "viết thêm test cho giỏi", mà là "thiết kế bộ khung để những test đó sống được lâu dài".

Tôi đã chứng kiến quá nhiều đội ở Việt Nam rơi vào cùng một cái bẫy: sáu tháng đầu hào hứng viết được 300 test Appium, ai cũng khen. Đến tháng thứ mười, app đổi màn hình login, và họ phát hiện ra id của nút "Đăng nhập" nằm rải rác trong 47 file test khác nhau. Sửa xong thì flaky. Người viết test cũ nghỉ việc, người mới đọc code không hiểu gì. Cuối cùng team quyết định... xóa hết, viết lại từ đầu. Hàng nghìn giờ công đổ sông đổ biển — không phải vì họ viết test dở, mà vì họ không có kiến trúc.

Bài này dạy bạn cách tránh đúng cái bi kịch đó. Đây là bài mang tính "kiến trúc sư" — nó nâng bạn từ người viết test lên người thiết kế hệ thống test.

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

Kiến trúc test tự động là cách bạn phân tách trách nhiệm (separation of concerns) để mỗi phần thay đổi độc lập với phần khác. Nguyên tắc vàng: "Khi app thay đổi, chỉ một lớp trong hệ thống test cần sửa, không phải toàn bộ."

Kiến trúc phân lớp (Layered Framework)

Một framework test mobile trưởng thành được chia thành nhiều lớp, mỗi lớp chỉ nói chuyện với lớp ngay dưới nó:

┌─────────────────────────────────────┐
│  Test cases (Gherkin / JS / Kotlin)  │  ← Nghiệp vụ đọc được
├─────────────────────────────────────┤
│  Business/Workflow layer             │  ← "Đăng nhập", "Thêm vào giỏ"
├─────────────────────────────────────┤
│  Page Objects / Screen Objects       │  ← Từng màn hình + hành động
├─────────────────────────────────────┤
│  Locators / Elements repository      │  ← Nơi DUY NHẤT chứa selector
├─────────────────────────────────────┤
│  Driver / Core wrapper (Appium API)  │  ← Bọc lệnh gốc, thêm wait/log
├─────────────────────────────────────┤
│  Config + Capabilities + Utilities   │  ← Thiết bị, môi trường, data
└─────────────────────────────────────┘

Hãy hiểu từng lớp qua ẩn dụ một tòa nhà:

  • Config & Driver layer là móng và hệ thống điện nước — không ai nhìn thấy nhưng mọi thứ dựa vào nó. Đây là nơi bạn khai báo capabilities (thiết bị iOS 17 hay Android 14), khởi tạo driver Appium, và bọc (wrap) các lệnh gốc để thêm log, retry, chụp ảnh khi lỗi.
  • Locators repository là nơi duy nhất chứa mọi selector. Nút "Đăng nhập" chỉ được định nghĩa một lần. Khi dev đổi accessibility id, bạn sửa đúng một dòng.
  • Page/Screen Objects mô tả từng màn hình: nó có những element gì, làm được những hành động gì. Ví dụ LoginScreen có method enterUsername(), tapLogin().
  • Business/Workflow layer ghép các hành động thành nghiệp vụ: loginAs(user) gọi ba màn hình liên tiếp.
  • Test case ở trên cùng chỉ nói ngôn ngữ nghiệp vụ, sạch sẽ, người không lập trình cũng đọc được.

Page Object Model (POM) — trái tim của kiến trúc

POM là mẫu thiết kế phổ biến nhất. Ý tưởng: mỗi màn hình app = một class. Class đó giấu đi mọi chi tiết kỹ thuật (selector, wait) và chỉ để lộ ra các hành động nghiệp vụ.

So sánh nhanh:

// KHÔNG có kiến trúc — selector trộn trong test
await driver.$('~btn_login').click();
await driver.$('//android.widget.EditText[@resource-id="user"]').setValue('an');

// CÓ POM — test sạch, chi tiết giấu trong Page Object
await loginScreen.login('an', 'matkhau123');

Khi selector btn_login đổi, ở cách thứ nhất bạn phải grep khắp dự án; ở cách thứ hai bạn sửa đúng một chỗ trong LoginScreen.

Nguyên tắc DRY và "một nguồn sự thật"

Toàn bộ triết lý gói gọn trong: mỗi thông tin chỉ tồn tại ở một nơi. Selector ở locator repository. Test data ở data provider. Cấu hình thiết bị ở config. Logic chờ đợi ở driver wrapper. Không sao chép, không lặp lại. Đó là điều phân biệt một bộ test "chạy được" với một bộ test "sống được".

Test Runner, Reporting và tách biệt môi trường

Kiến trúc còn bao gồm phần ngoài code test: test runner (WebdriverIO, TestNG, JUnit, pytest) điều phối chạy song song; reporter (Allure) tách khỏi logic test; và profile môi trường (dev/staging/prod) để cùng một bộ test chạy được trên nhiều backend chỉ bằng đổi biến môi trường.

Tình huống thực tế

Ví dụ 1 — Tiki và cái giá của "không có Page Object"

Một đội QA tại một sàn thương mại điện tử lớn ở TP.HCM (bối cảnh tương tự Tiki) bắt đầu tự động hóa app Android bằng Appium. Trong 4 tháng, họ viết 420 test theo kiểu "script tuyến tính" — mỗi test là một chuỗi lệnh driver.$(...).click() từ trên xuống dưới, selector nhúng thẳng vào.

App của họ có màn hình giỏ hàng xuất hiện trong 68 test khác nhau (vì hầu như luồng nào cũng đi qua giỏ hàng). Khi team frontend làm lại giỏ hàng, đổi resource-id của nút "Thanh toán" từ btn_checkout thành btn_pay_now, 68 test đỏ cùng lúc.

Một QA phải ngồi sửa tay ba ngày. Trong ba ngày đó, pipeline CI luôn đỏ, nên cả team mất niềm tin vào automation và bắt đầu... bỏ qua kết quả test. "Đằng nào cũng đỏ mà" — đó là dấu hiệu automation đã chết trên thực tế.

Bài học rút ra: nếu họ dùng POM, nút "Thanh toán" chỉ định nghĩa một lần trong CartScreen. Ba ngày sửa tay lẽ ra chỉ là một dòng và một lần commit. Chi phí không có kiến trúc không hiện ra lúc viết — nó hiện ra vào lúc app thay đổi, và app luôn thay đổi.

Ví dụ 2 — MoMo và kiến trúc đa nền tảng chung một lớp nghiệp vụ

Một ví bối cảnh giả định kiểu MoMo cần test cùng một luồng chuyển tiền trên cả iOS lẫn Android. Cách ngây thơ là viết hai bộ test riêng biệt — nhân đôi công sức, nhân đôi bug.

Đội của họ thiết kế thông minh hơn: lớp Business/Workflow (transferMoney(amount, receiver)) được viết một lần, dùng chung. Chỉ có lớp Page Object và Locators là tách theo nền tảng — TransferScreen.ios.js và TransferScreen.android.js — bởi vì selector iOS (-ios predicate string) khác Android (resource-id). Test runner chọn đúng file theo capability lúc chạy.

Kết quả: một luồng nghiệp vụ mới (ví dụ "chuyển tiền có ghi chú") chỉ cần viết logic một lần ở lớp business. Coverage cho cả hai nền tảng tăng gần gấp đôi mà công sức viết gần như không đổi. Khi có bug ở logic nghiệp vụ, sửa một chỗ, cả hai nền tảng cùng đúng.

Bài học rút ra: kiến trúc tốt cho phép bạn tách phần chung khỏi phần riêng theo nền tảng. Cái gì thuộc nghiệp vụ (giống nhau) đặt ở lớp trên; cái gì thuộc kỹ thuật nền tảng (khác nhau) đẩy xuống lớp dưới.

Ví dụ 3 — Startup fintech Đông Nam Á và bài toán "test chạy được ở mọi môi trường"

Một startup fintech ở Singapore có app chạy trên ba môi trường: dev, staging, production-like. Ban đầu QA hardcode URL API và tài khoản test ngay trong code test. Mỗi lần muốn chạy trên môi trường khác, họ sửa code rồi commit — cực kỳ rủi ro, có lần chạy nhầm test tạo dữ liệu rác lên production.

Họ tái cấu trúc: đưa toàn bộ config vào file .env và một ConfigManager. Chạy test giờ chỉ là ENV=staging npm run test. Test data lấy từ DataFactory sinh động (tạo user mới mỗi lần chạy qua API, không dùng account cứng). Capabilities thiết bị đọc từ file JSON riêng.

Sau tái cấu trúc, cùng một bộ test chạy được trên cả ba môi trường mà không đụng một dòng code test nào. Sự cố "chạy nhầm production" biến mất hoàn toàn vì môi trường được kiểm soát ở tầng config, không phải trong tay QA lúc viết.

Bài học rút ra: môi trường và dữ liệu phải tách khỏi logic test. Test không nên "biết" nó đang chạy ở đâu — nó chỉ thực thi hành vi, còn "ở đâu, với dữ liệu gì" là việc của lớp config.

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

Đây là lộ trình dựng một kiến trúc test mobile từ con số không:

Bước 1 — Dựng lớp Core/Driver wrapper. Tạo một class bọc quanh Appium driver. Đừng để test gọi thẳng driver.$(). Thay vào đó tạo BaseScreen với các method như tap(locator), type(locator, text) đã tích hợp sẵn explicit wait, log, và chụp ảnh khi lỗi. Sau này muốn thêm retry cho toàn hệ thống, bạn chỉ sửa một chỗ.

Bước 2 — Tạo Locators repository tách biệt. Với mỗi màn hình, gom selector vào một nơi (một object hoặc file riêng). Quy ước đặt tên rõ ràng: loginScreen.usernameField, loginScreen.loginButton. Đây là nguồn sự thật duy nhất về selector.

Bước 3 — Viết Page/Screen Objects. Mỗi màn hình một class kế thừa BaseScreen, dùng locator từ repository, để lộ hành động nghiệp vụ: enterUsername(), tapLogin(), isErrorShown(). Tuyệt đối không đưa assertion vào Page Object — page object chỉ hành động và truy vấn, assertion thuộc về test.

Bước 4 — Xây lớp Workflow/Business (tùy chọn nhưng nên có). Ghép các hành động lặp lại thành nghiệp vụ: AuthFlow.loginAs(user) gọi lần lượt các màn hình. Test không cần biết login gồm mấy bước.

Bước 5 — Tách Config và Test Data. Đưa capabilities, URL, credential vào file config/.env. Dùng data factory hoặc file fixture cho dữ liệu test. Không hardcode gì trong test.

Bước 6 — Cấu hình Test Runner + Reporter + tách biệt. Thiết lập runner chạy song song, gắn Allure report. Cấu trúc thư mục rõ: /screens, /locators, /flows, /tests, /config, /utils, /data.

Bước 7 — Viết test case đầu tiên chỉ bằng ngôn ngữ nghiệp vụ. Kiểm chứng: một người không rành Appium đọc test có hiểu nó làm gì không? Nếu có, kiến trúc của bạn đang đúng hướng.

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

  • Đưa selector thẳng vào test. Đây là lỗi kinh điển nhất. Selector phải nằm ở locator layer. Nếu bạn thấy driver.$('~...') trong file test, kiến trúc đang rò rỉ.
  • Nhồi assertion vào Page Object. Page Object trả về trạng thái (isDisplayed()), test mới phán xét (expect(...).toBe(true)). Trộn hai thứ khiến page object không tái sử dụng được.
  • Page Object khổng lồ (God Object). Một class 800 dòng cho cả app là dấu hiệu chia màn hình sai. Mỗi màn hình một class nhỏ gọn.
  • Over-engineering ngay từ đầu. Team 2 người, 30 test mà dựng 6 lớp trừu tượng là lãng phí. Bắt đầu với POM + config tách biệt; thêm lớp business/workflow khi nghiệp vụ lặp lại nhiều. Kiến trúc phải lớn theo nhu cầu.
  • Hardcode wait bằng sleep. Vi phạm trực tiếp bài "Waits & Flaky Test". Explicit wait phải nằm trong driver wrapper, không rải rác.
  • Mẹo — quy tắc "sửa một chỗ": trước khi merge, tự hỏi "nếu dev đổi màn hình này, tôi phải sửa bao nhiêu file?" Câu trả lời lý tưởng là một. Nếu nhiều hơn, kiến trúc đang có chỗ trùng lặp.
  • Mẹo — tách file theo nền tảng qua hậu tố (.ios, .android) chỉ ở lớp locator/page, giữ lớp business dùng chung. Đây là cách scale iOS + Android hiệu quả nhất.

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

  • Refactor một test "bẩn". Lấy một test Appium bạn đã viết ở các bài trước (ví dụ login flow bài 9) với selector nhúng thẳng. Tách nó thành: LoginScreen (page object) + login.locators.js (repository) + file test sạch chỉ gọi loginScreen.login(). So sánh độ dễ đọc trước và sau.
  • Đo "chi phí thay đổi". Với bộ test hiện tại, đếm xem selector nút chính (ví dụ nút submit) đang xuất hiện ở bao nhiêu file. Ghi lại con số. Sau khi refactor sang POM, đếm lại — bạn vừa chứng minh được giá trị của kiến trúc bằng con số cụ thể.
  • Thiết kế cây thư mục. Vẽ ra (trên giấy hoặc README) cấu trúc thư mục cho một dự án test mobile 200 test, hỗ trợ cả iOS lẫn Android, ba môi trường. Chỉ rõ file nào chứa selector, file nào chứa config, file nào chứa business flow.
  • Tách config. Lấy một test đang hardcode URL/credential, chuyển toàn bộ vào .env + ConfigManager, rồi chạy thử với hai giá trị ENV khác nhau mà không sửa code test.

Tóm tắt

Test Automation Architecture không phải là chuyện "viết test giỏi hơn" — nó là chuyện thiết kế để test sống lâu. Trái tim của nó là phân lớp và một nguồn sự thật duy nhất: selector ở locator layer, hành động ở page object, nghiệp vụ ở workflow layer, môi trường ở config. Khi app thay đổi — và nó luôn thay đổi — bạn chỉ sửa đúng một chỗ.

Ba bài học từ thực tế: đội Tiki trả giá vì trộn selector vào test; đội MoMo thắng lớn vì tách phần chung khỏi phần riêng theo nền tảng; startup fintech an toàn vì tách môi trường khỏi logic. Điểm chung: kiến trúc tốt biến thay đổi từ thảm họa thành một dòng commit.

Hãy nhớ quy tắc vàng để tự chấm điểm kiến trúc của mình: "Nếu dev đổi một màn hình, tôi phải sửa bao nhiêu file?" — câu trả lời càng gần một, kiến trúc của bạn càng trưởng thành. Đó là dấu hiệu bạn đã chuyển từ người viết test thành người kiến tạo hệ thống test.

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