Bắt đầu từ Jest Getting Started. Tập trung vào các phần: Matchers, Mock Functions, Setup and Teardown. Đọc kỹ phần Mock Functions vì đây là nền tảng cho mọi loại test trong React Native.
Đọc toàn bộ docs tại callstack.github.io/react-native-testing-library. Ưu tiên các phần: API Overview, Queries (getByText, getByRole, getByTestId), fireEvent, waitFor. Đọc thêm bài Testing Implementation Details của Kent C. Dodds.
Chọn 5 custom hooks đã viết từ Module trước (useDebounce, useToggle, useFetch, useForm, useLocalStorage). Viết tests bao gồm: initial state, state updates, edge cases, cleanup. Sử dụng renderHook và act.
Viết tests cho các component thực tế: LoginForm (validation, submit), SearchBar (input, debounce), FlatList rendering, Modal (open/close), ErrorBoundary. Tập trung test user interaction chứ không test internal state.
Cài đặt Detox theo official docs. Viết 1 E2E test hoàn chỉnh cho flow Login → Home Screen. Nếu gặp khó khăn với Detox, thử Maestro như alternative đơn giản hơn.
Nguyên tắc quan trọng nhất trong testing: test phải mô phỏng cách user sử dụng ứng dụng, không phải cách code được implement bên trong.
Tại sao? Vì khi refactor code (thay đổi implementation nhưng giữ nguyên behavior), tests không nên bị fail. Nếu tests fail khi refactor, nghĩa là tests đang coupled quá chặt với implementation details.
| ❌ Test Implementation | ✅ Test Behavior |
|---|---|
| Kiểm tra state internal có đúng value không | Kiểm tra UI hiển thị đúng text/element không |
| Kiểm tra function nào được gọi bên trong | Kiểm tra kết quả cuối cùng user nhìn thấy |
| Spy vào lifecycle methods | Kiểm tra component render đúng sau action |
| Kiểm tra instance.state.count === 5 | Kiểm tra screen.getByText("5") tồn tại |
Testing Pyramid là mô hình phân bổ số lượng test theo từng level. Mỗi level có chi phí (thời gian chạy, độ phức tạp setup) và giá trị (confidence) khác nhau.
| Level | Tỷ lệ | Tốc độ | Confidence | Ví dụ |
|---|---|---|---|---|
| Unit Tests | ~70% | Rất nhanh (ms) | Thấp-Trung bình | Test pure functions, utils, hooks |
| Integration Tests | ~20% | Nhanh (s) | Trung bình-Cao | Test component + context + API mock |
| E2E Tests | ~10% | Chậm (min) | Rất cao | Test full user flow trên device/emulator |
Tỷ lệ 70/20/10 là guideline, không phải rule cứng. Nhiều team hiện đại nghiêng về "Testing Trophy" (Kent C. Dodds) — ưu tiên integration tests hơn vì chúng mang lại confidence cao nhất so với chi phí bỏ ra.
Jest là test runner mặc định trong React Native. Dưới đây là các API core cần nắm vững.
describe nhóm các test liên quan. it (hoặc test) định nghĩa một test case. expect tạo assertion.
describe("Calculator", () => {
it("should add two numbers correctly", () => {
expect(add(2, 3)).toBe(5);
});
it("should handle negative numbers", () => {
expect(add(-1, -2)).toBe(-3);
});
it("should return 0 when adding opposites", () => {
expect(add(5, -5)).toBe(0);
});
});
Jest cung cấp nhiều matchers khác nhau cho từng kiểu dữ liệu.
expect(value).toBe(42);
expect(value).toEqual({ name: "Linh" });
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();
expect(value).toContain("hello");
expect(value).toHaveLength(3);
expect(value).toBeGreaterThan(10);
expect(fn).toThrow();
expect(fn).toThrow("specific error message");
beforeEach chạy trước mỗi test, dùng để reset state. afterEach chạy sau mỗi test, dùng để cleanup resources. Còn có beforeAll và afterAll chạy một lần cho cả describe block.
describe("UserService", () => {
let db;
beforeEach(() => {
db = createTestDatabase();
});
afterEach(() => {
db.cleanup();
});
it("should create a user", () => {
const user = db.createUser({ name: "Linh" });
expect(user.id).toBeDefined();
expect(user.name).toBe("Linh");
});
it("should find user by id", () => {
const created = db.createUser({ name: "Minh" });
const found = db.findById(created.id);
expect(found.name).toBe("Minh");
});
});
jest.fn() tạo mock function để theo dõi calls và control return value. jest.spyOn() wrap một method thật để theo dõi mà không thay đổi behavior.
const mockCallback = jest.fn();
mockCallback.mockReturnValue(42);
mockCallback.mockResolvedValue({ data: "test" });
expect(mockCallback).toHaveBeenCalled();
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("arg1", "arg2");
const spy = jest.spyOn(console, "warn");
doSomething();
expect(spy).toHaveBeenCalledWith("warning message");
spy.mockRestore();
Mock toàn bộ module, rất hữu ích khi cần thay thế native modules hoặc external dependencies.
jest.mock("@react-native-async-storage/async-storage", () => ({
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
}));
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
jest.mock("../api/userApi", () => ({
fetchUser: jest.fn().mockResolvedValue({
id: 1,
name: "Test User",
}),
}));
RNTL là thư viện testing cho React Native, xây dựng trên triết lý "test behavior, not implementation". Nó cung cấp API giống cách user tương tác với app.
render() mount component vào test environment. screen cung cấp các queries để tìm elements.
import { render, screen } from "@testing-library/react-native";
render(<LoginForm />);
const emailInput = screen.getByPlaceholderText("Email");
const submitBtn = screen.getByRole("button", { name: "Đăng nhập" });
const title = screen.getByText("Chào mừng");
const avatar = screen.getByTestId("user-avatar");
Thứ tự ưu tiên khi chọn query (theo accessibility):
| Priority | Query | Khi nào dùng |
|---|---|---|
| 1 | getByRole | Luôn ưu tiên — accessible với screen readers |
| 2 | getByText | Text user nhìn thấy trên screen |
| 3 | getByPlaceholderText | Input fields |
| 4 | getByDisplayValue | Input với giá trị hiện tại |
| 5 | getByTestId | Chỉ khi không có cách nào khác |
getBy* — throw error nếu không tìm thấy (dùng cho elements chắc chắn tồn tại).
queryBy* — trả về null nếu không tìm thấy (dùng để assert element KHÔNG tồn tại).
findBy* — trả về Promise, tự động retry (dùng cho async elements).
fireEvent trigger events trên elements. Dùng để simulate user interactions.
import { render, screen, fireEvent } from "@testing-library/react-native";
render(<LoginForm onSubmit={mockSubmit} />);
fireEvent.changeText(
screen.getByPlaceholderText("Email"),
"test@example.com"
);
fireEvent.changeText(
screen.getByPlaceholderText("Mật khẩu"),
"password123"
);
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
expect(mockSubmit).toHaveBeenCalledWith({
email: "test@example.com",
password: "password123",
});
waitFor retry assertion cho đến khi pass hoặc timeout. Dùng khi test async operations (API calls, animations, timers).
import { render, screen, waitFor } from "@testing-library/react-native";
render(<UserProfile userId={1} />);
expect(screen.getByText("Đang tải...")).toBeTruthy();
await waitFor(() => {
expect(screen.getByText("Linh Phan")).toBeTruthy();
});
expect(screen.queryByText("Đang tải...")).toBeNull();
Custom hooks không thể gọi trực tiếp ngoài component. RNTL cung cấp renderHook để test hooks trong isolation.
import { renderHook, act } from "@testing-library/react-native";
import { useCounter } from "../hooks/useCounter";
describe("useCounter", () => {
it("should initialize with default value", () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
});
it("should increment counter", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("should reset counter", () => {
const { result } = renderHook(() => useCounter(10));
act(() => {
result.current.increment();
result.current.increment();
});
expect(result.current.count).toBe(12);
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(10);
});
});
act() đảm bảo tất cả state updates và effects được flush trước khi assertion chạy. Mọi thao tác gây state change trong hook đều phải wrap trong act().
import { renderHook, waitFor } from "@testing-library/react-native";
import { useFetch } from "../hooks/useFetch";
jest.mock("../api/client", () => ({
get: jest.fn().mockResolvedValue({ data: { name: "Linh" } }),
}));
describe("useFetch", () => {
it("should fetch data and update state", async () => {
const { result } = renderHook(() => useFetch("/users/1"));
expect(result.current.loading).toBe(true);
expect(result.current.data).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.data).toEqual({ name: "Linh" });
});
});
});
React Native có nhiều native modules (Camera, Geolocation, AsyncStorage...) không chạy được trong Jest environment. Cần mock chúng.
Tạo file setup chung để mock các native modules thường dùng. Config trong jest.config.js hoặc package.json.
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
jest.mock("@react-native-async-storage/async-storage", () =>
require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);
jest.mock("react-native-safe-area-context", () => {
const inset = { top: 0, right: 0, bottom: 0, left: 0 };
return {
SafeAreaProvider: ({ children }) => children,
SafeAreaView: ({ children }) => children,
useSafeAreaInsets: () => inset,
};
});
jest.mock("@react-navigation/native", () => ({
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
}),
useRoute: () => ({
params: {},
}),
}));
Tạo thư mục __mocks__ cùng cấp với module cần mock. Jest tự động sử dụng file mock thay cho module thật.
project/
├── __mocks__/
│ └── react-native-camera.js
├── src/
│ └── components/
│ └── Scanner.tsx
└── jest.config.js
MSW intercept network requests ở tầng network, không cần mock fetch/axios. Tests chạy gần giống production code hơn so với jest.mock.
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
const handlers = [
http.get("https://api.example.com/users/:id", ({ params }) => {
return HttpResponse.json({
id: params.id,
name: "Test User",
email: "test@example.com",
});
}),
http.post("https://api.example.com/login", async ({ request }) => {
const body = await request.json();
if (body.email === "valid@test.com") {
return HttpResponse.json({ token: "fake-jwt-token" });
}
return HttpResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}),
];
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
it("should show error when API fails", async () => {
server.use(
http.get("https://api.example.com/users/:id", () => {
return HttpResponse.json(
{ error: "Server Error" },
{ status: 500 }
);
})
);
render(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText("Đã xảy ra lỗi")).toBeTruthy();
});
});
Snapshot testing lưu output render của component thành file. Lần chạy sau so sánh với snapshot cũ để phát hiện thay đổi.
import { render } from "@testing-library/react-native";
it("should match snapshot", () => {
const tree = render(<ProfileCard name="Linh" role="Developer" />);
expect(tree.toJSON()).toMatchSnapshot();
});
| Pros ✅ | Cons ❌ |
|---|---|
| Phát hiện thay đổi UI ngoài ý muốn (regression detection) | Dễ bị brittle — thay đổi nhỏ cũng fail |
| Setup nhanh, viết ít code | Developer thường "update all" mà không review kỹ |
| Document UI output tại thời điểm test | Snapshot files lớn gây noise trong code review |
| Tốt cho components ít thay đổi (icons, badges) | Không test behavior, chỉ test output structure |
toMatchInlineSnapshot() cho small components để dễ reviewDetox (by Wix) là framework E2E testing cho React Native. Chạy tests trên emulator/simulator thật, simulate user actions thực tế.
npm install detox --save-dev
npx detox init
File .detoxrc.js config devices và build configurations.
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/MyApp.app",
build:
"xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build",
},
},
devices: {
simulator: {
type: "ios.simulator",
device: {
type: "iPhone 15",
},
},
},
configurations: {
"ios.sim.debug": {
device: "simulator",
app: "ios.debug",
},
},
};
Detox cung cấp API: element() tìm element, by.id() / by.text() làm matcher, .tap() / .typeText() là actions.
describe("Login Flow", () => {
beforeAll(async () => {
await device.launchApp();
});
beforeEach(async () => {
await device.reloadReactNative();
});
it("should login successfully with valid credentials", async () => {
await element(by.id("email-input")).typeText("user@test.com");
await element(by.id("password-input")).typeText("password123");
await element(by.id("login-button")).tap();
await expect(element(by.text("Welcome"))).toBeVisible();
});
it("should show error with invalid credentials", async () => {
await element(by.id("email-input")).typeText("wrong@test.com");
await element(by.id("password-input")).typeText("wrong");
await element(by.id("login-button")).tap();
await expect(
element(by.text("Email hoặc mật khẩu không đúng"))
).toBeVisible();
});
});
| Action | Mô tả |
|---|---|
.tap() | Nhấn vào element |
.longPress() | Nhấn giữ |
.typeText("...") | Nhập text vào input |
.clearText() | Xóa text trong input |
.scroll(200, "down") | Scroll xuống 200px |
.swipe("left") | Swipe sang trái |
| Assertion | Mô tả |
|---|---|
.toBeVisible() | Element hiện trên màn hình |
.toExist() | Element tồn tại trong tree |
.toHaveText("...") | Element có text cụ thể |
.not.toBeVisible() | Element không hiện |
Maestro là E2E testing tool sử dụng YAML thay vì JavaScript. Không cần build step phức tạp, dễ setup hơn Detox nhiều.
appId: com.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "user@test.com"
- tapOn: "Mật khẩu"
- inputText: "password123"
- tapOn: "Đăng nhập"
- assertVisible: "Welcome"
| Tiêu chí | Detox | Maestro |
|---|---|---|
| Ngôn ngữ | JavaScript | YAML |
| Setup complexity | Cao | Thấp |
| CI/CD integration | Tốt | Tốt (Maestro Cloud) |
| Flakiness | Thấp (grey-box) | Trung bình (black-box) |
| Community | Lớn, mature | Đang phát triển |
| Cross-platform | iOS + Android | iOS + Android + Web |
Code coverage đo lường bao nhiêu phần code được chạy qua bởi tests. Targets hợp lý cho RN project:
| Metric | Target | Giải thích |
|---|---|---|
| Statements | ≥ 80% | Tỷ lệ statements được execute |
| Branches | ≥ 70% | Tỷ lệ if/else/switch branches được cover |
| Functions | ≥ 80% | Tỷ lệ functions được gọi |
| Lines | ≥ 80% | Tỷ lệ lines được execute |
Config coverage trong jest.config.js:
module.exports = {
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "clover"],
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/**/*.stories.{ts,tsx}",
"!src/**/index.{ts,tsx}",
],
coverageThreshold: {
global: {
statements: 80,
branches: 70,
functions: 80,
lines: 80,
},
},
};
| Triệu chứng | Kiểm tra | Fix thường gặp |
|---|---|---|
| Test pass local fail CI | Timing, network mock, emulator speed, fixed sleep | Dùng findBy/waitFor, deterministic mock, bỏ sleep cố định |
| Test sau bị ảnh hưởng test trước | Mock reset, QueryClient cache, persisted storage | New wrapper mỗi test, clear mocks/cache/storage |
| Snapshot fail liên tục | Snapshot đang assert gì có giá trị không | Thay bằng behavior assertions |
| Mock native module không chạy | jest.setup, module name, manual mock path | Mock boundary rõ, assert behavior thay vì native detail |
Khung trả lời phỏng vấn: Nói testing pyramid trước, sau đó ví dụ cụ thể: unit cho logic, component test cho user behavior, integration cho provider/navigation/API state, E2E cho smoke flow quan trọng.
Testing behavior nghĩa là test từ góc nhìn user — input gì, output gì, UI hiển thị gì. Không quan tâm code bên trong viết thế nào.
Ví dụ: Component Counter có nút "+" và hiển thị số. Test implementation sẽ kiểm tra component.state.count === 1 sau khi nhấn. Test behavior sẽ kiểm tra screen.getByText("1") tồn tại sau khi fireEvent.press(plusButton).
Khi refactor từ useState sang useReducer, test behavior vẫn pass vì UI không đổi, nhưng test implementation sẽ fail vì state structure thay đổi. Tests fragile = developer mất trust vào test suite = bỏ qua test failures = test suite vô giá trị.
Testing Pyramid gồm 3 tầng:
Tỷ lệ này đảm bảo: nhiều tests nhanh ở base, ít tests chậm ở top. Nếu lật ngược (nhiều E2E, ít unit) sẽ có test suite chậm, flaky, và expensive — gọi là "Ice Cream Cone" anti-pattern.
Nhớ gọi spy.mockRestore() trong afterEach để restore method gốc, tránh ảnh hưởng tests khác.
expect(screen.queryByText("Error")).toBeNull().const el = await screen.findByText("Data loaded").Rule: dùng getBy* by default, queryBy* cho negative assertions, findBy* cho async.
Có 3 approaches, sắp xếp từ tốt nhất đến kém nhất:
1. MSW (Mock Service Worker): Intercept ở network layer. Code thật vẫn gọi fetch/axios bình thường. Test sát production nhất. Không couple với HTTP library.
2. jest.mock API module: Mock toàn bộ API module (e.g., jest.mock("../api/userApi")). Nhanh, đơn giản, nhưng couple với module structure.
3. jest.mock("axios"): Mock HTTP library trực tiếp. Nhanh nhất nhưng couple mạnh nhất — đổi sang fetch phải sửa hết tests.
Dù dùng approach nào, luôn test cả happy path (API trả data) và error path (API trả lỗi, network timeout).
Giá trị: Phát hiện thay đổi UI ngoài ý muốn (regression). Viết nhanh, ít code. Tốt cho components ổn định, ít thay đổi (icon sets, static pages).
KHÔNG nên dùng khi:
Best practice: dùng snapshot như supplement, không phải replacement cho behavioral tests. Ưu tiên toMatchInlineSnapshot() cho small components.
act() đảm bảo tất cả state updates, effects (useEffect), và scheduled callbacks được xử lý xong trước khi assertion chạy. Nó mô phỏng cách React batch updates trong browser.
Cần act() khi:
act(() => result.current.increment())act(() => callback())act(() => jest.advanceTimersByTime(1000))KHÔNG cần act() khi: dùng fireEvent hoặc waitFor của RNTL — chúng đã wrap act() bên trong.
Warning "An update was not wrapped in act(...)" nghĩa là có state update xảy ra sau khi test đã kết thúc — thường do async operation không được await hoặc cleanup.
Detox:
Maestro:
Recommendation: Team lớn, project mature → Detox. Team nhỏ, cần nhanh → Maestro. Có thể dùng cả hai cho different purposes.
Có 2 approaches:
Approach 1: Mock hooks trực tiếp
jest.mock("@react-navigation/native", () => ({
useNavigation: () => ({
navigate: jest.fn(),
goBack: jest.fn(),
setOptions: jest.fn(),
}),
useRoute: () => ({
params: { userId: 1 },
}),
useFocusEffect: jest.fn(),
}));
Approach 2: Render với NavigationContainer thật
import { NavigationContainer } from "@react-navigation/native";
render(
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Profile" component={ProfileScreen} />
</Stack.Navigator>
</NavigationContainer>
);
Approach 1 đơn giản hơn cho unit tests. Approach 2 sát thực tế hơn cho integration tests — test navigation flow thật.
Không. Coverage 100% chỉ nghĩa là mọi dòng code đều được execute ít nhất 1 lần. Nó KHÔNG đảm bảo:
Coverage là metric hữu ích để tìm code CHƯA được test, nhưng không phải indicator của test quality. Statements 80% + branches 70% là target hợp lý — focus vào test quality hơn quantity.
Cho component LoginForm nhận props onSubmit và onForgotPassword. Viết test suite đầy đủ.
interface LoginFormProps {
onSubmit: (data: { email: string; password: string }) => Promise<void>;
onForgotPassword: () => void;
}
function LoginForm({ onSubmit, onForgotPassword }: LoginFormProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setError(null);
if (!email.includes("@")) {
setError("Email không hợp lệ");
return;
}
if (password.length < 6) {
setError("Mật khẩu phải có ít nhất 6 ký tự");
return;
}
setLoading(true);
try {
await onSubmit({ email, password });
} catch (err) {
setError("Đăng nhập thất bại");
} finally {
setLoading(false);
}
};
return (
<View>
<Text>Đăng nhập</Text>
{error && <Text testID="error-message">{error}</Text>}
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
autoCapitalize="none"
/>
<TextInput
placeholder="Mật khẩu"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Pressable
onPress={handleSubmit}
disabled={loading}
accessibilityRole="button"
>
<Text>{loading ? "Đang xử lý..." : "Đăng nhập"}</Text>
</Pressable>
<Pressable onPress={onForgotPassword} accessibilityRole="button">
<Text>Quên mật khẩu?</Text>
</Pressable>
</View>
);
}
import { render, screen, fireEvent, waitFor } from "@testing-library/react-native";
import { LoginForm } from "../LoginForm";
describe("LoginForm", () => {
const mockSubmit = jest.fn();
const mockForgotPassword = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
mockSubmit.mockResolvedValue(undefined);
});
const renderForm = () =>
render(
<LoginForm
onSubmit={mockSubmit}
onForgotPassword={mockForgotPassword}
/>
);
it("renders initial state correctly", () => {
renderForm();
expect(screen.getByText("Đăng nhập")).toBeTruthy();
expect(screen.getByPlaceholderText("Email")).toBeTruthy();
expect(screen.getByPlaceholderText("Mật khẩu")).toBeTruthy();
expect(screen.getByText("Quên mật khẩu?")).toBeTruthy();
expect(screen.queryByTestId("error-message")).toBeNull();
});
it("shows email validation error", () => {
renderForm();
fireEvent.changeText(screen.getByPlaceholderText("Email"), "invalid");
fireEvent.changeText(screen.getByPlaceholderText("Mật khẩu"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
expect(screen.getByText("Email không hợp lệ")).toBeTruthy();
expect(mockSubmit).not.toHaveBeenCalled();
});
it("shows password validation error", () => {
renderForm();
fireEvent.changeText(screen.getByPlaceholderText("Email"), "user@test.com");
fireEvent.changeText(screen.getByPlaceholderText("Mật khẩu"), "12345");
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
expect(screen.getByText("Mật khẩu phải có ít nhất 6 ký tự")).toBeTruthy();
expect(mockSubmit).not.toHaveBeenCalled();
});
it("calls onSubmit with valid data", async () => {
renderForm();
fireEvent.changeText(screen.getByPlaceholderText("Email"), "user@test.com");
fireEvent.changeText(screen.getByPlaceholderText("Mật khẩu"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
await waitFor(() => {
expect(mockSubmit).toHaveBeenCalledWith({
email: "user@test.com",
password: "password123",
});
});
});
it("shows loading state during submit", async () => {
mockSubmit.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 1000))
);
renderForm();
fireEvent.changeText(screen.getByPlaceholderText("Email"), "user@test.com");
fireEvent.changeText(screen.getByPlaceholderText("Mật khẩu"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
expect(screen.getByText("Đang xử lý...")).toBeTruthy();
});
it("shows error when submit fails", async () => {
mockSubmit.mockRejectedValue(new Error("Network error"));
renderForm();
fireEvent.changeText(screen.getByPlaceholderText("Email"), "user@test.com");
fireEvent.changeText(screen.getByPlaceholderText("Mật khẩu"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
await waitFor(() => {
expect(screen.getByText("Đăng nhập thất bại")).toBeTruthy();
});
});
it("disables submit button during loading", async () => {
mockSubmit.mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 1000))
);
renderForm();
fireEvent.changeText(screen.getByPlaceholderText("Email"), "user@test.com");
fireEvent.changeText(screen.getByPlaceholderText("Mật khẩu"), "password123");
fireEvent.press(screen.getByRole("button", { name: "Đăng nhập" }));
const button = screen.getByRole("button", { name: "Đang xử lý..." });
expect(button.props.accessibilityState?.disabled).toBe(true);
});
it("calls onForgotPassword when tapped", () => {
renderForm();
fireEvent.press(screen.getByText("Quên mật khẩu?"));
expect(mockForgotPassword).toHaveBeenCalledTimes(1);
});
});
Render app với NavigationContainer. Từ Login nhập credential hợp lệ, mock API success, assert Home screen xuất hiện và Login không còn visible.
Dùng MSW/mock handler cho 401, 422, 500 và network error. Assert UI hiển thị đúng message/action cho từng loại lỗi.
Mock react-native-keychain hoặc expo-secure-store. Test login lưu token đúng và logout xóa token.
Dùng Maestro hoặc Detox cho flow mở app, login, thấy Home, logout. Giữ test ngắn, ổn định, chạy được trên CI.
jest.mock(fetch), bỏ sót behavior thật của request/response.Case: Login test pass local nhưng fail ngẫu nhiên trên CI vì assert Home quá sớm, trước khi token storage và navigation reset hoàn tất.
Cách xử lý: Assert theo behavior bằng findByText/waitFor, reset QueryClient/mock storage mỗi test, tránh sleep cố định. Với E2E, chờ element bằng id/text thay vì hard-coded delay.
Test tốt phải fail khi behavior hỏng, không fail khi refactor implementation. Với RN, ưu tiên test form, navigation, API state, native module boundary và smoke flow quan trọng.
getBy*, queryBy*, findBy*?