ข้ามไปยังเนื้อหา

Frontend tests

apps/web/vitest.config.ts และ apps/web/vitest.setup.ts — ตั้งค่า Vitest ด้วย environment: 'jsdom' เพื่อให้ component test รันได้โดยไม่ต้องมี browser จริง apps/web/components/PostCard.test.tsx — render test สำหรับ PostCard apps/web/components/CommentForm.test.tsx — interaction test สำหรับ CommentForm: พิมพ์ลงในฟิลด์ กด submit assert ว่า gqlFetch ที่ mock ไว้ถูกเรียกด้วย argument ที่ถูกต้อง ยืนยันว่าข้อความ “awaiting moderation” ปรากฏขึ้น นอกจากนี้ยังมี apps/web/e2e/read-and-comment.spec.ts — Playwright describe sketch ที่ตั้งชื่อไว้สำหรับ journey เดียวกันแบบ end-to-end โดยจงใจไม่สร้างให้สมบูรณ์

PostCard กับ CommentForm เป็น component แบบที่ Vitest + jsdom + @testing-library/react ถูกสร้างมาเพื่อสิ่งนี้พอดี: การ render ล้วน ๆ จาก props (PostCard) กับ local useState บวกกับการเรียกออกไปหนึ่งครั้ง (CommentForm) ทั้งสองอย่างนี้ไม่ต้องใช้ browser จริงในการยืนยันเลย jsdom คือ implementation ของ DOM ที่เขียนด้วย JavaScript ไม่ใช่ rendering engine จริง — ไม่มี layout จริง ไม่มีการ paint จริง และ browser API บางตัวก็ถูก stub หรือไม่มีเลย แต่เร็วพอจะรัน component test เป็นร้อยตัวได้ในเวลาไม่ถึงวินาที และเท่านี้ก็พอสำหรับ React Testing Library ที่ต้องการแค่ mount component แล้ว query ผลลัพธ์ที่ render ออกมาแบบเดียวกับที่ผู้ใช้ทำ (getByRole, getByText, getByLabelText)

test ของ CommentForm mock ตัว gqlFetch จาก @/lib/graphql ไปเลย แทนที่จะปล่อยให้ยิง network จริง เป็นสัญชาตญาณเดียวกับ “mock collaborator แล้วเทสต์ตัวคลาส” ที่ Backend tests ใช้กับ Mongoose model ของ PostsService ผลคือ assertion แม่นยำขึ้น เพราะพิสูจน์แค่ว่า CommentForm เรียก gqlFetch ด้วย mutation ที่ถูกต้องและรูปทรง { postId, input } ที่ถูกต้อง แล้วแสดง “Thanks — your comment is awaiting moderation.” เมื่อการเรียกนั้น resolve โดยไม่ต้องมี NestJS API รันอยู่จริง ไม่ต้องมี Mongo จริง และไม่มี network round trip จริงสักครั้ง

แต่ทั้งสอง test ก็ไม่ได้พิสูจน์ว่า API จริง ยอมรับสิ่งที่ CommentForm ส่งไป gqlFetch ที่ mock ไว้พิสูจน์ได้แค่ว่า component เรียก mock ของตัวเองถูกต้อง เป็นข้อจำกัดชุดเดียวกับ Mongoose Model ที่ mock ไว้บนฝั่ง backend นี่คือสิ่งที่ Playwright sketch เอ่ยชื่อไว้ตรง ๆ: e2e test ที่ขับ browser จริงกับ stack ที่รันอยู่จริง (dev server ของ apps/web คุยกับ apps/api จริงและ Mongo จริง) เป็นชั้นเดียวที่จะจับได้จริงว่าการเรียก addComment ของ CommentForm เพี้ยนไปจากสิ่งที่ CommentsResolver จาก The comment model คาดหวังจริง ๆ คอร์สนี้จึง sketch รูปทรงของ test นั้นไว้ — ตั้งชื่อ ใส่ describe ร่างขั้นตอน — โดยไม่ทำให้สมบูรณ์ ยึดความซื่อตรงแบบ “พูดถึงช่องว่างตรง ๆ อย่าแกล้งว่าไม่มี” แบบเดียวกับที่ GraphQL client & auth ใช้กับ revalidateTag ไปแล้ว

Vitest + jsdom + React Testing Library (component unit test) เทียบกับ Playwright (browser e2e) component test แบบ jsdom ไม่เคยเปิด browser ไม่เคย start dev server และไม่เคยแตะ network socket เลย — PostCard.test.tsx กับ CommentForm.test.tsx รวมกันรันเสร็จในเสี้ยววินาที และเมื่อพังก็ isolate ไปที่ component เดียวพร้อม stack trace ชี้ไปที่ assertion ที่พังพอดี ต้นทุนก็มีจริงเช่นกัน: jsdom ไม่ execute layout หรือ paint จริง บั๊ก CSS ที่เห็นได้ด้วยตาเท่านั้นจึงหลุดรอดไปหมด และจับ integration break จริงไม่ได้ เช่นกรณีที่ CommentForm ส่งชื่อ field ที่ GraphQL API จริงเมินเฉยแบบเงียบ ๆ เพราะ gqlFetch ถูก mock ออกไปทั้งตัว Playwright จ่ายต้นทุนตรงข้าม — ต้อง launch Chromium จริง ต้องโหลดหน้าจริงกับ apps/web ที่รันอยู่ (และสำหรับการรัน end-to-end จริง ๆ ต้องมี apps/api กับ Mongo ที่รันอยู่เบื้องหลังด้วย) ดังนั้นแต่ละ test จึงกินเวลาเป็นวินาทีไม่ใช่มิลลิวินาที — แลกกับการเป็นชั้นเดียวที่พิสูจน์ว่า user journey จริงใช้งานได้: คลิกจริง submit form จริง network round trip จริง ผลลัพธ์ที่ render จริง การแบ่งที่ถูกต้องสะท้อน testing pyramid ของฝั่ง backend เป๊ะ ๆ: coverage ส่วนใหญ่ยังอยู่ที่ component unit test เร็ว ๆ และ Playwright e2e test จำนวนน้อยถูกเก็บไว้สำหรับ journey หยิบมือหนึ่ง — อย่างการอ่านโพสต์แล้วคอมเมนต์ — ที่ “สิ่งนี้ใช้งานได้จริงไหมเมื่อประกอบเข้าด้วยกันสำหรับผู้เข้าชมจริง” คือคำถามทั้งหมด

เพิ่ม dev dependency ที่ Vitest กับ Playwright ต้องการเข้าไปใน apps/web/package.json:

{
"devDependencies": {
"vitest": "^3.0.0",
"@vitejs/plugin-react": "^4.3.0",
"jsdom": "^25.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/user-event": "^14.5.0",
"@playwright/test": "^1.48.0"
}
}

สร้าง apps/web/vitest.config.ts:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: {
'@': new URL('./', import.meta.url).pathname,
},
},
});

สร้าง apps/web/vitest.setup.ts:

import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
afterEach(() => {
cleanup();
});
  • environment: 'jsdom' คือ option เดียวที่ทำให้ render() จาก @testing-library/react เป็นไปได้เลย — environment node ที่เป็นค่า default ของ Vitest ไม่มี document/window ให้ component mount เข้าไปได้
  • resolve.alias['@'] สะท้อน path mapping @/* ที่ apps/web/tsconfig.json ประกาศไว้แล้วสำหรับ Next.js เอง — ทุก import ... from '@/lib/graphql' ใน CommentForm.tsx/PostCard.tsx ต้องการให้ Vitest ที่เป็นเครื่องมือ build คนละตัวกับ bundler ของ Next เอง resolve alias นี้ในแบบเดียวกัน
  • @testing-library/jest-dom/vitest ไม่ใช่ @testing-library/jest-dom เปล่า ๆ — นี่คือ entry point เฉพาะของ Vitest ที่ลงทะเบียน matcher อย่าง .toBeInTheDocument()/.toHaveTextContent() เข้ากับ expect ของ Vitest เอง ไม่ใช่ของ Jest
  • cleanup() ใน afterEach unmount สิ่งที่ test ก่อนหน้า render ลงใน document ที่ jsdom ใช้ร่วมกัน เพื่อไม่ให้ DOM ที่เหลือจาก test หนึ่งรั่วไปปนกับ query ของ test ถัดไป

สร้าง apps/web/components/PostCard.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { PostCard } from './PostCard';
import type { Post } from '@/lib/graphql';
vi.mock('next/link', () => ({
default: ({
href,
children,
className,
}: {
href: string;
children: React.ReactNode;
className?: string;
}) => (
<a href={href} className={className}>
{children}
</a>
),
}));
type CardPost = Pick<Post, 'title' | 'slug' | 'excerpt' | 'coverImage' | 'tags' | 'publishedAt'>;
const basePost: CardPost = {
title: 'Hello, DevBlog',
slug: 'hello-devblog',
excerpt: 'This is the first post.',
coverImage: undefined,
tags: ['nestjs', 'graphql'],
publishedAt: '2026-01-01T00:00:00.000Z',
};
describe('PostCard', () => {
it('renders the title, excerpt, tags, and a link to the post', () => {
render(<PostCard post={basePost} />);
expect(screen.getByRole('heading', { name: 'Hello, DevBlog' })).toBeInTheDocument();
expect(screen.getByText('This is the first post.')).toBeInTheDocument();
expect(screen.getByText('nestjs, graphql')).toBeInTheDocument();
expect(screen.getByRole('link')).toHaveAttribute('href', '/posts/hello-devblog');
});
it('omits the excerpt and the cover image when neither is set', () => {
const post: CardPost = { ...basePost, excerpt: undefined, coverImage: undefined };
render(<PostCard post={post} />);
expect(screen.queryByText('This is the first post.')).not.toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
});
  • vi.mock('next/link', ...) แทนที่ Link ของ Next ด้วย <a> ธรรมดาตลอดไฟล์นี้ — PostCard ต้องการแค่ให้ Link render anchor ที่มี href เท่านั้น mock นี้จึงทำให้ test เป็นอิสระจาก router context ของ Next เอง แทนที่จะไป assert อะไรเกี่ยวกับ internal ของ Next
  • screen.queryByText/queryByRole ไม่ใช่ getByText/getByRole ใน test ที่สองquery* คืนค่า null เมื่อไม่พบ element แทนที่จะ throw นั่นคือสิ่งที่การ assert การ ไม่มีอยู่ ของ element ต้องการพอดี get* จะทำให้ test พังด้วย error “unable to find element” ที่ไม่มีประโยชน์ ก่อนที่ assertion not.toBeInTheDocument() จะได้รันเลยด้วยซ้ำ

สร้าง apps/web/components/CommentForm.test.tsx:

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CommentForm } from './CommentForm';
import { gqlFetch } from '@/lib/graphql';
vi.mock('@/lib/graphql', () => ({
gqlFetch: vi.fn(),
}));
const mockedGqlFetch = vi.mocked(gqlFetch);
describe('CommentForm', () => {
beforeEach(() => {
mockedGqlFetch.mockReset();
});
it('submits the form through gqlFetch and shows the awaiting-moderation message', async () => {
mockedGqlFetch.mockResolvedValue({
addComment: {
id: 'c1',
authorName: 'Alex',
body: 'Great post, thanks for writing this up!',
createdAt: '2026-01-01T00:00:00.000Z',
},
});
const user = userEvent.setup();
render(<CommentForm postId="post-1" />);
await user.type(screen.getByLabelText('Name'), 'Alex');
await user.type(screen.getByLabelText('Email'), 'alex@example.com');
await user.type(screen.getByLabelText('Comment'), 'Great post, thanks for writing this up!');
await user.click(screen.getByRole('button', { name: /submit comment/i }));
await waitFor(() => {
expect(mockedGqlFetch).toHaveBeenCalledTimes(1);
});
expect(mockedGqlFetch).toHaveBeenCalledWith(expect.stringContaining('mutation AddComment'), {
postId: 'post-1',
input: {
authorName: 'Alex',
authorEmail: 'alex@example.com',
body: 'Great post, thanks for writing this up!',
},
});
expect(
await screen.findByText('Thanks — your comment is awaiting moderation.'),
).toBeInTheDocument();
});
it('shows the GraphQL error message and keeps the form visible when gqlFetch rejects', async () => {
mockedGqlFetch.mockRejectedValue(new Error('Cannot comment on a post that is not published'));
const user = userEvent.setup();
render(<CommentForm postId="post-1" />);
await user.type(screen.getByLabelText('Name'), 'Alex');
await user.type(screen.getByLabelText('Email'), 'alex@example.com');
await user.type(screen.getByLabelText('Comment'), 'First!');
await user.click(screen.getByRole('button', { name: /submit comment/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(
'Cannot comment on a post that is not published',
);
expect(screen.getByRole('button', { name: /submit comment/i })).toBeInTheDocument();
});
});
  • vi.mock('@/lib/graphql', ...) บวกกับ vi.mocked(gqlFetch) แทนที่ gqlFetch จริงด้วย vi.fn() ตลอดไฟล์นี้ และ vi.mocked() คืน handle ของ mock ที่มี type ถูกต้อง (.mockResolvedValue/.mockRejectedValue) แทนที่จะต้อง cast มือเปล่า
  • getByLabelText('Name') หา <input> ที่ซ้อนอยู่ใน <label>Name<input ... /></label> Testing Library resolve ความสัมพันธ์ label แบบ implicit นี้เหมือนที่ screen reader ทำ โดยไม่ต้องมีคู่ htmlFor/id ซึ่ง CommentForm.tsx ก็ไม่เคยใส่ไว้
  • await waitFor(() => expect(mockedGqlFetch).toHaveBeenCalledTimes(1))handleSubmit เป็น async; ถ้าไม่รอ assertion อาจรันก่อนที่การเรียก gqlFetch ที่ await ไว้จะเกิดขึ้นจริงด้วยซ้ำ
  • ข้อความ success เป๊ะ ๆ 'Thanks — your comment is awaiting moderation.' ถูก assert แบบตัวอักษรต่อตัวอักษรตรงกับ JSX จริงของ CommentForm.tsx จาก Post page — test ที่เฉพาะเจาะจงขนาดนี้จะจับการเปลี่ยนคำในข้อความนั้นเป็น diff ที่มองเห็นได้จริง ไม่ใช่ผ่านเฉย ๆ ไม่ว่าจะเปลี่ยนคำว่าอะไร
  • test ที่สองพิสูจน์ branch catch ไม่ใช่แค่ happy path — err instanceof Error ? err.message : '...' ใน CommentForm.tsx หมายความว่าการเรียก gqlFetch ที่ reject จะแสดงข้อความจริงผ่าน role="alert" และ form ยังคง mount อยู่ (submitted ไม่กลายเป็น true) เพื่อให้ผู้ใช้แก้ input แล้วลองใหม่ได้

สร้าง apps/web/e2e/read-and-comment.spec.ts — sketch ไม่ใช่ test ที่สร้างเสร็จสมบูรณ์:

import { test, expect } from '@playwright/test';
test.describe('reading a post and leaving a comment', () => {
test('a visitor can open a published post and submit a comment', async ({ page }) => {
// 1. Visit the home list and open the first published post.
await page.goto('/');
await page.getByRole('link').first().click();
// 2. Fill in and submit the comment form.
// await page.getByLabel('Name').fill('Alex');
// await page.getByLabel('Email').fill('alex@example.com');
// await page.getByLabel('Comment').fill('Great post, thanks for writing this up!');
// await page.getByRole('button', { name: 'Submit comment' }).click();
// 3. Assert the "awaiting moderation" message replaces the form.
// await expect(page.getByText('Thanks — your comment is awaiting moderation.')).toBeVisible();
// TODO: needs a real apps/api + at least one published, seeded post before
// this can run for real — see Docker & Compose for standing up the whole
// stack this e2e test would run against.
});
});
  • test.describe/test ตั้งชื่อตรงตาม journey ที่เป็นตัวแทน — “reading a post and leaving a comment” — แม้ body ของขั้นตอนที่ 2 กับ 3 จะยัง comment ไว้ก็ตาม การตั้งชื่อ test ตั้งแต่ยังรันไม่ได้เป็นเรื่องจงใจ เพราะชื่อนี้คือสิ่งแรกที่ CI dashboard หรือเพื่อนร่วมทีมที่เปิดไฟล์นี้จะเห็น และควรอธิบาย user journey จริงไม่ว่า implementation จะเสร็จไปแค่ไหน
  • sketch นี้พึ่งพา infrastructure ที่ module นี้ไม่ได้ตั้งขึ้นมาให้apps/api จริง, Mongo จริง, และโพสต์ที่ publish แล้วอย่างน้อยหนึ่งอันที่ seed ไว้แล้ว Docker & Compose คือจุดที่ stack ทั้งหมดกลายเป็น docker compose up คำสั่งเดียว นั่นคือสิ่งที่จะทำให้ test นี้รันได้จริงแทนที่จะเป็นแค่ความตั้งใจ
  • Playwright เองมี API page.goto/getByRole/getByLabel ที่จงใจให้มีรูปทรงเดียวกับ query ของ React Testing Library — ผู้อ่านที่คุ้นกับ CommentForm.test.tsx ข้างบนแล้วก็แทบจะอ่านโค้ด Playwright จริงได้เลย นั่นคือเหตุผลพอดีว่าทำไม sketch นี้ถึงคุ้มค่าที่จะใส่ไว้แม้จะยังไม่เสร็จ
Terminal window
cd apps/web
npm run test
✓ components/PostCard.test.tsx (2 tests) 15ms
✓ components/CommentForm.test.tsx (2 tests) 120ms
Test Files 2 passed (2)
Tests 4 passed (4)
Start at 10:15:00
Duration 850ms

read-and-comment.spec.ts ไม่ได้รันตรงนี้เลย — pattern include ของ Vitest เก็บเฉพาะไฟล์ .test.tsx เท่านั้น และ Playwright test ก็รันผ่านคำสั่ง npx playwright test ของตัวเองต่างหาก กับแอปที่รันอยู่จริง ไม่ใช่ผ่าน npm run test การแยกกันแบบนี้เป็นเรื่องจงใจ: component unit test suite ควรเร็วพอที่จะรันได้ทุกครั้งที่ save ส่วน e2e suite ที่ต้องมี stack ที่รันอยู่จริงทั้งชุดไม่ควรมาบล็อก feedback loop นั้น

PostCard.test.tsx กับ CommentForm.test.tsx รันอยู่ใน jsdom ทั้งหมด โดย gqlFetch ถูก mock ออกจาก CommentForm ในแบบเดียวกับที่ Mongoose model ของ PostsService ถูก mock บนฝั่ง backend — เร็ว isolate และชี้ได้แม่นยำว่า component ไหนพังเมื่อ test ล้มเหลว environment: 'jsdom' ใน vitest.config.ts บวกกับ @testing-library/jest-dom/vitest ใน vitest.setup.ts คือ setup ทั้งหมดที่ทำให้ render()/screen/.toBeInTheDocument() ใช้งานได้ ส่วน read-and-comment.spec.ts พูดถึงสิ่งเดียวที่ unit test แบบ mock ไหน ๆ ก็พิสูจน์ไม่ได้ คือแอปจริงที่ประกอบเข้าด้วยกันแล้วใช้งานได้จริงสำหรับผู้เข้าชมจริง จึงเป็น Playwright sketch ที่ปล่อยไว้ไม่สมบูรณ์อย่างซื่อตรง จนกว่า Docker & Compose จะยก stack ที่ test นี้ต้องใช้ขึ้นมา การแลกแบบ testing pyramid เดียวกับที่ Backend tests เอ่ยไว้ — ความเร็วกับการ isolate ที่ฐาน ความแม่นยำกับความมั่นใจที่ยอด — ก็นำมาใช้กับฝั่ง frontend ได้เป๊ะเหมือนที่ใช้กับ API

ถัดไป: Docker & Compose →