Testing & Type Safety trong Next.js: Setup pipeline bài bản từ đầu
Từ TypeScript strict mode đến Playwright E2E cách mình setup một testing pipeline hoàn chỉnh cho dự án Next.js production.
Nguyễn Nhật Long
@nguyennhatlong1303
Mình đã từng làm việc trên một codebase Next.js mà không có test nào, TypeScript thì dùng any vô tội vạ, và mỗi lần deploy là một lần cầu nguyện. Sau vài lần production bị lỗi vì những thứ hoàn toàn có thể catch được từ trước, mình quyết định ngồi setup lại toàn bộ pipeline đúng cách. Bài này mình chia sẻ lại những gì mình đã làm từ TypeScript config, runtime validation, unit test, đến E2E và CI.
TypeScript Strict Mode đừng để nó "dễ tính" quá
Mặc định khi tạo project Next.js, TypeScript config khá lỏng. Thứ đầu tiên mình làm là bật strict: true trong tsconfig.json:
1{2 "compilerOptions": {3 "strict": true,4 "noUncheckedIndexedAccess": true,5 "baseUrl": ".",6 "paths": {7 "@/*": ["./src/*"]8 }9 }10}
Cái noUncheckedIndexedAccess này nhiều người hay bỏ qua nhưng mình thấy rất hữu ích. Khi bạn access array bằng index ví dụ arr[0] TypeScript sẽ trả về kiểu T | undefined thay vì T. Nghe có vẻ phiền, nhưng nó bắt được khá nhiều bug kiểu "tưởng có phần tử mà thực ra array rỗng".
Về cách tổ chức types, mình theo convention đặt tất cả vào src/types/ và export từ một file index.ts duy nhất:
1src/2 types/3 user.ts4 product.ts5 api.ts6 index.ts ← re-export hết từ đây
Path alias @/ thì khỏi nói, import @/types nhìn sạch hơn ../../../types rất nhiều.
Zod validate runtime vì TypeScript không chạy lúc runtime
Đây là điểm nhiều anh em hay nhầm: TypeScript chỉ check type lúc compile time. Khi data từ API về, từ form user nhập, hay từ URL params tất cả đều là unknown ở runtime. Zod giải quyết đúng vấn đề này.
1import { z } from 'zod'23const RegisterSchema = z.object({4 email: z.string().email('Email không hợp lệ'),5 age: z.number().min(18, 'Phải đủ 18 tuổi'),6 password: z.string().min(8),7})89type RegisterInput = z.infer<typeof RegisterSchema>
Hai method quan trọng nhất:
Cái hay nhất của Zod là share schema giữa client và server. Mình define schema một lần, dùng cho cả react-hook-form validation lẫn server action:
| Method | Hành vi khi fail | Dùng khi nào |
|---|---|---|
| `parse()` | Throw `ZodError` | Server-side, biết chắc data hợp lệ |
| `safeParse()` | Return `{ success, data, error }` | Client-side, cần handle lỗi gracefully |
1// Dùng với react-hook-form2import { zodResolver } from '@hookform/resolvers/zod'34const form = useForm<RegisterInput>({5 resolver: zodResolver(RegisterSchema),6})78// Dùng trong server action9async function registerAction(formData: FormData) {10 const result = RegisterSchema.safeParse({11 email: formData.get('email'),12 age: Number(formData.get('age')),13 password: formData.get('password'),14 })1516 if (!result.success) {17 return { error: result.error.flatten() }18 }19 // result.data đã được type-safe hoàn toàn20}
Theo kinh nghiệm của mình, pattern này cực kỳ đáng giá trong các form phức tạp bạn không cần viết validation logic hai lần, và client/server luôn đồng bộ với nhau.
Vitest cho unit test nhanh hơn Jest, config ít hơn
Mình chuyển từ Jest sang Vitest từ khoảng một năm trước và không có lý do gì để quay lại. Config đơn giản hơn nhiều, chạy nhanh hơn, và API gần như giống hệt Jest nên không mất công học lại.
1// vitest.config.ts2import { defineConfig } from 'vitest/config'3import react from '@vitejs/plugin-react'45export default defineConfig({6 plugins: [react()],7 test: {8 environment: 'jsdom',9 globals: true,10 setupFiles: ['./src/test/setup.ts'],11 },12})
Test một pure function:
1import { describe, it, expect } from 'vitest'2import { formatCurrency } from '@/lib/utils'34describe('formatCurrency', () => {5 it('format số tiền VND đúng cách', () => {6 expect(formatCurrency(1000000)).toBe('1.000.000 ₫')7 })89 it('xử lý số âm', () => {10 expect(formatCurrency(-500)).toBe('-500 ₫')11 })12})
Test custom hook với renderHook:
1import { renderHook, act } from '@testing-library/react'2import { useCounter } from '@/hooks/useCounter'34it('increment counter đúng', () => {5 const { result } = renderHook(() => useCounter())67 act(() => {8 result.current.increment()9 })1011 expect(result.current.count).toBe(1)12})
Mock thì dùng vi.fn() và vi.mock() syntax y chang Jest, anh em không cần lo.
React Testing Library test behavior, không test implementation
Triết lý của RTL là: test những gì user thấy và làm, không phải internal state hay implementation detail. Mình thấy cái này rất đúng sau nhiều lần refactor component mà không cần sửa test.
1import { render, screen } from '@testing-library/react'2import userEvent from '@testing-library/user-event'3import { LoginForm } from '@/components/LoginForm'45describe('LoginForm', () => {6 it('hiển thị lỗi khi submit form rỗng', async () => {7 const user = userEvent.setup()8 render(<LoginForm />)910 // Arrange: form đã render11 const submitButton = screen.getByRole('button', { name: /đăng nhập/i })1213 // Act: user click submit14 await user.click(submitButton)1516 // Assert: lỗi hiện ra17 expect(screen.getByText(/email không được để trống/i)).toBeInTheDocument()18 })1920 it('call onSubmit với data đúng', async () => {21 const user = userEvent.setup()22 const mockSubmit = vi.fn()23 render(<LoginForm onSubmit={mockSubmit} />)2425 await user.type(screen.getByLabelText(/email/i), 'test@example.com')26 await user.type(screen.getByLabelText(/mật khẩu/i), 'password123')27 await user.click(screen.getByRole('button', { name: /đăng nhập/i }))2829 expect(mockSubmit).toHaveBeenCalledWith({30 email: 'test@example.com',31 password: 'password123',32 })33 })34})
Một vài lưu ý về queries:
Anh em lưu ý: getByRole nên là lựa chọn đầu tiên vì nó test accessibility luôn nếu screen reader không tìm được element, test của bạn cũng không tìm được.
| Query | Async? | Dùng khi nào |
|---|---|---|
| `getByRole` | Không | Element luôn có mặt, ưu tiên dùng nhất |
| `getByText` | Không | Tìm theo text content |
| `findByText` | Có (await) | Element xuất hiện sau async operation |
| `queryByText` | Không | Check element KHÔNG tồn tại |
Playwright E2E test thực sự chạy trên browser
Vitest và RTL test component trong môi trường giả lập. Playwright thì khác nó spin up browser thật (Chromium, Firefox, WebKit) và test như user thật đang dùng app.
1// tests/auth.spec.ts2import { test, expect } from '@playwright/test'34test('user đăng nhập thành công', async ({ page }) => {5 await page.goto('/login')67 await page.getByLabel('Email').fill('user@example.com')8 await page.getByLabel('Mật khẩu').fill('correctpassword')9 await page.getByRole('button', { name: 'Đăng nhập' }).click()1011 // Sau khi login, redirect về dashboard12 await expect(page).toHaveURL('/dashboard')13 await expect(page.getByText('Chào mừng trở lại')).toBeVisible()14})1516test('hiển thị lỗi với credentials sai', async ({ page }) => {17 await page.goto('/login')1819 await page.getByLabel('Email').fill('user@example.com')20 await page.getByLabel('Mật khẩu').fill('wrongpassword')21 await page.getByRole('button', { name: 'Đăng nhập' }).click()2223 await expect(page.getByText('Email hoặc mật khẩu không đúng')).toBeVisible()24})
Playwright còn có visual regression testing screenshot comparison để detect UI thay đổi không mong muốn. Mình dùng cái này cho các component design system quan trọng.
CI với GitHub Actions tất cả chạy tự động
Sau khi có đủ các loại test, bước cuối là tích hợp vào CI pipeline:
1# .github/workflows/ci.yml2name: CI34on: [push, pull_request]56jobs:7 test:8 runs-on: ubuntu-latest9 steps:10 - uses: actions/checkout@v41112 - uses: actions/setup-node@v413 with:14 node-version: '20'15 cache: 'npm'1617 - run: npm ci1819 - name: TypeScript check20 run: npx tsc --noEmit2122 - name: Unit & Component tests23 run: npx vitest run --coverage2425 - name: Install Playwright browsers26 run: npx playwright install --with-deps chromium2728 - name: E2E tests29 run: npx playwright test3031 - name: Build32 run: npm run build
Mình cũng setup pre-commit hooks với Husky và lint-staged để catch lỗi sớm hơn, trước khi push:
1// package.json2{3 "lint-staged": {4 "*.{ts,tsx}": [5 "eslint --fix",6 "vitest related --run"7 ]8 }9}
vitest related chỉ chạy test liên quan đến file đang thay đổi nhanh hơn chạy toàn bộ test suite rất nhiều.
Nhìn lại thì setup này tốn khoảng nửa ngày để config đúng lần đầu, nhưng về lâu dài nó tiết kiệm thời gian debug production rất đáng kể. Theo kinh nghiệm của mình, cái quan trọng nhất không phải là coverage percentage cao mà là test đúng thứ quan trọng: critical user flows, validation logic, và edge cases mà bạn đã từng bị bug ở đó rồi. Bắt đầu từ đó, rồi mở rộng dần.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!
