现在我开始撰写第9章 9

现在我开始撰写第9章 9.2 的完整教程。


第9章 9.2 测试:Jest + Supertest

目标:读完本文,你能用 Jest 写单元测试、用 Supertest 测试 API 接口,理解什么是 mock 和覆盖率。
预计用时:90 分钟(按节奏走,每段都能复制运行)


🎯 开场 3 分钟:为什么要学这个?

场景:你花了一周写了 500 行代码,交给了测试同学。测试同学跑了一下,说「有 12 个 bug」。你一个个改完,再跑,还有 5 个 bug。改完再跑,还有 3 个……

痛点来了
- 每次改代码都不知道会不会扯坏别的功能——手动回归测试累死人
- 代码上线后才发现 bug,运维半夜给你打电话——线上事故
- 别人接手你的代码,改一行要猜半天这行干啥的——没有文档,代码会变谜语

学完本文你能做什么
- 给每个函数写「自动体检单」,改代码后一键跑完所有检查
- 模拟测试 API 接口,不用真的起一个服务器
- 用 mock 隔离外部依赖(比如数据库、第三方 API),让测试跑得又快又稳

上一章我们学了 TypeScript 给代码加类型保障,这一章我们给代码加「测试保障」——让 bug 在本地就被抓出来,而不是等到用户投诉。


🧱 基础 25 分钟:核心概念(小白视角)

2.1 什么是测试?为什么需要自动测试?

想象你开了一家餐厅:
- 人工测试 = 每道菜端出去,服务员要站在桌边看着你吃,看你皱眉头就记下来——慢、贵、容易漏
- 自动测试 = 给厨房装一套传感器,菜一出锅就自动检测温度、颜色、摆盘——快、准、不漏

说白了:自动测试就是「用代码检测代码」,你写一次,以后每次改代码都能自动跑一遍,确保没扯坏原有的功能。

配图1 - 配图1

2.2 Jest 是什么?

Jest 是 Node.js 里最流行的测试框架,Facebook 出品,Vue、React、TypeScript 项目都在用。

生活类比:Jest 像一个「自动阅卷老师」——你写好试题(测试用例),Jest 自动帮你批卷打分。

最简例子:测试一个加法函数

先建一个项目,安装 Jest:

npm init -y
npm install --save-dev jest

写一个待测的 math.js

// math.js
function add(a, b) {
return a + b;
}

module.exports = { add };

写测试文件 math.test.js(注意 .test.js 后缀是 Jest 约定):

// math.test.js
const { add } = require('./math');

// 测试用例:expect(实际值).toBe(期望值)
test('1 + 2 应该等于 3', () => {
expect(add(1, 2)).toBe(3);
});

test('-1 + 1 应该等于 0', () => {
expect(add(-1, 1)).toBe(0);
});

跑一下:

npx jest

预期输出

PASS  math.test.js
✓ 1 + 2 应该等于 3 (3ms)
✓ -1 + 1 应该等于 0 (1ms)
  • test() 是 Jest 的测试函数,第一个参数是测试描述,第二个是测试函数
  • expect(x).toBe(y) 断言 x 等于 y,不相等就报错

2.3 describe 和 it:组织测试的结构

当测试多了,需要分组。用 describe 把相关测试放一起,用 ittest 写单个用例。

// string.test.js
const { reverseString } = require('./string');

describe('字符串处理', () => {
describe('reverseString', () => {
    it('应该反转 "hello" 为 "olleh"', () => {
        expect(reverseString('hello')).toBe('olleh');
    });

    it('应该反转 "abc" 为 "cba"', () => {
        expect(reverseString('abc')).toBe('cba');
    });
});
});

输出

字符串处理
reverseString
  ✓ 应该反转 "hello" 为 "olleh"
  ✓ 应该反转 "abc" 为 "cba"
  • describe('组名', () => { ... }):分组,让输出更清晰
  • it('描述', () => { ... }):单个测试用例,和 test() 等价

2.4 常用断言:不只是 toBe

Jest 提供了很多断言方法,覆盖各种场景:

test('常用断言演示', () => {
// 相等
expect(1 + 1).toBe(2);
expect([1, 2, 3]).toEqual([1, 2, 3]);  // 数组/对象用 toEqual 深比较

// 真假
expect(true).toBeTruthy();
expect(null).toBeFalsy();
expect(undefined).toBeUndefined();

// 数字
expect(10).toBeGreaterThan(5);
expect(10).toBeLessThan(20);
expect(3.14).toBeCloseTo(3.14);  // 浮点数比较用 toBeCloseTo

// 包含
expect('hello world').toContain('hello');
expect([1, 2, 3]).toContain(2);

// 正则
expect('abc@example.com').toMatch(/@.*\.com/);

// 例外
expect(() => JSON.parse('invalid')).toThrow();  // 应该有异常
});

记忆口诀toBe 是精确相等,toEqual 是深度相等(对象/数组);toBeTruthy 判断真假,toBeNull 判断空值。

2.5 beforeEach / afterEach:每个测试前后的准备

很多测试需要「准备环境」——比如创建数据、连接数据库。beforeEachafterEach 帮你自动做这件事。

// user.test.js
const { createUser, deleteUser } = require('./user');

let userId;

beforeEach(async () => {
// 每个测试前:创建一个测试用户
userId = await createUser({ name: '测试用户', age: 25 });
});

afterEach(async () => {
// 每个测试后:清理测试数据
await deleteUser(userId);
});

test('用户应该有 name 属性', () => {
// 这里 userId 已经初始化好了
expect(createUser({ name: '小明' })).toHaveProperty('name');
});
  • beforeEach:每个 it 执行前跑一次
  • afterEach:每个 it 执行后跑一次
  • 适合需要初始化/清理的场景,比如数据库操作

配图2 - 配图2

2.6 Supertest 是什么?

Supertest 是用来测试 HTTP 接口的库,不用真的起服务器,就能模拟发送请求、验证响应。

生活类比:Supertest 像一个「快递测试员」——不用真的寄包裹,模拟一下就知道包裹能不能按时送到、会不会摔坏。

最简例子:测试一个 Express 接口

先装依赖:

npm install --save-dev supertest

写一个简单的 Express app:

// app.js
const express = require('express');
const app = express();

app.get('/hello', (req, res) => {
res.json({ message: 'Hello, World!' });
});

app.get('/users/:id', (req, res) => {
const user = { id: req.params.id, name: '张三' };
res.json(user);
});

module.exports = app;

写测试:

// app.test.js
const request = require('supertest');
const app = require('./app');

test('GET /hello 应该返回 Hello, World!', async () => {
const res = await request(app)
    .get('/hello')
    .expect(200)
    .expect('Content-Type', /json/);

expect(res.body).toEqual({ message: 'Hello, World!' });
});

test('GET /users/123 应该返回 id=123 的用户', async () => {
const res = await request(app)
    .get('/users/123')
    .expect(200);

expect(res.body.id).toBe('123');
expect(res.body.name).toBe('张三');
});
  • request(app):创建一个针对 app 的请求对象

  • .get('/hello'):发送 GET 请求

  • .expect(200):断言状态码是 200
  • .expect('Content-Type', /json/):断言响应头
  • res.body:响应体(自动解析 JSON)

🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):测试一个计算器 —— 跟着抄就能跑

目标:写一个计算器模块,测试它的加减乘除功能。

目录结构

calculator/
├── calculator.js
└── calculator.test.js

calculator.js

// calculator.js
const calculator = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b,
divide: (a, b) => {
    if (b === 0) throw new Error('除数不能为0');
    return a / b;
}
};

module.exports = calculator;

calculator.test.js

// calculator.test.js
const calc = require('./calculator');

describe('计算器', () => {
describe('加法', () => {
    it('1 + 2 = 3', () => {
        expect(calc.add(1, 2)).toBe(3);
    });

    it('-1 + 1 = 0', () => {
        expect(calc.add(-1, 1)).toBe(0);
    });
});

describe('除法', () => {
    it('10 / 2 = 5', () => {
        expect(calc.divide(10, 2)).toBe(5);
    });

    it('除以 0 应该报错', () => {
        expect(() => calc.divide(10, 0)).toThrow('除数不能为0');
    });
});
});

运行

npx jest calculator.test.js

预期输出

PASS  calculator.test.js
计算器
加法
  ✓ 1 + 2 = 3
  ✓ -1 + 1 = 0
除法
  ✓ 10 / 2 = 5
  ✓ 除以 0 应该报错

项目 2(15 分钟):测试一个待办清单 API —— 从 JSON 读数据

目标:写一个待办清单的 RESTful API,用 Supertest 测试 CRUD 操作。

app.js(完整的 Express API):

// app.js
const express = require('express');
const app = express();
app.use(express.json());

// 模拟数据库(内存)
let todos = [
{ id: 1, title: '写周报', done: false, priority: '高' },
{ id: 2, title: '买牛奶', done: false, priority: '低' }
];
let nextId = 3;

// GET /todos - 列出所有
app.get('/todos', (req, res) => {
res.json(todos);
});

// POST /todos - 添加

app.post('/todos', (req, res) => {
const { title, priority = '中' } = req.body;
const todo = { id: nextId++, title, done: false, priority };
todos.push(todo);
res.status(201).json(todo);
});

// PATCH /todos/:id - 更新状态
app.patch('/todos/:id', (req, res) => {
const id = parseInt(req.params.id);
const todo = todos.find(t => t.id === id);
if (!todo) return res.status(404).json({ error: '未找到' });

if (typeof req.body.done === 'boolean') todo.done = req.body.done;
if (req.body.title) todo.title = req.body.title;
res.json(todo);
});

// DELETE /todos/:id - 删除
app.delete('/todos/:id', (req, res) => {
const id = parseInt(req.params.id);
const index = todos.findIndex(t => t.id === id);
if (index === -1) return res.status(404).json({ error: '未找到' });

todos.splice(index, 1);
res.status(204).send();
});

module.exports = app;

app.test.js

// app.test.js
const request = require('supertest');
const app = require('./app');

describe('待办清单 API', () => {
describe('GET /todos', () => {
    it('应该返回待办列表', async () => {
        const res = await request(app).get('/todos');
        expect(res.status).toBe(200);
        expect(Array.isArray(res.body)).toBe(true);
        expect(res.body.length).toBeGreaterThan(0);
    });
});

describe('POST /todos', () => {
    it('应该创建新待办', async () => {
        const newTodo = { title: '测试任务', priority: '高' };
        const res = await request(app)
            .post('/todos')
            .send(newTodo)
            .expect(201);

        expect(res.body).toMatchObject({
            title: '测试任务',
            done: false,
            priority: '高'
        });
        expect(res.body.id).toBeDefined();
    });
});

describe('PATCH /todos/:id', () => {
    it('应该标记待办为已完成', async () => {
        // 先创建一个
        const createRes = await request(app)
            .post('/todos')
            .send({ title: '待标记' });

        const id = createRes.body.id;

        // 再标记为完成
        const res = await request(app)
            .patch(`/todos/${id}`)
            .send({ done: true })
            .expect(200);

        expect(res.body.done).toBe(true);
    });
});

describe('DELETE /todos/:id', () => {
    it('应该删除待办', async () => {
        // 先创建一个
        const createRes = await request(app)
            .post('/todos')
            .send({ title: '待删除' });

        const id = createRes.body.id;

        // 再删除
        await request(app)
            .delete(`/todos/${id}`)
            .expect(204);

        // 确认删除成功
        await request(app)
            .get(`/todos/${id}`)
            .expect(404);
    });
});
});

运行

npx jest app.test.js

预期输出

PASS  app.test.js
待办清单 API
GET /todos
  ✓ 应该返回待办列表
POST /todos
  ✓ 应该创建新待办
PATCH /todos/:id
  ✓ 应该标记待办为已完成
DELETE /todos/:id
  ✓ 应该删除待办

项目 3(15 分钟):用 mock 隔离外部依赖 —— 组合前两个项目的能力

场景:你的代码里调用了第三方 API(比如获取天气),测试时不想真的发网络请求,怎么办?用 mock 模拟!

目标:写一个「根据城市获取天气」的服务,测试时 mock 掉 HTTP 请求。

weatherService.js

// weatherService.js
const https = require('https');

function getWeather(city) {
return new Promise((resolve, reject) => {
    // 模拟调用第三方 API(实际项目里用 axios 或 node-fetch)
    const options = {
        hostname: 'api.example.com',
        path: `/weather?city=${encodeURIComponent(city)}`,
        method: 'GET'
    };

    const req = https.request(options, (res) => {
        let data = '';
        res.on('data', chunk => data += chunk);
        res.on('end', () => {
            try {
                const result = JSON.parse(data);
                resolve(result);
            } catch (e) {
                reject(e);
            }
        });
    });

    req.on('error', reject);
    req.end();
});
}

module.exports = { getWeather };

weatherService.test.js(用 Jest mock):

// weatherService.test.js
const { getWeather } = require('./weatherService');

// 手动 mock 整个模块
jest.mock('./weatherService', () => ({
getWeather: jest.fn()
}));

describe('天气服务', () => {
beforeEach(() => {
    // 每个测试前清空 mock 的调用记录
    jest.clearAllMocks();
});

it('应该返回北京的天气', async () => {
    // mock 返回值
    getWeather.mockResolvedValue({
        city: '北京',
        temperature: 22,
        weather: '晴'
    });

    const result = await getWeather('北京');

    expect(result.city).toBe('北京');
    expect(result.temperature).toBe(22);
});

it('应该处理 API 错误', async () => {
    getWeather.mockRejectedValue(new Error('网络错误'));

    await expect(getWeather('北京')).rejects.toThrow('网络错误');
});
});

运行

npx jest weatherService.test.js

预期输出

PASS  weatherService.test.js
天气服务
✓ 应该返回北京的天气
✓ 应该处理 API 错误

核心理解
- jest.mock() 把指定的模块替换成 mock 版本
- mockResolvedValue() 设置异步返回值
- mockRejectedValue() 设置抛异常
- 这样测试不依赖真实网络,又快又稳


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:异步测试忘记加 async/await

❌ 错误示例:

// 测试异步函数,没加 async/await —— 测试会直接通过(假阳性)
test('异步测试错误写法', () => {
fetchData().then(data => {
    expect(data).toBe('success');  // 这个断言永远不会失败,因为没 await
});
});

✅ 正确示例:

// 正确写法:async + await
test('异步测试正确写法', async () => {
const data = await fetchData();
expect(data).toBe('success');
});

坑 2:测试里改了全局状态,没还原

❌ 错误示例:

let count = 0;

test('第一次测试', () => {
count = 10;
expect(count).toBe(10);  // 通过
});

test('第二次测试', () => {
expect(count).toBe(0);  // 失败!因为上次改了全局 count
});

✅ 正确示例(用 beforeEach 初始化):

let count = 0;

beforeEach(() => {
count = 0;  // 每次测试前还原
});

test('第一次测试', () => {
count = 10;
expect(count).toBe(10);
});

test('第二次测试', () => {
expect(count).toBe(0);  // 通过!
});

坑 3:mock 了没清理,影响后续测试

❌ 错误示例:

test('测试 A', () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
// console.log 被 mock 了
});

test('测试 B', () => {
console.log('hello');  // 这行不会输出,因为 mock 没清理!
});

✅ 正确示例:

test('测试 A', () => {
const logMock = jest.spyOn(console, 'log').mockImplementation(() => {});
// 测试逻辑...
logMock.mockRestore();  // 手动还原
});

test('测试 B', () => {
console.log('hello');  // 正常输出!
});

或者用 afterEach 统一清理:

afterEach(() => {
jest.restoreAllMocks();  // 清理所有 spy/mock
});

坑 4:期望值写错,导致测试永远通过

❌ 错误示例:

// 把 toBe 错写成了 toEqual 的反向
test('错误的断言', () => {
expect(1).not.toBe(1);  // 永远失败!1 当然等于 1
});

✅ 正确示例:

test('正确的断言', () => {
expect(1).toBe(1);  // 通过
expect([1, 2]).toEqual([1, 2]);  // 数组用 toEqual
});

坑 5:用 Supertest 测试时,忘记处理异步

❌ 错误示例:

test('忘记 await', () => {
request(app).get('/todos').expect(200);  // 没 await,不会等待响应
// 测试会直接通过,但实际请求可能失败了
});

✅ 正确示例:

test('正确用法', async () => {
await request(app).get('/todos').expect(200);
});

性能小贴士:用 Jest --coverage 看覆盖率

想知道你的代码有多少被测到了?跑:

npx jest --coverage

输出示例

PASS  calculator.test.js
计算器
加法
  ✓ 1 + 2 = 3
除法
  ✓ 10 / 2 = 5

File      |  % Stmts |  % Branch |  % Funcs |  % Lines |
----------|----------|-----------|----------|----------|
calculator.js |    85.71 |      100 |      100 |    85.71 |
  • Stmts:语句覆盖率,85% 说明有 15% 的代码没跑到
  • Branch:分支覆盖率,比如 if/else 只测了一个分支
  • Funcs:函数覆盖率
  • Lines:行覆盖率

覆盖率不是越高越好,一般 80% 是及格线。

调试技巧:用 --verbose 看详细输出

npx jest --verbose

输出

PASS  calculator.test.js
计算器
加法
  ✓ 1 + 2 = 3 (4ms)
  ✓ -1 + 1 = 0 (2ms)

每个测试用例耗时都显示出来,方便定位慢的测试。


✏️ 练习题 + 作业题(共 7 分钟)

练习题(5 道,10 分钟内完成)

练习 1(2 分钟):改一个变量
- 输入:把项目 1 的 add 测试改成测试 multiply,期望 3 * 4 = 12
- 预期输出:测试通过
- 提示:把 calc.add(1, 2) 改成 calc.multiply(3, 4)toBe(3) 改成 toBe(12)

练习 2(2 分钟):加一个断言
- 输入:在项目 1 的除法测试里,加一个断言:验证 8 / 4 = 2
- 预期输出:两个测试都通过
- 提示:用 it() 再加一个用例,或者在现有用例里加一行 expect(calc.divide(8, 4)).toBe(2);

练习 3(3 分钟):测试一个新 API
- 输入:在项目 2 的 app 上加一个 GET /stats 接口,返回 { total: todos.length },然后写测试验证
- 预期输出:测试通过,返回 { total: 2 }(因为初始有两个待办)
- 提示:先在 app.js 加路由,再在 test 文件里 await request(app).get('/stats').expect(200).expect({ total: 2 })

练习 4(3 分钟):串起来
- 输入:给项目 2 的 POST /todos 加一个测试:创建后,再 GET /todos,验证列表长度增加了 1
- 预期输出:测试通过
- 提示:先记录初始长度 const before = (await request(app).get('/todos')).body.length;,再 POST,再验证 body.length === before + 1

练习 5(挑战题,5 分钟):修报错
- 输入:运行以下代码,报错 Cannot find module './math'

// math.test.js
const { add } = require('./math');

test('1 + 1 = 2', () => {
expect(add(1, 1)).toBe(2);
});
  • 预期输出:测试通过
  • 提示:检查 math.js 是否和 math.test.js 在同一目录,且文件名拼写一致

作业题(1 道大题,30 分钟~2 小时)

作业:做一个「学生成绩管理系统」的 API 测试

  • 需求描述:写一个学生成绩管理的 RESTful API,给它写完整的测试
  • 功能点:
    1. GET /students - 返回学生列表(id, name, score)
    2. POST /students - 添加学生(name, score)
    3. GET /students/:id - 获取单个学生
    4. GET /students/avg - 返回平均分
    5. DELETE /students/:id - 删除学生
  • 加分项:
    1. 用 mock 模拟一个外部验证服务(比如验证学生姓名是否合法)
    2. 测试边界情况(如删除不存在的学生、成绩超出 0-100 范围)
    3. 生成覆盖率报告,贴到评论区
  • 验收标准:能跑起来 + 5 个功能点都有测试 + 测试全部通过
  • 提交方式:评论区贴代码或 GitHub 链接

📚 总结 + 资源(5 分钟)

一句话总结:本文你学会了——
1. 用 test() + expect() 写单元测试,给函数写「自动体检单」
2. 用 Supertest 测试 HTTP API,不用起服务器也能验证接口
3. 用 jest.mock() 隔离外部依赖,测试跑得快又稳

延伸学习
- Jest 官方文档 —— 最权威,有中文版
- Supertest GitHub —— 看看作者的示例
- 《Node.js 实战》第 8 章 —— 专门讲测试,讲得很细

互动钩子:你在项目里用过 Jest 或 Supertest 吗?遇到过什么坑?评论区聊聊,下一章我们要讲部署(Docker + PM2 + Nginx),学完这章你就能把带测试的代码优雅地部署上线了!


全文完。跑测试遇到报错,把截图和代码贴评论区,我帮你盯。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。