用 Vitest + RTL + API Route 测试,打造可靠的 Next.js 全栈项目

November, 2nd 2025 9 min read
Next.js 全栈项目:单元测试与集成测试实战

写在前面(学习笔记)

  • 我在给 Next.js 全栈项目补测试时的随手笔记,记录选型、踩坑和能跑的最小例子。
  • 我的组合:Vitest + React Testing Library + 原生 Request/Response(直接调 app/api 的处理器)。
  • 目标不是把理论讲全,而是“照抄就能起步”,再逐步补全。

一句话概览:用 Vitest + React Testing Library + 原生 Fetch API,把工具函数、组件、以及 app/api 路由都各写一两条最小测试,覆盖常见路径,先跑起来再优化。

目录

  • 前提与选择
  • 安装与基础配置
  • 单元测试 1:工具函数(Node 环境)
  • 单元测试 2:React 组件(JSDOM 环境)
  • 集成测试 1:app/api 路由处理器(原生 Request/Response)
  • 集成测试 2:带参数与校验的 API(小型示例)
  • 运行与调试、常见问题

前提与选择

  • 目标:为 Next.js 全栈项目建立“快速、稳定、可维护”的测试体系。
  • 工具选择:
    • 单元/组件测试:Vitest(速度快、语法与 Jest 类似)、React Testing Library(更贴近用户行为)。
    • 集成测试(API 路由):直接调用 App Router 的 GET/POST 处理器,构造原生 Request,断言 Response。无需额外起服务器。
    • 端到端测试(可选):Playwright/Cypress(本文不展开)。

安装与基础配置

我用的是 Next.js 13/14(App Router)。先装依赖:

bash
12
      pnpm add -D vitest @testing-library/react @testing-library/jest-dom jsdom @testing-library/user-event
# 或 npm/yarn 按需替换
    

添加 vitest.config.ts

ts
1234567891011121314151617
      // vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.{test,spec}.{ts,tsx}'],
    globals: true,
    setupFiles: ['./vitest.setup.ts'],
    environment: 'jsdom',
    environmentMatchGlobs: [
      // 约定 server 目录下使用 Node 环境(测 API/服务端逻辑)
      ['src/**/server/**', 'node'],
      ['src/**/api/**', 'node'],
    ],
    css: false,
  },
});
    

添加测试初始化文件 vitest.setup.ts

ts
12
      // vitest.setup.ts
import '@testing-library/jest-dom';
    

建议的源码与测试布局(示例):

plaintext
1234567891011
      src/
  lib/
    sum.ts
    sum.test.ts
  components/
    Counter.tsx
    Counter.test.tsx
  api/
    todos/
      route.ts        // app/api/todos/route.ts 的同构示例
      route.test.ts
    

单元测试 1:工具函数(Node 环境)

实现与测试一个最小的工具函数:

ts
1234
      // src/lib/sum.ts
export function sum(a: number, b: number) {
  return a + b;
}
    
ts
12345678910111213
      // src/lib/sum.test.ts
import { describe, it, expect } from 'vitest';
import { sum } from './sum';

describe('sum()', () => {
  it('adds two numbers', () => {
    expect(sum(1, 2)).toBe(3);
  });

  it('handles negatives', () => {
    expect(sum(-2, 5)).toBe(3);
  });
});
    

运行:pnpm vitestpnpm vitest run


单元测试 2:React 组件(JSDOM 环境)

一个简单计数器组件:

tsx
12345678910111213
      // src/components/Counter.tsx
import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
      <button onClick={() => setCount((c) => c - 1)}>-1</button>
    </div>
  );
}
    

对应测试:

tsx
123456789101112131415161718192021
      // src/components/Counter.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  it('increments and decrements', async () => {
    render(<Counter />);
    const plus = screen.getByRole('button', { name: '+1' });
    const minus = screen.getByRole('button', { name: '-1' });

    expect(screen.getByText(/Count: 0/i)).toBeInTheDocument();

    await userEvent.click(plus);
    expect(screen.getByText(/Count: 1/i)).toBeInTheDocument();

    await userEvent.click(minus);
    expect(screen.getByText(/Count: 0/i)).toBeInTheDocument();
  });
});
    

要点:

  • 使用 RTL 以“用户视角”查询元素(getByRole, getByText)。
  • vitest 配置了 environment: 'jsdom',支持 DOM 与事件。

集成测试 1:app/api 路由处理器(原生 Request/Response)

Next.js App Router 中的 API 形如:app/api/todos/route.ts,导出 GET/POST 等处理器,基于 Web 标准 RequestResponse

示例实现(用内存数组替代数据库):

ts
123456789101112131415161718192021222324252627282930313233343536
      // src/api/todos/route.ts  (对应 app/api/todos/route.ts 的逻辑)
type Todo = { id: number; title: string; done: boolean };

let todos: Todo[] = [
  { id: 1, title: 'Learn Next.js', done: false },
  { id: 2, title: 'Write tests', done: false },
];

export async function GET() {
  return new Response(JSON.stringify(todos), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
}

export async function POST(req: Request) {
  try {
    const body = await req.json();
    if (!body?.title || typeof body.title !== 'string') {
      return new Response(JSON.stringify({ message: 'Invalid title' }), { status: 400 });
    }
    const nextId = Math.max(0, ...todos.map((t) => t.id)) + 1;
    const todo: Todo = { id: nextId, title: body.title, done: false };
    todos.push(todo);
    return new Response(JSON.stringify(todo), {
      status: 201,
      headers: { 'Content-Type': 'application/json' },
    });
  } catch (e) {
    return new Response(JSON.stringify({ message: 'Bad JSON' }), { status: 400 });
  }
}

export function __reset(data?: Todo[]) {
  todos = data ?? [];
}
    

对应测试:

ts
12345678910111213141516171819202122232425262728293031323334353637383940414243
      // src/api/todos/route.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { GET, POST, __reset } from './route';

describe('app/api/todos', () => {
  beforeEach(() => {
    __reset([
      { id: 1, title: 'Learn Next.js', done: false },
      { id: 2, title: 'Write tests', done: false },
    ]);
  });

  it('GET returns list', async () => {
    const res = await GET();
    expect(res.status).toBe(200);
    const json = await res.json();
    expect(Array.isArray(json)).toBe(true);
    expect(json).toHaveLength(2);
  });

  it('POST creates new todo', async () => {
    const req = new Request('http://localhost/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ title: 'Ship it' }),
    });
    const res = await POST(req);
    expect(res.status).toBe(201);
    const json = await res.json();
    expect(json.title).toBe('Ship it');
    expect(json.id).toBeGreaterThan(0);
  });

  it('POST validates payload', async () => {
    const req = new Request('http://localhost/api/todos', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({}),
    });
    const res = await POST(req);
    expect(res.status).toBe(400);
  });
});
    

要点:

  • 直接构造 Request,调用路由处理器函数,无需起 Next 服务器。
  • 断言 Response.statusawait res.json() 的内容。
  • 用可控的内存状态或注入的仓库接口,避免对真实数据库的依赖。

集成测试 2:带参数与校验的 API(小型示例)

加入路径参数与更严谨的校验可以这样组织:

ts
12345678910111213141516
      // src/api/todos/[id]/route.ts  (演示实现)
type Todo = { id: number; title: string; done: boolean };
let todos: Todo[] = [];

export function __seed(data: Todo[]) { todos = data; }

export async function PATCH(req: Request, ctx: { params: { id: string } }) {
  const id = Number(ctx.params.id);
  if (!Number.isFinite(id)) return new Response('Bad id', { status: 400 });
  const body = await req.json().catch(() => ({}));
  if (typeof body.done !== 'boolean') return new Response('Bad payload', { status: 400 });
  const idx = todos.findIndex((t) => t.id === id);
  if (idx === -1) return new Response('Not found', { status: 404 });
  todos[idx] = { ...todos[idx], done: body.done };
  return new Response(JSON.stringify(todos[idx]), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
    
ts
123456789101112131415161718192021222324
      // src/api/todos/[id]/route.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { PATCH, __seed } from './route';

describe('app/api/todos/[id]', () => {
  beforeEach(() => {
    __seed([
      { id: 1, title: 'A', done: false },
      { id: 2, title: 'B', done: false },
    ]);
  });

  it('PATCH toggles done', async () => {
    const req = new Request('http://localhost/api/todos/2', {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ done: true }),
    });
    const res = await PATCH(req, { params: { id: '2' } });
    expect(res.status).toBe(200);
    const json = await res.json();
    expect(json.done).toBe(true);
  });
});
    

要点:App Router 的路由处理器支持 ctx 形参(含 params),测试时手动传入即可。


运行与调试、常见问题

  • 运行命令:
    • 全量:pnpm vitest(交互 watch)或 pnpm vitest run(CI)。
    • 过滤:pnpm vitest src/api/todos/route.test.ts
  • 环境切换:
    • 通过 environmentMatchGlobssrc/**/api/**src/**/server/** 指定为 node 环境;其余默认为 jsdom,方便组件测试。
  • Mock 与依赖注入:
    • 访问数据库建议通过可注入的仓库接口,在测试中传入内存实现或 vi.mock() 的替身。
  • 与 Jest 的差异:
    • Vitest 基本兼容 Jest 断言与 API,但配置文件、环境与快照目录可能略有不同。
  • E2E 建议:
    • 如果需要端到端覆盖(页面渲染 + 跳转 + API 交互),推荐 Playwright;与单元/集成测试互补。

小结

  • 使用 Vitest + RTL 构建单元与组件测试,快速稳定。
  • App Router 的 API 测试可以直接构造 Request/Response,不必起服务器,执行快、定位准。
  • 通过合理的目录与环境划分,既方便前端交互测试,也能覆盖服务端逻辑与数据流