AgentSkillsCN

testing

使用Vitest、React Testing Library与Playwright测试Next.js应用。涵盖组件单元测试、Server组件测试、Server Actions测试、端到端测试、API路由测试、中间件测试,以及利用MSW进行API模拟。

SKILL.md
--- frontmatter
name: testing
description: "Testing Next.js applications with Vitest, React Testing Library, and Playwright. Covers unit testing components, testing Server Components, testing Server Actions, E2E testing, API route testing, middleware testing, and MSW for API mocking."
license: MIT
metadata:
  author: Balazs Barta
  version: "0.1.0"

Testing Next.js Applications

Comprehensive guide to testing Next.js applications using modern testing frameworks and best practices.

Testing Pyramid for Next.js

The testing pyramid helps prioritize test coverage:

code
       E2E (Playwright)
     /                 \
   Integration Tests
 /                        \
Unit Tests (Vitest + RTL)
  • Unit Tests (70%): Test individual functions, components, Server Actions in isolation
  • Integration Tests (20%): Test multiple components/features working together, Route Handlers with middleware
  • E2E Tests (10%): Test complete user flows through the browser

Vitest Setup for Next.js

Installation

bash
npm install -D vitest @vitest/ui jsdom
npm install -D @testing-library/react @testing-library/user-event

vitest.config.ts

typescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

vitest.setup.ts

typescript
import { expect, afterEach, vi } from 'vitest'
import { cleanup } from '@testing-library/react'
import '@testing-library/jest-dom'

// Cleanup after each test
afterEach(() => {
  cleanup()
})

// Mock Next.js router if needed
vi.mock('next/router', () => ({
  useRouter: () => ({
    push: vi.fn(),
    pathname: '/',
    query: {},
  }),
}))

// Mock next/navigation
vi.mock('next/navigation', () => ({
  useRouter: () => ({
    push: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => '/',
  useSearchParams: () => new URLSearchParams(),
}))

React Testing Library Patterns

Core Concepts

  • Render: Mount a component in a test DOM
  • Screen: Query elements the way users see them (by role, label, text)
  • userEvent: Simulate user interactions (typing, clicking, etc.)
  • Within: Scope queries to a specific element

Common Queries (Preferred Order)

typescript
// 1. By accessible roles (most user-centric)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })

// 2. By label text (forms)
screen.getByLabelText(/password/i)

// 3. By placeholder text
screen.getByPlaceholderText(/search/i)

// 4. By text content
screen.getByText(/welcome/i)

// 5. By test ID (last resort)
screen.getByTestId('custom-element')

Async Queries

typescript
// For elements that appear after loading
const button = await screen.findByRole('button', { name: /load/i })

// For elements that might not exist
const element = screen.queryByText(/might not exist/i)
expect(element).not.toBeInTheDocument()

Testing Server Components

Server Components are async and need special handling. They cannot be rendered directly in tests using render().

Approach 1: Test the Integration Point

Test the page or layout that uses the Server Component:

typescript
import { render, screen } from '@testing-library/react'
import { UserProfile } from '@/app/components/UserProfile'

// This only works if UserProfile is a Client Component
// For actual Server Components, see Approach 2
describe('UserProfile', () => {
  it('displays user data', async () => {
    const user = { id: '1', name: 'John' }
    render(<UserProfile userId={user.id} />)

    const name = await screen.findByText('John')
    expect(name).toBeInTheDocument()
  })
})

Approach 2: Test the Data Fetching

Extract data fetching logic into a separate function and test it independently:

typescript
// lib/user.ts
export async function getUser(id: string) {
  const response = await fetch(`/api/users/${id}`)
  if (!response.ok) throw new Error('Failed to fetch user')
  return response.json()
}

// lib/user.test.ts
import { vi } from 'vitest'
import { getUser } from './user'

describe('getUser', () => {
  it('fetches user data', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ id: '1', name: 'John' }),
      })
    ) as any

    const user = await getUser('1')
    expect(user.name).toBe('John')
  })

  it('throws on fetch error', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({ ok: false })
    ) as any

    await expect(getUser('1')).rejects.toThrow('Failed to fetch user')
  })
})

Approach 3: Mock the Data

If the Server Component fetches data, mock the data source:

typescript
import { vi } from 'vitest'

// Mock fetch for all tests
vi.stubGlobal('fetch', vi.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve({ id: '1', name: 'John' }),
  })
))

describe('UserProfile Page', () => {
  it('renders user profile', async () => {
    // Component logic that uses the mocked fetch
  })
})

Testing Server Actions

Server Actions are functions that run on the server. Test them like any async function.

typescript
// app/actions.ts
'use server'

export async function updateUser(id: string, data: Record<string, any>) {
  // Validation
  if (!id) throw new Error('User ID required')

  // Update logic
  const response = await fetch(`/api/users/${id}`, {
    method: 'PUT',
    body: JSON.stringify(data),
  })

  if (!response.ok) throw new Error('Update failed')
  return response.json()
}

// app/actions.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { updateUser } from './actions'

describe('Server Actions', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('updates user successfully', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ id: '1', name: 'Updated Name' }),
      })
    ) as any

    const result = await updateUser('1', { name: 'Updated Name' })
    expect(result.name).toBe('Updated Name')
    expect(fetch).toHaveBeenCalledWith(
      '/api/users/1',
      expect.objectContaining({ method: 'PUT' })
    )
  })

  it('throws error for missing ID', async () => {
    await expect(updateUser('', { name: 'Test' })).rejects.toThrow('User ID required')
  })

  it('handles fetch errors', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({ ok: false })
    ) as any

    await expect(updateUser('1', { name: 'Test' })).rejects.toThrow('Update failed')
  })

  it('validates input data', async () => {
    global.fetch = vi.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({}),
      })
    ) as any

    await updateUser('1', { email: 'test@example.com' })

    const callArgs = (fetch as any).mock.calls[0]
    const body = JSON.parse(callArgs[1].body)
    expect(body.email).toBe('test@example.com')
  })
})

Testing Route Handlers

Route Handlers are like API routes. Test them using NextRequest and mocking responses.

typescript
// app/api/users/route.ts
export async function GET(request: NextRequest) {
  const id = request.nextUrl.searchParams.get('id')

  if (!id) {
    return Response.json(
      { error: 'ID required' },
      { status: 400 }
    )
  }

  // Fetch data
  return Response.json({ id, name: 'John' })
}

// app/api/users/route.test.ts
import { describe, it, expect, vi } from 'vitest'
import { GET } from './route'
import { NextRequest } from 'next/server'

describe('GET /api/users', () => {
  it('returns user for valid ID', async () => {
    const request = new NextRequest('http://localhost:3000/api/users?id=1')
    const response = await GET(request)
    const data = await response.json()

    expect(response.status).toBe(200)
    expect(data.name).toBe('John')
  })

  it('returns 400 for missing ID', async () => {
    const request = new NextRequest('http://localhost:3000/api/users')
    const response = await GET(request)
    const data = await response.json()

    expect(response.status).toBe(400)
    expect(data.error).toBe('ID required')
  })
})

Testing Middleware

Middleware can be tested by mocking the request/response and calling the middleware function.

typescript
// middleware.ts
import { NextRequest, NextResponse } from 'next/server'

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/admin')) {
    const token = request.cookies.get('auth')?.value

    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }

  return NextResponse.next()
}

// middleware.test.ts
import { describe, it, expect } from 'vitest'
import { middleware } from './middleware'
import { NextRequest } from 'next/server'

describe('Middleware', () => {
  it('allows access with valid token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/admin'),
      {
        cookies: new Map([['auth', 'valid-token']]),
      }
    )

    const response = middleware(request)
    expect(response?.status).not.toBe(307) // Not a redirect
  })

  it('redirects to login without token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/admin')
    )

    const response = middleware(request)
    expect(response?.status).toBe(307)
    expect(response?.headers.get('location')).toContain('/login')
  })

  it('allows public pages without token', () => {
    const request = new NextRequest(
      new URL('http://localhost:3000/public')
    )

    const response = middleware(request)
    expect(response?.status).not.toBe(307)
  })
})

MSW (Mock Service Worker) for API Mocking

MSW allows you to mock HTTP requests at the network level without mocking fetch directly.

Setup

bash
npm install -D msw
npx msw init public --save

MSW Handlers

typescript
// lib/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'John Doe',
      email: 'john@example.com',
    })
  }),

  http.post('/api/users', async ({ request }) => {
    const body = await request.json()

    if (!body.name) {
      return HttpResponse.json(
        { error: 'Name required' },
        { status: 400 }
      )
    }

    return HttpResponse.json(
      { id: '1', ...body },
      { status: 201 }
    )
  }),

  http.put('/api/users/:id', async ({ params, request }) => {
    const body = await request.json()

    return HttpResponse.json({
      id: params.id,
      ...body,
    })
  }),

  http.delete('/api/users/:id', ({ params }) => {
    return new HttpResponse(null, { status: 204 })
  }),
]

Test Setup with MSW

typescript
// lib/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

// vitest.setup.ts (add to existing setup)
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './lib/mocks/server'

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

// Example test using MSW
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserForm } from '@/components/UserForm'

describe('UserForm with MSW', () => {
  it('submits form and displays success', async () => {
    const user = userEvent.setup()
    render(<UserForm />)

    await user.type(screen.getByLabelText(/name/i), 'Jane Doe')
    await user.click(screen.getByRole('button', { name: /submit/i }))

    const success = await screen.findByText(/success/i)
    expect(success).toBeInTheDocument()
  })

  it('handles server errors', async () => {
    // Override handler for this test
    server.use(
      http.post('/api/users', () => {
        return HttpResponse.json(
          { error: 'Server error' },
          { status: 500 }
        )
      })
    )

    const user = userEvent.setup()
    render(<UserForm />)

    await user.type(screen.getByLabelText(/name/i), 'Jane Doe')
    await user.click(screen.getByRole('button', { name: /submit/i }))

    const error = await screen.findByText(/error/i)
    expect(error).toBeInTheDocument()
  })
})

Common Testing Patterns

Testing Components with Hooks

typescript
describe('useUserData hook', () => {
  it('fetches and sets user data', async () => {
    const { result } = renderHook(() => useUserData('1'))

    expect(result.current.loading).toBe(true)

    await waitFor(() => {
      expect(result.current.loading).toBe(false)
    })

    expect(result.current.user.name).toBe('John')
  })
})

Testing Context Providers

typescript
describe('UserContext', () => {
  it('provides user data to children', () => {
    render(
      <UserProvider>
        <UserConsumer />
      </UserProvider>
    )

    expect(screen.getByText(/john/i)).toBeInTheDocument()
  })
})

Testing Error Boundaries

typescript
describe('ErrorBoundary', () => {
  it('catches errors and displays fallback', () => {
    const ThrowError = () => {
      throw new Error('Test error')
    }

    render(
      <ErrorBoundary>
        <ThrowError />
      </ErrorBoundary>
    )

    expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
  })
})

What to Test vs What NOT to Test

DO Test

  • User interactions and workflows
  • Business logic and validation
  • Error handling and edge cases
  • Integration between components
  • Server Actions and Route Handlers
  • Conditional rendering based on props/state
  • Form submissions and data transformations

DON'T Test

  • Implementation details (how something is done)
  • Third-party library internals
  • CSS styles or exact HTML structure
  • React internals (rendering, updates, etc.)
  • Console logs or debug output
  • Snapshot tests (brittle and unhelpful)

Anti-Patterns to Avoid

typescript
// Testing implementation details
expect(component.state.isOpen).toBe(true)

// Testing user-visible behavior
expect(screen.getByRole('button', { name: /open/i })).toBeInTheDocument()

// Snapshot testing
expect(component).toMatchSnapshot()

// Testing specific properties
expect(screen.getByRole('heading')).toHaveTextContent('Welcome')

// Testing third-party library
expect(mockLibrary).toHaveBeenCalled()

// Testing your code's behavior
expect(screen.getByText(/success/i)).toBeInTheDocument()

Running Tests

bash
# Run all tests
npm run test

# Run tests in watch mode
npm run test -- --watch

# Run tests with coverage
npm run test -- --coverage

# Run specific test file
npm run test -- components/Button.test.ts

# Run tests matching a pattern
npm run test -- --grep "Button"

# Open UI dashboard
npm run test -- --ui

References

  • Vitest Documentation: https://vitest.dev/llms.txt
  • React Testing Library: See references/doc-lookup.md
  • Playwright E2E Testing: See references/test-templates.md
  • MSW Mocking: See references/test-templates.md