โหมดมืด
บทที่ 8 — Testing (Vitest + React Testing Library + Playwright)
🟡 ระดับ: กลาง
📖 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
| Type | Test อะไร | เครื่องมือ |
|---|---|---|
| Unit | function เดี่ยว ๆ | Vitest |
| Component | render + interact | Vitest + RTL |
| Integration | หลาย component ทำงานด้วยกัน | Vitest + RTL + MSW |
| E2E | flow ใน 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 jsdomts
// 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 caseexpect(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 test5. 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 modulePart 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 error | element ต้องมี |
queryBy... | return null | check ว่าไม่มี |
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 mswts
// 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 # เห็น browser15. 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
| Cypress | Playwright | |
|---|---|---|
| Speed | ปานกลาง | เร็วกว่า |
| Browser support | Chrome, Edge, Firefox, Electron | Chrome, Firefox, Safari, Edge |
| API | Chainable | async/await |
| iframe / cross-origin | จำกัด | รองรับเต็ม |
| Parallel | paid feature | ฟรี |
| Debug | UI ดีมาก | 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 มีปัญหาในทางปฏิบัติ
- Update ง่ายเกินไป → developer แค่
npm test -- -uกลายเป็น "rubber stamp" (อนุมัติแบบไม่ตรวจ) ไม่ได้คิดว่า diff ถูกหรือผิด - Big snapshots เน่าเร็ว → render component ใหญ่ → snapshot 200 บรรทัด → review ใน PR ไม่มีใครอ่าน
- ไม่บอกว่าทำไม → 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:
- เขียน Storybook stories ของ component
- รัน
chromatic→ Chromatic capture screenshot ของทุก story บน cloud - PR ใหม่ → compare กับ baseline → highlight diff
- 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 fisherytsx
// 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:coveragets
// 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/reacttsx
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 internal | test สิ่งที่ 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 test | ❌ await 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 ไม่ใช่ target ✅ Vitest Browser Mode (stable ตั้งแต่ Vitest 2.0, 2024) = run test ใน real browser (Chromium/Firefox/WebKit) แทน jsdom — accurate กว่า + เร็ว ต้อง install เพิ่ม:
bash
npm install --save-dev @vitest/browser playwrightts
// 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