Next.jsTestingJestPlaywrightCI/CD

自动化测试在 Next.js 项目中的最佳实践:从单元到端到端

2026-03-07

自动化测试在 Next.js 项目中的最佳实践:从单元到端到端

在现代 Web 开发中,自动化测试是保证代码质量和项目可维护性的关键。Next.js 作为流行的 React 框架,其混合渲染特性(SSR/SSG/CSR)为测试带来了独特的挑战。本文将分享在 Next.js 项目中实施自动化测试的完整策略。

测试金字塔与策略

遵循测试金字塔原则,我们的测试策略应该是:

        /\
       /E2E\      ← 少量,覆盖关键用户流程
      /------\
     /集成测试\    ← 中等数量,测试组件交互
    /----------\
   /  单元测试  \  ← 大量,快速反馈
  /--------------\
  • 单元测试(70%):快速、隔离、大量
  • 集成测试(20%):组件协作、API 交互
  • E2E 测试(10%):关键路径、用户场景

单元测试:Jest + React Testing Library

环境配置

首先安装必要的依赖:

npm install -D jest @testing-library/react @testing-library/jest-dom \
  @testing-library/user-event jest-environment-jsdom

配置 jest.config.js

const nextJest = require('next/jest')

const createJestConfig = nextJest({
  dir: './',
})

const customJestConfig = {
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  testEnvironment: 'jest-environment-jsdom',
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/**/*.stories.tsx',
  ],
  coverageThresholds: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },
}

module.exports = createJestConfig(customJestConfig)

组件测试示例

测试一个典型的 Next.js 组件:

// components/ArticleCard.tsx
import Link from 'next/link';
import Image from 'next/image';

interface ArticleCardProps {
  title: string;
  excerpt: string;
  slug: string;
  coverImage?: string;
}

export function ArticleCard({ title, excerpt, slug, coverImage }: ArticleCardProps) {
  return (
    <article className="article-card">
      {coverImage && (
        <Image src={coverImage} alt={title} width={400} height={250} />
      )}
      <h2>{title}</h2>
      <p>{excerpt}</p>
      <Link href={`/articles/${slug}`}>阅读更多</Link>
    </article>
  );
}

对应的测试文件:

// components/ArticleCard.test.tsx
import { render, screen } from '@testing-library/react';
import { ArticleCard } from './ArticleCard';

describe('ArticleCard', () => {
  const mockProps = {
    title: 'Test Article',
    excerpt: 'This is a test excerpt',
    slug: 'test-article',
  };

  it('renders article information correctly', () => {
    render(<ArticleCard {...mockProps} />);
    
    expect(screen.getByRole('heading', { name: 'Test Article' })).toBeInTheDocument();
    expect(screen.getByText('This is a test excerpt')).toBeInTheDocument();
    expect(screen.getByRole('link', { name: '阅读更多' })).toHaveAttribute(
      'href',
      '/articles/test-article'
    );
  });

  it('renders cover image when provided', () => {
    render(<ArticleCard {...mockProps} coverImage="/test.jpg" />);
    
    const image = screen.getByAlt('Test Article');
    expect(image).toBeInTheDocument();
  });

  it('does not render image when coverImage is not provided', () => {
    render(<ArticleCard {...mockProps} />);
    
    expect(screen.queryByRole('img')).not.toBeInTheDocument();
  });
});

测试 Server Components

Next.js 13+ 的 Server Components 需要特殊处理:

// app/components/ServerArticleList.test.tsx
import { render, screen } from '@testing-library/react';
import { ServerArticleList } from './ServerArticleList';

// Mock fetch
global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () => Promise.resolve([
      { id: 1, title: 'Article 1' },
      { id: 2, title: 'Article 2' },
    ]),
  })
) as jest.Mock;

describe('ServerArticleList', () => {
  it('fetches and displays articles', async () => {
    const { container } = render(await ServerArticleList());
    
    expect(screen.getByText('Article 1')).toBeInTheDocument();
    expect(screen.getByText('Article 2')).toBeInTheDocument();
  });
});

集成测试:API Routes 与数据流

测试 API Routes

Next.js API Routes 的测试需要模拟请求和响应:

// pages/api/articles.test.ts
import { createMocks } from 'node-mocks-http';
import handler from './articles';

describe('/api/articles', () => {
  it('returns articles list', async () => {
    const { req, res } = createMocks({
      method: 'GET',
    });

    await handler(req, res);

    expect(res._getStatusCode()).toBe(200);
    expect(JSON.parse(res._getData())).toEqual(
      expect.arrayContaining([
        expect.objectContaining({
          id: expect.any(Number),
          title: expect.any(String),
        }),
      ])
    );
  });

  it('handles POST requests', async () => {
    const { req, res } = createMocks({
      method: 'POST',
      body: {
        title: 'New Article',
        content: 'Article content',
      },
    });

    await handler(req, res);

    expect(res._getStatusCode()).toBe(201);
  });

  it('returns 405 for unsupported methods', async () => {
    const { req, res } = createMocks({
      method: 'DELETE',
    });

    await handler(req, res);

    expect(res._getStatusCode()).toBe(405);
  });
});

测试数据获取

使用 MSW (Mock Service Worker) 模拟 API 响应:

// mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/articles', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: 1, title: 'Mocked Article' },
      ])
    );
  }),
];

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

export const server = setupServer(...handlers);

在测试中使用:

// jest.setup.js
import { server } from './mocks/server';

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

E2E 测试:Playwright

配置 Playwright

安装并初始化:

npm init playwright@latest

配置 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: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 13'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

E2E 测试示例

测试完整的用户流程:

// e2e/article-workflow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Article Workflow', () => {
  test('user can browse and read articles', async ({ page }) => {
    // 访问首页
    await page.goto('/');
    
    // 验证文章列表加载
    await expect(page.locator('article')).toHaveCount(10);
    
    // 点击第一篇文章
    await page.locator('article').first().click();
    
    // 验证文章详情页
    await expect(page).toHaveURL(/\/articles\/.+/);
    await expect(page.locator('h1')).toBeVisible();
    
    // 验证相关文章推荐
    await expect(page.locator('.related-articles')).toBeVisible();
  });

  test('search functionality works correctly', async ({ page }) => {
    await page.goto('/');
    
    // 输入搜索关键词
    await page.fill('input[type="search"]', 'Next.js');
    await page.press('input[type="search"]', 'Enter');
    
    // 等待搜索结果
    await page.waitForURL(/\/search\?q=Next\.js/);
    
    // 验证搜索结果
    const results = page.locator('.search-result');
    await expect(results).toHaveCountGreaterThan(0);
    
    // 验证结果包含关键词
    const firstResult = results.first();
    await expect(firstResult).toContainText('Next.js', { ignoreCase: true });
  });
});

CI/CD 集成

GitHub Actions 配置

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit tests
        run: npm test -- --coverage
      
      - name: Run E2E tests
        run: npx playwright test
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

最佳实践总结

  1. 测试命名清晰:使用描述性的测试名称,说明测试的内容和预期结果
  2. 避免测试实现细节:测试用户行为而非内部实现
  3. 使用测试 ID:为关键元素添加 data-testid 属性,提高测试稳定性
  4. Mock 外部依赖:隔离外部 API 和服务,确保测试可靠性
  5. 保持测试独立:每个测试应该能够独立运行,不依赖其他测试
  6. 定期审查覆盖率:设置合理的覆盖率目标,但不要盲目追求 100%
  7. 快速反馈循环:优先运行单元测试,E2E 测试放在 CI 环境

性能优化技巧

  • 并行执行:使用 Jest 的 --maxWorkers 参数
  • 智能缓存:利用 --onlyChanged 只测试修改的文件
  • 分片测试:在 CI 中将测试分配到多个 worker
  • 避免 watch 模式:CI 环境使用 --run 标志

通过系统化的测试策略和工具链,我们可以构建高质量、可维护的 Next.js 应用,确保每次发布都充满信心。


相关资源: