Giới thiệu
6 phút đọc15 tháng 6, 2026339

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.

N

Nguyễn Nhật Long

@nguyennhatlong1303

Testing & Type Safety trong Next.js: Setup pipeline bài bản từ đầu

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:

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:

TEXT
1src/
2 types/
3 user.ts
4 product.ts
5 api.ts
6 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.

TypeScript
1import { z } from 'zod'
2
3const 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})
8
9type 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:

MethodHành vi khi failDù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
TypeScript
1// Dùng với react-hook-form
2import { zodResolver } from '@hookform/resolvers/zod'
3
4const form = useForm<RegisterInput>({
5 resolver: zodResolver(RegisterSchema),
6})
7
8// Dùng trong server action
9async 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 })
15
16 if (!result.success) {
17 return { error: result.error.flatten() }
18 }
19 // result.data đã được type-safe hoàn toàn
20}

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.

TypeScript
1// vitest.config.ts
2import { defineConfig } from 'vitest/config'
3import react from '@vitejs/plugin-react'
4
5export 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:

TypeScript
1import { describe, it, expect } from 'vitest'
2import { formatCurrency } from '@/lib/utils'
3
4describe('formatCurrency', () => {
5 it('format số tiền VND đúng cách', () => {
6 expect(formatCurrency(1000000)).toBe('1.000.000 ₫')
7 })
8
9 it('xử lý số âm', () => {
10 expect(formatCurrency(-500)).toBe('-500 ₫')
11 })
12})

Test custom hook với renderHook:

TypeScript
1import { renderHook, act } from '@testing-library/react'
2import { useCounter } from '@/hooks/useCounter'
3
4it('increment counter đúng', () => {
5 const { result } = renderHook(() => useCounter())
6
7 act(() => {
8 result.current.increment()
9 })
10
11 expect(result.current.count).toBe(1)
12})

Mock thì dùng vi.fn()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.

TypeScript
1import { render, screen } from '@testing-library/react'
2import userEvent from '@testing-library/user-event'
3import { LoginForm } from '@/components/LoginForm'
4
5describe('LoginForm', () => {
6 it('hiển thị lỗi khi submit form rỗng', async () => {
7 const user = userEvent.setup()
8 render(<LoginForm />)
9
10 // Arrange: form đã render
11 const submitButton = screen.getByRole('button', { name: /đăng nhập/i })
12
13 // Act: user click submit
14 await user.click(submitButton)
15
16 // Assert: lỗi hiện ra
17 expect(screen.getByText(/email không được để trống/i)).toBeInTheDocument()
18 })
19
20 it('call onSubmit với data đúng', async () => {
21 const user = userEvent.setup()
22 const mockSubmit = vi.fn()
23 render(<LoginForm onSubmit={mockSubmit} />)
24
25 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 }))
28
29 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.

QueryAsync?Dùng khi nào
`getByRole`KhôngElement luôn có mặt, ưu tiên dùng nhất
`getByText`KhôngTìm theo text content
`findByText`Có (await)Element xuất hiện sau async operation
`queryByText`KhôngCheck 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.

TypeScript
1// tests/auth.spec.ts
2import { test, expect } from '@playwright/test'
3
4test('user đăng nhập thành công', async ({ page }) => {
5 await page.goto('/login')
6
7 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()
10
11 // Sau khi login, redirect về dashboard
12 await expect(page).toHaveURL('/dashboard')
13 await expect(page.getByText('Chào mừng trở lại')).toBeVisible()
14})
15
16test('hiển thị lỗi với credentials sai', async ({ page }) => {
17 await page.goto('/login')
18
19 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()
22
23 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:

YAML
1# .github/workflows/ci.yml
2name: CI
3
4on: [push, pull_request]
5
6jobs:
7 test:
8 runs-on: ubuntu-latest
9 steps:
10 - uses: actions/checkout@v4
11
12 - uses: actions/setup-node@v4
13 with:
14 node-version: '20'
15 cache: 'npm'
16
17 - run: npm ci
18
19 - name: TypeScript check
20 run: npx tsc --noEmit
21
22 - name: Unit & Component tests
23 run: npx vitest run --coverage
24
25 - name: Install Playwright browsers
26 run: npx playwright install --with-deps chromium
27
28 - name: E2E tests
29 run: npx playwright test
30
31 - name: Build
32 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:

JSON
1// package.json
2{
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.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễ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è!

Testing & Type Safety trong Next.js: Setup pipeline bài bản từ đầu — Stacklog