Skip to content

บทที่ 8 — Testing (Vitest + React Testing Library + Playwright)

← บทที่ 7 | สารบัญ | บทที่ 9 →

🟡 ระดับ: กลาง

📖 5 คำที่ต้องรู้ก่อนเริ่ม (อ่านก่อน):

  • test runner = โปรแกรมที่หาไฟล์ *.test.ts แล้วรันทุกไฟล์ + รายงานว่าผ่าน/ไม่ผ่าน (Vitest, Jest = test runner)
  • unit / component / integration / E2E = ระดับของ test
    • unit = ทดสอบ function เดี่ยว (เร็วสุด)
    • component = ทดสอบ React component
    • integration = ทดสอบหลาย component + service ทำงานด้วยกัน
    • E2E (End-to-End) = ทดสอบทั้งระบบจริงในบราวเซอร์ (ช้าสุด)
  • assertion = ประโยค "คาดว่า X เท่ากับ Y" ที่ใช้ตัดสินว่า test ผ่าน/ไม่ผ่าน (expect(x).toBe(y))
  • mock / stub / spy = "ของปลอม" สร้างมาแทนของจริงตอน test (mock API, mock timer)
  • Jest = test runner รุ่นพี่ที่ Vitest ออกแบบมาแทน — API คล้ายกัน 95%

ปี 2026 — เขียน React โดยไม่ test = ปล่อย bug ขึ้น production ทุกครั้ง

บทนี้สอน:

  • Vitest — test runner (ตัวรันเทสต์) ปี 2026 (เร็วกว่า Jest)
  • React Testing Library (RTL) — test component แบบ "user's perspective" (มุมมองของผู้ใช้จริง)
  • MSW (Mock Service Worker) — mock (จำลอง) API
  • Playwright — E2E (End-to-End) test = ทดสอบทั้งระบบเหมือนคนใช้งานจริงตั้งแต่ต้นจนจบ

หลังจบบท คุณจะ:

  • เขียน unit test ของ utility function
  • เขียน component test ที่ test "พฤติกรรม" ไม่ใช่ "implementation"
  • Mock API call ใน test
  • เขียน E2E test ทำงานจริงใน browser

1. ประเภท test ใน frontend

TypeTest อะไรเครื่องมือ
Unitfunction เดี่ยว ๆVitest
Componentrender + interactVitest + RTL
Integrationหลาย component ทำงานด้วยกันVitest + RTL + MSW
E2Eflow ใน browser จริงPlaywright (Cypress เป็นทางเลือก)

⚠️ อย่าใช้ Enzyme (เครื่องมือเก่า — ไม่มี official adapter รองรับ React 18+ มีแค่ community adapter ไม่เป็นทางการ และหยุดพัฒนาแล้ว) ถ้าเจอใน tutorial เก่าให้ข้าม — ปี 2026 ใช้ React Testing Library (RTL) เท่านั้น


2. Testing Philosophy — "Test Behavior, Not Implementation"

❌ Test แบบที่จะพังถ้า refactor (รื้อโค้ดภายในใหม่โดยพฤติกรรมเหมือนเดิม — ไม่ใช่เพิ่ม feature ใหม่):

tsx
expect(component.state.count).toBe(5);          // ผูกกับ implementation
expect(component.find('CounterImpl')).toExist(); // ผูกกับ class name

✅ Test แบบที่ user เห็น:

tsx
expect(screen.getByText('Count: 5')).toBeVisible();
expect(screen.getByRole('button', { name: 'Increment' })).toBeEnabled();

→ ถ้า refactor internal โดย UI เหมือนเดิม — test ยังผ่าน

Kent C. Dodds: "The more your tests resemble the way your software is used, the more confidence they can give you."

(แปล: "ยิ่งเทสต์ของคุณเลียนแบบวิธีที่ผู้ใช้จริงใช้ซอฟต์แวร์มากเท่าไหร่ มันก็ยิ่งให้ความมั่นใจกับคุณได้มากเท่านั้น")


Part 1: Vitest Setup

3. Install

เริ่มจากตั้ง toolchain ของการ test — Vitest (test runner ที่เร็วและเข้ากับ Vite), Testing Library (test component แบบที่ user เห็น), jsdom (จำลอง browser environment ใน Node.js เพราะ Vitest รันบน Node ซึ่งไม่มี browser จริง — ทำให้ test render component ได้โดยไม่ต้องเปิด browser) ตั้ง vite.config.ts ให้รู้จัก test environment + setup file แล้วเพิ่ม script ใน package.json:

💡 --save-dev (หรือ -D) = install เป็น devDependency — package ที่ใช้เฉพาะตอน develop/test ไม่ถูกรวมไปใน production build

bash
npm install --save-dev vitest @vitest/ui @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
ts
// vite.config.ts
/// <reference types="vitest" />   // บอก TypeScript ให้รู้จัก type ของ Vitest (เช่น describe, it) ใน config file — ต้องมีไว้ไม่งั้น TypeScript ฟ้อง error
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    test: {
        environment: 'jsdom',
        setupFiles: './src/test/setup.ts',
        globals: true,  // ให้ใช้ describe, it, expect ฯลฯ ได้โดยไม่ต้อง import ในทุกไฟล์ test
    },
});
ts
// src/test/setup.ts
import '@testing-library/jest-dom';   // @testing-library/jest-dom v6+ — import ตรงๆ ไม่ต้องระบุ subpath (v5 หรือต่ำกว่าต้อง import '@testing-library/jest-dom/extend-expect' แทน)
json
// package.json
{
    "scripts": {
        "test": "vitest",
        "test:run": "vitest run",
        "test:ui": "vitest --ui",
        "test:coverage": "vitest run --coverage"
    }
}

4. Test แรก — Utility Function

📖 โครงสร้างของ test 1 ไฟล์ (อ่านก่อนดูตัวอย่าง):

describe('ชื่อกลุ่ม', () => {           // จัดกลุ่ม test ที่เกี่ยวกัน
    it('สิ่งที่คาดหวัง', () => {       // 1 test case (เขียน `test` แทน `it` ก็ได้)
        expect(ผลจริง).toBe(ที่คาด);  // assertion = ประโยคตัดสิน
    });
});
  • describe = หัวข้อกลุ่ม (nested ได้)
  • it หรือ test = 1 test case
  • expect(x).toBe(y) = "คาดว่า x เท่ากับ y" — ถ้าไม่ตรง test fail

เริ่มเรียน test จากของง่ายสุดก่อน — pure function ที่ไม่มี UI/state (เช่น formatPrice, slugify) เพราะ test ง่ายและเห็นโครงสร้างชัด: describe จัดกลุ่ม, it หนึ่ง test case, expect().toBe() ตรวจผล สังเกต it.each ที่รัน test เดียวกันหลายชุดข้อมูล:

ts
// src/utils/format.ts
export function formatPrice(amount: number, currency = 'USD'): string {
    return new Intl.NumberFormat('en-US', {
        style: 'currency',
        currency,
    }).format(amount);
}

export function slugify(text: string): string {
    return text.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '');
}
ts
// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatPrice, slugify } from './format';

describe('formatPrice', () => {
    it('formats USD with cents', () => {
        expect(formatPrice(19.99)).toBe('$19.99');
    });
    
    it('rounds to 2 decimals', () => {
        expect(formatPrice(10.999)).toBe('$11.00');
    });
    
    it('handles different currency', () => {
        expect(formatPrice(100, 'EUR')).toBe('€100.00');
    });
    
    it('handles zero', () => {
        expect(formatPrice(0)).toBe('$0.00');
    });
});

describe('slugify', () => {
    it.each([
        ['Hello World', 'hello-world'],           // basic: lowercase + spaces → dashes
        ['Hello   World!', 'hello-world'],         // multiple spaces → single dash
        ['Hello, World!', 'hello-world'],          // special chars removed
        ['', ''],                                  // edge case: empty string
    ])('slugifies "%s" → "%s"', (input, expected) => {
        expect(slugify(input)).toBe(expected);
    });
});

Run:

bash
npm test

5. Vitest API ที่ใช้บ่อย

รวม API ของ Vitest ที่ใช้จริงเป็นจุดอ้างอิงเดียว มี 4 หมวด:

  • lifecycle hooks (beforeEach/afterEach) — รันโค้ดก่อน/หลัง test แต่ละตัว
  • matchers (toBe/toEqual/toThrow...) — ตรวจผลลัพธ์
  • skip/only/todo — ควบคุมว่า test ไหนรัน/ข้าม
  • mock (vi.fn, vi.mock) — สร้างของปลอมแทนของจริง

จำ 4 หมวดนี้ได้ก็เขียน test ส่วนใหญ่ได้ ไม่ต้องเปิด docs บ่อย:

ts
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';

// Lifecycle
beforeAll(() => { ... });
afterAll(() => { ... });
beforeEach(() => { ... });
afterEach(() => { ... });

// Matchers
expect(value).toBe(other);                  // ===
expect(value).toEqual({...});               // deep equal
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toContain('substring');
expect(value).toHaveLength(3);
expect(array).toContainEqual({...});
expect(value).toBeGreaterThan(5);
expect(value).toMatch(/regex/);
expect(() => fn()).toThrow(Error);
expect(promise).resolves.toBe(...);
expect(promise).rejects.toThrow(...);
expect(element).toBeInTheDocument();       // มาจาก @testing-library/jest-dom ไม่ใช่ Vitest core (setup แล้วใน §3)

// Skip / only
it.skip('not now', ...);
it.only('only run this', ...);
it.todo('write this later');

// Mock
const fn = vi.fn();
fn('arg');
expect(fn).toHaveBeenCalled();
expect(fn).toHaveBeenCalledWith('arg');
expect(fn).toHaveBeenCalledTimes(1);

vi.mock('./api');                           // mock module

Part 2: React Testing Library

6. Component Test แรก

ต่อมาคือ test component จริง — render component แล้วตรวจสิ่งที่ผู้ใช้เห็น (ข้อความ, ปุ่ม) ปรัชญาของ Testing Library คือ "test แบบที่ user ใช้งาน" (หา element จากข้อความ/role ไม่ใช่จาก class/id ภายใน) ทำให้ test ไม่พังเวลา refactor โครงสร้างภายใน:

tsx
// Counter.tsx
import { useState } from 'react';

export function Counter({ initial = 0 }: { initial?: number }) {
    const [count, setCount] = useState(initial);
    
    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(c => c + 1)}>Increment</button>
            <button onClick={() => setCount(c => c - 1)}>Decrement</button>
        </div>
    );
}
tsx
// Counter.test.tsx
// หมายเหตุ: เราจะ import describe/it/expect ชัดเจนทุกครั้ง แม้จะมี globals: true — เพื่อให้โค้ดอ่านง่ายและ editor แสดง autocomplete
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect } from 'vitest';
import { Counter } from './Counter';

describe('Counter', () => {
    it('renders initial count', () => {
        render(<Counter initial={5} />);
        expect(screen.getByText('Count: 5')).toBeInTheDocument();
    });
    
    it('increments when button clicked', async () => {
        const user = userEvent.setup();
        render(<Counter />);
        
        const button = screen.getByRole('button', { name: 'Increment' });
        await user.click(button);
        
        expect(screen.getByText('Count: 1')).toBeInTheDocument();
    });
    
    it('increments twice', async () => {
        const user = userEvent.setup();
        render(<Counter />);
        
        const button = screen.getByRole('button', { name: 'Increment' });
        await user.click(button);
        await user.click(button);
        
        expect(screen.getByText('Count: 2')).toBeInTheDocument();
    });
});

7. Query Methods — เรียง priority

RTL แนะนำให้ใช้ query เรียง priority (เรียงจากดีสุด→ทางเลือกสุดท้าย): getByRole > getByLabelText > getByPlaceholderText > getByText > getByDisplayValue > getByAltText / getByTitle > getByTestId

1. Accessible to everyone (ทุกคนเข้าถึงได้ — รวม screen reader — เป็น query ที่ดีที่สุดเพราะใกล้กับวิธีที่ user จริงและ assistive technology ใช้)

tsx
screen.getByRole('button', { name: 'Submit' });   // ⭐ ใช้บ่อยที่สุด
screen.getByRole('heading', { level: 1 });
screen.getByLabelText('Email');
screen.getByPlaceholderText('Enter name');
screen.getByText('Hello');
screen.getByDisplayValue('current value');

2. Semantic queries

tsx
screen.getByAltText('logo');
screen.getByTitle('tooltip');

3. Test IDs (last resort — ทางเลือกสุดท้าย ใช้เมื่อ getByRole/getByText หาไม่เจอ)

tsx
screen.getByTestId('user-card');

อย่า ใช้ container.querySelector('.user-card') — ผูกกับ class name


8. Query Variants

Prefixถ้าไม่เจอใช้เมื่อ
getBy...throw errorelement ต้องมี
queryBy...return nullcheck ว่าไม่มี
findBy...return Promise (รอ)async — ต้องรอแสดง
getAllBy... / queryAllBy... / findAllBy...หลายตัวlist
tsx
// ✅ ต้องมี
const button = screen.getByRole('button');

// ✅ ไม่ควรมี
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// ✅ รอให้มา (async)
const greeting = await screen.findByText('Welcome');

9. User Interaction

การ test ที่มีคุณค่าคือจำลองสิ่งที่ user ทำจริง — ใช้ userEvent เสมอ สำหรับ user interaction เพราะจำลอง click, type, tab, upload ฯลฯ ใกล้เคียงพฤติกรรมจริงกว่า fireEvent มาก (เช่น user.type ยิง event ทีละตัวอักษรเหมือนพิมพ์จริง + ห่อ act() ให้แล้ว) ใช้ fireEvent เฉพาะกรณีที่ userEvent ไม่รองรับเท่านั้น สังเกตว่าทุก action เป็น async ต้อง await:

tsx
import userEvent from '@testing-library/user-event';

const user = userEvent.setup();

await user.click(button);
await user.dblClick(div);
await user.type(input, 'hello');
await user.clear(input);
await user.selectOptions(select, 'option-value');
await user.keyboard('{Enter}');
await user.tab();                            // Tab key
await user.hover(element);
await user.upload(fileInput, file);

คำแนะนำ: ใช้ userEvent สำหรับ user interaction ทุกครั้ง — fireEvent เป็น low-level ที่ไม่ค่อย realistic


10. ตัวอย่างจริง — Login Form Test

ตัวอย่างที่รวมทุกอย่าง — test Login Form ครบ 3 เคสที่ควร cover: submit สำเร็จ (เช็คว่าเรียก onSubmit ด้วยค่าถูก), สถานะ loading, และแสดง error เมื่อ fail สังเกตการใช้ findByRole('alert') รอ error แบบ async นี่คือแบบที่ test form จริงควรเป็น:

tsx
// LoginForm.tsx
import { useState } from 'react';

interface LoginData {
    email: string;
    password: string;
}

export function LoginForm({ onSubmit }: { onSubmit: (data: LoginData) => Promise<void> }) {
    const [email, setEmail] = useState('');
    const [password, setPassword] = useState('');
    const [error, setError] = useState<string | null>(null);
    const [loading, setLoading] = useState(false);
    
    async function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        setError(null);
        setLoading(true);
        try {
            await onSubmit({ email, password });
        } catch (err) {
            setError((err as Error).message);
        } finally {
            setLoading(false);
        }
    }
    
    return (
        <form onSubmit={handleSubmit}>
            <label>
                Email
                <input type="email" value={email} onChange={e => setEmail(e.target.value)} required />
            </label>
            <label>
                Password
                <input type="password" value={password} onChange={e => setPassword(e.target.value)} required />
            </label>
            {error && <p role="alert">{error}</p>}
            <button type="submit" disabled={loading}>
                {loading ? 'Logging in...' : 'Login'}
            </button>
        </form>
    );
}
tsx
// LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
    it('submits with entered values', async () => {
        const onSubmit = vi.fn().mockResolvedValue(undefined);
        const user = userEvent.setup();
        
        render(<LoginForm onSubmit={onSubmit} />);
        
        await user.type(screen.getByLabelText('Email'), 'anna@example.com');
        await user.type(screen.getByLabelText('Password'), 'secret123');
        await user.click(screen.getByRole('button', { name: 'Login' }));
        
        expect(onSubmit).toHaveBeenCalledWith({
            email: 'anna@example.com',
            password: 'secret123',
        });
    });
    
    it('shows loading state during submit', async () => {
        let resolveSubmit: () => void;
        const onSubmit = vi.fn(() => new Promise<void>(r => { resolveSubmit = r; }));
        const user = userEvent.setup();
        
        render(<LoginForm onSubmit={onSubmit} />);
        
        await user.type(screen.getByLabelText('Email'), 'a@b.com');
        await user.type(screen.getByLabelText('Password'), 'pwd');
        await user.click(screen.getByRole('button', { name: 'Login' }));
        
        expect(await screen.findByRole('button', { name: 'Logging in...' })).toBeDisabled();
        
        resolveSubmit!(); // `!` = non-null assertion บอก TypeScript ว่า variable นี้ต้องมีค่าแน่ๆ ณ จุดนี้ (assign ใน closure ข้างบน)
        await screen.findByRole('button', { name: 'Login' }); // รอ loading หาย — กัน act() warning
    });
    
    it('shows error when submit fails', async () => {
        const onSubmit = vi.fn().mockRejectedValue(new Error('Invalid credentials'));
        const user = userEvent.setup();
        
        render(<LoginForm onSubmit={onSubmit} />);
        
        await user.type(screen.getByLabelText('Email'), 'a@b.com');
        await user.type(screen.getByLabelText('Password'), 'wrong');
        await user.click(screen.getByRole('button', { name: 'Login' }));
        
        const error = await screen.findByRole('alert');
        expect(error).toHaveTextContent('Invalid credentials');
    });
});

Part 3: Mock API ด้วย MSW

11. MSW — Mock Service Worker

วิธี mock API ที่ดีที่สุดคือ MSW — MSW ทำตัวเป็น fake server ตัวหนึ่ง เวลา component เรียก fetch('/api/users') MSW จะ "จับ" request ก่อนที่มันจะออกไป network จริง แล้วตอบ response ที่เรากำหนดไว้แทน

ข้อดีคือ code ของ component เรียก fetch จริงตามปกติ (component ไม่รู้ตัวว่าถูก mock) และ handler ชุดเดียวกันใช้ได้ทั้งตอน test และตอน dev

ใน test ใช้ msw/node (ทำงานใน Node.js) ต่างจาก msw/browser ที่ใช้สำหรับ dev ใน browser จริง:

bash
npm install --save-dev msw
ts
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
    http.get('/api/users', () => {
        return HttpResponse.json([
            { id: 1, name: 'Anna' },
            { id: 2, name: 'Ben' },
        ]);
    }),
    
    http.post('/api/users', async ({ request }) => {
        const body = await request.json() as { name: string };
        return HttpResponse.json({ id: 3, name: body.name }, { status: 201 });
    }),
    
    http.get('/api/users/:id', ({ params }) => {
        if (params.id === '999') {
            return new HttpResponse(null, { status: 404 });
        }
        return HttpResponse.json({ id: params.id, name: 'Anna' });
    }),
];
ts
// src/test/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
ts
// src/test/setup.ts (อัปเดตจาก §3 — รวม jest-dom + MSW)
import '@testing-library/jest-dom';
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './mocks/server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));  // error = ถ้า test เรียก API ที่ไม่มี handler กำหนดไว้ → test fail ทันที (ดีกว่าเงียบๆ fail)
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

⚠️ ถ้าเจอ error Failed to parse URL from /api/users: บาง version ของ jsdom/undici ต้องการ URL แบบเต็ม ไม่รับ path สั้นๆ อย่าง /api/users — แก้โดยตั้ง test.environmentOptions.jsdom.url ใน vite.config.ts (เช่น 'http://localhost:3000') หรือใช้ absolute URL ใน fetch() โดยตรง

💡 โค้ดนี้ import ชัดเจน (explicit import) เพื่อความชัดเจน — ถ้า globals: true จะ import หรือไม่ก็ได้ แต่แนะนำ import เพื่อความชัดเจน


12. Test ที่ใช้ MSW

พอ setup MSW แล้ว test ก็เขียน component ที่เรียก API ได้เหมือนต่อ server จริง — render แล้วรอข้อมูล (findByText) ส่วน error case ใช้ server.use() override handler เฉพาะ test นั้นให้ตอบ 500 ข้อดีคือไม่ต้อง mock fetch รายจุด:

tsx
// UserList.tsx — component ที่จะ test
import { useState, useEffect } from 'react';

export function UserList() {
    const [users, setUsers] = useState<{ id: number; name: string }[]>([]);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        fetch('/api/users')
            .then(res => {
                if (!res.ok) throw new Error('Failed to fetch');
                return res.json();
            })
            .then(data => { setUsers(data); setLoading(false); })
            .catch(err => { setError(err.message); setLoading(false); });
    }, []);

    if (loading) return <p>Loading...</p>;
    if (error) return <p>error: {error}</p>;
    return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
tsx
import { render, screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/mocks/server';
import { UserList } from './UserList';

describe('UserList', () => {
    it('shows users from API', async () => {
        render(<UserList />);
        
        // 1. render → component เริ่ม fetch → แสดง Loading ทันที
        expect(screen.getByText('Loading...')).toBeInTheDocument();
        
        // 2. MSW ตอบ → component อัพเดท → Loading หายและแสดง data
        // getByText (ไม่มี await) = หา element ทันทีตอนนั้น
        // findByText (ต้อง await) = รอจนกว่า element จะปรากฏ (ใช้กับ async เช่น หลัง API ตอบกลับ)
        expect(await screen.findByText('Anna')).toBeInTheDocument();
        expect(screen.getByText('Ben')).toBeInTheDocument();
    });
    
    it('shows error when API fails', async () => {
        // Override handler สำหรับ test นี้
        server.use(
            http.get('/api/users', () => new HttpResponse(null, { status: 500 }))
        );
        
        render(<UserList />);
        
        expect(await screen.findByText(/error/i)).toBeInTheDocument();
    });
});

Part 4: Test Custom Hook

tsx
// useCounter.ts — hook ที่จะ test
import { useState } from 'react';

export function useCounter(initial = 0) {
    const [count, setCount] = useState(initial);
    return { count, increment: () => setCount(c => c + 1) };
}
tsx
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter', () => {
    it('starts at 0', () => {
        const { result } = renderHook(() => useCounter());
        expect(result.current.count).toBe(0);
    });
    
    it('increments', async () => {
        const { result } = renderHook(() => useCounter());
        
        // React 18/19 batches (รวม) state update หลายครั้งเป็นครั้งเดียวก่อน re-render — ทำให้ re-render น้อยลงแต่ update ไม่เสร็จทันทีแบบ sync
        // ต้อง await act แม้ action จะ synchronous
        await act(async () => {
            result.current.increment();
        });
        
        expect(result.current.count).toBe(1);
    });
    
    it('respects initial value', () => {
        const { result } = renderHook(({ initial }) => useCounter(initial), {
            initialProps: { initial: 10 },
        });
        expect(result.current.count).toBe(10);
    });
});

Hook ที่ใช้ Context / Provider

📝 หมายเหตุ: ตัวอย่างนี้สมมติว่าโปรเจกต์ใช้ TanStack Query v5 (บท 4) และมี AuthProvider แล้ว — ถ้ายังไม่มีสามารถข้ามตัวอย่างนี้ไปก่อน

tsx
// TanStack Query v5 (gcTime คือ v5 — v4 ใช้ cacheTime)
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactNode } from 'react';

// ⭐ สร้าง QueryClient ใหม่ทุก test — disable retry กัน test timeout ตอน error
function createTestQueryClient() {
    return new QueryClient({
        defaultOptions: { queries: { retry: false, gcTime: 0 } },
    });
}

const wrapper = ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={createTestQueryClient()}>
        <AuthProvider>{children}</AuthProvider>
    </QueryClientProvider>
);

const { result } = renderHook(() => useUser(), { wrapper });

Part 5: Playwright — E2E Testing

13. Setup

unit/component test ตรวจชิ้นส่วน แต่ E2E test ตรวจ flow ทั้งหมดบน browser จริง (เหมือน user ใช้งาน) — Playwright เป็นเครื่องมือยอดนิยม เริ่มจาก init ที่ตั้ง config + ตัวอย่างให้:

bash
npm init playwright@latest

จะถาม options หลายข้อ — คำตอบแนะนำ:

  • TypeScript? → Yes
  • Where to put your end-to-end tests?e2e/
  • Add a GitHub Actions workflow? → No (เพิ่มทีหลังได้)
  • Install Playwright browsers? → Yes

ถ้าไม่แน่ใจ กด Enter ทุกข้อ (default) แล้วแก้ทีหลังใน playwright.config.ts ได้

ได้:

e2e/
├── example.spec.ts
playwright.config.ts

ตั้ง playwright.config.ts ให้มี baseURL (ทำให้ใช้ path สั้น /login แทน full URL) และ webServer (รัน dev server อัตโนมัติ):

ts
// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
    testDir: './e2e',
    use: {
        baseURL: 'http://localhost:5173',
    },
    webServer: {
        command: 'npm run dev',
        url: 'http://localhost:5173',
        reuseExistingServer: !process.env.CI,
    },
});

14. Test แรก

E2E test เขียนเหมือนเล่าเรื่องที่ user ทำ — เปิดหน้า, กรอกฟอร์ม, คลิก, แล้วตรวจว่าไปหน้าถูกและเห็นผลลัพธ์ Playwright มี --ui mode ที่เห็นทุกขั้นแบบ visual ช่วย debug ได้ดี ต่างจาก component test ตรงที่รันบน browser จริงกับ backend จริง:

⚠️ E2E test ต้องมี dev server รันอยู่ก่อน — รัน npm run dev ใน terminal อีกหน้าต่างก่อน แล้วค่อยรัน Playwright หรือตั้ง webServer ใน playwright.config.ts ให้รัน server ให้อัตโนมัติ (ดูตัวอย่าง config ด้านล่าง)

ts
// e2e/login.spec.ts
import { test, expect } from '@playwright/test';

test('user can login and see dashboard', async ({ page }) => {
    await page.goto('http://localhost:5173/login');
    
    await page.getByLabel('Email').fill('anna@example.com');
    await page.getByLabel('Password').fill('secret123');
    await page.getByRole('button', { name: 'Login' }).click();
    
    await expect(page).toHaveURL(/.*\/dashboard/);
    await expect(page.getByText('Welcome, Anna')).toBeVisible();
});

Run:

bash
npx playwright test
npx playwright test --ui          # ⭐ visual mode
npx playwright test --headed      # เห็น browser

15. Locator Patterns

หัวใจของ Playwright คือ locator — วิธีหา element บนหน้า ควรเลือกแบบ user-facing (getByRole/getByLabel/getByText) ไม่ใช่ CSS selector เปราะ ๆ จุดเด่นคือ assertion auto-retry จน timeout (รอ element ปรากฏเอง) จึงไม่ต้องใส่ sleep:

ts
// เลือกแบบ user-facing
page.getByRole('button', { name: 'Submit' });
page.getByLabel('Email');
page.getByPlaceholder('Search...');
page.getByText('Hello');
page.getByTestId('user-card');

// Action
await locator.click();
await locator.fill('value');
await locator.selectOption('option-value');
await locator.check();
await locator.hover();

// Assertion (auto-retry until timeout)
await expect(locator).toBeVisible();
await expect(locator).toHaveText('Hello');
await expect(locator).toHaveValue('xxx');
await expect(locator).toHaveCount(3);
await expect(page).toHaveURL(/.*/);

16. Setup ที่ใช้บ่อย — Login ก่อน test

หลาย E2E test ต้อง login ก่อน — เขียน login ซ้ำทุก test เปลือง Playwright มี "fixture" (ของจัดเตรียม — Playwright ให้เราเขียน setup ที่ใช้ซ้ำได้ ส่งเข้า test เป็น argument) ให้แยก setup ออกมา reuse ได้ ตัวอย่างนี้สร้าง authenticatedPage fixture ที่ login ให้เสร็จ แล้ว test ก็เริ่มจากสถานะ logged-in เลย:

ts
// e2e/fixtures.ts
import { test as base } from '@playwright/test';

export const test = base.extend({
    authenticatedPage: async ({ page }, use) => {
        await page.goto('/login');
        await page.getByLabel('Email').fill('test@example.com');
        await page.getByLabel('Password').fill('test');
        await page.getByRole('button', { name: 'Login' }).click();
        await page.waitForURL('**/dashboard');
        await use(page);
    },
});

// e2e/orders.spec.ts
import { test, expect } from './fixtures';

test('user can view orders', async ({ authenticatedPage: page }) => {
    await page.goto('/orders');
    await expect(page.getByRole('table')).toBeVisible();
});

17. Cypress vs Playwright

CypressPlaywright
Speedปานกลางเร็วกว่า
Browser supportChrome, Edge, Firefox, ElectronChrome, Firefox, Safari, Edge
APIChainableasync/await
iframe / cross-originจำกัดรองรับเต็ม
Parallelpaid featureฟรี
DebugUI ดีมากtrace viewer + UI mode
ปี 2026ยังมีคนใช้standard ใหม่

Part 5.5: Snapshot Testing — เมื่อไหร่ใช้, เมื่อไหร่หลีก

17.5 Snapshot คืออะไร

Snapshot test = "บันทึก output ของ component → compare ครั้งถัดไป → ถ้าต่าง = warn"

tsx
import { render } from '@testing-library/react';

test('Button matches snapshot', () => {
    const { container } = render(<Button label="Save" />);
    expect(container.firstChild).toMatchSnapshot();
});

ครั้งแรก run → สร้างไฟล์ __snapshots__/Button.test.tsx.snap:

text
exports[`Button matches snapshot`] = `
<button class="btn btn-primary">Save</button>
`;

ครั้งต่อไป → compare กับไฟล์เก่า → ถ้า output เปลี่ยน → test fail

⚠️ Snapshot test มีปัญหาในทางปฏิบัติ

  1. Update ง่ายเกินไป → developer แค่ npm test -- -u กลายเป็น "rubber stamp" (อนุมัติแบบไม่ตรวจ) ไม่ได้คิดว่า diff ถูกหรือผิด
  2. Big snapshots เน่าเร็ว → render component ใหญ่ → snapshot 200 บรรทัด → review ใน PR ไม่มีใครอ่าน
  3. ไม่บอกว่าทำไม → fail แค่ "output ต่าง" ไม่บอกว่า user experience เสียหรือเปล่า

✅ ใช้ snapshot เมื่อไหร่

  • Leaf component เล็ก (Button, Badge, Icon) — output ไม่ควรเปลี่ยนบ่อย
  • Pure transformation เช่น utility ที่ render markdown → HTML
  • Inline snapshot สำหรับ assertion เล็กๆ:
tsx
expect(slugify('Hello World')).toMatchInlineSnapshot(`"hello-world"`);

→ inline = อยู่ในไฟล์ test ตรงนั้นเลย, review ง่ายกว่า

❌ อย่าใช้ snapshot สำหรับ

  • Page component ใหญ่
  • Component ที่มี date/random/uuid (ต้อง mock ก่อน หรือเลี่ยง)
  • Component ที่ output เปลี่ยนตาม props มาก ๆ

Part 5.6: Visual Regression Testing — Chromatic + Storybook

17.6 ปัญหาที่ snapshot/RTL แก้ไม่ได้

  • CSS regression (button หาย shadow, padding ผิด)
  • Cross-browser rendering (Safari render ต่าง Chrome)
  • Dark mode breakage

→ ต้องการ visual regression test ที่เปรียบเทียบ "ภาพจริง" pixel ต่อ pixel

17.7 Chromatic (ของทีม Storybook)

📝 หมายเหตุ: §17.7 ต้องการ Storybook — Storybook = เครื่องมือ develop และ document component แบบแยกเดี่ยว (รายละเอียดในบทขั้นสูง) ถ้ายังไม่รู้จัก ข้าม §17.7 ไปก่อนได้

bash
npm install --save-dev chromatic
npx chromatic --project-token=$CHROMATIC_PROJECT_TOKEN

💡 เอา token จริงได้จากหน้า project settings บน chromatic.com — เก็บเป็น environment variable (เช่น CHROMATIC_PROJECT_TOKEN) อย่า hardcode/commit token ลงโค้ด

Workflow:

  1. เขียน Storybook stories ของ component
  2. รัน chromatic → Chromatic capture screenshot ของทุก story บน cloud
  3. PR ใหม่ → compare กับ baseline → highlight diff
  4. Reviewer approve → baseline updated

ตัวอย่าง story

tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

const meta: Meta<typeof Button> = { component: Button };
export default meta;

// shadcn Button ใช้ children แทน label prop — variant: 'default' | 'destructive' | 'outline' | 'ghost'
export const Primary: StoryObj<typeof Button> = {
    args: { variant: 'default', children: 'Save' },
};

export const Destructive: StoryObj<typeof Button> = {
    args: { variant: 'destructive', children: 'Delete' },
};

export const Disabled: StoryObj<typeof Button> = {
    args: { variant: 'default', children: 'Save', disabled: true },
};

Chromatic capture 3 ภาพ — เปลี่ยน CSS → เห็น diff ทันที

→ เนื้อหา Storybook จะอยู่ในบทขั้นสูงที่จะเพิ่มในอนาคต


Part 5.7: Test Data Factories — เลิกเขียน mock data ซ้ำซ้อน

17.8 ปัญหา

ปัญหาที่โตขึ้นเมื่อ test เยอะ — สร้าง mock data (เช่น user object) ซ้ำกระจายทั่ว test ไฟล์ พอ schema เปลี่ยน field ต้องไล่แก้เป็นสิบ ๆ ที่ test data factory แก้ปัญหานี้ด้วยการสร้าง mock จากที่เดียว:

tsx
// test 1
const user = { id: 1, name: 'Anna', email: 'a@b.com', age: 25, role: 'user' };

// test 2
const user = { id: 2, name: 'Ben', email: 'b@c.com', age: 30, role: 'admin' };

// test 50
const user = { id: 50, name: '...' };

→ field เปลี่ยน schema → update 50 ที่. test data scatter ทั่ว codebase

17.9 ใช้ Factory pattern

แบบทำเอง:

tsx
// test/factories/user.ts
// User type — define ไว้ที่นี่หรือ import จาก types ของโปรเจกต์
interface User {
    id: number;
    name: string;
    email: string;
    age: number;
    role: string;
    createdAt: Date;
}

let userId = 1;

export function makeUser(overrides?: Partial<User>): User {
    const id = userId++;  // capture id ก่อน increment — ทำให้ id กับ email ตรงกัน (id:1, email: user1@test.com)
    return {
        id,
        name: 'Test User',
        email: `user${id}@test.com`,
        age: 25,
        role: 'user',
        createdAt: new Date('2026-01-01'),
        ...overrides,
    };
}

// in test
const admin = makeUser({ role: 'admin' });
const teen = makeUser({ age: 16 });
const list = Array.from({ length: 5 }, () => makeUser());

แบบใช้ library — @faker-js/faker + Fishery:

bash
npm install --save-dev @faker-js/faker fishery
tsx
// test/factories/user.factory.ts
import { Factory } from 'fishery';
import { faker } from '@faker-js/faker';
import type { User } from './user';   // import type User ที่ประกาศไว้ใน §17.9 (แบบทำเอง) — ไฟล์นี้แยกจากกันจึงต้อง import เอง

export const userFactory = Factory.define<User>(({ sequence }) => ({
    id: sequence,
    name: faker.person.fullName(),
    email: faker.internet.email(),
    age: faker.number.int({ min: 18, max: 80 }),
    role: 'user',
    createdAt: faker.date.past(),
}));

// usage
const user = userFactory.build();
const admin = userFactory.build({ role: 'admin' });
const list = userFactory.buildList(10);

⭐ ข้อดีของ Factory Pattern

  • ✅ Realistic data — name จริง, email จริง (ใช้ดูใน UI ก็ดู natural)
  • ✅ Sequence id ไม่ชน (1, 2, 3, ...)
  • ✅ Override เฉพาะ field ที่เกี่ยวกับ test
  • ✅ Compose factories — postFactory.build({ author: userFactory.build() })

Combine กับ MSW

tsx
import { http, HttpResponse } from 'msw';
import { userFactory } from '../factories/user.factory';

export const handlers = [
    http.get('/api/users', () => {
        return HttpResponse.json(userFactory.buildList(20));
    }),
];

→ MSW return realistic data ทุก request — dev environment ก็ใช้ได้


Part 6: Coverage

bash
npm install --save-dev @vitest/coverage-v8
npm run test:coverage
ts
// vite.config.ts
test: {
    coverage: {
        provider: 'v8',
        reporter: ['text', 'html'],
        exclude: ['node_modules/', 'src/test/'],
    }
}

Run → ได้ HTML report ที่ coverage/index.html

⚠️ อย่าตั้งเป้า 100% coverage — เป้าจริง = test สำคัญทุก path
80% coverage จากการ test ที่มีคุณค่า > 100% coverage จาก test ที่ไม่ครอบคลุม case จริง


17.95 Accessibility test ด้วย axe — auto-detect a11y bug ใน CI

@axe-core/react + vitest-axe ทำให้ test เช็คได้ว่า component ไม่มี a11y violation (missing label, contrast ต่ำ, ARIA ผิด) — รัน ทุก PR กัน regression:

📝 vitest-axe เป็นตัวเลือกหนึ่ง — ควรตรวจสอบ maintenance status (ดู npm downloads + last commit) ก่อนใช้ในโปรเจกต์ ทางเลือก: ใช้ axe-core โดยตรงกับ Testing Library

bash
npm i -D vitest-axe @axe-core/react
tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'vitest-axe';

expect.extend(toHaveNoViolations);

describe('LoginForm a11y', () => {
    it('has no a11y violations', async () => {
        const { container } = render(<LoginForm />);
        const results = await axe(container);
        expect(results).toHaveNoViolations();
    });
});

ถ้า fail → message บอกชัด: input has no accessible name (แปลว่า: input นี้ไม่มีชื่อที่ screen reader อ่านได้ — แก้ด้วยการเพิ่ม <label> หรือ aria-label) นอกจากนี้ message ยังมี link แนบไปหน้าอธิบาย rule ที่ละเมิดด้วย

สำหรับ E2E (Playwright) — มี @axe-core/playwright:

ts
import AxeBuilder from '@axe-core/playwright';
test('homepage has no a11y violations', async ({ page }) => {
    await page.goto('/');
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
});

💡 ไม่ใช่ test ครบ a11y — axe จับ ~30-50% ของ a11y bug (rule-based) ที่เหลือยังต้องทดสอบด้วย keyboard + screen reader จริง แต่ตัว axe ดักของง่ายๆ ก่อน merge ได้ดี


18. ⚠️ Common Pitfalls

Pitfallแก้
Test ที่ดู state internaltest สิ่งที่ user เห็น
Query ด้วย CSS classใช้ getByRole/getByLabelText
Mock เยอะเกินจน test ไม่มีค่าmock เฉพาะ I/O (API, time)
Test แสนช้าE2E เฉพาะ critical path
act() warning หายใช้ userEvent + findBy* (act = wrapper ที่บังคับให้ React ประมวลผล state update ทั้งหมดให้เสร็จก่อนที่เราจะตรวจผล — userEvent ห่อ act ให้อัตโนมัติแล้ว ถ้าห่อซ้ำจะได้ warning)
Race condition ใน async testawait waitFor(() => expect(getByText('OK')).toBeInTheDocument()) ใช้ findByText('OK') แทน — auto-retry รอจน element ขึ้นแล้วค่อย return
Flaky testหลีกเลี่ยง setTimeout, ใช้ findBy* / waitFor, mock เวลาด้วย vi.useFakeTimers()
Snapshot test ที่ใหญ่snapshot เฉพาะ leaf component เล็ก ๆ
ไม่ test a11yใส่ axe ใน suite ใหญ่ (ดู §17.95)

19. Testing Pyramid (สำหรับ Frontend)

แนวคิดสำคัญในการจัดสัดส่วน test — เขียน unit/component test เยอะ (เร็ว ถูก แม่น) ตรงฐาน, integration test ปานกลางตรงกลาง, และ E2E น้อย ๆ เฉพาะ flow สำคัญตรงยอด (เพราะช้าและ flaky) อย่ากลับหัวพีระมิด (E2E เยอะ unit น้อย) ที่ทำให้ test ช้าและเปราะ:


20. Checkpoint

📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง

🛠️ Checkpoint 8.1 — Test utility
เขียน utility:

  • debounce(fn, ms)
  • formatRelativeTime(date) ("3 minutes ago", "yesterday")
  • chunk(array, size) — แบ่ง array

เขียน test ครอบคลุม — รวม edge case (empty, null, ขอบล่าง/บน)

🛠️ Checkpoint 8.2 — Test component
สร้าง <TodoList>:

  • แสดง todo
  • เพิ่ม
  • ลบ
  • toggle done
  • filter (all/active/done)

เขียน test:

  • เริ่มต้นว่าง → "No todos"
  • เพิ่ม → แสดง
  • กด toggle → "completed" class
  • filter "active" → ไม่เห็น completed

🛠️ Checkpoint 8.3 — E2E flow
ใช้ Playwright:

  • เข้า /
  • click "Sign up"
  • กรอก form
  • redirect ไป /dashboard
  • เห็นชื่อตัวเอง
  • logout → กลับ /

21. สรุปบท

Vitest = test runner ปี 2026 (เร็ว, ESM-native, ใช้ config ของ Vite)
React Testing Library = test "พฤติกรรม" ไม่ใช่ "implementation"
✅ Query priority: getByRole > getByLabelText > getByText > getByTestId
userEvent > fireEvent — simulate user จริงกว่า
MSW = mock API ในระดับ network — ใช้ได้ทั้ง test + dev
✅ Test custom hook ด้วย renderHook + act
Playwright = E2E ปี 2026 (เร็ว, multi-browser, ดี debug)
✅ Testing pyramid — unit เยอะ, E2E น้อย
✅ Coverage เป็น guide ไม่ใช่ targetVitest Browser Mode (stable ตั้งแต่ Vitest 2.0, 2024) = run test ใน real browser (Chromium/Firefox/WebKit) แทน jsdom — accurate กว่า + เร็ว ต้อง install เพิ่ม:

bash
npm install --save-dev @vitest/browser playwright
ts
// vite.config.ts — Vitest browser mode (Vitest 3.x)
test: {
    browser: {
        enabled: true,
        provider: 'playwright',
        instances: [{ browser: 'chromium' }],
    },
}

📝 Vitest Browser Mode + Storybook 8 + Vitest add-on เป็น advanced feature — ลองทำได้เมื่อคุ้นกับ testing พื้นฐานแล้ว ดูเอกสารล่าสุดที่ vitest.dev

Storybook (เครื่องมือพัฒนา component แยกเดี่ยว — จะอยู่ในบทขั้นสูง) 8 + Vitest add-on (ส่วนเสริม) = run stories เป็น test cases อัตโนมัติ ✅ Component Testing in Playwright = alternative ของ RTL+jsdom, run ใน real browser


← บทที่ 7 | บทที่ 9 → Advanced Patterns