使用 Jest 测试你的 Node.js 应用
目的
- 增强代码的健壮性
- 及时发现未被覆盖的代码逻辑
- 项目交接或重构更加放心
工具
1. 安装
1
| npm install --save-dev jest supertest
|
2. 配置 package.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| "scripts": { "test": "NODE_ENV=development jest", "test-watch": "npm test -- --watch", }, "jest": { "verbose": true, "notify": true, "collectCoverage": true, "testEnvironment": "node", "modulePaths": [ "<rootDir>/server" ], "roots": [ "<rootDir>/__tests__" ], "testPathIgnorePatterns": [ "__tests__/(fixtures|__mocks__)/" ], "coverageReporters": [ "html", "text", "text-summary" ] }
|
3. 添加 gitignore
- 在 .gitignore 配置文件中增加忽略
coverage
目录
4. 运行
1 2
| npm test npm run test-watch
|
5. jest
命令的实用参数
npm test -- fileName
文件名支持正则,比如 npm test -- server/*
;支持部分匹配,比如 npm run test -- controllers/login
npm test --bail [-- fileName]
当遇到失败的用例时,立马退出,方便查看报错信息
npm test --watch [-- fileName]
监听测试文件修改,仅重新执行所修改的测试用例
npm test --watchAll [-- fileName]
监听测试修改,重新执行所有测试用例
6. 目录结构约定
- 测试文件:
__tests__
- mock 模块:
__mocks__
- 辅助工具:
__test__/fixtures
1 2 3 4 5 6 7 8 9
| __tests__ ├── fixtures ├── __mocks__ │ └── request.js └── server ├── controllers │ └── thread │ └── index.test.js └── server.test.js
|
测试维度
- 正向测试:这个函数是否按照其声明的那样实现了非常基本的功能?
- 负向测试:代码是否可以处理非期待值?
测试覆盖率
源代码被测试的比例, 有四个测量维度
- 行覆盖率(line coverage):是否每一行都执行了?
- 函数覆盖率(function coverage):是否每个函数都调用了?
- 分支覆盖率(branch coverage):是否每个if代码块都执行了?
- 语句覆盖率(statement coverage):是否每个语句都执行了?
1 2 3 4 5 6 7 8 9 10 11
| -----------|----------|----------|----------|----------|----------------| File | % Stmts | % Branch | % Funcs | % Lines |Uncovered Lines | -----------|----------|----------|----------|----------|----------------| All files | 100 | 85.71 | 100 | 100 | | logger.js | 100 | 85.71 | 100 | 100 | | -----------|----------|----------|----------|----------|----------------| Test Suites: 1 passed, 1 total Tests: 9 passed, 9 total Snapshots: 0 total Time: 0.836s, estimated 1s Ran all test suites.
|
附:单元测试准则 文档较长,建议饭后查看
测哪些东西
- server - 启动是否正常
- middlewares - 加载正常,请求时正常工作
- controllers - 请求特定路由,看响应是否是符合预期
- services - 调用特定方法,返回结果符合预期,边界情况
- routes、lib - 普通测试
测试用例撰写
一个普通且完备的单测文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| describe('api 映射模块', () => { beforeAll(() => {
})
beforeEach(() => {
})
afterEach(() => { jest.resetModules() })
afterAll(() => {
})
it('当 env 为默认的 development 环境时,返回 localhost 地址', async() => { process.env.NODE_ENV = ''
const API = require('lib/api')
expect(API).toThrow() expect(API('')).toMatch(/localhost/) })
it.only('当 env 为测试环境时,返回测试环境地址', async() => { process.env.NODE_ENV = 'test'
const API = require('lib/api')
expect(API('get_items')).toMatch(/test.baidu.info/) }) })
|
附:expect 常用语句,更多请查看官方 expect 文档
1 2 3 4 5 6 7 8 9 10 11 12 13
| .toBe(value) .toEqual(value) .toBeDefined() .toBeFalsy() .toBeTruthy() .toMatch() .toThrow()
.toHaveBeenCalled() .toHaveBeenCalledWith(arg1, arg2, ...) .toHaveBeenCalledTimes(number)
|
mock 示例
jest 中 mock 主要有两种作用:
屏蔽外部影响:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ... const debug = require('debug')
module.exports = (a, b) => { debug('value a: ', a) debug('value b: ', b)
return a + b }
...
jest.mock('debug') ... it('返回 a 和 b 的和', () => { const add = require('utils/number-add') const total = add(1, 2)
expect(total).toBe(3) }) ...
|
模拟外部调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| const fetch = require('node-fetch')
module.exports = async (apiA, apiB) => { const stringA = await fetch(apiA) const stringB = await fetch(apiB)
return stringA + stringB }
describe('测试 string-add-async 模块', () => { it('返回接口 a 和 接口 b 所返回的字符串拼接', async () => { jest.mock('node-fetch', () => { return jest .fn() .mockImplementationOnce(async () => 'Hello ') .mockImplementationOnce(async () => 'world!') })
const addAsync = require('utils/string-add-async') const string = await addAsync('apiA', 'apiB')
expect(string).toBe('Hello world!') }) })
|
如何正确的 mock 一个模块
此处以 string-add-async 模块为例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| describe('测试 string-add-async 模块', () => { it('返回接口 a 和 接口 b 所返回的字符串拼接', async () => { jest.mock('node-fetch', () => { return jest .fn() .mockImplementationOnce(async () => 'Hello ') .mockImplementationOnce(async () => 'world!') })
const addAsync = require('utils/string-add-async') const string = await addAsync('apiA', 'apiB')
expect(string).toBe('Hello world!') }) })
describe('测试 string-add-async 模块 2', () => { it('返回接口 a 和 接口 b 所返回的字符串拼接', async () => { jest.mock('node-fetch')
const fetch = require('node-fetch')
fetch .mockImplementationOnce(async () => 'Hello ') .mockImplementationOnce(async () => 'world!')
const addAsync = require('utils/string-add-async') const string = await addAsync('apiA', 'apiB')
expect(string).toBe('Hello world!') }) })
module.exports = async apiUrl => { return apiUrl }
|
注:强烈不建议使用方式三,因为该方式影响范围比较大,不过适合 屏蔽外部影响
的情况
mock 实例
当一个模块被 mock 之后,便返回了一个 mock 实例,该实例上有丰富的方法可以用来进一步 mock;且还给出了丰富的属性用以断言
mockImplementation(fn)
其中 fn 就是所 mock 模块的实现
mockImplementationOnce(fn)
与 1 类似,但是仅生效一次,可链式调用,使得每次 mock 的返回都不一样
mockReturnValue(value)
直接定义一个 mock 模块的返回值
mockReturnValueOnce(value)
直接定义一个 mock 模块的返回值(一次性)
mock.calls
调用属性,比如一个 mock 函数 fun 被调用两次:fun(arg1, arg2); fun(arg3, arg4);
,则 mock.calls 值为 [['arg1', 'arg2'], ['arg3', 'arg4']]
附:更多 mock 实例属性与方法详见官方文档
测试示例
完整代码暂不提供
工具模块的测试方法
参看本文档 mock 示例
部分
服务启动的测试方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| const supertest = require('supertest')
describe('server 服务', () => { let app, server
beforeEach(async () => { app = await require('server')
app.log.level('fatal') })
afterEach(() => { if (server) { server.close() }
app = null server = null })
const request = () => { if (!server) { server = app.listen(0) }
return supertest(server) }
it('启动正常', async () => { expect(request).not.toThrow() })
it('app 抛出异常处理', async () => { app.use(async ctx => { app.emit('error', new Error('app error'), ctx) ctx.body = 'ok' })
await request() .get('/throw-error') .expect(200) .then(res => { expect(res.text).toBe('ok') }) }) })
|
中间件测试的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| const supertest = require('supertest')
describe('错误中间件', () => { let app, server
beforeEach(async () => { app = await require('server')
jest.resetModules() })
afterEach(() => { if (server) { server.close() }
app = null server = null })
const request = () => { if (!server) { server = app.listen(0) }
return supertest(server) }
it('抛出异常-中间件出错(自定义错误)', async () => { app.use(async (ctx, next) => { await Promise.reject(new Error('中间件出错')) await next() })
await request() .get('/throw-error') .expect(200) .then(res => { expect(res.body.error).toBe('中间件出错') }) })
it('app 抛出异常-系统异常,请稍后再试(默认错误)', async () => { app.use(async (ctx, next) => { await Promise.reject(new Error('')) await next() })
await request() .get('/throw-error') .expect(200) .then(res => { expect(res.body.error).toBe('系统异常,请稍后再试') }) }) })
|
接口测试的方法
// add-api.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| const AddService = require('./add-service')
module.exports = async router => { router.get('/add', async ctx => { const { a, b } = ctx.query const numberA = Number(a) const numberB = Number(b)
if (Number.isNaN(numberA) || Number.isNaN(numberB)) { throw new Error('参数必须为数字!') }
const projectService = new AddService(ctx) const ret = await projectService.add(numberA, numberB)
ctx.body = `接口计算结果:${ret}` }) }
|
// add.test.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
| jest.mock('./add-service') const Service = require('./add-service') const addApi = require('add-api') const Router = class { constructor (ctx) { return new Proxy({}, { get (target, name) { return async (path, callback) => { callback(ctx) } } }) } }
describe('测试 add 接口', () => { it(`当 a=1 且 b=2,返回 '接口计算结果:1 + 2 = 3'`, async () => { const mockedAdd = jest.fn(async () => '1 + 2 = 3') const ctx = { query: { a: '1', b: '2' } }
Service.mockImplementation(() => { return { add: mockedAdd } })
const router = new Router(ctx)
await addApi(router) expect(mockedAdd).toBeCalledWith(1, 2) expect(ctx.body).toBe('接口计算结果:1 + 2 = 3') })
it(`当 a=1 且 b=xxx,接口报错`, async () => { const mockedAdd = jest.fn(async () => '1 + 2 = 3') const ctx = { query: { a: '1', b: 'xxx' } }
Service.mockImplementation(() => { return { add: mockedAdd } })
const router = new Router(ctx)
try { await addApi(router) } catch (error) { expect(error).toBeEqual(new Error('参数必须为数字!')) } expect(mockedAdd).not.toBeCalled() }) })
|
服务层的测试方法
// project-service.js
1 2 3 4 5 6 7 8 9 10
| const add = require('utils/number-add')
module.exports = class { add (a, b) { const ret = add(a, b)
return `${a} + ${b} = ${ret}` } }
|
// project-service.test.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| describe('测试 project service', function() { it('测试 service 的 add 方法', async () => { jest.mock('utils/number-add')
const add = require('utils/number-add') const Service = require('project-service') const service = new Service()
add.mockImplementation(() => 100)
const ret = await service.add(1, 2)
expect(ret).toBe('1 + 2 = 100') }) })
|
FAQ
console.log 有时无效
试试 console.warn
mock 没起作用
mock 模块是否在多个测试用例中相互影响了;
mock 操作是否在 require 之后;
是否需要在 beforeEach
中执行 jest.resetModules()
或 jest.resetAllMocks()
;
是否需要单独执行 mock 的实例方法mockReset
;
参考