第 19 章:测试体系(Vitest + RTL + Playwright)

构建 Next.js 的完整测试金字塔——从 Vitest 单元测试、React Testing Library 组件测试、MSW Mock 集成测试,到 Playwright E2E 测试,建立可维护的测试体系。

本章目标:建立 Next.js App Router 的完整测试体系——使用 Vitest 做单元测试、React Testing Library 做组件测试、MSW 做 API Mock、Playwright 做 E2E 测试,并配置覆盖率、CI 集成与测试最佳实践。


19.1 测试金字塔

三层测试体系

           E2E 测试
          ╱        ╲
        ╱   集成测试  ╲        少量(10-20%)
      ╱                ╲       慢、贵、端到端验证
    ╱                    ╲
  ╱    组件测试 / 单元测试  ╲    大量(80-90%)
╱______________________________╲   快、便宜、隔离验证
测试类型工具速度关注点
单元测试Vitest毫秒级纯函数、工具函数、业务逻辑
组件测试RTL + Vitest百毫秒级组件渲染、交互、状态
集成测试RTL + MSW秒级多组件协作、API 交互
E2E 测试Playwright秒级完整用户流程、跨系统

测试覆盖率目标

核心业务逻辑:  ≥ 80%
工具函数:      ≥ 90%
UI 组件:       ≥ 70%
E2E 关键路径:   100%(登录、购买、发布等)

19.2 Vitest 安装与配置

安装

npm install -D vitest @vitejs/plugin-react
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install -D jsdom msw@latest

Vitest 配置

// vitest.config.ts

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./tests/setup.ts'],
    include: [
      '**/*.{test,spec}.{js,jsx,ts,tsx}',
    ],
    exclude: [
      'node_modules',
      '.next',
      'e2e',  // E2E 测试用 Playwright
    ],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html', 'lcov'],
      exclude: [
        'node_modules/',
        'tests/',
        'e2e/',
        '**/*.d.ts',
        '**/*.config.*',
        'app/layout.tsx',
        'app/**/loading.tsx',
        'app/**/error.tsx',
      ],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './'),
    },
  },
});

测试环境设置

// tests/setup.ts

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

// 每次测试后清理 DOM
afterEach(() => {
  cleanup();
});

// Mock next/navigation
vi.mock('next/navigation', () => ({
  useRouter: () => ({
    push: vi.fn(),
    replace: vi.fn(),
    prefetch: vi.fn(),
    back: vi.fn(),
    forward: vi.fn(),
    refresh: vi.fn(),
  }),
  usePathname: () => '/',
  useSearchParams: () => new URLSearchParams(),
  redirect: vi.fn(),
  notFound: vi.fn(),
}));

// Mock next/headers
vi.mock('next/headers', () => ({
  cookies: () => ({
    get: vi.fn(),
    set: vi.fn(),
  }),
  headers: () => new Headers(),
}));

// Mock next/cache
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
  unstable_cache: vi.fn((fn) => fn),
}));

// Mock next/image
vi.mock('next/image', () => ({
  default: (props: any) => {
    // eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
    return <img {...props} />;
  },
}));

// Mock next/link
vi.mock('next/link', () => ({
  default: ({ children, href, ...props }: any) => (
    <a href={href} {...props}>
      {children}
    </a>
  ),
}));

package.json 脚本

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui",
    "test:all": "npm run test:run && npm run test:e2e"
  }
}

19.3 单元测试

工具函数测试

// lib/utils.test.ts

import { describe, it, expect } from 'vitest';
import { cn, formatDate, slugify, truncate } from './utils';

describe('cn()', () => {
  it('should merge class names', () => {
    expect(cn('foo', 'bar')).toBe('foo bar');
  });

  it('should handle conditional classes', () => {
    expect(cn('base', true && 'active', false && 'hidden')).toBe('base active');
  });

  it('should merge tailwind classes correctly', () => {
    // tailwind-merge 应该正确处理冲突
    expect(cn('px-4 py-2', 'px-6')).toBe('py-2 px-6');
    expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
  });

  it('should handle undefined and null', () => {
    expect(cn('base', undefined, null, 'extra')).toBe('base extra');
  });
});

describe('formatDate()', () => {
  it('should format date in Chinese locale', () => {
    const date = new Date('2025-06-11T10:30:00Z');
    const formatted = formatDate(date);
    expect(formatted).toContain('2025');
    expect(formatted).toContain('6');
    expect(formatted).toContain('11');
  });

  it('should return empty string for invalid date', () => {
    expect(formatDate('invalid')).toBe('');
  });
});

describe('slugify()', () => {
  it('should convert string to slug', () => {
    expect(slugify('Hello World')).toBe('hello-world');
    expect(slugify('Next.js 完全指南')).toMatch(/^[a-z0-9-]+$/);
  });

  it('should remove special characters', () => {
    expect(slugify('Hello, World!')).toBe('hello-world');
  });

  it('should handle multiple spaces and dashes', () => {
    expect(slugify('hello    world')).toBe('hello-world');
    expect(slugify('hello---world')).toBe('hello-world');
  });
});

describe('truncate()', () => {
  it('should truncate long strings', () => {
    expect(truncate('Hello World', 5)).toBe('Hello...');
  });

  it('should not truncate short strings', () => {
    expect(truncate('Hi', 10)).toBe('Hi');
  });

  it('should handle custom suffix', () => {
    expect(truncate('Hello World', 5, '…')).toBe('Hello…');
  });
});

业务逻辑测试

// lib/services/article-utils.test.ts

import { describe, it, expect } from 'vitest';
import {
  calculateReadTime,
  extractExcerpt,
  generateTOC,
} from './article-utils';

describe('calculateReadTime()', () => {
  it('should calculate read time based on 500 chars/min', () => {
    const content = 'a'.repeat(1000);
    expect(calculateReadTime(content)).toBe(2);
  });

  it('should round up partial minutes', () => {
    const content = 'a'.repeat(750);
    expect(calculateReadTime(content)).toBe(2);
  });

  it('should return 1 for very short content', () => {
    expect(calculateReadTime('Hello')).toBe(1);
  });
});

describe('extractExcerpt()', () => {
  it('should extract first paragraph as excerpt', () => {
    const content = 'First paragraph.\n\nSecond paragraph.';
    expect(extractExcerpt(content)).toBe('First paragraph.');
  });

  it('should truncate to max length', () => {
    const content = 'a'.repeat(500);
    expect(extractExcerpt(content, 100).length).toBeLessThanOrEqual(103); // + "..."
  });

  it('should strip markdown syntax', () => {
    const content = '# Title\n\n**Bold** and _italic_ text.';
    const excerpt = extractExcerpt(content);
    expect(excerpt).not.toContain('#');
    expect(excerpt).not.toContain('**');
    expect(excerpt).not.toContain('_');
  });
});

describe('generateTOC()', () => {
  it('should generate table of contents from headings', () => {
    const content = `
# Title
## Section 1
### Subsection 1.1
## Section 2
    `.trim();

    const toc = generateTOC(content);
    expect(toc).toHaveLength(3);
    expect(toc[0]).toEqual({
      id: 'section-1',
      text: 'Section 1',
      level: 2,
    });
    expect(toc[1]).toEqual({
      id: 'subsection-11',
      text: 'Subsection 1.1',
      level: 3,
    });
  });

  it('should handle empty content', () => {
    expect(generateTOC('')).toEqual([]);
  });
});

Zod Schema 测试

// lib/validators/article.test.ts

import { describe, it, expect } from 'vitest';
import { createArticleSchema } from './article';

describe('createArticleSchema', () => {
  it('should validate correct data', () => {
    const result = createArticleSchema.safeParse({
      title: 'Test Article',
      slug: 'test-article',
      content: 'This is the content of the test article, at least 10 chars.',
      category: 'frontend',
      published: false,
    });

    expect(result.success).toBe(true);
  });

  it('should reject empty title', () => {
    const result = createArticleSchema.safeParse({
      title: '',
      slug: 'test',
      content: 'Content here',
      category: 'frontend',
    });

    expect(result.success).toBe(false);
    if (!result.success) {
      expect(result.error.issues[0].path).toContain('title');
    }
  });

  it('should reject invalid slug format', () => {
    const result = createArticleSchema.safeParse({
      title: 'Test',
      slug: 'Invalid Slug',  // 包含空格和大写
      content: 'Content here',
      category: 'frontend',
    });

    expect(result.success).toBe(false);
  });

  it('should reject short content', () => {
    const result = createArticleSchema.safeParse({
      title: 'Test',
      slug: 'test',
      content: 'Short',  // 少于 10 字符
      category: 'frontend',
    });

    expect(result.success).toBe(false);
  });

  it('should reject invalid category', () => {
    const result = createArticleSchema.safeParse({
      title: 'Test',
      slug: 'test',
      content: 'Long enough content',
      category: 'invalid',
    });

    expect(result.success).toBe(false);
  });
});

19.4 组件测试(React Testing Library)

基础组件测试

// components/ui/__tests__/button.test.tsx

import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { Button } from '../button';

describe('Button', () => {
  it('renders children', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
  });

  it('applies variant classes', () => {
    render(<Button variant="destructive">Delete</Button>);
    const button = screen.getByRole('button');
    expect(button.className).toContain('bg-destructive');
  });

  it('applies size classes', () => {
    render(<Button size="lg">Large</Button>);
    const button = screen.getByRole('button');
    expect(button.className).toContain('h-10');
  });

  it('handles click events', () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Click</Button>);

    fireEvent.click(screen.getByRole('button'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    const onClick = vi.fn();
    render(
      <Button disabled onClick={onClick}>
        Disabled
      </Button>
    );

    const button = screen.getByRole('button');
    expect(button).toBeDisabled();

    fireEvent.click(button);
    expect(onClick).not.toHaveBeenCalled();
  });

  it('forwards ref correctly', () => {
    const ref = vi.fn();
    render(<Button ref={ref}>Ref</Button>);
    expect(ref).toHaveBeenCalled();
  });
});

业务组件测试

// components/article/__tests__/article-card.test.tsx

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { ArticleCard } from '../article-card';

const mockArticle = {
  id: '1',
  title: 'Test Article',
  slug: 'test-article',
  excerpt: 'This is a test article excerpt.',
  coverImage: '/test.jpg',
  category: 'frontend',
  tags: ['nextjs', 'react'],
  createdAt: '2025-06-11T00:00:00Z',
  author: {
    id: 'u1',
    name: 'Test Author',
    avatar: '/avatar.jpg',
  },
  _count: {
    comments: 5,
    likes: 10,
  },
};

describe('ArticleCard', () => {
  it('renders article title', () => {
    render(<ArticleCard article={mockArticle} />);
    expect(screen.getByText('Test Article')).toBeInTheDocument();
  });

  it('renders article excerpt', () => {
    render(<ArticleCard article={mockArticle} />);
    expect(screen.getByText('This is a test article excerpt.')).toBeInTheDocument();
  });

  it('renders author name', () => {
    render(<ArticleCard article={mockArticle} />);
    expect(screen.getByText('Test Author')).toBeInTheDocument();
  });

  it('renders category badge', () => {
    render(<ArticleCard article={mockArticle} />);
    expect(screen.getByText('frontend')).toBeInTheDocument();
  });

  it('renders comment and like counts', () => {
    render(<ArticleCard article={mockArticle} />);
    expect(screen.getByText(/5/)).toBeInTheDocument();
    expect(screen.getByText(/10/)).toBeInTheDocument();
  });

  it('links to the article slug', () => {
    render(<ArticleCard article={mockArticle} />);
    const link = screen.getByRole('link', { name: /Test Article/i });
    expect(link).toHaveAttribute('href', '/articles/test-article');
  });

  it('renders formatted date', () => {
    render(<ArticleCard article={mockArticle} />);
    // 根据 formatDate 实现调整
    expect(screen.getByText(/2025/)).toBeInTheDocument();
  });
});

用户交互测试

// components/article/__tests__/like-button.test.tsx

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { LikeButton } from '../like-button';

// Mock Server Action
vi.mock('@/app/actions/like', () => ({
  toggleLike: vi.fn().mockResolvedValue(undefined),
}));

describe('LikeButton', () => {
  const articleId = 'test-article-id';

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('renders initial like count', () => {
    render(<LikeButton articleId={articleId} initialCount={10} />);
    expect(screen.getByText(/10/)).toBeInTheDocument();
  });

  it('toggles like state on click', async () => {
    const user = userEvent.setup();
    render(<LikeButton articleId={articleId} initialCount={10} />);

    const button = screen.getByRole('button');
    await user.click(button);

    // 乐观更新后,计数应该 +1
    await waitFor(() => {
      expect(screen.getByText(/11/)).toBeInTheDocument();
    });
  });

  it('calls toggleLike action on click', async () => {
    const { toggleLike } = await import('@/app/actions/like');
    const user = userEvent.setup();

    render(<LikeButton articleId={articleId} initialCount={10} />);

    await user.click(screen.getByRole('button'));

    expect(toggleLike).toHaveBeenCalledWith(articleId);
  });

  it('reverts on action failure', async () => {
    const { toggleLike } = await import('@/app/actions/like');
    (toggleLike as any).mockRejectedValueOnce(new Error('Failed'));

    const user = userEvent.setup();
    render(<LikeButton articleId={articleId} initialCount={10} />);

    await user.click(screen.getByRole('button'));

    // 失败后应该回滚
    await waitFor(() => {
      expect(screen.getByText(/10/)).toBeInTheDocument();
    });
  });
});

表单测试

// components/forms/__tests__/contact-form.test.tsx

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { ContactForm } from '../contact-form';

describe('ContactForm', () => {
  it('renders all form fields', () => {
    render(<ContactForm />);

    expect(screen.getByLabelText(/姓名/)).toBeInTheDocument();
    expect(screen.getByLabelText(/邮箱/)).toBeInTheDocument();
    expect(screen.getByLabelText(/主题/)).toBeInTheDocument();
    expect(screen.getByLabelText(/内容/)).toBeInTheDocument();
  });

  it('validates required fields', async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    await user.click(screen.getByRole('button', { name: /提交/ }));

    await waitFor(() => {
      expect(screen.getByText(/姓名至少/)).toBeInTheDocument();
      expect(screen.getByText(/邮箱/)).toBeInTheDocument();
    });
  });

  it('validates email format', async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    const emailInput = screen.getByLabelText(/邮箱/);
    await user.type(emailInput, 'invalid-email');
    await user.click(screen.getByRole('button', { name: /提交/ }));

    await waitFor(() => {
      expect(screen.getByText(/邮箱格式不正确/)).toBeInTheDocument();
    });
  });

  it('submits valid form data', async () => {
    const user = userEvent.setup();
    const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

    render(<ContactForm />);

    await user.type(screen.getByLabelText(/姓名/), '张三');
    await user.type(screen.getByLabelText(/邮箱/), 'zhangsan@example.com');
    await user.type(screen.getByLabelText(/主题/), '测试主题');
    await user.type(screen.getByLabelText(/内容/), '这是至少十个字符的测试内容,应该通过验证。');

    await user.click(screen.getByRole('button', { name: /提交/ }));

    await waitFor(() => {
      expect(consoleSpy).toHaveBeenCalledWith(
        'Submitted:',
        expect.objectContaining({
          name: '张三',
          email: 'zhangsan@example.com',
          subject: '测试主题',
        })
      );
    });

    consoleSpy.mockRestore();
  });
});

19.5 MSW(Mock Service Worker)集成测试

MSW 安装与配置

npx msw@latest init ./public --save
// tests/mocks/handlers.ts

import { http, HttpResponse } from 'msw';

export const handlers = [
  // Mock 文章 API
  http.get('/api/v1/articles', () => {
    return HttpResponse.json({
      data: [
        {
          id: '1',
          title: 'Mock Article',
          slug: 'mock-article',
          excerpt: 'This is a mock article.',
          createdAt: '2025-06-11T00:00:00Z',
        },
      ],
      pagination: {
        page: 1,
        limit: 10,
        total: 1,
        totalPages: 1,
      },
    });
  }),

  // Mock 登录 API
  http.post('/api/v1/auth/login', async ({ request }) => {
    const body = await request.json() as any;

    if (body.email === 'test@example.com' && body.password === 'password') {
      return HttpResponse.json({
        token: 'mock-jwt-token',
        user: { id: '1', email: 'test@example.com', name: 'Test User' },
      });
    }

    return HttpResponse.json(
      { error: 'Invalid credentials' },
      { status: 401 }
    );
  }),

  // Mock 外部 API
  http.get('https://api.example.com/data', () => {
    return HttpResponse.json({ result: 'mocked' });
  }),
];
// tests/mocks/server.ts

import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// tests/setup.ts

import { server } from './mocks/server';
import { beforeAll, afterEach, afterAll } from 'vitest';

// 启动 MSW
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

集成测试示例

// app/articles/__tests__/page.test.tsx

import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '@/tests/mocks/server';
import ArticlesPage from '../page';

describe('ArticlesPage', () => {
  it('renders article list', async () => {
    render(<ArticlesPage searchParams={Promise.resolve({})} />);

    await waitFor(() => {
      expect(screen.getByText('Mock Article')).toBeInTheDocument();
    });
  });

  it('handles API error gracefully', async () => {
    server.use(
      http.get('/api/v1/articles', () => {
        return HttpResponse.json(
          { error: 'Internal server error' },
          { status: 500 }
        );
      })
    );

    render(<ArticlesPage searchParams={Promise.resolve({})} />);

    await waitFor(() => {
      expect(screen.getByText(/加载失败/)).toBeInTheDocument();
    });
  });

  it('shows empty state when no articles', async () => {
    server.use(
      http.get('/api/v1/articles', () => {
        return HttpResponse.json({
          data: [],
          pagination: { page: 1, limit: 10, total: 0, totalPages: 0 },
        });
      })
    );

    render(<ArticlesPage searchParams={Promise.resolve({})} />);

    await waitFor(() => {
      expect(screen.getByText(/暂无文章/)).toBeInTheDocument();
    });
  });
});

19.6 Playwright E2E 测试

安装

npm install -D @playwright/test
npx playwright install --with-deps chromium

配置

// playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI
    ? [['html'], ['github']]
    : [['html', { open: 'never' }]],

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
  },

  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120 * 1000,
  },
});

基础 E2E 测试

// e2e/home.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Home Page', () => {
  test('should display hero section', async ({ page }) => {
    await page.goto('/');

    await expect(
      page.getByRole('heading', { name: /Next\.js/ })
    ).toBeVisible();
  });

  test('should navigate to articles page', async ({ page }) => {
    await page.goto('/');

    await page.getByRole('link', { name: /文章/ }).click();

    await expect(page).toHaveURL('/articles');
    await expect(page.getByRole('heading', { name: /文章列表/ })).toBeVisible();
  });

  test('should have working theme toggle', async ({ page }) => {
    await page.goto('/');

    // 点击主题切换
    await page.getByRole('button', { name: /🌙/ }).click();

    // 验证暗色模式
    await expect(page.locator('html')).toHaveClass(/dark/);
  });

  test('should have correct SEO metadata', async ({ page }) => {
    await page.goto('/');

    await expect(page).toHaveTitle(/MyBlog/);
    await expect(page.locator('meta[name="description"]')).toHaveAttribute(
      'content',
      /Next\.js/
    );
  });
});

登录流程测试

// e2e/auth.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test.beforeEach(async ({ page }) => {
    // 访问登录页
    await page.goto('/login');
  });

  test('should show validation errors on empty submit', async ({ page }) => {
    await page.getByRole('button', { name: /登录/ }).click();

    await expect(page.getByText(/邮箱/)).toBeVisible();
  });

  test('should login successfully with valid credentials', async ({ page }) => {
    await page.getByLabel(/邮箱/).fill('test@example.com');
    await page.getByLabel(/密码/).fill('password123');
    await page.getByRole('button', { name: /登录/ }).click();

    // 应该跳转到 dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.getByText(/欢迎/)).toBeVisible();
  });

  test('should show error on invalid credentials', async ({ page }) => {
    await page.getByLabel(/邮箱/).fill('wrong@example.com');
    await page.getByLabel(/密码/).fill('wrongpassword');
    await page.getByRole('button', { name: /登录/ }).click();

    await expect(page.getByText(/邮箱或密码错误/)).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('should register new user', async ({ page }) => {
    await page.goto('/register');

    await page.getByLabel(/昵称/).fill('NewUser');
    await page.getByLabel(/邮箱/).fill('new@example.com');
    await page.getByLabel(/密码/).fill('password123');
    await page.getByRole('button', { name: /注册/ }).click();

    await expect(page).toHaveURL('/dashboard');
  });

  test('should logout successfully', async ({ page, context }) => {
    // 先登录
    await page.getByLabel(/邮箱/).fill('test@example.com');
    await page.getByLabel(/密码/).fill('password123');
    await page.getByRole('button', { name: /登录/ }).click();
    await expect(page).toHaveURL('/dashboard');

    // 登出
    await page.getByRole('button', { name: /退出/ }).click();

    await expect(page).toHaveURL('/');

    // 验证 cookie 已清除
    const cookies = await context.cookies();
    const authCookie = cookies.find((c) => c.name === 'auth-token');
    expect(authCookie).toBeUndefined();
  });
});

文章发布流程测试

// e2e/article.spec.ts

import { test, expect } from '@playwright/test';

test.describe('Article Management', () => {
  // 复用登录状态
  test.use({
    storageState: 'e2e/.auth/user.json',
  });

  test.beforeEach(async ({ page }) => {
    await page.goto('/dashboard/articles/new');
  });

  test('should create new article', async ({ page }) => {
    // 填写表单
    await page.getByLabel(/标题/).fill('My Test Article');
    await page.getByLabel(/Slug/).fill('my-test-article');
    await page.getByLabel(/内容/).fill('This is the content of my test article. It has more than ten characters.');
    await page.getByLabel(/分类/).selectOption('frontend');

    // 提交
    await page.getByRole('button', { name: /保存/ }).click();

    // 等待跳转
    await expect(page).toHaveURL('/dashboard/articles');
    await expect(page.getByText('My Test Article')).toBeVisible();
  });

  test('should validate form before submit', async ({ page }) => {
    // 直接提交空表单
    await page.getByRole('button', { name: /保存/ }).click();

    // 应该显示验证错误
    await expect(page.getByText(/标题不能为空/)).toBeVisible();
    await expect(page.getByText(/内容至少/)).toBeVisible();
  });

  test('should auto-generate slug from title', async ({ page }) => {
    await page.getByLabel(/标题/).fill('Hello World Test');

    // Slug 应该自动生成
    const slugInput = page.getByLabel(/Slug/);
    await expect(slugInput).toHaveValue('hello-world-test');
  });

  test('should edit existing article', async ({ page }) => {
    // 先进入编辑页
    await page.goto('/dashboard/articles');
    await page.getByRole('link', { name: /My Test Article/ }).click();

    // 修改标题
    await page.getByLabel(/标题/).fill('Updated Title');
    await page.getByRole('button', { name: /保存/ }).click();

    await expect(page.getByText('Updated Title')).toBeVisible();
  });

  test('should delete article', async ({ page }) => {
    await page.goto('/dashboard/articles');

    // 打开操作菜单
    await page.getByRole('button', { name: /⋯/ }).first().click();
    await page.getByRole('menuitem', { name: /删除/ }).click();

    // 确认删除
    page.on('dialog', (dialog) => dialog.accept());

    await expect(page.getByText(/删除成功/)).toBeVisible();
  });
});

全局 Auth Setup

// e2e/global-setup.ts

import { test as setup, expect } from '@playwright/test';

const authFile = 'e2e/.auth/user.json';

setup('authenticate as user', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel(/邮箱/).fill('test@example.com');
  await page.getByLabel(/密码/).fill('password123');
  await page.getByRole('button', { name: /登录/ }).click();

  await expect(page).toHaveURL('/dashboard');

  // 保存认证状态
  await page.context().storageState({ path: authFile });
});

19.7 Server Action 测试

// app/actions/__tests__/article.test.ts

import { describe, it, expect, vi, beforeEach } from 'vitest';

// Mock Prisma
vi.mock('@/lib/prisma', () => ({
  prisma: {
    article: {
      create: vi.fn(),
      findUnique: vi.fn(),
      update: vi.fn(),
      delete: vi.fn(),
    },
  },
}));

// Mock auth
vi.mock('@/lib/auth-utils', () => ({
  requireAuth: vi.fn().mockResolvedValue({
    id: 'user-1',
    email: 'test@example.com',
    role: 'admin',
  }),
}));

// Mock cache
vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}));

import { createArticle, deleteArticle } from '../article';
import { prisma } from '@/lib/prisma';

describe('createArticle', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('creates article with valid data', async () => {
    const mockArticle = { id: '1', title: 'Test', slug: 'test' };
    (prisma.article.create as any).mockResolvedValue(mockArticle);
    (prisma.article.findUnique as any).mockResolvedValue(null);

    const result = await createArticle({}, {
      title: 'Test',
      slug: 'test',
      content: 'Long enough content for testing',
      category: 'frontend',
      published: false,
    });

    expect(result.success).toBe(true);
    expect(prisma.article.create).toHaveBeenCalledWith(
      expect.objectContaining({
        data: expect.objectContaining({
          title: 'Test',
          authorId: 'user-1',
        }),
      })
    );
  });

  it('rejects duplicate slug', async () => {
    (prisma.article.findUnique as any).mockResolvedValue({ id: 'existing' });

    const result = await createArticle({}, {
      title: 'Test',
      slug: 'existing-slug',
      content: 'Long enough content',
      category: 'frontend',
    });

    expect(result.fieldErrors?.slug).toBeDefined();
    expect(prisma.article.create).not.toHaveBeenCalled();
  });

  it('validates input data', async () => {
    const result = await createArticle({}, {
      title: '',
      slug: 'invalid slug',
      content: 'short',
      category: 'invalid',
    } as any);

    expect(result.fieldErrors).toBeDefined();
    expect(prisma.article.create).not.toHaveBeenCalled();
  });
});

describe('deleteArticle', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('deletes article owned by user', async () => {
    (prisma.article.findUnique as any).mockResolvedValue({
      id: '1',
      slug: 'test',
      authorId: 'user-1',
    });

    const result = await deleteArticle('1');

    expect(result.success).toBe(true);
    expect(prisma.article.delete).toHaveBeenCalledWith({
      where: { id: '1' },
    });
  });

  it('rejects deletion of other user articles', async () => {
    (prisma.article.findUnique as any).mockResolvedValue({
      id: '1',
      slug: 'test',
      authorId: 'other-user',
    });

    const result = await deleteArticle('1');

    expect(result.error).toBeDefined();
    expect(prisma.article.delete).not.toHaveBeenCalled();
  });
});

19.8 测试辅助工具

自定义 render 函数

// tests/test-utils.tsx

import { render, RenderOptions } from '@testing-library/react';
import { ReactElement, ReactNode } from 'react';
import { SessionProvider } from 'next-auth/react';

// 模拟 session
const mockSession = {
  user: {
    id: 'user-1',
    email: 'test@example.com',
    name: 'Test User',
    role: 'user',
  },
  expires: '2025-12-31T00:00:00Z',
};

// 提供所有必要的 Provider
function AllProviders({ children }: { children: ReactNode }) {
  return (
    <SessionProvider session={mockSession}>
      {children}
    </SessionProvider>
  );
}

function customRender(
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  return render(ui, { wrapper: AllProviders, ...options });
}

// 未认证的 render
function renderWithoutAuth(
  ui: ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) {
  return render(ui, options);
}

export * from '@testing-library/react';
export { customRender as render, renderWithoutAuth };

使用方式

// 自动包含所有 Provider
import { render, screen } from '@/tests/test-utils';

it('renders user info', () => {
  render(<UserProfile />);
  expect(screen.getByText('Test User')).toBeInTheDocument();
});

Mock 数据工厂

// tests/factories.ts

import { faker } from '@faker-js/faker';

export function createMockUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    avatar: faker.image.avatar(),
    bio: faker.lorem.paragraph(),
    role: 'USER',
    password: faker.internet.password(),
    emailVerified: new Date(),
    isActive: true,
    createdAt: faker.date.past(),
    updatedAt: faker.date.recent(),
    ...overrides,
  };
}

export function createMockArticle(overrides: Partial<Article> = {}): Article {
  return {
    id: faker.string.uuid(),
    title: faker.lorem.sentence(),
    slug: faker.lorem.slug(),
    content: faker.lorem.paragraphs(5),
    excerpt: faker.lorem.paragraph(),
    coverImage: faker.image.url(),
    category: faker.helpers.arrayElement(['frontend', 'backend', 'devops']),
    tags: faker.helpers.arrayElements(['react', 'nextjs', 'typescript'], 3),
    published: true,
    featured: false,
    views: faker.number.int({ min: 0, max: 1000 }),
    readTime: faker.number.int({ min: 1, max: 20 }),
    authorId: faker.string.uuid(),
    createdAt: faker.date.past(),
    updatedAt: faker.date.recent(),
    publishedAt: faker.date.recent(),
    ...overrides,
  };
}

19.9 CI/CD 集成

GitHub Actions

# .github/workflows/test.yml

name: Test

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npm run test:run
      - run: npm run test:coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          files: ./coverage/lcov.info
          fail_ci_if_error: true

  e2e-test:
    runs-on: ubuntu-latest
    needs: unit-test
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps chromium

      - name: Start database
        run: docker-compose up -d postgres

      - name: Run migrations
        run: npx prisma migrate deploy

      - name: Seed test data
        run: npx prisma db seed

      - name: Run E2E tests
        run: npm run test:e2e

      - name: Upload test artifacts
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

19.10 测试最佳实践

## 测试最佳实践清单

### 测试命名
- [ ] 使用描述性的测试名称(应该... 当...)
- [ ] 按功能模块分组(describe)
- [ ] 测试文件与源文件同名(foo.ts → foo.test.ts)

### 测试结构
- [ ] 每个测试只验证一个行为
- [ ] 使用 AAA 模式(Arrange-Act-Assert)
- [ ] 避免测试之间的依赖和顺序

### Mock 策略
- [ ] 只 mock 外部依赖(API、数据库、文件系统)
- [ ] 不 mock 待测试的代码本身
- [ ] 使用 MSW 替代手动 mock fetch

### 组件测试
- [ ] 通过角色查询元素(getByRole > getByText > getByTestId)
- [ ] 测试用户行为而非实现细节
- [ ] 避免直接访问组件内部状态

### E2E 测试
- [ ] 只覆盖关键用户路径
- [ ] 使用 data-testid 定位元素(UI 变化不影响测试)
- [ ] 避免测试第三方库的行为

### 持续改进
- [ ] 每次修 bug 都添加对应的测试
- [ ] 定期审查测试覆盖率报告
- [ ] 删除不再相关的测试

本章小结

Key Takeaways

  1. 测试金字塔是测试策略的基础:单元测试大量、E2E 测试少量
  2. Vitest 是 Next.js 单元测试的首选:快、兼容 Vite、类型安全
  3. RTL 测试用户行为而非实现getByRole > getByText > getByTestId
  4. MSW 是 API Mock 的标准方案:比手动 mock fetch 更贴近真实
  5. Playwright 是 E2E 测试的最佳工具:跨浏览器、稳定、API 友好
  6. 测试是投资而非成本:每次修 bug 都添加测试,长期受益

下一步

下一章我们将深入 错误处理与监控——构建 Next.js 的完整错误处理体系,包括 Error Boundary、全局错误页、日志记录、Sentry 集成与生产监控。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页