Mở đầu — vì sao bài này quan trọng
Nếu bạn làm QA cho một sản phẩm mobile ở Việt Nam trong vài năm gần đây, xác suất rất cao là bạn đang test một ứng dụng viết bằng React Native (RN). MoMo, Tiki, nhiều ngân hàng số, hàng loạt startup và app nội bộ doanh nghiệp — tất cả đều chọn React Native vì một lý do rất thực tế: viết một codebase JavaScript, chạy được cả iOS lẫn Android, tiết kiệm nửa chi phí đội dev.
Nhưng chính cái "một codebase chạy hai nền tảng" đó lại tạo ra một lớp rủi ro rất riêng mà nếu bạn test app React Native giống hệt như test một app native thuần, bạn sẽ bỏ lọt cả một họ bug. Bug không nằm ở logic JavaScript, cũng không nằm ở tầng native, mà nằm ở cái cầu nối (bridge) giữa hai thế giới đó — nơi dữ liệu phải được đóng gói, tuần tự hóa, gửi qua lại một cách bất đồng bộ.
Ở các bài trước trong khóa này, bạn đã học kiến trúc Appium, cách viết locator, cách xử lý flaky test. Bài 36 không lặp lại những thứ đó. Bài này tập trung vào những đặc thù riêng của React Native khiến việc test khác đi: kiến trúc bridge (và kiến trúc mới thay thế nó), ba tầng test đặc thù của RN, cách locator hoạt động khác với native thuần, và những loại bug chỉ xuất hiện ở app RN. Hiểu được lớp này, bạn sẽ trở thành QA mà đội React Native thực sự cần — người biết bug đến từ đâu chứ không chỉ báo "app crash".
Khái niệm cốt lõi
Kiến trúc Bridge — nguồn gốc của mọi đặc thù
Trái tim của React Native (kiến trúc cũ, gọi là "Old Architecture" hay Paper) là the Bridge. Hãy hình dung app RN của bạn có hai luồng (thread) chạy song song:
- JS thread: nơi code JavaScript của bạn chạy — logic nghiệp vụ, gọi API, quản lý state Redux, xử lý sự kiện người dùng.
- Native thread (UI thread / Main thread): nơi các thành phần giao diện thật sự được vẽ ra bằng UIKit (iOS, Obj-C/Swift) hoặc Android View (Java/Kotlin).
Điều này có ba hệ quả cực kỳ quan trọng cho QA:
- Mọi thứ đều async. Một thao tác bấm nút không cập nhật UI ngay lập tức mà phải "đi một vòng" qua bridge. Đây là mảnh đất màu mỡ cho flaky test và bug kiểu "UI cập nhật chậm một nhịp".
- Bridge có thể bị nghẽn cổ chai (bottleneck). Nếu bạn gửi quá nhiều message qua bridge cùng lúc — ví dụ animation chạy trên JS thread, hoặc list cuộn nhanh với hàng nghìn item — bridge quá tải, khung hình rớt (dropped frames), app giật lag.
- Dữ liệu phải serialize được. Chỉ những gì chuyển thành JSON được mới đi qua bridge. Đây là lý do nhiều bug xuất hiện khi truyền số lớn, ngày tháng, hay ký tự đặc biệt.
Ba tầng test của React Native
App React Native cần được kiểm thử ở ba tầng riêng biệt, mỗi tầng bắt một loại lỗi khác nhau:
Tầng 1 — Unit / Component test (Jest + React Native Testing Library). Test logic JavaScript và render của từng component một cách cô lập, không cần thiết bị thật. Đây là tầng rẻ nhất và nhanh nhất. render() một component, kiểm tra text hiển thị, giả lập bấm nút bằng fireEvent.press(). QA nên đọc hiểu được các test này vì chúng phản ánh contract của UI.
Tầng 2 — Integration test. Test nhiều component ghép lại, có state management (Redux/Zustand), có mock API. Vẫn chạy trong môi trường Node giả lập (jsdom-like), chưa lên thiết bị.
Tầng 3 — End-to-End (E2E) trên thiết bị thật/giả lập. Đây là tầng QA quan tâm nhất. Có hai lựa chọn phổ biến:
- Detox — framework "gray-box" của Wix, sinh ra riêng cho React Native. Nó biết khi nào app đang bận (bridge còn message chưa xử lý, network đang chạy, timer đang chờ) và tự động đồng bộ, nhờ đó giảm mạnh flaky test.
- Appium — "black-box", không biết nội tình app, phải dùng wait thủ công như bạn đã học ở Bài 10.
Locator trong React Native khác gì?
Đây là điểm QA hay vấp. Trong RN, các component như <View>, <Text>, <TouchableOpacity> được map sang phần tử native tương ứng (UIView/android.view.ViewGroup...). Vì vậy khi bạn dùng Appium Inspector, bạn thấy cây native, không thấy tên component RN.
Chìa khóa là prop testID. Khi dev đặt testID="login-button" trên một component RN, nó được ánh xạ thành:
accessibilityIdentifiertrên iOS → tìm bằng~login-button(accessibility id).resource-id(content-description tùy cấu hình) trên Android.
Tình huống thực tế
Tình huống 1 — MoMo và bug "double tap" trên bridge
Một fintech lớn ở Việt Nam (bối cảnh giả định hợp lý dựa trên các app ví như MoMo/ZaloPay) phát hành tính năng "Chuyển tiền nhanh" viết bằng React Native kiến trúc cũ. QA báo một bug rất khó tái hiện: thỉnh thoảng người dùng bấm "Xác nhận chuyển" một lần nhưng giao dịch bị gửi hai lần, trừ tiền hai lần.
Điều tra cho thấy nguyên nhân đúng là bridge async. Khi bấm nút, lệnh gọi API đi qua bridge; nhưng UI chưa kịp disable nút (việc disable cũng phải đi qua bridge một vòng), nên trong khoảng 200–400ms đó, người dùng "sốt ruột" bấm lần hai và message thứ hai lọt qua trước khi state isSubmitting=true được phản ánh lên UI.
Bài học rút ra: với app RN, QA phải có test case chuyên cho "double tap / rapid tap" trên mọi nút gây side-effect (thanh toán, submit form). Trong Detox, tái hiện bằng cách bấm hai lần liên tiếp không chờ đồng bộ. Con số cần đo: độ trễ giữa lúc bấm và lúc nút thực sự bị disable — nếu trên 100ms là rủi ro. Đội đã fix bằng debounce ở tầng JS và khóa ở backend (idempotency key), nhưng chính QA là người phát hiện lớp rủi ro bridge này.
Tình huống 2 — Tiki và list sản phẩm giật lag khi cuộn
Một sàn TMĐT (bối cảnh kiểu Tiki/Shopee) có màn hình danh sách sản phẩm dài viết bằng RN. Trên iPhone đời mới mọi thứ mượt, nhưng trên các máy Android tầm trung phổ biến ở Việt Nam (Samsung A-series, Xiaomi Redmi), danh sách cuộn giật rõ rệt, FPS tụt xuống dưới 40.
Nguyên nhân: dev dùng ScrollView thường thay vì FlatList, khiến toàn bộ 500 sản phẩm được render và giữ trong bộ nhớ, mỗi lần cuộn lại đẩy quá nhiều message layout qua bridge. Bridge nghẽn → dropped frames.
Bài học rút ra: QA React Native phải test performance cuộn trên máy Android tầm trung thật, không chỉ trên simulator hay iPhone flagship (liên hệ lại Bài 5 về real device và Bài 14 về FPS). Với RN, một dấu hiệu bug đặc thù cần soi là: component dùng ScrollView cho list dài thay vì FlatList/FlashList. Đội Tiki chuyển sang FlatList với windowSize và getItemLayout tối ưu, FPS trên Redmi Note tăng từ 38 lên 58.
Tình huống 3 — Startup logistics và bug hiển thị số tiền âm
Một startup logistics ở TP.HCM có app tài xế viết bằng RN. Bug: với các đơn giá trị lớn (trên 10 triệu đồng), tiền cước hiển thị sai lệch vài đồng lẻ hoặc ra số kỳ lạ ở màn hình đối soát.
Nguyên nhân nằm ở tuần tự hóa qua bridge: JavaScript chỉ có một kiểu số (double 64-bit), và khi backend gửi các số nguyên rất lớn (như số dư tính bằng đồng, hoặc ID giao dịch dạng int64), việc đi qua bridge/JSON làm mất độ chính xác. 9007199254740993 biến thành 9007199254740992.
Bài học rút ra: đây là bug chỉ QA hiểu bridge mới nghĩ tới. Test case cần có: nhập/hiển thị số tiền cực lớn, ID dài, và kiểm tra chính xác từng chữ số. Fix ở phía dev là truyền các con số đó dưới dạng string qua API, không dùng number. QA là người đặt ra được test case biên này chính vì hiểu "mọi thứ qua bridge đều phải serialize".
Hướng dẫn từng bước
Đây là quy trình thực chiến để tiếp cận một dự án React Native với vai trò QA:
Bước 1 — Xác định kiến trúc và stack. Hỏi dev: app chạy Old Architecture (Bridge) hay New Architecture (Fabric/JSI)? Dùng thư viện navigation nào (React Navigation), state management nào? Điều này quyết định bạn kỳ vọng loại bug gì.
Bước 2 — Đàm phán testID. Yêu cầu dev gắn testID cho mọi phần tử tương tác quan trọng (nút, ô input, list item, tab). Thống nhất quy ước đặt tên, ví dụ screen-element-action: login-email-input, cart-checkout-button. Đây là khoản đầu tư cho toàn bộ automation về sau.
Bước 3 — Chọn công cụ E2E. Nếu đội thuần RN và muốn ổn định cao, ưu tiên Detox vì nó đồng bộ tự động theo bridge. Nếu bạn cần chạy cả app native khác trong cùng bộ khung, hoặc dùng device farm như BrowserStack (Bài 49), thì Appium linh hoạt hơn.
Bước 4 — Viết smoke test đường đi chính. Ví dụ với Detox, một test login:
describe('Đăng nhập', () => {
it('đăng nhập thành công', async () => {
await element(by.id('login-email-input')).typeText('qa@vietnamcos.com');
await element(by.id('login-password-input')).typeText('secret123');
await element(by.id('login-submit-button')).tap();
await expect(element(by.id('home-welcome-text'))).toBeVisible();
});
});
Chú ý Detox tự chờ bridge rảnh trước mỗi lệnh, nên bạn hầu như không cần sleep.
Bước 5 — Bổ sung test case đặc thù RN. Ba nhóm bắt buộc: (a) double/rapid tap trên nút side-effect; (b) performance cuộn list trên Android tầm trung; (c) biên dữ liệu serialize (số lớn, ký tự đặc biệt, tiếng Việt có dấu — liên hệ Bài 19).
Bước 6 — Đọc kết quả và phối hợp với unit test. Trước khi mở JIRA, kiểm tra bug thuộc tầng nào: nếu là logic JS, dev có thể viết Jest test tái hiện nhanh; nếu là bridge/native, bạn cần E2E. Báo bug kèm đúng tầng giúp dev fix nhanh gấp đôi.
Lỗi thường gặp & mẹo
Lỗi 1 — Chỉ test trên iOS simulator rồi kết luận app ổn. RN bug thường lệch nền tảng (bài này khác Bài 4 ở chỗ: bug đến từ cách bridge map component sang từng native khác nhau). Luôn test song song cả hai và ưu tiên real device Android tầm trung.
Mẹo: Giữ một "ma trận thiết bị tối thiểu" cho thị trường Việt Nam: một iPhone đời cũ, một Samsung A-series, một Xiaomi Redmi. Đó là nơi bug RN lộ ra nhiều nhất.
Lỗi 2 — Dùng XPath/text làm locator vì thiếu testID. Test sẽ vỡ liên tục. Đừng vá bằng XPath — hãy đẩy ngược yêu cầu testID về dev.
Lỗi 3 — Nhầm flaky test do bridge với bug thật. Trong Appium, nhiều test RN "chớp tắt" chỉ vì UI chưa đồng bộ qua bridge. Trước khi báo bug, thử với công cụ có auto-sync (Detox) để loại trừ.
Mẹo: Bật màn hình Perf Monitor của RN (lắc máy → Show Perf Monitor) để nhìn UI FPS và JS FPS tách biệt. Nếu JS FPS tụt mà UI FPS ổn, vấn đề nằm ở JS thread (logic nặng); nếu UI FPS tụt, nghi ngờ bridge nghẽn hoặc render.
Lỗi 4 — Bỏ qua log bridge. Chạy Flipper (hoặc React Native DevTools) để soi lưu lượng bridge, network, và Redux state. Nhiều bug "biến mất" khi bạn thấy được message nào đang kẹt.
Mẹo: Test cả hành vi hot reload / fast refresh ở môi trường dev nếu team dùng, nhưng luôn verify bug cuối cùng trên bản release build, vì JS được bundle và tối ưu khác hẳn bản debug.
Bài tập thực hành
- Đọc kiến trúc: Lấy một app RN (hoặc app công ty bạn). Xác định nó dùng Old hay New Architecture, và liệt kê 3 điểm điều đó ảnh hưởng tới cách bạn test.
- Săn testID: Mở app RN bất kỳ bằng Appium Inspector. Ghi lại 5 phần tử tương tác và cho biết phần tử nào có accessibility id (tức có testID) và phần tử nào không. Viết một đề xuất testID gửi dev.
- Tái hiện bug bridge: Với một nút submit/thanh toán, thiết kế test case "rapid double tap". Mô tả kết quả kỳ vọng (chỉ một request) và cách bạn đo độ trễ disable nút.
- Performance cuộn: Test cuộn một danh sách dài trên một máy Android tầm trung thật. Ghi lại UI FPS qua Perf Monitor và kết luận app dùng
ScrollViewhayFlatListcó phù hợp không.
- Biên serialize: Viết 3 test case dữ liệu biên đặc thù bridge (số tiền cực lớn, chuỗi tiếng Việt có dấu dài, emoji trong tên) và dự đoán chỗ nào có thể sai lệch.
Tóm tắt
React Native không phải "app native bình thường" cũng chẳng phải "web app": nó là hai thế giới JS và native nối với nhau qua một cây cầu bất đồng bộ. Toàn bộ đặc thù test của nó bắt nguồn từ cây cầu đó — mọi thao tác là async, bridge có thể nghẽn, và dữ liệu phải serialize được. Là QA, bạn cần: (1) biết app chạy kiến trúc Bridge cũ hay JSI/Fabric mới; (2) đàm phán testID với dev để có locator ổn định; (3) test ở ba tầng (unit/component, integration, E2E) và ưu tiên Detox cho E2E vì nó tự đồng bộ theo bridge; (4) luôn có test case đặc thù RN — double tap, performance cuộn trên Android tầm trung, và biên dữ liệu serialize. Ba tình huống MoMo, Tiki và startup logistics cho thấy cùng một gốc rễ: người QA hiểu bridge sẽ phát hiện được lớp bug mà người khác bỏ lọt. Đó chính là giá trị bạn mang lại cho một đội React Native.