Tại sao testing là khoản đầu tư sinh lời nhất trong dự án React Native
Nếu bạn đã theo dõi series — từ Expo SDK 55, tối ưu hiệu năng, quản lý state đến debug ứng dụng — thì bạn đã có nền tảng khá vững để xây dựng app React Native chất lượng cao rồi. Nhưng có một câu hỏi mà mình nhận được khá thường xuyên: "Code chạy được rồi, sao phải viết test?"
Thành thật mà nói, mình cũng từng nghĩ vậy. Cho đến khi một lần deploy lên production, một thay đổi "nhỏ xíu" trong logic thanh toán khiến toàn bộ flow checkout bị crash. Nếu có test, mình đã phát hiện ra trong 2 phút thay vì 2 tiếng sau khi user phàn nàn.
Câu trả lời ngắn gọn: code hôm nay chạy đúng không có nghĩa là ngày mai vẫn đúng. Mỗi lần bạn thêm tính năng mới, refactor code, hay cập nhật dependency — đều có khả năng phá vỡ thứ gì đó đang hoạt động. Testing tự động là cách duy nhất để phát hiện regression sớm, trước khi mọi thứ trở nên phức tạp hơn.
Năm 2026, hệ sinh thái testing cho React Native đã trưởng thành hơn rất nhiều. React Test Renderer chính thức bị deprecate từ React 19 — thay vào đó, React Native Testing Library (RNTL) v13 trở thành tiêu chuẩn mới cho component testing. Về E2E testing, bên cạnh Detox đã quen thuộc, Maestro nổi lên như một lựa chọn cực kỳ đơn giản với cú pháp YAML dễ đọc và khả năng chống flaky test khá ấn tượng.
Bài viết này sẽ đưa bạn qua toàn bộ chiến lược testing cho React Native năm 2026: từ unit test cơ bản đến E2E testing trên thiết bị thật. Mỗi phần đều có code ví dụ thực tế, nên bạn có thể áp dụng ngay.
Kiến trúc testing: Kim tự tháp test cho React Native
Trước khi bắt tay vào code, bạn cần nắm được testing pyramid — mô hình kinh điển giúp cân bằng giữa tốc độ, chi phí và độ tin cậy của test:
- Unit tests (đáy kim tự tháp): Nhiều nhất, nhanh nhất, rẻ nhất. Test từng function, hook, utility riêng lẻ. Dùng Jest.
- Component/Integration tests (giữa): Test component với props, state, và tương tác người dùng. Dùng Jest + React Native Testing Library.
- E2E tests (đỉnh): Ít nhất nhưng đáng tin cậy nhất. Test toàn bộ luồng user trên app thật. Dùng Maestro hoặc Detox.
Một tỷ lệ hợp lý cho dự án React Native trung bình là: 70% unit/component tests, 20% integration tests, 10% E2E tests. Bạn không cần đạt 100% coverage — hãy tập trung vào các luồng quan trọng nhất là đủ.
Thiết lập môi trường testing với Expo và Jest
Nếu bạn đang dùng Expo (mà hầu hết dự án mới đều nên dùng), việc setup testing khá đơn giản. Expo đã cung cấp sẵn preset jest-expo để mock tất cả native module cho bạn.
Cài đặt dependencies
# Cài đặt Jest và preset cho Expo
npx expo install jest-expo jest
# Cài đặt React Native Testing Library v13
npm install --save-dev @testing-library/react-native
# Cài đặt jest-native matchers (tuỳ chọn nhưng rất hữu ích)
npm install --save-dev @testing-library/jest-native
Cấu hình Jest
Thêm cấu hình sau vào package.json:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"preset": "jest-expo",
"setupFilesAfterSetup": [
"@testing-library/jest-native/extend-expect"
],
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg)"
],
"collectCoverageFrom": [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/**/__tests__/**"
]
}
}
Lưu ý quan trọng: transformIgnorePatterns là phần hay gây đau đầu nhất khi setup Jest cho React Native. Regex ở trên đảm bảo Jest sẽ transform đúng các module React Native thay vì bỏ qua chúng. Nếu bạn gặp lỗi kiểu SyntaxError: Unexpected token export — rất có thể là do pattern này chưa bao gồm thư viện bạn đang dùng. Mình đã từng mất cả buổi chiều debug lỗi này chỉ vì quên thêm một thư viện vào regex.
Tạo file setup
Tạo file jest.setup.ts ở thư mục gốc để cấu hình global mocks:
// jest.setup.ts
import "@testing-library/jest-native/extend-expect";
// Mock các native module phổ biến
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);
// Mock expo-router nếu bạn dùng Expo Router
jest.mock("expo-router", () => ({
useRouter: () => ({
push: jest.fn(),
replace: jest.fn(),
back: jest.fn(),
}),
useLocalSearchParams: () => ({}),
useSegments: () => [],
Link: "Link",
}));
// Tắt warning Animated trong test
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
Unit testing với Jest: Nền tảng vững chắc
Unit test là loại test đơn giản nhất nhưng lại mang lại giá trị lớn nhất tính theo thời gian đầu tư. Nói thật, nếu chỉ có thời gian viết một loại test thì hãy chọn cái này.
Bạn nên viết unit test cho: utility functions, custom hooks, business logic, và data transformations.
Test utility functions
// utils/formatPrice.ts
export const formatPrice = (amount: number, currency = "VND"): string => {
if (amount < 0) throw new Error("Amount cannot be negative");
if (currency === "VND") {
return new Intl.NumberFormat("vi-VN", {
style: "currency",
currency: "VND",
maximumFractionDigits: 0,
}).format(amount);
}
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
}).format(amount);
};
// __tests__/utils/formatPrice.test.ts
import { formatPrice } from "../../utils/formatPrice";
describe("formatPrice", () => {
it("formats VND correctly without decimals", () => {
expect(formatPrice(1500000)).toContain("1.500.000");
});
it("formats USD with two decimals", () => {
expect(formatPrice(29.99, "USD")).toBe("$29.99");
});
it("throws error for negative amounts", () => {
expect(() => formatPrice(-100)).toThrow("Amount cannot be negative");
});
it("handles zero amount", () => {
expect(formatPrice(0)).toContain("0");
});
});
Test custom hooks với renderHook
Custom hooks là phần quan trọng trong React Native — và cũng là phần hay bị bỏ qua khi testing. RNTL cung cấp renderHook để test hooks một cách tách biệt, rất tiện:
// hooks/useDebounce.ts
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// __tests__/hooks/useDebounce.test.ts
import { renderHook, act } from "@testing-library/react-native";
import { useDebounce } from "../../hooks/useDebounce";
describe("useDebounce", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it("returns initial value immediately", () => {
const { result } = renderHook(() => useDebounce("hello", 500));
expect(result.current).toBe("hello");
});
it("updates value after delay", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "hello", delay: 500 } }
);
rerender({ value: "world", delay: 500 });
// Giá trị chưa thay đổi ngay
expect(result.current).toBe("hello");
// Tua thời gian 500ms
act(() => {
jest.advanceTimersByTime(500);
});
// Bây giờ giá trị đã cập nhật
expect(result.current).toBe("world");
});
it("cancels previous timer on rapid changes", () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: "a", delay: 300 } }
);
rerender({ value: "ab", delay: 300 });
act(() => jest.advanceTimersByTime(100));
rerender({ value: "abc", delay: 300 });
act(() => jest.advanceTimersByTime(300));
// Chỉ giá trị cuối cùng được áp dụng
expect(result.current).toBe("abc");
});
});
Component testing với React Native Testing Library
React Native Testing Library (RNTL) v13 là công cụ tiêu chuẩn để test component trong React Native năm 2026. Điểm khác biệt lớn nhất so với các công cụ cũ như Enzyme (đã deprecate từ lâu) là triết lý: test behavior, không test implementation.
Nói đơn giản hơn — bạn nên test những gì user thực sự nhìn thấy và tương tác, thay vì đi soi internal state hay method của component. Cách tiếp cận này giúp test ít bị break khi bạn refactor code, và theo kinh nghiệm của mình thì test viết theo kiểu này cũng dễ đọc hơn nhiều.
Các query chính trong RNTL
RNTL cung cấp nhiều cách để tìm element trong component tree. Thứ tự ưu tiên nên dùng:
getByRole— tìm theo accessibility role (tốt nhất cho accessibility)getByText— tìm theo text hiển thịgetByPlaceholderText— tìm input theo placeholdergetByDisplayValue— tìm input theo giá trị hiện tạigetByTestId— tìm theo testID (chỉ dùng khi không còn cách nào khác)
Mỗi query có 3 biến thể: getBy (throw error nếu không tìm thấy), queryBy (return null nếu không tìm thấy), và findBy (async, đợi element xuất hiện). Cái này nghe phức tạp nhưng dùng quen sẽ thấy rất logic.
Ví dụ: Test component LoginForm
// components/LoginForm.tsx
import React, { useState } from "react";
import {
View,
Text,
TextInput,
TouchableOpacity,
ActivityIndicator,
StyleSheet,
} from "react-native";
interface LoginFormProps {
onSubmit: (email: string, password: string) => Promise<void>;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!email.trim() || !password.trim()) {
setError("Vui lòng nhập đầy đủ email và mật khẩu");
return;
}
setLoading(true);
setError(null);
try {
await onSubmit(email, password);
} catch (e: any) {
setError(e.message || "Đăng nhập thất bại");
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Đăng nhập</Text>
{error && (
<Text style={styles.error} accessibilityRole="alert">
{error}
</Text>
)}
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
accessibilityLabel="Email input"
/>
<TextInput
placeholder="Mật khẩu"
value={password}
onChangeText={setPassword}
secureTextEntry
accessibilityLabel="Password input"
/>
<TouchableOpacity
onPress={handleSubmit}
disabled={loading}
accessibilityRole="button"
accessibilityLabel="Login button"
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text>Đăng nhập</Text>
)}
</TouchableOpacity>
</View>
);
}
Giờ viết test cho component này:
// __tests__/components/LoginForm.test.tsx
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react-native";
import { LoginForm } from "../../components/LoginForm";
describe("LoginForm", () => {
const mockOnSubmit = jest.fn();
beforeEach(() => {
mockOnSubmit.mockClear();
});
it("renders all form elements", () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
expect(screen.getByText("Đăng nhập")).toBeTruthy();
expect(screen.getByPlaceholderText("Email")).toBeTruthy();
expect(screen.getByPlaceholderText("Mật khẩu")).toBeTruthy();
expect(screen.getByRole("button", { name: "Login button" })).toBeTruthy();
});
it("shows validation error when fields are empty", () => {
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.press(screen.getByRole("button", { name: "Login button" }));
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Vui lòng nhập đầy đủ email và mật khẩu")).toBeTruthy();
expect(mockOnSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with email and password", async () => {
mockOnSubmit.mockResolvedValueOnce(undefined);
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.changeText(
screen.getByPlaceholderText("Email"),
"[email protected]"
);
fireEvent.changeText(
screen.getByPlaceholderText("Mật khẩu"),
"password123"
);
fireEvent.press(screen.getByRole("button", { name: "Login button" }));
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
"[email protected]",
"password123"
);
});
});
it("displays error message on login failure", async () => {
mockOnSubmit.mockRejectedValueOnce(new Error("Sai email hoặc mật khẩu"));
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.changeText(
screen.getByPlaceholderText("Email"),
"[email protected]"
);
fireEvent.changeText(
screen.getByPlaceholderText("Mật khẩu"),
"wrong"
);
fireEvent.press(screen.getByRole("button", { name: "Login button" }));
await waitFor(() => {
expect(screen.getByText("Sai email hoặc mật khẩu")).toBeTruthy();
});
});
it("disables button while loading", async () => {
// Tạo promise không resolve ngay để giữ trạng thái loading
mockOnSubmit.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 5000))
);
render(<LoginForm onSubmit={mockOnSubmit} />);
fireEvent.changeText(
screen.getByPlaceholderText("Email"),
"[email protected]"
);
fireEvent.changeText(
screen.getByPlaceholderText("Mật khẩu"),
"password123"
);
fireEvent.press(screen.getByRole("button", { name: "Login button" }));
await waitFor(() => {
const button = screen.getByRole("button", { name: "Login button" });
expect(button.props.accessibilityState?.disabled).toBe(true);
});
});
});
Test component với async data (TanStack Query)
Nếu bạn đang dùng TanStack Query cho data fetching (như đã nói ở bài quản lý state), bạn cần wrap component trong QueryClientProvider khi test. Đây là cách mình hay setup:
// test-utils/renderWithProviders.tsx
import React from "react";
import { render, RenderOptions } from "@testing-library/react-native";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // Không retry trong test
gcTime: Infinity,
},
},
});
}
export function renderWithProviders(
ui: React.ReactElement,
options?: Omit<RenderOptions, "wrapper">
) {
const testQueryClient = createTestQueryClient();
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={testQueryClient}>
{children}
</QueryClientProvider>
);
}
return {
...render(ui, { wrapper: Wrapper, ...options }),
queryClient: testQueryClient,
};
}
// __tests__/components/ProductList.test.tsx
import React from "react";
import { screen, waitFor } from "@testing-library/react-native";
import { renderWithProviders } from "../../test-utils/renderWithProviders";
import { ProductList } from "../../components/ProductList";
// Mock API module
jest.mock("../../api/products", () => ({
fetchProducts: jest.fn(),
}));
import { fetchProducts } from "../../api/products";
const mockFetchProducts = fetchProducts as jest.MockedFunction<typeof fetchProducts>;
describe("ProductList", () => {
it("shows loading indicator initially", () => {
mockFetchProducts.mockImplementation(
() => new Promise(() => {}) // Never resolves
);
renderWithProviders(<ProductList />);
expect(screen.getByTestId("loading-indicator")).toBeTruthy();
});
it("renders products after data loads", async () => {
mockFetchProducts.mockResolvedValueOnce([
{ id: "1", name: "iPhone 16", price: 28990000 },
{ id: "2", name: "Galaxy S25", price: 23990000 },
]);
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByText("iPhone 16")).toBeTruthy();
expect(screen.getByText("Galaxy S25")).toBeTruthy();
});
});
it("shows error message on fetch failure", async () => {
mockFetchProducts.mockRejectedValueOnce(new Error("Network error"));
renderWithProviders(<ProductList />);
await waitFor(() => {
expect(screen.getByText(/lỗi/i)).toBeTruthy();
});
});
});
Mock API calls với MSW (Mock Service Worker)
Thay vì mock từng function bằng jest.mock(), bạn có thể dùng MSW (Mock Service Worker) để intercept HTTP request ở tầng network. Mình thích cách này hơn vì nó gần thực tế hơn — test luôn cả phần fetch data, không chỉ component render.
# Cài đặt MSW
npm install --save-dev msw
// mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.get("https://api.example.com/products", () => {
return HttpResponse.json([
{ id: "1", name: "iPhone 16", price: 28990000 },
{ id: "2", name: "Galaxy S25", price: 23990000 },
]);
}),
http.post("https://api.example.com/auth/login", async ({ request }) => {
const body = await request.json() as Record<string, string>;
if (body.email === "[email protected]" && body.password === "correct") {
return HttpResponse.json({ token: "fake-jwt-token", user: { id: "1" } });
}
return HttpResponse.json(
{ message: "Sai email hoặc mật khẩu" },
{ status: 401 }
);
}),
];
// mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
// jest.setup.ts — thêm vào file setup
import { server } from "./mocks/server";
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Giờ đây, bất kỳ HTTP request nào trong test đều bị MSW intercept và trả về response giả lập. Bạn không cần mock từng module API nữa — component gọi fetch hay axios đều hoạt động bình thường. Khá tiện, phải không?
E2E testing với Maestro: Đơn giản đến bất ngờ
Maestro là công cụ E2E testing mà mình khuyến nghị nhất cho React Native năm 2026. Đặc biệt nếu bạn mới bắt đầu với E2E testing thì đây là lựa chọn tuyệt vời.
Tại sao lại là Maestro? Vì mấy lý do sau:
- Không cần cài gì vào project — Maestro là CLI tool độc lập, không ảnh hưởng đến code hay dependencies của bạn
- Viết test bằng YAML — đọc như ngôn ngữ tự nhiên, không cần biết JavaScript
- Tự động đợi element — quên đi
sleep()haywaitFor()thủ công - Chống flaky test — có cơ chế retry thông minh, tỷ lệ test flaky rất thấp
- Hỗ trợ cả iOS và Android — cùng một file YAML chạy trên cả hai platform
Cài đặt Maestro
# macOS / Linux
curl -Ls "https://get.maestro.mobile.dev" | bash
# Kiểm tra cài đặt
maestro -v
# Khởi động Maestro Studio (IDE trực quan)
maestro studio
Lưu ý: Maestro yêu cầu emulator/simulator đang chạy và app đã được build. Với Expo, bạn chạy npx expo run:ios hoặc npx expo run:android trước.
Viết flow test đầu tiên
Tạo thư mục maestro/ ở gốc project và tạo file login-flow.yaml:
# maestro/login-flow.yaml
appId: com.yourapp.example
---
# Test luồng đăng nhập
- launchApp:
clearState: true
# Kiểm tra màn hình đăng nhập hiển thị
- assertVisible: "Đăng nhập"
# Nhập email
- tapOn: "Email"
- inputText: "[email protected]"
# Nhập mật khẩu
- tapOn: "Mật khẩu"
- inputText: "password123"
# Ẩn bàn phím
- hideKeyboard
# Nhấn nút đăng nhập
- tapOn: "Đăng nhập"
# Kiểm tra chuyển sang màn hình chính
- assertVisible: "Trang chủ"
Thấy chưa? Đọc file YAML này gần như đọc tiếng Việt bình thường vậy. Đó là lý do mình thích Maestro.
# Chạy test
maestro test maestro/login-flow.yaml
# Chạy tất cả test trong thư mục
maestro test maestro/
# Chạy với chế độ theo dõi file (hot-reload)
maestro test --continuous maestro/login-flow.yaml
Flow test nâng cao: Thêm sản phẩm vào giỏ hàng
# maestro/add-to-cart-flow.yaml
appId: com.yourapp.example
---
- launchApp:
clearState: true
# Đăng nhập trước (dùng reusable flow)
- runFlow: "shared/login.yaml"
# Chờ danh sách sản phẩm load xong
- assertVisible: "Sản phẩm nổi bật"
# Cuộn xuống tìm sản phẩm
- scrollUntilVisible:
element: "iPhone 16"
direction: DOWN
timeout: 10000
# Nhấn vào sản phẩm
- tapOn: "iPhone 16"
# Kiểm tra màn hình chi tiết
- assertVisible: "Thêm vào giỏ"
# Chọn số lượng
- tapOn:
id: "quantity-increase"
- tapOn:
id: "quantity-increase"
# Thêm vào giỏ hàng
- tapOn: "Thêm vào giỏ"
# Kiểm tra toast thông báo
- assertVisible: "Đã thêm vào giỏ hàng"
# Vào giỏ hàng kiểm tra
- tapOn:
id: "cart-tab"
- assertVisible: "iPhone 16"
- assertVisible: "Số lượng: 3"
# Chụp ảnh để verify
- takeScreenshot: "cart-with-iphone"
Reusable flows — tái sử dụng test
Tạo các flow dùng chung để tránh lặp code (giống kiểu DRY trong code vậy):
# maestro/shared/login.yaml
appId: com.yourapp.example
---
- assertVisible: "Đăng nhập"
- tapOn: "Email"
- inputText: "[email protected]"
- tapOn: "Mật khẩu"
- inputText: "password123"
- hideKeyboard
- tapOn: "Đăng nhập"
- assertVisible: "Trang chủ"
Rồi gọi lại trong bất kỳ flow nào bằng runFlow. Khi UI thay đổi, bạn chỉ cần sửa một nơi — đỡ đau đầu hơn nhiều.
E2E testing với Detox: Khi cần kiểm soát sâu hơn
Detox là framework E2E testing do Wix phát triển, dùng phương pháp gray-box testing — nghĩa là Detox hiểu được internal state của app (network requests, animations, timers) để đồng bộ hoá test chính xác hơn.
So với Maestro, Detox phức tạp hơn khi setup nhưng mạnh hơn trong một số tình huống cụ thể:
- Cần test native modules hoặc tương tác sâu với platform
- Cần đồng bộ chính xác với animations và network requests
- Team đã quen viết test bằng JavaScript/TypeScript
- Cần tích hợp chặt chẽ với Jest runner
Cài đặt Detox
# Cài Detox CLI global
npm install -g detox-cli
# Cài Detox vào project
npm install --save-dev detox
# Khởi tạo cấu hình Detox
detox init
Lệnh detox init sẽ tạo file .detoxrc.js và thư mục e2e/ với cấu hình mặc định. Từ đây trở đi thì bắt đầu customize theo project của bạn.
Cấu hình Detox cho Expo
// .detoxrc.js
/** @type {Detox.DetoxConfig} */
module.exports = {
testRunner: {
args: {
$0: "jest",
config: "e2e/jest.config.js",
},
jest: {
setupTimeout: 120000,
},
},
apps: {
"ios.debug": {
type: "ios.app",
binaryPath:
"ios/build/Build/Products/Debug-iphonesimulator/YourApp.app",
build:
"xcodebuild -workspace ios/YourApp.xcworkspace -scheme YourApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
},
"android.debug": {
type: "android.apk",
binaryPath: "android/app/build/outputs/apk/debug/app-debug.apk",
build:
"cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug",
},
},
devices: {
simulator: {
type: "ios.simulator",
device: { type: "iPhone 16" },
},
emulator: {
type: "android.emulator",
device: { avdName: "Pixel_7_API_35" },
},
},
configurations: {
"ios.sim.debug": {
device: "simulator",
app: "ios.debug",
},
"android.emu.debug": {
device: "emulator",
app: "android.debug",
},
},
};
Viết test với Detox
// e2e/login.test.ts
import { device, element, by, expect } from "detox";
describe("Login Flow", () => {
beforeAll(async () => {
await device.launchApp({ newInstance: true });
});
beforeEach(async () => {
await device.reloadReactNative();
});
it("should show login screen", async () => {
await expect(element(by.text("Đăng nhập"))).toBeVisible();
});
it("should show error for empty fields", async () => {
await element(by.text("Đăng nhập")).tap();
await expect(
element(by.text("Vui lòng nhập đầy đủ email và mật khẩu"))
).toBeVisible();
});
it("should login successfully with valid credentials", async () => {
await element(by.id("email-input")).typeText("[email protected]");
await element(by.id("password-input")).typeText("password123");
// Ẩn keyboard trước khi tap button
await element(by.id("password-input")).tapReturnKey();
await element(by.id("login-button")).tap();
// Detox tự đợi navigation animation hoàn tất
await expect(element(by.text("Trang chủ"))).toBeVisible();
});
it("should show error for wrong credentials", async () => {
await element(by.id("email-input")).typeText("[email protected]");
await element(by.id("password-input")).typeText("wrongpassword");
await element(by.id("password-input")).tapReturnKey();
await element(by.id("login-button")).tap();
await expect(
element(by.text("Sai email hoặc mật khẩu"))
).toBeVisible();
});
});
# Build app cho test
detox build --configuration ios.sim.debug
# Chạy test
detox test --configuration ios.sim.debug
So sánh Maestro và Detox: Chọn cái nào?
Đây là câu hỏi mà khá nhiều team đặt ra. Và thành thật, không có câu trả lời đúng cho tất cả — nó phụ thuộc vào context của bạn.
| Tiêu chí | Maestro | Detox |
|---|---|---|
| Cách tiếp cận | Black-box (không biết internal) | Gray-box (hiểu internal state) |
| Ngôn ngữ test | YAML | JavaScript / TypeScript |
| Thời gian setup | 5 phút | 30–60 phút |
| Cần sửa code app? | Không | Có (testID, cấu hình native) |
| Tốc độ chạy test | 12–18 giây/flow | 8–12 giây/flow |
| Tỷ lệ flaky test | Rất thấp (auto-retry) | Thấp (<2%, auto-sync) |
| Debug khi fail | Screenshot + recording | Stack trace chi tiết |
| CI/CD | Maestro Cloud hoặc self-host | Self-host (cần build server) |
| Phù hợp với | Hầu hết dự án, team nhỏ-vừa | Dự án lớn, cần test native sâu |
Khuyến nghị của mình: Nếu bạn chưa có E2E test, cứ bắt đầu với Maestro. Setup nhanh, viết test dễ, và đủ mạnh cho hầu hết use case. Chỉ cân nhắc chuyển sang Detox khi bạn thực sự cần test native interaction phức tạp hoặc cần sync chính xác với animations.
Tích hợp testing vào CI/CD với EAS
Test chỉ thực sự có giá trị khi chạy tự động. Viết test xong rồi để đó không chạy thì cũng như không viết vậy. Đây là cách tích hợp testing vào pipeline CI/CD với EAS Workflows:
Chạy Jest tests trong CI
# .eas/workflows/test.yml
name: Run Tests
on:
push:
branches: ["main", "develop"]
pull_request:
branches: ["main"]
jobs:
unit-tests:
name: Unit & Component Tests
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test -- --coverage --ci
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
Chạy Maestro E2E tests trong CI
# .eas/workflows/e2e-test-android.yml
name: E2E Tests (Android)
on:
push:
branches: ["main"]
jobs:
e2e-android:
name: Maestro E2E Android
steps:
- uses: actions/checkout@v4
- uses: eas/build@v1
id: build
with:
platform: android
profile: preview
- uses: mobile-dev-inc/action-maestro-cloud@v1
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
app-file: ${{ steps.build.outputs.app-path }}
flows: maestro/
Với setup này, mỗi khi push code, Jest tests sẽ chạy ngay để phát hiện regression nhanh. Còn Maestro E2E tests chạy trên main branch để verify toàn bộ luồng trước khi release. Cá nhân mình thấy workflow này hoạt động khá ổn cho team 3-8 người.
Best practices khi viết test cho React Native
Sau khi làm việc với testing trong nhiều dự án React Native, đây là những best practices mà mình thấy thực sự có giá trị:
1. Tuân theo pattern Arrange-Act-Assert (AAA)
it("should add item to cart", async () => {
// Arrange — chuẩn bị dữ liệu và render component
const mockAddToCart = jest.fn();
render(<ProductCard product={sampleProduct} onAddToCart={mockAddToCart} />);
// Act — thực hiện hành động
fireEvent.press(screen.getByText("Thêm vào giỏ"));
// Assert — kiểm tra kết quả
expect(mockAddToCart).toHaveBeenCalledWith(sampleProduct.id);
});
Pattern này giúp test dễ đọc và dễ maintain. Khi test fail, bạn biết ngay vấn đề nằm ở phần nào.
2. Test behavior, không test implementation
// ❌ SAI — test implementation detail
it("should set loading state to true", () => {
const { result } = renderHook(() => useAuth());
act(() => result.current.login("email", "pass"));
expect(result.current.isLoading).toBe(true); // Test internal state
});
// ✅ ĐÚNG — test behavior mà user thấy
it("should show loading indicator during login", async () => {
render(<LoginScreen />);
fireEvent.press(screen.getByText("Đăng nhập"));
expect(screen.getByTestId("loading-spinner")).toBeTruthy();
});
Đây có lẽ là bài học quan trọng nhất. Test implementation detail sẽ khiến test liên tục break khi bạn refactor, dù behavior không thay đổi gì cả.
3. Giữ test độc lập
Mỗi test phải chạy được riêng lẻ mà không phụ thuộc vào test khác. Dùng beforeEach để reset state, và tránh chia sẻ biến giữa các test case. Mình từng debug mất nửa ngày chỉ vì test A vô tình thay đổi state ảnh hưởng đến test B.
4. Ưu tiên query theo thứ tự accessibility
Dùng getByRole và getByText trước, getByTestId sau cùng. Cách này vừa giúp test gần với cách user tương tác, vừa khuyến khích bạn viết code accessible hơn — một mũi tên trúng hai đích.
5. Không lạm dụng snapshot testing
Snapshot test dễ viết nhưng dễ trở thành gánh nặng. Chỉ dùng snapshot cho component ít thay đổi (icons, static layouts). Đối với component UI phức tạp, hãy viết assertion cụ thể thay vì snapshot. Mình đã thấy nhiều project có hàng trăm snapshot file mà không ai thèm review khi chúng thay đổi — lúc đó snapshot test không còn ý nghĩa gì nữa.
Câu hỏi thường gặp
Nên bắt đầu viết test cho React Native từ đâu?
Hãy bắt đầu từ utility functions và custom hooks — đây là phần dễ test nhất và mang lại ROI cao nhất. Sau đó mở rộng sang component test cho các component quan trọng (form, auth, checkout). E2E test thì viết cho 3-5 luồng critical nhất của app là đủ. Không cần đạt 100% coverage — tập trung vào phần code thay đổi thường xuyên và có impact lớn.
React Test Renderer có còn dùng được không?
Không nên dùng nữa. React Test Renderer đã bị deprecate từ React 19 và không còn được hỗ trợ chính thức. React Native Testing Library (RNTL) v13 là sự thay thế được khuyến nghị. Nếu project cũ vẫn đang dùng React Test Renderer, hãy lên kế hoạch migrate sang RNTL sớm — API tương tự nhưng triết lý testing tốt hơn nhiều.
Maestro có miễn phí không? Chi phí chạy trên CI như thế nào?
Maestro CLI hoàn toàn miễn phí và mã nguồn mở. Bạn có thể chạy test trên máy local hoặc CI server riêng mà không tốn phí. Maestro Cloud là dịch vụ trả phí cho việc chạy test song song trên cloud — phù hợp khi team cần tốc độ. Với team nhỏ (dưới 5 người), chạy Maestro local hoặc trên GitHub Actions với emulator là quá đủ.
Làm sao để test React Native app có dùng native modules?
Với unit/component test, bạn cần mock native modules trong jest.setup.ts. Hầu hết thư viện phổ biến như react-native-camera, react-native-maps, expo-location đều cung cấp sẵn mock hoặc có hướng dẫn mock. Với E2E test thì đơn giản hơn — Maestro và Detox đều chạy trên thiết bị thật hoặc emulator nên native modules hoạt động bình thường, không cần mock gì cả.
Detox hay Maestro phù hợp hơn cho dự án dùng Expo?
Maestro phù hợp hơn cho hầu hết dự án Expo. Lý do rất đơn giản: Maestro không yêu cầu sửa code hay cấu hình native — rất hợp với managed workflow của Expo. Detox cần cấu hình native build phức tạp hơn, phù hợp hơn với bare workflow hoặc khi bạn thực sự cần test tương tác native sâu. Expo cũng đã tích hợp sẵn hướng dẫn chạy Maestro trên EAS Workflows, nên setup CI khá dễ dàng.