Module 12 — Testing

PHẢI CÓ ⏱ 8-10 giờ 📋 Prerequisites: Module 01-04

🎯 MỤC TIÊU HỌC TẬP

📖 HƯỚNG DẪN HỌC

Step 1: Đọc Jest Documentation

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.

Step 2: Đọc React Native Testing Library Documentation

Đọ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.

Step 3: Viết 5 unit tests cho custom hooks

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.

Step 4: Viết 5 component tests cho form/list

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.

Step 5: Setup Detox, viết 1 E2E test

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.

⚡ ÔN NHANH

🧭 Vòng lặp học module này

📚 LÝ THUYẾT CHI TIẾT

1. Testing Philosophy: Test Behavior, Not Implementation

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

2. Testing Pyramid

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

💡 Lưu ý thực tế

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.

3. Jest Fundamentals

Jest là test runner mặc định trong React Native. Dưới đây là các API core cần nắm vững.

3.1 Test Structure: describe, it, expect

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);
  });
});

3.2 Common Matchers

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");

3.3 Setup & Teardown: beforeEach, afterEach

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ó beforeAllafterAll 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");
  });
});

3.4 Mock Functions & Spies

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();

3.5 jest.mock — Module Mocking

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",
  }),
}));

4. React Native Testing Library (RNTL)

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.

4.1 render & screen queries

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

⚠️ Query Variants

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).

4.2 fireEvent & userEvent

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",
});

4.3 waitFor — Async Testing

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();

5. Testing Custom Hooks

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().

Test async hook

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" });
    });
  });
});

6. Mocking Native Modules

React Native có nhiều native modules (Camera, Geolocation, AsyncStorage...) không chạy được trong Jest environment. Cần mock chúng.

6.1 File jest.setup.js

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: {},
  }),
}));

6.2 Manual Mocks (__mocks__ directory)

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

7. Mocking API với MSW (Mock Service Worker)

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.

7.1 Setup MSW

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());

7.2 Override handler cho test cụ thể

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();
  });
});

🔑 Tại sao MSW tốt hơn jest.mock(fetch)?

  • Test gần production hơn: code thật vẫn gọi fetch/axios, chỉ network bị intercept
  • Không couple với implementation: đổi từ fetch sang axios không cần sửa test
  • Reuse handlers: dùng chung handlers giữa tests, Storybook, dev environment
  • Request matching mạnh: match theo URL pattern, method, headers, body

8. Snapshot Testing

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

⚠️ Best Practices cho Snapshot Testing

  • Dùng toMatchInlineSnapshot() cho small components để dễ review
  • Giữ snapshot nhỏ — render chỉ component cần test, không render cả page
  • Review snapshot changes cẩn thận trong PR, đừng blindly update
  • Kết hợp snapshot với behavioral tests, không dùng snapshot đơn lẻ

9. E2E Testing với Detox

Detox (by Wix) là framework E2E testing cho React Native. Chạy tests trên emulator/simulator thật, simulate user actions thực tế.

9.1 Setup Detox

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",
    },
  },
};

9.2 Viết E2E Test

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();
  });
});

9.3 Common Detox Actions & Assertions

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

10. Maestro — Alternative E2E đơn giản hơ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ữJavaScriptYAML
Setup complexityCaoThấp
CI/CD integrationTốtTốt (Maestro Cloud)
FlakinessThấp (grey-box)Trung bình (black-box)
CommunityLớn, matureĐang phát triển
Cross-platformiOS + AndroidiOS + Android + Web

11. Coverage Targets

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,
    },
  },
};

🚫 Coverage Pitfalls

  • 100% coverage ≠ bug-free: coverage chỉ đo code được chạy, không đo logic đúng sai
  • Chasing numbers: viết test vô nghĩa chỉ để tăng coverage % là anti-pattern
  • Branch coverage quan trọng hơn line coverage: vì nó cover edge cases và error paths

❓ CÂU HỎI PHỎNG VẤN + ĐÁP ÁN

🛠️ Debug playbook: test bug

Triệu chứngKiểm traFix thường gặp
Test pass local fail CITiming, network mock, emulator speed, fixed sleepDùng findBy/waitFor, deterministic mock, bỏ sleep cố định
Test sau bị ảnh hưởng test trướcMock reset, QueryClient cache, persisted storageNew wrapper mỗi test, clear mocks/cache/storage
Snapshot fail liên tụcSnapshot đang assert gì có giá trị khôngThay bằng behavior assertions
Mock native module không chạyjest.setup, module name, manual mock pathMock 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.

1. Tại sao nên "test behavior, not implementation"? Cho ví dụ cụ thể.

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ị.

2. Giải thích Testing Pyramid và tỷ lệ phân bổ tests.

Testing Pyramid gồm 3 tầng:

  • Unit Tests (~70%): Test isolated functions, utils, hooks. Chạy rất nhanh (ms). Dễ viết, dễ maintain. Nhưng confidence thấp vì không test integration giữa các parts.
  • Integration Tests (~20%): Test component + dependencies (context, API calls, navigation). Chạy nhanh (seconds). Confidence cao vì test cách các phần kết hợp.
  • E2E Tests (~10%): Test full user flow trên device thật/emulator. Chạy chậm (minutes). Confidence rất cao nhưng expensive, flaky, khó maintain.

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.

3. Sự khác nhau giữa jest.fn(), jest.mock(), và jest.spyOn()?
  • jest.fn(): Tạo một mock function mới hoàn toàn. Không implementation, chỉ record calls. Dùng khi cần truyền callback/handler giả vào component.
  • jest.mock("module"): Mock toàn bộ module. Thay thế tất cả exports bằng jest.fn(). Dùng khi cần thay thế native modules, API clients, hoặc external libraries.
  • jest.spyOn(object, "method"): Wrap method thật, vẫn giữ implementation gốc (trừ khi gọi .mockImplementation()). Record calls để assert. Dùng khi cần theo dõi method call mà vẫn muốn giữ behavior gốc.

Nhớ gọi spy.mockRestore() trong afterEach để restore method gốc, tránh ảnh hưởng tests khác.

4. getByText vs queryByText vs findByText — khi nào dùng cái nào?
  • getByText: Throw error nếu không tìm thấy. Dùng khi chắc chắn element tồn tại. Nếu có 2+ matches cũng throw error.
  • queryByText: Trả về null nếu không tìm thấy. Dùng để assert element KHÔNG tồn tại: expect(screen.queryByText("Error")).toBeNull().
  • findByText: Trả về Promise, retry tự động (default timeout 1000ms). Dùng cho elements xuất hiện sau async operation: const el = await screen.findByText("Data loaded").

Rule: dùng getBy* by default, queryBy* cho negative assertions, findBy* cho async.

5. Làm sao test một component có API call?

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).

6. Snapshot testing có giá trị gì? Khi nào KHÔNG nên dùng?

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:

  • Component thay đổi thường xuyên — snapshot liên tục outdated, developers blindly update
  • Component có dynamic data (dates, random IDs) — snapshots flaky
  • Large components — snapshot files hàng trăm dòng, code review nightmare
  • Thay thế cho behavioral tests — snapshot không test interaction, chỉ test output structure

Best practice: dùng snapshot như supplement, không phải replacement cho behavioral tests. Ưu tiên toMatchInlineSnapshot() cho small components.

7. act() dùng để làm gì? Khi nào cần wrap code trong act()?

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:

  • Gọi function từ renderHook result: act(() => result.current.increment())
  • Trigger state update outside render: act(() => callback())
  • Advance timers: 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.

8. So sánh Detox và Maestro cho E2E testing.

Detox:

  • Grey-box testing — biết app state, tự đợi animations/network kết thúc
  • Ít flaky hơn nhờ synchronization mechanism
  • Viết bằng JavaScript — flexible, programmatic
  • Setup phức tạp, cần build app trước khi test
  • Community lớn, mature, by Wix

Maestro:

  • Black-box testing — không biết app internals
  • YAML-based — rất dễ viết, non-developers cũng viết được
  • Setup đơn giản, dùng app đã build sẵn
  • Maestro Cloud cho CI/CD
  • Hỗ trợ cả iOS, Android, và Web

Recommendation: Team lớn, project mature → Detox. Team nhỏ, cần nhanh → Maestro. Có thể dùng cả hai cho different purposes.

9. Làm sao mock React Navigation trong tests?

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.

10. Code coverage 100% có đảm bảo app không có bug không? Tại sao?

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:

  • Logic correctness: Test có thể execute code mà không assert gì cả
  • Edge cases: Function add(a, b) có coverage 100% chỉ với 1 test case, nhưng có thể fail với negative numbers, overflow, NaN
  • Integration bugs: Mỗi unit pass riêng lẻ nhưng fail khi kết hợp
  • Race conditions: Async bugs không thể detect bằng synchronous tests
  • UI/UX bugs: Layout sai, animation lag, accessibility issues

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.

🏋️ BÀI TẬP

Bài tập: Write tests cho LoginForm

Yêu cầu

Cho component LoginForm nhận props onSubmitonForgotPassword. Viết test suite đầy đủ.

LoginForm Component (để test)

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>
  );
}

Test Cases cần viết

  1. Render đúng initial state (có title, 2 inputs, 2 buttons, không có error)
  2. Hiển thị lỗi "Email không hợp lệ" khi email thiếu @
  3. Hiển thị lỗi "Mật khẩu phải có ít nhất 6 ký tự" khi password ngắn
  4. Gọi onSubmit với email và password đúng format
  5. Hiển thị "Đang xử lý..." khi đang loading
  6. Hiển thị lỗi "Đăng nhập thất bại" khi onSubmit reject
  7. Disable button khi đang loading
  8. Gọi onForgotPassword khi nhấn "Quên mật khẩu?"

Lời giải

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);
  });
});

🧩 MINI EXERCISE THỰC TẾ REACT NATIVE

Exercise 1: Test navigation flow

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.

Exercise 2: Test API error matrix

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.

Exercise 3: Mock native module

Mock react-native-keychain hoặc expo-secure-store. Test login lưu token đúng và logout xóa token.

Exercise 4: E2E smoke test

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.

⚠️ LỖI THƯỜNG GẶP

🏢 CASE ĐI LÀM THẬT

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.

🧠 GHI NHỚ NHANH

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.

❔ CÂU HỎI TỰ KIỂM TRA

1. Khi nào dùng getBy*, queryBy*, findBy*?
2. Vì sao E2E không nên cover mọi edge case?
3. Một test flaky trên CI nhưng pass local. Bạn kiểm tra gì trước?
4. Native module nào trong app cần mock thủ công và vì sao?

✅ CHECKLIST TỰ ĐÁNH GIÁ

📎 TÀI NGUYÊN

Documentation

Articles & Blogs

Videos

Tools