Node.js 测试实战:Jest、Vitest 与 Playwright 构建分层测试体系
测试不是开发的负担,而是持续交付的保险绳。很多团队把测试留到最后一天,结果线上频出回归 Bug;也有些团队一股脑写了几百个测试,但维护成本极高,重构时反而拖慢节奏。问题不在于测试本身,而在于没有分层。本文从单元测试到 E2E,用 Jest、Vitest 和 Playwright 搭建一套可落地的 Node.js 分层测试体系,帮你找到速度与覆盖率的平衡点。
一、为什么测试必须分层?
测试分层本质上是在模拟真实世界的故障成本。单元测试验证函数逻辑,毫秒级反馈;集成测试验证模块协作,秒级反馈;E2E 测试验证用户路径,分钟级反馈。如果所有测试都走浏览器,CI 跑半小时,开发者会本能地跳过;如果只有单元测试,接口契约变了也发现不了。
经典的测试金字塔告诉我们:
- 底层(70%):单元测试,成本低、速度快、定位准;
- 中层(20%):集成/接口测试,验证数据库、HTTP、消息队列等外部依赖;
- 顶层(10%):E2E 测试,模拟真实用户操作,成本最高但最贴近业务。
记住一个原则: bug 越容易被用户发现,测试就应该越靠近顶层;bug 越隐蔽,就越应该被单元测试拦截。
二、Jest:单元测试的瑞士军刀
2.1 快速上手
Jest 是 Node.js 生态里事实标准的测试框架,内置断言、Mock、覆盖率、快照,几乎开箱即用。
# 安装
npm install --save-dev jest
# package.json
{
"scripts": {
"test": "jest",
"test:cov": "jest --coverage"
}
}
写一个最简单的单元测试:
// src/cart.js
function calcDiscount(price, level) {
if (price < 0) throw new Error('price must be positive');
if (level === 'vip') return price * 0.8;
if (level === 'svip') return price * 0.7;
return price;
}
module.exports = { calcDiscount };
// __tests__/cart.test.js
const { calcDiscount } = require('../src/cart');
describe('calcDiscount', () => {
test('普通会员无折扣', () => {
expect(calcDiscount(100, 'normal')).toBe(100);
});
test('VIP 八折', () => {
expect(calcDiscount(100, 'vip')).toBe(80);
});
test('SVIP 七折', () => {
expect(calcDiscount(100, 'svip')).toBe(70);
});
test('负数价格抛异常', () => {
expect(() => calcDiscount(-1, 'vip')).toThrow('price must be positive');
});
});
2.2 Mock 外部依赖
单元测试的关键是隔离。对于 HTTP、数据库、第三方 SDK,统一用 Mock 替代。
// src/pay.js
const axios = require('axios');
async function createOrder(amount) {
const res = await axios.post('https://api.pay.com/order', { amount });
return res.data.orderId;
}
module.exports = { createOrder };
// __tests__/pay.test.js
jest.mock('axios');
const axios = require('axios');
const { createOrder } = require('../src/pay');
test('创建订单返回 orderId', async () => {
axios.post.mockResolvedValue({ data: { orderId: 'ORD-20260811' } });
const id = await createOrder(99);
expect(id).toBe('ORD-20260811');
expect(axios.post).toHaveBeenCalledWith(
'https://api.pay.com/order',
{ amount: 99 }
);
});
2.3 快照测试的正确用法
快照适合输出结构稳定的场景,比如 CLI 输出、复杂对象、React 组件渲染结果。但不要拿快照测业务逻辑,否则每次改字段都要更新快照,反而增加噪音。
test('生成的配置对象结构稳定', () => {
const config = buildConfig({ env: 'prod', port: 3000 });
expect(config).toMatchSnapshot();
});
三、Vitest:比 Jest 更快的现代化选择
3.1 为什么要换 Vitest?
Vitest 由 Vite 团队打造,原生支持 ESM,冷启动和热更新速度明显优于 Jest。如果你的项目已经在用 Vite,或者想拥抱 ESM,Vitest 几乎是最佳选择。
| 维度 | Jest | Vitest |
|---|---|---|
| 配置方式 | jest.config.js | vite.config.js 或 vitest.config.js |
| ESM 支持 | 需额外配置 | 原生支持 |
| 启动速度 | 较慢(需转译) | 快(基于 Vite) |
| Mock 语法 | jest.mock | vi.mock |
| IDE 支持 | 成熟 | 极佳(VS Code 插件) |
| TypeScript | 需 ts-jest | 原生支持 |
3.2 迁移成本几乎为零
Vitest 的 API 设计几乎照搬 Jest,把 jest 换成 vi 就能跑。
// vitest.config.js
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
coverage: {
reporter: ['text', 'html'],
exclude: ['node_modules/', '__tests__/']
}
}
});
// __tests__/cart.vitest.js
import { describe, it, expect, vi } from 'vitest';
import { calcDiscount } from '../src/cart.js';
describe('calcDiscount', () => {
it('VIP 八折', () => {
expect(calcDiscount(100, 'vip')).toBe(80);
});
});
3.3 实战技巧:in-source 测试
Vitest 支持把测试写在源码旁边,方便阅读和维护:
// src/utils.js
export function sum(a, b) {
return a + b;
}
// 源码内测试,仅开发模式生效
if (import.meta.vitest) {
const { it, expect } = import.meta.vitest;
it('sum', () => {
expect(sum(2, 3)).toBe(5);
});
}
四、集成测试:supertest 验证接口契约
4.1 为什么单元测试不够?
单元测试能告诉你函数对不对,但没法告诉你路由有没有挂、中间件顺序有没有错、数据库连接池有没有泄漏。集成测试就是把这些串起来跑一遍。
4.2 Express + supertest 最小实战
// src/app.js
const express = require('express');
const app = express();
app.use(express.json());
const users = [];
app.post('/users', (req, res) => {
const { name } = req.body;
if (!name) return res.status(400).json({ error: 'name required' });
const user = { id: users.length + 1, name };
users.push(user);
res.status(201).json(user);
});
app.get('/users/:id', (req, res) => {
const user = users.find(u => u.id === parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'not found' });
res.json(user);
});
module.exports = app;
// __tests__/app.test.js
const request = require('supertest');
const app = require('../src/app');
describe('User API', () => {
test('POST /users 创建用户', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Alice' })
.expect(201);
expect(res.body.name).toBe('Alice');
});
test('POST /users 缺少 name 返回 400', async () => {
await request(app).post('/users').send({}).expect(400);
});
test('GET /users/:id 查询用户', async () => {
const create = await request(app).post('/users').send({ name: 'Bob' });
const res = await request(app).get(`/users/${create.body.id}`).expect(200);
expect(res.body.name).toBe('Bob');
});
});
4.3 数据库怎么测?
不要直接连生产库。推荐三种方案:
- SQLite 内存库:Prisma/Sequelize 可快速切换,适合 CRUD 测试;
- Testcontainers:启动真实 MySQL/Postgres 容器,适合复杂 SQL 和事务;
- 每层事务回滚:测试前后 rollback,保持数据干净。
// 用 Testcontainers 启动 Postgres
const { PostgreSqlContainer } = require('@testcontainers/postgresql');
let container;
beforeAll(async () => {
container = await new PostgreSqlContainer().start();
process.env.DATABASE_URL = container.getConnectionUri();
});
afterAll(async () => {
await container.stop();
});
五、Playwright:E2E 测试的终极方案
5.1 为什么选 Playwright?
相比 Selenium,Playwright 的自动等待、Trace Viewer、多浏览器并行、Codegen 录制都更现代化。它的断言会智能重试,极大减少 flaky test。
# 安装
npm init playwright@latest
# 自动生成配置文件和示例测试
npx playwright test
5.2 一个完整的登录流程测试
// e2e/login.spec.js
const { test, expect } = require('@playwright/test');
test('用户登录并跳转仪表盘', async ({ page }) => {
await page.goto('https://app.example.com/login');
await page.fill('[data-testid="email"]', 'user@example.com');
await page.fill('[data-testid="password"]', 'secret123');
await page.click('[data-testid="submit"]');
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.locator('h1')).toContainText('仪表盘');
});
5.3 用 codegen 自动生成测试
手动写选择器很痛苦。Playwright 提供了录制工具:
npx playwright codegen https://app.example.com
录制完成后会得到一段可运行的代码,再手动加上 data-testid 等稳定选择器,就是一份可靠的 E2E 用例。
5.4 并行与 trace
// playwright.config.js
module.exports = {
workers: 4,
retries: 1,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure'
}
};
开启 trace 后,失败用例会生成可交互的 trace.zip,用 npx playwright show-trace trace.zip 可以看到每一步的 DOM、网络请求和控制台输出,排查效率比截图高一个数量级。
六、CI/CD 里的测试流水线
测试写在本地只是第一步,真正发挥价值是在 CI 里强制执行。下面是一份 GitHub Actions 配置:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
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 lint
- run: npm run test:unit -- --coverage
- run: npm run test:integration
- run: npm run test:e2e
为了让 E2E 更稳定,建议:
- 用 Playwright 的
webServer选项自动启动本地服务; - E2E 放在单独 job,失败时不阻塞单元/集成测试的反馈;
- 对覆盖率设置门禁,例如单元测试行覆盖率不低于 80%。
七、常见陷阱与避坑指南
| 陷阱 | 表现 | 解法 |
|---|---|---|
| 测试依赖真实网络 | CI 随机失败 | 统一 Mock HTTP / 用 Testcontainers |
| 数据库未隔离 | 测试互相污染 | 每个测试独立事务 / 内存数据库 |
| 选择器过于脆弱 | UI 一改 E2E 全挂 | 优先使用 data-testid |
| 测试名描述不清 | 失败后看不出业务含义 | 用 "should...when..." 命名 |
| 过度 Mock | 测试通过但线上挂 | 关键路径保留集成测试 |
| 忽略测试性能 | CI 越跑越慢 | 分层 + 并行 + 只跑改动相关测试 |
八、Node.js 异步测试的四种模式
Node.js 到处都是回调、Promise 和 async/await,测试异步代码是新手最容易踩坑的地方。下面四种模式覆盖了绝大多数场景。
8.1 Promise + async/await
最推荐的方式,可读性最好:
test('异步获取用户', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Alice');
});
8.2 resolves / rejects 断言
test('Promise 成功', () => {
return expect(fetchUser(1)).resolves.toMatchObject({ id: 1 });
});
test('Promise 失败', () => {
return expect(fetchUser(999)).rejects.toThrow('not found');
});
8.3 done 回调
处理事件驱动或流式 API 时仍然有用:
test('事件触发', (done) => {
const emitter = new EventEmitter();
emitter.on('done', (data) => {
expect(data).toBe('ok');
done();
});
emitter.emit('done', 'ok');
});
8.4 定时器控制
测试含 setTimeout/setInterval 的代码时,用 Jest 的 fake timers:
test('定时任务', () => {
jest.useFakeTimers();
const fn = jest.fn();
setTimeout(fn, 1000);
jest.advanceTimersByTime(1000);
expect(fn).toHaveBeenCalled();
jest.useRealTimers();
});
九、测试数据管理:从硬编码到 Factory
测试代码里最恶心的就是一堆硬编码对象。业务字段一改,几十处测试要跟着改。用 factory 函数生成数据是更优雅的做法。
// tests/factories/user.js
function createUser(overrides = {}) {
return {
id: 1,
name: 'Alice',
email: 'alice@example.com',
role: 'user',
createdAt: new Date().toISOString(),
...overrides
};
}
module.exports = { createUser };
// 使用 factory
const { createUser } = require('./factories/user');
test('管理员可删除普通用户', () => {
const admin = createUser({ id: 1, role: 'admin' });
const target = createUser({ id: 2, role: 'user' });
expect(canDelete(admin, target)).toBe(true);
});
test('普通用户不能删除管理员', () => {
const user = createUser({ id: 3, role: 'user' });
const admin = createUser({ id: 4, role: 'admin' });
expect(canDelete(user, admin)).toBe(false);
});
对于复杂关系数据,可以引入 @faker-js/faker:
import { faker } from '@faker-js/faker';
function createRandomUser() {
return {
id: faker.number.int(),
name: faker.person.fullName(),
email: faker.internet.email(),
avatar: faker.image.avatar()
};
}
十、TDD 实战:红绿重构循环
测试驱动开发不是神话,它是一种让设计更清晰的节奏。以"密码强度校验"为例:
第一步:写失败的测试(红)
test('密码必须至少 8 位', () => {
expect(isStrongPassword('123')).toBe(false);
});
第二步:写最小实现(绿)
function isStrongPassword(pwd) {
return pwd.length >= 8;
}
第三步:补充更多测试并重构
test('强密码需包含大小写、数字和特殊字符', () => {
expect(isStrongPassword('Hello1!')).toBe(false); // 只有 7 位
expect(isStrongPassword('Hello1!a')).toBe(true); // 满足全部条件
expect(isStrongPassword('hello1!a')).toBe(false); // 缺少大写
expect(isStrongPassword('HELLO1!A')).toBe(false); // 缺少小写
});
TDD 的真正价值不在于"先写测试",而在于用小步快跑的方式暴露设计缺陷。如果你发现某个功能很难测试,往往说明模块耦合过高,需要重构。
十一、覆盖率不是终点
很多团队把"行覆盖率 80%"当成 KPI,结果出现大量无意义断言。覆盖率只能告诉你哪些代码被执行过,不能告诉你测试是否有效。
更合理的指标组合:
| 指标 | 意义 | 健康阈值 |
|---|---|---|
| 行覆盖率 | 代码被执行的比例 | > 70% |
| 分支覆盖率 | 条件分支是否都覆盖 | > 60% |
| 变异测试得分 | 测试能否发现人为注入的 Bug | > 50% |
| 测试失败率 | CI 中 flaky test 的比例 | < 1% |
| 测试执行时间 | 反馈速度 | < 5 分钟 |
推荐使用 stryker-js 做变异测试,它会自动修改你的源码并检查测试是否失败,能真实反映测试的"杀伤力"。
十二、最佳实践 Checklist
- 每个公共函数至少覆盖正常路径、边界条件和异常路径;
- Mock 只替代外部依赖,不要 Mock 被测函数自己的逻辑;
- 测试数据用 factory 函数生成,不要硬编码大量对象;
- 集成测试优先验证状态码、契约字段和副作用(数据库、消息);
- E2E 只覆盖核心用户旅程,不要事无巨细;
- 失败测试先本地复现,再提交修复,不要直接跳过;
- 定期清理无用测试,防止测试代码腐化;
- 把测试速度作为工程指标,CI 超过 10 分钟必须优化;
- 避免测试私有方法,测试的是行为而不是实现细节;
- 断言要有业务含义,不要只检查 "not null"。
十三、Flaky Test 的识别与治理
Flaky test(不稳定测试)是测试体系最大的敌人。它时而过时而过,开发者会逐渐对测试失去信心,最后把失败当成"正常噪音"。
13.1 常见诱因
- 时间依赖:用真实 sleep 等待动画或请求完成;
- 顺序依赖:测试之间共享状态,A 测试失败导致 B 测试也失败;
- 外部依赖:数据库、Redis、第三方 API 响应不稳定;
- 随机数据:用随机值生成测试数据,偶尔触发边界条件。
13.2 Playwright 的自动等待机制
Playwright 内置了智能等待,不要手动 sleep:
// 推荐:Playwright 会自动轮询直到条件满足
await page.click('[data-testid="submit"]');
await expect(page.locator('.toast')).toContainText('保存成功');
// 不推荐:硬编码等待
await page.waitForTimeout(2000);
13.3 重跑策略
完全消除 flaky 很难,可以先通过重跑隔离偶发问题:
// jest.config.js
module.exports = {
retry: 2, // 失败时重试 2 次
testRunner: 'jest-circus/runner'
};
但重跑只是止痛药,长期还是要回到根因治理。建议每周统计 flaky test 榜单,TOP 3 必须限期修复。
十四、测试与重构的正向循环
很多人害怕重构,是因为没有测试兜底。反过来,如果没有重构,测试代码也会越来越臃肿。测试与重构应该形成正向循环:
- 先补测试:在改动旧代码前,先写测试把当前行为"固化";
- 小步重构:每次只改一个函数或一个类,跑测试确认没破坏行为;
- 删除死代码:覆盖率报告会暴露没人调用的代码,放心删除;
- 提升抽象:重复出现的测试准备代码,抽取成 helper 或 fixture。
一个实用的重构信号是:当你发现写一个新测试要复制粘贴大量代码时,说明被测代码或测试辅助工具需要抽象了。
十五、从 0 到 1 的落地路线图
如果你的项目现在几乎没有测试,不要试图一次性补完。按下面四步走,阻力最小:
| 阶段 | 目标 | 周期 | 产出 |
|---|---|---|---|
| 第一阶段 | 为核心工具函数补单元测试 | 1 周 | 覆盖率 30% |
| 第二阶段 | 为关键 API 加集成测试 | 2 周 | 登录/支付/订单主流程覆盖 |
| 第三阶段 | 接入 CI 强制门禁 | 1 周 | PR 合并前必须全绿 |
| 第四阶段 | 补齐 E2E 和监控告警 | 持续 | 核心用户旅程自动化 |
记住:有测试总比没有好,跑起来的测试才有价值。不要陷入"等技术债还清再写测试"的拖延陷阱。
十六、结语
一套好的测试体系不是越多越好,而是每层都各司其职。Jest 和 Vitest 守住单元测试的底线,supertest 验证接口契约,Playwright 把住用户入口。把它们串进 CI,配合清晰的命名和稳定的依赖隔离,测试才能真正从"开发负担"变成"重构底气"。如果你还在为线上回归 Bug 头疼,不妨从今天开始,给你的 Node.js 项目补上一张分层测试网。