คู่มือทดสอบ React Native ปี 2026: Jest, RNTL และ Maestro จาก Unit Test ถึง E2E

คู่มือทดสอบแอป React Native ครบวงจรปี 2026 ตั้งแต่ Unit Test ด้วย Jest, Component Test ด้วย RNTL จนถึง E2E Test ด้วย Maestro พร้อมโค้ดตัวอย่าง การ mock native modules, ทดสอบ Expo Router และ CI/CD setup

ทำไมการทดสอบแอป React Native ถึงสำคัญมากในปี 2026

ถ้าคุณเคยเจอสถานการณ์ที่แก้บั๊กตรงนี้แล้วดันพังตรงโน้น หรือรีลีสแอปไปแล้วมีผู้ใช้มาแจ้งว่าฟีเจอร์เก่าใช้ไม่ได้ — คุณไม่ได้อยู่คนเดียว ปัญหาพวกนี้มีต้นตอเดียวกันเกือบทุกครั้ง ก็คือไม่มีการทดสอบอัตโนมัติที่ดีพอนั่นเอง

ยิ่งในปี 2026 นี้ แอป React Native มันซับซ้อนขึ้นเยอะมาก New Architecture (Fabric + TurboModules) เปลี่ยนวิธีที่ native bridge ทำงาน, Expo SDK 53+ อัปเดตถี่มากจนตามแทบไม่ทัน, แล้วก็ React Navigation v7 ที่เปลี่ยน API ไปพอสมควร ถ้าไม่มีชุดเทสต์คอยช่วยตรวจจับ การอัปเกรดแต่ละทีจะกลายเป็นฝันร้ายเลยจริงๆ

บทความนี้จะพาคุณผ่านกลยุทธ์การทดสอบแบบครบวงจรสำหรับ React Native ตั้งแต่ Unit Test ด้วย Jest, Component Test ด้วย React Native Testing Library (RNTL) ไปจนถึง E2E Test ด้วย Maestro พร้อมโค้ดตัวอย่างที่ copy ไปใช้ได้เลยทุกตัว

Testing Pyramid — วางแผนการทดสอบให้ถูกทาง

ก่อนจะลงมือเขียนเทสต์ เรามาเคลียร์เรื่อง Testing Pyramid กันก่อนดีกว่า เพราะหลักการนี้ช่วยให้คุณจัดสรรเวลาและทรัพยากรได้อย่างมีประสิทธิภาพจริงๆ

  • Unit Tests (70%): ทดสอบฟังก์ชันเล็กๆ แยกอิสระ รันเร็วมาก ใช้ Jest
  • Component/Integration Tests (20%): ทดสอบ component ว่าเรนเดอร์ถูกต้องและตอบสนองต่อ interaction ของผู้ใช้ได้ ใช้ RNTL
  • E2E Tests (10%): ทดสอบ flow ทั้งหมดของแอปจริงบน simulator/emulator ใช้ Maestro

หลักคิดง่ายๆ คือ ยิ่งอยู่ชั้นล่างของพีระมิด ยิ่งควรเขียนมาก เพราะรันเร็วและต้นทุนต่ำ ส่วน E2E Test เขียนเฉพาะ flow สำคัญๆ พอ เช่น login, ชำระเงิน, หรือ core feature หลักของแอป

เริ่มต้น: ตั้งค่า Jest สำหรับ React Native

การติดตั้งสำหรับโปรเจกต์ Expo

สำหรับโปรเจกต์ Expo (SDK 53+) การตั้งค่า Jest ทำได้ง่ายมากครับ เพราะมี preset สำเร็จรูปอย่าง jest-expo ที่จัดการ mock native modules ให้อัตโนมัติ ไม่ต้องนั่ง config อะไรเยอะ

npx expo install jest-expo jest @types/jest -- --save-dev

จากนั้นสร้างหรือแก้ไฟล์ jest.config.js ที่ root ของโปรเจกต์:

/** @type {import("jest").Config} */
module.exports = {
  preset: "jest-expo/universal",
  setupFilesAfterSetup: [
    "@testing-library/react-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)",
  ],
};

ส่วน preset: "jest-expo/universal" จะรันเทสต์บนทุก platform ที่ Expo รองรับ ทั้ง iOS, Android, Web และ Node (สำหรับ SSR) ซึ่งมีประโยชน์มากถ้าแอปของคุณต้องรองรับหลาย platform พร้อมกัน

การติดตั้งสำหรับ React Native CLI

สำหรับโปรเจกต์ที่ใช้ React Native CLI โดยตรง ก็ไม่ได้ยากอะไร:

npm install --save-dev jest @types/jest @testing-library/react-native @testing-library/jest-native

แล้วตั้งค่า jest.config.js แบบนี้:

/** @type {import("jest").Config} */
module.exports = {
  preset: "react-native",
  setupFilesAfterSetup: [
    "@testing-library/react-native/extend-expect",
  ],
  moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
};

เพิ่ม script ใน package.json

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Unit Test ด้วย Jest — ทดสอบ Logic แบบแยกอิสระ

Unit test เหมาะกับการทดสอบฟังก์ชัน utility, business logic, custom hooks และ helper ต่างๆ ที่ไม่ได้ยุ่งกับ UI โดยตรง พูดง่ายๆ คือเทสต์ "สมอง" ของแอปนั่นแหละ

ตัวอย่าง: ทดสอบฟังก์ชัน Utility

สมมติเรามีฟังก์ชันจัดรูปแบบราคาสินค้า แบบที่น่าจะเจอในแอป e-commerce ทั่วไป:

// utils/formatPrice.ts
export function formatPrice(amount: number, currency: string = "THB"): string {
  if (amount < 0) throw new Error("Amount cannot be negative");

  const formatted = amount.toLocaleString("th-TH", {
    minimumFractionDigits: 2,
    maximumFractionDigits: 2,
  });

  return currency === "THB" ? `฿${formatted}` : `${formatted} ${currency}`;
}

export function calculateDiscount(
  price: number,
  discountPercent: number
): number {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new Error("Discount must be between 0 and 100");
  }
  return Math.round((price * (1 - discountPercent / 100)) * 100) / 100;
}

มาดูไฟล์เทสต์กัน:

// __tests__/utils/formatPrice.test.ts
import { formatPrice, calculateDiscount } from "../../utils/formatPrice";

describe("formatPrice", () => {
  it("formats THB price correctly", () => {
    expect(formatPrice(1500)).toBe("฿1,500.00");
  });

  it("formats zero price", () => {
    expect(formatPrice(0)).toBe("฿0.00");
  });

  it("formats with custom currency", () => {
    expect(formatPrice(99.5, "USD")).toBe("99.50 USD");
  });

  it("throws error for negative amount", () => {
    expect(() => formatPrice(-100)).toThrow("Amount cannot be negative");
  });
});

describe("calculateDiscount", () => {
  it("calculates 20% discount correctly", () => {
    expect(calculateDiscount(1000, 20)).toBe(800);
  });

  it("returns same price for 0% discount", () => {
    expect(calculateDiscount(500, 0)).toBe(500);
  });

  it("returns 0 for 100% discount", () => {
    expect(calculateDiscount(500, 100)).toBe(0);
  });

  it("handles decimal results", () => {
    expect(calculateDiscount(99, 15)).toBe(84.15);
  });

  it("throws error for invalid discount", () => {
    expect(() => calculateDiscount(100, 150)).toThrow(
      "Discount must be between 0 and 100"
    );
  });
});

การ Mock API Calls ด้วย Jest

เวลาต้องทดสอบฟังก์ชันที่เรียก API จะต้อง mock การเรียก network ออก ไม่งั้นเทสต์จะไปพึ่งพา server จริงซึ่งทำให้เทสต์ช้าและไม่เสถียร (เคยเจอเทสต์พังเพราะ server ล่มไหม? ไม่สนุกเลย)

// services/userService.ts
export async function fetchUserProfile(userId: string) {
  const response = await fetch(
    `https://api.example.com/users/${userId}`
  );
  if (!response.ok) throw new Error("User not found");
  return response.json();
}

// __tests__/services/userService.test.ts
import { fetchUserProfile } from "../../services/userService";

// Mock global fetch
global.fetch = jest.fn();

describe("fetchUserProfile", () => {
  beforeEach(() => {
    (fetch as jest.Mock).mockClear();
  });

  it("returns user data on success", async () => {
    const mockUser = { id: "1", name: "สมชาย", email: "[email protected]" };

    (fetch as jest.Mock).mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    });

    const result = await fetchUserProfile("1");
    expect(result).toEqual(mockUser);
    expect(fetch).toHaveBeenCalledWith(
      "https://api.example.com/users/1"
    );
  });

  it("throws error when user not found", async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({ ok: false });

    await expect(fetchUserProfile("999")).rejects.toThrow("User not found");
  });
});

Component Test ด้วย React Native Testing Library (RNTL)

ถัดมาจาก Unit Test ก็คือ Component Test ซึ่งใช้ทดสอบว่า component ของเราเรนเดอร์ถูกต้องและตอบสนองต่อ user interaction ได้ตามที่คาดหวัง

React Native Testing Library (RNTL) เป็นไลบรารีจาก Callstack ที่สร้างมาเพื่อจุดประสงค์นี้โดยเฉพาะ หลักการที่ผมชอบมากคือ "ยิ่งเทสต์คล้ายกับวิธีที่ผู้ใช้จริงใช้งาน ยิ่งให้ความมั่นใจมากขึ้น" ฟังดูเรียบง่าย แต่มันเปลี่ยนวิธีคิดเรื่องการเขียนเทสต์ไปเลย

การติดตั้ง RNTL

npm install --save-dev @testing-library/react-native

ตัวอย่าง: ทดสอบ Login Form

มาดูตัวอย่างจริงๆ กัน สมมติเรามี component ฟอร์มเข้าสู่ระบบ:

// components/LoginForm.tsx
import { useState } from "react";
import {
  View,
  Text,
  TextInput,
  Pressable,
  ActivityIndicator,
  StyleSheet,
} from "react-native";

interface LoginFormProps {
  onSubmit: (email: string, password: string) => Promise<void>;
}

export default 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 handleLogin = async () => {
    if (!email.trim() || !password.trim()) {
      setError("กรุณากรอกอีเมลและรหัสผ่าน");
      return;
    }
    setError(null);
    setLoading(true);
    try {
      await onSubmit(email, password);
    } catch (err) {
      setError("อีเมลหรือรหัสผ่านไม่ถูกต้อง");
    } finally {
      setLoading(false);
    }
  };

  return (
    <View style={styles.container}>
      <Text style={styles.title}>เข้าสู่ระบบ</Text>
      <TextInput
        accessibilityLabel="อีเมล"
        placeholder="กรอกอีเมล"
        value={email}
        onChangeText={setEmail}
        keyboardType="email-address"
        autoCapitalize="none"
        style={styles.input}
      />
      <TextInput
        accessibilityLabel="รหัสผ่าน"
        placeholder="กรอกรหัสผ่าน"
        value={password}
        onChangeText={setPassword}
        secureTextEntry
        style={styles.input}
      />
      {error && <Text style={styles.error}>{error}</Text>}
      <Pressable
        accessibilityRole="button"
        onPress={handleLogin}
        disabled={loading}
        style={styles.button}
      >
        {loading ? (
          <ActivityIndicator color="white" />
        ) : (
          <Text style={styles.buttonText}>เข้าสู่ระบบ</Text>
        )}
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 20 },
  title: { fontSize: 24, fontWeight: "bold", marginBottom: 20 },
  input: {
    borderWidth: 1,
    borderColor: "#ccc",
    borderRadius: 8,
    padding: 12,
    marginBottom: 12,
  },
  error: { color: "red", marginBottom: 12 },
  button: {
    backgroundColor: "#3b82f6",
    padding: 14,
    borderRadius: 8,
    alignItems: "center",
  },
  buttonText: { color: "white", fontWeight: "bold", fontSize: 16 },
});

แล้วเทสต์ยังไงล่ะ? ดูไฟล์เทสต์:

// __tests__/components/LoginForm.test.tsx
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("เข้าสู่ระบบ")).toBeVisible();
    expect(screen.getByLabelText("อีเมล")).toBeVisible();
    expect(screen.getByLabelText("รหัสผ่าน")).toBeVisible();
    expect(screen.getByRole("button")).toBeVisible();
  });

  it("shows validation error when fields are empty", () => {
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.press(screen.getByRole("button"));

    expect(screen.getByText("กรุณากรอกอีเมลและรหัสผ่าน")).toBeVisible();
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });

  it("calls onSubmit with email and password", async () => {
    mockOnSubmit.mockResolvedValueOnce(undefined);
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.changeText(screen.getByLabelText("อีเมล"), "[email protected]");
    fireEvent.changeText(screen.getByLabelText("รหัสผ่าน"), "password123");
    fireEvent.press(screen.getByRole("button"));

    await waitFor(() => {
      expect(mockOnSubmit).toHaveBeenCalledWith(
        "[email protected]",
        "password123"
      );
    });
  });

  it("shows error message on login failure", async () => {
    mockOnSubmit.mockRejectedValueOnce(new Error("Invalid credentials"));
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.changeText(screen.getByLabelText("อีเมล"), "[email protected]");
    fireEvent.changeText(screen.getByLabelText("รหัสผ่าน"), "wrongpassword");
    fireEvent.press(screen.getByRole("button"));

    await waitFor(() => {
      expect(
        screen.getByText("อีเมลหรือรหัสผ่านไม่ถูกต้อง")
      ).toBeVisible();
    });
  });

  it("disables button while loading", async () => {
    mockOnSubmit.mockImplementation(
      () => new Promise((resolve) => setTimeout(resolve, 1000))
    );
    render(<LoginForm onSubmit={mockOnSubmit} />);

    fireEvent.changeText(screen.getByLabelText("อีเมล"), "[email protected]");
    fireEvent.changeText(screen.getByLabelText("รหัสผ่าน"), "password123");
    fireEvent.press(screen.getByRole("button"));

    await waitFor(() => {
      expect(screen.getByRole("button")).toBeDisabled();
    });
  });
});

เทคนิคสำคัญที่ควรจำ

จากประสบการณ์เขียน Component Test มาพอสมควร มีเทคนิคที่อยากแชร์:

  • ใช้ Accessible Queries เป็นหลัก: ค้นหา element ด้วย getByRole, getByLabelText, getByText แทนที่จะใช้ getByTestId เพราะเทสต์แบบนี้จะสะท้อนวิธีที่ผู้ใช้จริงมองเห็นและใช้งานแอป
  • ใช้ waitFor กับ async operations: ถ้า component มีการเรียก API หรือ state update แบบ async ให้ครอบ assertion ด้วย waitFor เสมอ ไม่งั้นเทสต์จะ flaky
  • Mock ให้น้อยที่สุดเท่าที่จำเป็น: mock เฉพาะ API calls หรือ native modules พอ อย่า mock จนเกินไปจนสุดท้ายเทสต์ไม่ได้ทดสอบอะไรจริงๆ
  • อย่าลืม edge cases: อย่าทดสอบแค่ happy path ทดสอบ validation error, network error, empty state ด้วย ตรงนี้แหละที่บั๊กมักจะซ่อนอยู่

การ Mock Native Modules ที่เจอบ่อย

ตรงนี้เป็นจุดที่คนเขียนเทสต์ React Native มักจะปวดหัว เพราะ Jest รันใน Node.js ไม่ใช่บนอุปกรณ์จริง ดังนั้น native modules ต่างๆ ต้อง mock ออกหมด

Mock React Navigation

// test-setup.ts (เพิ่มใน setupFiles ของ jest.config.js)
jest.mock("@react-navigation/native", () => {
  const actualNav = jest.requireActual("@react-navigation/native");
  return {
    ...actualNav,
    useNavigation: () => ({
      navigate: jest.fn(),
      goBack: jest.fn(),
      setOptions: jest.fn(),
    }),
    useRoute: () => ({
      params: {},
    }),
  };
});

Mock AsyncStorage / MMKV

// สำหรับ AsyncStorage
jest.mock("@react-native-async-storage/async-storage",
  () => require("@react-native-async-storage/async-storage/jest/async-storage-mock")
);

// สำหรับ MMKV
jest.mock("react-native-mmkv", () => ({
  MMKV: jest.fn().mockImplementation(() => ({
    getString: jest.fn(),
    set: jest.fn(),
    delete: jest.fn(),
    contains: jest.fn().mockReturnValue(false),
  })),
}));

Mock Expo Modules

// jest-expo จัดการ mock ส่วนใหญ่ให้อัตโนมัติ
// แต่ถ้าต้อง mock module เพิ่มเติม:
jest.mock("expo-camera", () => ({
  Camera: "Camera",
  useCameraPermissions: () => [{ granted: true }, jest.fn()],
}));

jest.mock("expo-location", () => ({
  requestForegroundPermissionsAsync: jest.fn().mockResolvedValue({
    status: "granted",
  }),
  getCurrentPositionAsync: jest.fn().mockResolvedValue({
    coords: { latitude: 13.7563, longitude: 100.5018 },
  }),
}));

ทดสอบ Custom Hooks

Custom hooks เป็นหัวใจสำคัญของแอป React Native สมัยนี้เลยก็ว่าได้ ข่าวดีคือ RNTL มี renderHook ที่ทำให้ทดสอบ hooks ได้ง่ายมาก

// hooks/useCounter.ts
import { useState, useCallback } from "react";

export function useCounter(initialValue: number = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const decrement = useCallback(() => setCount((c) => Math.max(0, c - 1)), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);

  return { count, increment, decrement, reset };
}

// __tests__/hooks/useCounter.test.ts
import { renderHook, act } from "@testing-library/react-native";
import { useCounter } from "../../hooks/useCounter";

describe("useCounter", () => {
  it("starts with initial value", () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  it("defaults to 0", () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  it("increments count", () => {
    const { result } = renderHook(() => useCounter(0));
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });

  it("does not decrement below 0", () => {
    const { result } = renderHook(() => useCounter(0));
    act(() => result.current.decrement());
    expect(result.current.count).toBe(0);
  });

  it("resets to initial value", () => {
    const { result } = renderHook(() => useCounter(5));
    act(() => result.current.increment());
    act(() => result.current.increment());
    expect(result.current.count).toBe(7);
    act(() => result.current.reset());
    expect(result.current.count).toBe(5);
  });
});

ทดสอบ Expo Router

ถ้าใช้ Expo Router อยู่ (ซึ่งปี 2026 นี้ก็เยอะขึ้นมาก) ตัว Expo เองมี testing utility สำหรับสร้าง in-memory router ให้ทดสอบได้สะดวก

// __tests__/app/profile.test.tsx
import { renderRouter, screen } from "expo-router/testing-library";

it("renders profile screen with user name", async () => {
  renderRouter(
    {
      index: () => null,
      "profile/[id]": () => <ProfileScreen />,
    },
    {
      initialUrl: "/profile/123",
    }
  );

  expect(await screen.findByText("โปรไฟล์")).toBeVisible();
});

หมายเหตุสำคัญ: อย่าวางไฟล์เทสต์ไว้ในโฟลเดอร์ app/ เด็ดขาด เพราะ Expo Router จะถือว่าทุกไฟล์ใน app/ เป็น route หรือ layout ให้ใช้โฟลเดอร์ __tests__/ ที่แยกออกมาแทน (เชื่อเถอะ ผิดพลาดตรงนี้แล้ว debug ปวดหัวมาก)

E2E Test ด้วย Maestro — ทดสอบแอปจริงแบบไม่ต้องเขียนโค้ด

ถ้าถามว่า E2E testing framework ตัวไหนมาแรงที่สุดในปี 2026 คำตอบคือ Maestro แบบไม่ต้องคิดเลย เหตุผลหลักๆ ก็คือ:

  • ไม่ต้องติดตั้งอะไรในโปรเจกต์: ต่างจาก Detox ที่ต้องเพิ่ม dependency แล้วก็ไปแก้ native config อีก
  • ใช้ YAML เขียนเทสต์: ไม่ต้องเขียน JavaScript เลย QA ที่ไม่ถนัดโค้ดก็เขียนได้สบายๆ
  • เสถียรมากจริงๆ: มี auto-retry และ smart waiting ในตัว ลา sleep() ได้เลย
  • รันเร็ว: แก้ YAML แล้วรันใหม่ได้ทันที ไม่ต้องรอ compile

การติดตั้ง Maestro

สำหรับ macOS:

# ติดตั้งผ่าน Homebrew
brew install maestro

# หรือติดตั้งผ่าน curl
curl -fsSL "https://get.maestro.mobile.dev" | bash

สำหรับ Linux/Windows (WSL):

curl -fsSL "https://get.maestro.mobile.dev" | bash

ตรวจสอบว่าติดตั้งสำเร็จ:

maestro --version

โครงสร้างไฟล์ E2E Tests

แนะนำให้สร้างโฟลเดอร์ .maestro/ ที่ root ของโปรเจกต์ แล้วจัดแบบนี้:

project/
├── .maestro/
│   ├── flows/
│   │   ├── login.yaml
│   │   ├── add-to-cart.yaml
│   │   └── checkout.yaml
│   └── subflows/
│       ├── login-helper.yaml
│       └── navigate-to-home.yaml
├── app/
├── components/
└── ...

ตัวอย่าง: ทดสอบ Flow การเข้าสู่ระบบ

# .maestro/flows/login.yaml
appId: com.myapp.example
---
- launchApp

# ทดสอบ login สำเร็จ
- tapOn: "กรอกอีเมล"
- inputText: "[email protected]"
- tapOn: "กรอกรหัสผ่าน"
- inputText: "password123"
- tapOn: "เข้าสู่ระบบ"
- assertVisible: "หน้าหลัก"

ตัวอย่าง: ทดสอบ Flow การเพิ่มสินค้าลงตะกร้า

# .maestro/flows/add-to-cart.yaml
appId: com.myapp.example
---
- launchApp

# Login ก่อน (ใช้ subflow)
- runFlow: ../subflows/login-helper.yaml

# ไปหน้าสินค้า
- tapOn: "สินค้า"
- assertVisible: "รายการสินค้า"

# เลือกสินค้าชิ้นแรก
- tapOn:
    index: 0
    text: "เพิ่มลงตะกร้า"

# ตรวจสอบว่าเพิ่มสำเร็จ
- assertVisible: "เพิ่มลงตะกร้าแล้ว"

# ไปหน้าตะกร้า
- tapOn: "ตะกร้า"
- assertVisible:
    text: ".*สินค้า.*"
    regex: true

ตัวอย่าง: Subflow สำหรับ Login (ใช้ซ้ำได้)

# .maestro/subflows/login-helper.yaml
appId: com.myapp.example
---
- tapOn: "กรอกอีเมล"
- inputText: "[email protected]"
- tapOn: "กรอกรหัสผ่าน"
- inputText: "password123"
- tapOn: "เข้าสู่ระบบ"
- assertVisible: "หน้าหลัก"

การรัน Maestro Tests

# รันเทสต์เดียว
maestro test .maestro/flows/login.yaml

# รันทุกเทสต์ในโฟลเดอร์
maestro test .maestro/flows/

# รันพร้อมบันทึกผลเป็น JUnit format
maestro test --format junit --output results.xml .maestro/flows/

# ใช้ Maestro Studio สร้างเทสต์แบบ visual
maestro studio

Maestro Studio นี่ต้องบอกว่าเจ๋งมาก มันเปิดหน้าเว็บให้คุณคลิกบน element ของแอปแล้วจะ generate YAML command ให้อัตโนมัติเลย สำหรับคนที่เพิ่งเริ่มใช้ Maestro ลองเล่นดูก่อนจะช่วยให้เข้าใจ syntax ได้เร็วขึ้นมาก

Maestro vs Detox — ควรเลือกตัวไหนดี?

คำถามนี้เจอบ่อยมากๆ งั้นมาเทียบกันตรงๆ เลยดีกว่า:

  • การตั้งค่า: Maestro ติดตั้ง CLI ตัวเดียวจบ ส่วน Detox ต้องแก้ native build config, เพิ่ม dependency, แล้วก็ตั้งค่า Jest runner อีก
  • ภาษาเขียนเทสต์: Maestro ใช้ YAML ที่อ่านง่ายสุดๆ ส่วน Detox ใช้ JavaScript/TypeScript
  • ความเสถียร: Maestro มี auto-retry ในตัว flakiness ต่ำมากแค่ไม่ถึง 1% ส่วน Detox ใช้ gray-box sync ซึ่งก็เสถียรดีแต่อาจมีปัญหากับ animation บางอย่าง
  • การเข้าถึงแอป: Maestro เป็น black-box ไม่ต้องแก้โค้ดแอปเลย ส่วน Detox ต้องฝัง hooks ลงไปใน native code
  • System UI: Maestro จัดการ system dialogs กับ notifications ได้ Detox ทำได้จำกัดกว่า
  • ความเร็วในการ iterate: Maestro แก้ YAML แล้วรันใหม่ได้ทันที ส่วน Detox ต้อง build ใหม่เมื่อเทสต์เปลี่ยน

ความเห็นตรงๆ: สำหรับทีมส่วนใหญ่ในปี 2026 Maestro เป็นตัวเลือกที่ดีกว่าเยอะ ตั้งค่าง่ายกว่า เสถียรกว่า และ QA ที่ไม่ถนัดเขียนโค้ดก็ใช้ได้ ส่วน Detox ยังเหมาะสำหรับทีมที่ต้องการ gray-box testing จริงจัง หรือต้องการ integration ลึกๆ กับ React Native internals

รวม Tests เข้ากับ CI/CD

การทดสอบจะมีประโยชน์สูงสุดเมื่อรันอัตโนมัติทุกครั้งที่มี Pull Request เข้ามา ถ้ายังรันเทสต์ด้วยมือทุกครั้ง สักวันก็จะลืม (พูดจากประสบการณ์)

มาดูตัวอย่างการตั้งค่ากับ GitHub Actions กัน:

Unit/Component Tests (Jest + RNTL)

# .github/workflows/test.yml
name: Run Tests

on:
  pull_request:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm test -- --coverage --ci
      - name: Upload Coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

E2E Tests (Maestro) บน EAS

ถ้าใช้ Expo EAS อยู่ สามารถรัน Maestro tests บน cloud ได้ด้วย ซึ่งสะดวกมากเพราะไม่ต้องพึ่ง local machine:

// eas.json - เพิ่ม build profile สำหรับ E2E
{
  "build": {
    "e2e-test": {
      "distribution": "internal",
      "ios": {
        "simulator": true
      },
      "env": {
        "E2E_TESTING": "true"
      }
    }
  }
}

เทคนิคขั้นสูง: Snapshot Testing

Snapshot testing เป็นอีกเทคนิคที่ Jest รองรับ ใช้สำหรับตรวจจับการเปลี่ยนแปลงของ UI ที่ไม่คาดคิด ลองดูตัวอย่าง:

// __tests__/components/Badge.test.tsx
import { render } from "@testing-library/react-native";
import Badge from "../../components/Badge";

it("renders success badge correctly", () => {
  const tree = render(
    <Badge variant="success" text="สำเร็จ" />
  );
  expect(tree.toJSON()).toMatchSnapshot();
});

it("renders error badge correctly", () => {
  const tree = render(
    <Badge variant="error" text="ผิดพลาด" />
  );
  expect(tree.toJSON()).toMatchSnapshot();
});

แต่ต้องระวัง: ทีม Expo เองก็แนะนำให้ใช้ snapshot testing อย่างระมัดระวัง เพราะ snapshot ที่ใหญ่เกินไปมักกลายเป็นภาระ developer เจอ snapshot failed ก็มักจะกด u อัปเดตทันทีโดยไม่ดูว่าอะไรเปลี่ยนไปจริงๆ แนะนำให้ใช้กับ component เล็กๆ ที่ต้องการตรวจ structure เท่านั้น

โครงสร้างโฟลเดอร์ที่แนะนำ

การจัดโครงสร้างไฟล์เทสต์ที่ดีช่วยให้ maintain ง่ายขึ้นมากในระยะยาว:

project/
├── __tests__/
│   ├── components/
│   │   ├── LoginForm.test.tsx
│   │   └── Badge.test.tsx
│   ├── hooks/
│   │   └── useCounter.test.ts
│   ├── services/
│   │   └── userService.test.ts
│   └── utils/
│       └── formatPrice.test.ts
├── .maestro/
│   ├── flows/
│   └── subflows/
├── app/           # Expo Router routes
├── components/
├── hooks/
├── services/
├── utils/
└── jest.config.js

หลักสำคัญคือแยก test files ออกจาก source code แล้วจัดโครงสร้างให้ mirror กับ source โฟลเดอร์ จะได้หาไฟล์เทสต์ได้ง่ายเวลาต้องกลับมาแก้ไข

สรุป: เริ่มต้นทดสอบ React Native ยังไงดี

ถ้าคุณยังไม่เคยเขียนเทสต์เลย อย่าพยายามเขียนเทสต์ให้ครอบคลุมทั้งโปรเจกต์ในทีเดียว มันไม่ realistic

ให้เริ่มแบบนี้แทน:

  1. เขียน Unit test สำหรับ utility ใหม่ทุกตัว — เริ่มง่ายๆ ตรงนี้ก่อน สร้างนิสัยให้ได้
  2. เพิ่ม Component test ตอนแก้บั๊ก — เขียนเทสต์ที่จำลองบั๊กนั้นก่อน แล้วค่อยแก้ไข ถ้าเทสต์ผ่านแปลว่าบั๊กหายจริง (Test-Driven Bug Fix)
  3. เพิ่ม E2E test สำหรับ flow หลัก — login, sign up, core feature ของแอป
  4. ค่อยๆ ขยาย coverage — ตั้งเป้า coverage threshold เช่น 80% สำหรับ branches

จริงๆ แค่ Jest + RNTL สองตัวก็พาไปได้ไกลมากแล้ว ส่วน Maestro ค่อยเพิ่มเข้ามาเมื่อพร้อม แค่นี้แอป React Native ของคุณก็จะ deploy ได้อย่างมั่นใจทุกครั้ง

คำถามที่พบบ่อย (FAQ)

ควรเริ่มเขียนเทสต์ React Native ตัวไหนก่อน?

เริ่มจาก Unit test ด้วย Jest ก่อนเลย เพราะตั้งค่าง่ายสุด รันเร็วสุด และ feedback loop ก็เร็วด้วย เขียนเทสต์ให้ utility functions, business logic กับ custom hooks ก่อน พอคล่องแล้วค่อยขยายไป Component test ด้วย RNTL แล้วก็ E2E test ด้วย Maestro ตามลำดับ

Maestro กับ Detox ต่างกันยังไง ควรเลือกตัวไหน?

Maestro เป็น black-box framework ที่ใช้ YAML เขียนเทสต์ ไม่ต้องยุ่งกับโค้ดแอป ตั้งค่าเร็ว เหมาะกับทีมที่อยากเริ่มต้นได้ทันที ส่วน Detox เป็น gray-box framework จาก Wix ที่ฝัง hooks ลงไปใน native code ทำให้ sync กับแอปได้ละเอียดกว่า แต่ตั้งค่ายากกว่าเยอะ สำหรับปี 2026 แนะนำ Maestro เป็นตัวเลือกแรก เว้นแต่คุณมีเหตุผลเฉพาะที่ต้องใช้ gray-box testing

jest-expo กับ react-native preset ต่างกันอย่างไร?

jest-expo เป็น preset เฉพาะสำหรับโปรเจกต์ Expo มัน mock native modules ของ Expo SDK ให้อัตโนมัติ แล้วก็รองรับ multi-platform testing ทั้ง iOS, Android และ Web ส่วน react-native preset เป็นค่าเริ่มต้นสำหรับโปรเจกต์ที่ใช้ React Native CLI โดยตรง ง่ายๆ คือ ถ้าใช้ Expo ให้เลือก jest-expo ไม่ต้องคิดเยอะ

ต้องเขียน test ครอบคลุมกี่เปอร์เซ็นต์ถึงจะเพียงพอ?

ไม่มีตัวเลขตายตัวหรอก แต่เป้าหมายที่ดีคือประมาณ 80% สำหรับ branch coverage ของ business logic กับ utility functions สิ่งที่สำคัญกว่าตัวเลข coverage คือคุณภาพของเทสต์ เทสต์ 50% ที่จับ edge cases สำคัญได้ ดีกว่าเทสต์ 100% ที่ทดสอบแค่ happy path ตื้นๆ

ทำไมต้องแยกไฟล์ test ออกจากโฟลเดอร์ app/ ของ Expo Router?

เพราะ Expo Router ใช้ file-based routing ครับ ทุกไฟล์ในโฟลเดอร์ app/ จะถูกตีความว่าเป็น route หรือ layout โดยอัตโนมัติ ถ้าเอาไฟล์เทสต์ไปวางไว้ตรงนั้น มันจะกลายเป็น route ไปด้วย ซึ่งทำให้เกิดปัญหาแปลกๆ ทางออกคือใช้โฟลเดอร์ __tests__/ ที่อยู่นอก app/ แทน

เกี่ยวกับผู้เขียน Editorial Team

Our team of expert writers and editors.