Mở đầu — vì sao bài này quan trọng
Nếu bạn từng test một app viết bằng React Native (Bài 36), bạn đã biết một app "lai" (cross-platform) mang lại thách thức riêng cho QA: cùng một codebase nhưng chạy trên hai hệ điều hành, và những gì bạn thấy trên màn hình không nhất thiết là một widget "thật" của hệ điều hành. Flutter đẩy triết lý này đi xa hơn bất kỳ framework nào khác — và đó chính là lý do bài này quan trọng.
Flutter không dùng widget gốc (native) của iOS hay Android. Thay vào đó, nó tự vẽ toàn bộ giao diện lên màn hình bằng engine đồ họa Skia (nay là Impeller trên iOS). Nghĩa là một nút "Đăng nhập" bạn thấy trên app Flutter không phải là một UIButton của iOS hay một Button của Android — nó là một hình chữ nhật do Flutter vẽ ra và bên trong có chữ. Hệ quả trực tiếp với QA: Appium, XCUITest hay Espresso thường "không nhìn thấy" các phần tử Flutter theo cách thông thường, vì cây accessibility và cây view gốc gần như trống rỗng.
Điều này khiến nhiều bạn QA lần đầu test app Flutter hoảng loạn: "Sao Appium Inspector chỉ thấy một FlutterView khổng lồ, không có nút nào để nhấn?". Bài học hôm nay giúp bạn hiểu bản chất kiến trúc test của Flutter, biết dùng đúng công cụ cho đúng tầng, và tránh việc cố ép một app Flutter vào bộ đồ nghề Appium truyền thống rồi đổ lỗi cho "test flaky".
Tại Việt Nam, Flutter đã trở thành lựa chọn mặc định của rất nhiều startup và team mobile — từ các app fintech, giao đồ ăn, đến app nội bộ ngân hàng — nhờ tốc độ phát triển nhanh và một codebase cho cả hai nền tảng. Nếu bạn muốn làm QA mobile ở thị trường này, biết test Flutter đúng cách gần như là kỹ năng bắt buộc.
Khái niệm cốt lõi
Ba tầng test của Flutter
Flutter có một mô hình test rất rõ ràng, ánh xạ gần đúng với Testing Pyramid (Bài 6). Ba tầng, ba package:
| Tầng | Package | Test cái gì | Tốc độ |
|---|---|---|---|
| Unit | package:test | Logic thuần Dart: hàm, class, tính toán, xử lý dữ liệu — không có UI | Rất nhanh (mili-giây) |
| Widget | flutter_test | Một hoặc một nhóm widget trong môi trường ảo, không cần thiết bị | Nhanh (giây) |
| Integration | integration_test | Cả app chạy thật trên emulator/thiết bị, kiểm end-to-end | Chậm (chục giây/phút) |
WidgetTester — trái tim của widget test
Trong widget test, bạn không có thiết bị thật. Thay vào đó bạn có một đối tượng WidgetTester — nó "bơm" (pump) widget của bạn vào một cây widget ảo, cho phép bạn tìm phần tử, chạm, nhập liệu, và kiểm tra kết quả.
Ba khái niệm bạn phải nắm:
pumpWidget(): dựng widget lên "màn hình ảo" lần đầu.pump()vàpumpAndSettle(): đẩy khung hình tiếp theo.pump()render đúng một frame;pumpAndSettle()render liên tục cho đến khi không còn animation nào đang chạy. Đây là cách Flutter xử lý bất đồng bộ — thay vì "chờ mù" bằngsleepnhư Appium (Bài 10), bạn chủ động điều khiển đồng hồ frame.Finder: cách định vị widget —find.text('Đăng nhập'),find.byKey(Key('login_btn')),find.byType(ElevatedButton),find.byIcon(Icons.search).
flutter_driver vs integration_test
Ngày xưa Flutter dùng flutter_driver cho integration test — chạy test trong một process riêng, giao tiếp với app qua một cầu nối. Cách này chậm và không truy cập được state của app. Từ 2020, Google đã thay bằng gói integration_test — test chạy bên trong cùng process với app, dùng lại API WidgetTester, nên bạn viết integration test gần giống hệt widget test nhưng nó chạy trên thiết bị thật. Nếu bạn thấy tài liệu cũ nói về flutter_driver, hãy coi đó là di sản; dự án mới nên dùng integration_test.
Vấn đề với Appium: bật cờ semantics
Khi QA cần dùng Appium (ví dụ để test chung một luồng với các app native khác trong cùng bộ automation), có giải pháp: Appium Flutter Driver (hoặc bản kế nhiệm appium-flutter-integration-driver). Nó giao tiếp trực tiếp với Flutter engine thay vì qua cây accessibility gốc. Điều kiện bắt buộc: app phải bật semantics và gắn Key/Semantics label cho widget thì driver mới định vị được. Không có bước này, Appium chỉ thấy một khối FlutterView đặc.
Tình huống thực tế
Tình huống 1 — App giao đồ ăn "biến mất nút" trên Appium
Một team QA tại một startup giao đồ ăn ở TP.HCM (giả định, quy mô ~40 kỹ sư) được giao test app khách hàng vừa được viết lại từ native sang Flutter. Bộ automation cũ của họ dùng Appium + Java, khoảng 180 test case. Ngày đầu chạy lại trên bản Flutter, hơn 150 test fail với lỗi NoSuchElementException.
QA lead mở Appium Inspector và chỉ thấy đúng một phần tử: XCUIElementTypeOther phủ kín màn hình — chính là FlutterView. Không có nút, không có ô nhập nào.
Diễn giải: đây không phải bug của app, mà là bản chất Flutter tự vẽ UI. Cây accessibility native trống vì mặc định Flutter chưa expose semantics đầy đủ, và Appium XCUITest driver không hiểu widget Flutter. Team đã đi sai hướng khi cố "sửa locator".
Bài học rút ra: Trước khi viết một dòng automation cho app Flutter, hãy hỏi dev "App đã bật SemanticsBinding / gắn Key cho các phần tử quan trọng chưa?". Team này cuối cùng chọn hai hướng: chuyển các luồng end-to-end quan trọng sang integration_test (do dev viết cùng QA), và chỉ giữ Appium cho một số ít luồng cần chạy chung với cổng thanh toán bên thứ ba, kèm Appium Flutter Driver. Tổng thời gian chạy bộ test giảm từ 22 phút xuống còn 6 phút vì phần lớn kịch bản được đẩy xuống widget test.
Tình huống 2 — Widget test bắt được bug tính tiền ship trước khi lên staging
Một team fintech ví điện tử (bối cảnh Đông Nam Á) có màn hình xác nhận đơn hàng hiển thị tổng tiền = giá món + phí ship − voucher. Trước đây họ chỉ test qua manual và một ít Appium end-to-end. Một sprint nọ, dev đổi công thức làm tròn phí ship, và bug chỉ xuất hiện khi voucher lớn hơn phí ship (ra số âm hiển thị sai).
QA quyết định viết một widget test bơm thẳng màn hình OrderSummary với các bộ dữ liệu biên: voucher = 0, voucher = phí ship, voucher > tổng đơn. Test dùng find.text('0 ₫') và find.textContaining('−') để khẳng định app không bao giờ hiển thị tổng âm. Test chạy trong 0,8 giây, không cần emulator, và bắt được đúng bug ngay trong CI trước khi merge.
Diễn giải: nếu chờ đến khi luồng này lên staging rồi test bằng Appium, mỗi vòng lặp mất vài phút và dễ flaky vì phụ thuộc mạng, dữ liệu. Widget test cô lập đúng widget cần kiểm và cho phản hồi gần như tức thì.
Bài học rút ra: Với app Flutter, hãy nghĩ "tầng widget trước, end-to-end sau". Rất nhiều lỗi hiển thị và logic-trên-UI có thể bắt ở tầng widget với chi phí cực thấp. QA nên chủ động đề xuất và thậm chí đồng viết widget test cùng dev.
Tình huống 3 — pumpAndSettle treo vì animation vô tận
Một QA viết integration test cho luồng đăng nhập. Test luôn timeout ở bước sau khi nhấn nút "Đăng nhập". Log cho thấy pumpAndSettle() chạy đến hết 10 phút rồi fail.
Nguyên nhân: màn hình chờ có một CircularProgressIndicator — spinner quay vô hạn. pumpAndSettle() chờ đến khi không còn animation nào chạy, nhưng spinner không bao giờ dừng, nên nó chờ mãi.
Diễn giải: đây là một trong những lỗi kinh điển nhất khi test Flutter. pumpAndSettle không phải "cây đũa thần chờ mọi thứ" — nó không thể vượt qua animation lặp vô hạn.
Bài học rút ra: khi có loading indicator vòng lặp, đừng dùng pumpAndSettle(). Thay bằng vòng lặp pump() có kiểm tra điều kiện, ví dụ chờ đến khi find.text('Trang chủ') xuất hiện, với một mốc thời gian tối đa (timeout) rõ ràng. Đây chính là tư duy "chờ có điều kiện" đã học ở Bài 10, áp dụng vào ngữ cảnh Flutter.
Hướng dẫn từng bước
Giả sử bạn nhận một app Flutter và cần xây dựng bộ test từ đầu. Đây là quy trình thực hành:
Bước 1 — Xác nhận cấu trúc thư mục test. Flutter quy ước: unit và widget test nằm trong thư mục test/, integration test nằm trong integration_test/. Kiểm tra pubspec.yaml có flutter_test và integration_test trong dev_dependencies chưa.
Bước 2 — Chạy thử một widget test có sẵn. App Flutter mới tạo luôn có test/widget_test.dart. Chạy flutter test để chắc chắn môi trường hoạt động. Lệnh này chạy toàn bộ unit + widget test headless, rất nhanh.
Bước 3 — Yêu cầu dev gắn Key cho các phần tử quan trọng. Đây là bước hợp tác QA–dev then chốt. Mỗi nút, ô nhập, phần tử cần test nên có Key(const ValueKey('login_email')). Key ổn định hơn nhiều so với tìm theo text (text đổi khi làm đa ngôn ngữ — xem Bài 27) hay theo vị trí. Đây là tương đương của testID/accessibilityId trong thế giới native.
Bước 4 — Viết widget test cho một màn hình. Mẫu cơ bản:
testWidgets('nút đăng nhập bị vô hiệu khi email trống', (tester) async {
await tester.pumpWidget(const MyApp());
await tester.enterText(find.byKey(const ValueKey('login_password')), '123456');
await tester.pump();
final loginBtn = tester.widget<ElevatedButton>(find.byKey(const ValueKey('login_btn')));
expect(loginBtn.onPressed, isNull); // null = bị disable
});
Bước 5 — Thiết lập integration test cho luồng end-to-end. Tạo integration_test/app_test.dart, gọi IntegrationTestWidgetsFlutterBinding.ensureInitialized() ở đầu, rồi viết như widget test nhưng bơm cả app thật. Chạy bằng flutter test integration_test/app_test.dart trên một emulator đang mở.
Bước 6 — Đưa vào CI. Unit + widget test chạy ở mọi commit (nhanh, chặn merge nếu fail). Integration test chạy trên device farm hoặc emulator trong pipeline (xem Bài 48, 49) — thường chạy chọn lọc vì tốn thời gian.
Bước 7 — Kiểm coverage. Chạy flutter test --coverage để sinh file lcov.info, đo độ phủ. Đặt ngưỡng hợp lý cho phần logic, đừng cố ép 100%.
Lỗi thường gặp & mẹo
- Cố dùng Appium truyền thống rồi than "không thấy element". Đây là hiểu lầm số một. App Flutter cần Appium Flutter Driver + semantics bật, hoặc chuyển sang
integration_test. Đừng đổ lỗi cho locator.
- Lạm dụng
pumpAndSettle(). Ngoài trường hợp spinner vô hạn ở Tình huống 3,pumpAndSettlecòn nguy hiểm khi có video, GIF, hay animation nền. Mẹo: chỉ dùngpumpAndSettlecho transition ngắn có điểm dừng; còn lại dùng vòngpump()chờ điều kiện xuất hiện.
- Tìm widget theo text trong app đa ngôn ngữ.
find.text('Đăng nhập')sẽ vỡ khi test trên bản tiếng Anh. Dùngfind.byKeyđể tách test khỏi nội dung hiển thị.
- Quên
await. APIWidgetTestergần như đều bất đồng bộ. Thiếuawaittrướctester.pump()haytester.tap()gây ra kết quả kiểm sai lệch khó hiểu. Bật lint để bắt lỗi này.
- Nhầm widget test với integration test. Widget test không kiểm được tương tác với hệ điều hành thật: quyền (permission), camera, sinh trắc học, deep link. Những thứ đó thuộc integration test hoặc test thủ công (xem Bài 30, 31, 21).
- Bỏ qua kiểm accessibility trong widget test. Flutter cho phép
expect(tester.getSemantics(...), ...)và có matcher nhưmeetsGuideline(textContrastGuideline). Bạn có thể bắt lỗi a11y (Bài 13) ngay ở tầng widget — tận dụng đi.
- Mẹo vàng cho QA không code: dù bạn không viết Dart, hãy học đọc widget test. Khi review PR, một widget test tốt cho bạn thấy chính xác kịch bản dev đã cover và cả những biên chưa cover. QA hiểu test code là QA có tiếng nói mạnh hơn.
Bài tập thực hành
- Đọc và phân loại. Lấy một repo Flutter mã nguồn mở (ví dụ app mẫu trong tài liệu Flutter). Mở thư mục
test/vàintegration_test/, liệt kê mỗi file thuộc tầng nào trong ba tầng, và tóm tắt bằng tiếng Việt file đó kiểm cái gì.
- Tái hiện bẫy
pumpAndSettle. Tạo một màn hình cóCircularProgressIndicatorvô hạn, viết một test dùngpumpAndSettle()và quan sát nó timeout. Sau đó sửa lại thành vòngpump()chờ đến khi một text mục tiêu xuất hiện, có timeout 5 giây. Ghi lại khác biệt thời gian chạy.
- Viết checklist QA cho app Flutter. Soạn một checklist một trang trả lời: (a) làm sao xác nhận app đã sẵn sàng cho automation, (b) kịch bản nào nên ở tầng widget, kịch bản nào phải end-to-end, (c) danh sách phần tử cần dev gắn
Key. Đây là tài liệu bạn có thể dùng thật khi onboard một dự án Flutter mới.
- Widget test kiểm biên. Chọn một màn hình hiển thị số tiền hoặc số lượng. Viết (hoặc phác thảo) một widget test cho ba giá trị biên: 0, số lớn, và giá trị gây kết quả âm. Khẳng định app không bao giờ hiển thị định dạng sai.
Tóm tắt
Flutter tự vẽ toàn bộ UI bằng engine đồ họa, nên các công cụ automation native (Appium, XCUITest, Espresso) không nhìn thấy widget của nó theo cách thông thường — đây là điều đầu tiên và quan trọng nhất một QA phải hiểu. Thay vào đó, Flutter có ba tầng test rõ ràng: unit (package:test) cho logic thuần Dart, widget (flutter_test) cho UI cô lập chạy headless siêu nhanh, và integration (integration_test) cho end-to-end trên thiết bị thật.
Triết lý cốt lõi: ưu tiên tầng widget. Rất nhiều lỗi hiển thị và logic-trên-UI có thể bắt ở đây với chi phí gần như bằng không, thay vì phải chạy Appium chậm và flaky. Nắm vững WidgetTester, Finder, và đặc biệt là sự khác biệt giữa pump() và pumpAndSettle() — nhớ rằng pumpAndSettle sẽ treo với animation vô hạn. Nếu buộc phải dùng Appium, hãy dùng Appium Flutter Driver và yêu cầu dev bật semantics cùng gắn Key.
Với đà phát triển của Flutter tại Việt Nam và Đông Nam Á, kỹ năng test Flutter đúng cách sẽ giúp bạn nổi bật trong vai trò QA mobile. Hãy tập đọc widget test, hợp tác chặt với dev ngay từ khâu gắn Key, và luôn chọn đúng tầng test cho đúng loại kịch bản.