简介
单元测试(英语:Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。在过程化编程中,一个单元就是单个程序、函数、过程等;对于面向对象编程,最小单元就是方法,包括基类(超类)、抽象类、或者派生类(子类)中的方法。
单元测试的目标是隔离程序部件并证明这些单个部件是正确的。一个单元测试提供了代码片断需要满足的严密的书面规约。
良好设计的单元测试案例覆盖程序单元分支和循环条件的所有路径。
单元测试不应超出待测试的类边界。
因为很多类会引用其它类,对这个类的测试经常会要求测试其它的类。一个最普遍的例子是依赖于数据库的类:为了测试它,测试人员通常编写代码去操作数据库。这是不对的,因为单元测试不应超出待测试的类边界。
我们通过模拟数据库数据而非使用真正的数据库来进行后端测试。
每个理想的测试案例独立于其它案例;为测试时隔离模块,经常使用stubs、mock或 fake 等测试马甲程序。
如果应用逻辑简单,编写单元测试意义不大。
定义测试环境
Node 中的约定是用 NODE_ENV
环境变量定义应用的执行模式。 在我们当前的应用中,如果应用不是在生产模式下,我们只加载 .env
中定义的环境变量。
通常的做法是为开发和测试定义不同的模式。
NODE_ENV=development
NODE_ENV=production
NODE_ENV=test
通过 NODE_ENV=production
设置环境变量,Windows 下绝大部分 command prompts 不能正常工作,但 bash on Windows 除外。
安装 [cross-env](https://www.npmjs.com/package/cross-env)
作为一个开发依赖包,可以解决以上问题。
npm install --save-dev cross-env
用法
设置如下 npm scripts
{
// ...
"scripts": {
"dev": "cross-env NODE_ENV=production nodemon index.js"
}
}
测试库
安装
npm install --save-dev jest
定义 npm script test
"test": "jest —verbose"
{
//...
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js",
"build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend",
"deploy": "git push heroku master",
"deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy",
"logs:prod": "heroku logs --tail",
"lint": "eslint .",
"test": "jest --verbose"
},
}
Jest 需要指定执行环境为 Node。
可以通过在 package.json 的末尾添加一下内容:
{
//...
"jest": {
"testEnvironment": "node"
}
}或者,Jest 会查找默认名为
jest.config.js
的配置文件module.exports = {
testEnvironment: 'node'
}
开始
创建一个名为 tests 单独的目录,在其目录下创建名为 *.test.js
的测试文件。
Jest 默认情况下希望测试文件的名称包含 .test.
。
创建一个名为 average.test.js
的测试文件。
const average = require('../utils/for_testing').average;
/* eslint-disable no-undef */
describe('average', () => {
test('of one value is the value itself', () => {
expect(average([1])).toBe(1);
});
test('of many is calculated right', () => {
expect(average([1,2,3,4,5,6])).toBe(3.5);
});
test('of empty array is zero', () => {
expect(average([])).toBe(0);
});
});
在第一行,测试文件要导入将要测试的函数。
单个测试用例是用 test(string, function)
函数定义的。第一个参数为字符串,是测试的描述,第二个参数为函数,其定义测试用例的功能,定义功能如下:
() => {
expect(average([1])).toBe(1);
}
expect
函数
toBe
compare primitive values or to check referential identity of object instances. It calls Object.is to compare values, which is even better for testing than === strict equality operator.
toEqual
compare recursively all properties of object instances (also known as "deep" equality). It calls Object.is to compare primitive values, which is even better for testing than === strict equality operator.
toContain
toContainEqual
toHaveLength
测试数据库
多人开发同一应用的情况下,通常要求并发运行测试,需要在开发人员本地机器上安装单独的数据库。最佳的解决方案是让每一个测试用例执行时使用自己独立的数据库,通过运行内存中的 Mongo 或使用 Docker 容器来实现这个,相对简单。
测试 API
使用 supertest 包编写 API 的测试
npm install --save-dev supertest
将 Express 应用提取到 app.js 文件中,并且改变了 index.js 文件的角色,使用 Node 内置的 http 对象在指定端口启动应用。
const app = require('./app')
const http = require('http')
const config = require('./utils/config')
const logger = require('./utils/loger')
const server = http.createServer(app)
server.listen(config.PORT, () => {
logger.info(`Server running on port ${config.PORT}`)
})
测试端只使用 app.js 文件中定义的 express 应用:
const mongoose = require('mongoose')
const supertest = require('supertest')
const app = require('../app')
const api = supertest(app)
// ...
supertest 的文档说明如下:
if the server is not already listening fot connection then it is bound to an ephmeral port for you so ther is no need to keep track of ports.
换句话说,supertest 负责在内部使用端口启动被测试的应用。
执行每个 test 之前使用 beforeEach
函数初始化数据库。
所有的 test 执行结束后使用 afterAll
函数关闭数据库连接。
const mongoose = require('mongoose');
const supertest = require('supertest');
const app = require('../app');
const api = supertest(app);
const Note = require('../models/note');
const initialNotes = [
{
content: 'HTML is easy',
date: new Date(),
important: false,
},
{
content: 'Browser can excute only JavaScript',
date: new Date(),
important: true
}
];
/* eslint-disable no-undef*/
beforeEach(async () => {
await Note.deleteMany({});
let noteObject = new Note(initialNotes[0]);
await noteObject.save();
noteObject = new Note(initialNotes[1]);
await noteObject.save();
});
describe('REST API test', () => {
test('notes are returned as json', async () => {
await api
.get('/api/notes')
.expect(200)
.expect('Content-Type', /json/);
});
test('all notes are returned', async () => {
const response = await api.get('/api/notes');
expect(response.body).toHaveLength(initialNotes.length);
});
test('a specific note is within the returned notes', async () => {
const response = await api.get('/api/notes');
const contents = response.body.map(r => r.content);
expect(contents).toContain('HTML is easy');
});
});
afterAll(() => {
mongoose.connection.close();
});
Running tests one by one
npm test 命令执行应用的所有测试。通常明智的做法是一次只执行一个或两个测试。
only
方法test.only(string,fn)
如果测试是跨多个文件编写的,这种方法不是很友好。
使用下面的命令只运行 tests/note_api.test.js 文件中的测试
npm test -- tests/note_api.test.js
-t 选项可用于具有特定名称的测试
npm test -- -t 'a specific note is within the returned notes'
-t 提供的参数可以引用测试或描述块的名称。参数也可以包含名称的一部分,下面的命令将运行名称中包含 notes 的所有测试;
npm test -- -t 'notes'
注意: 当运行单个测试时,如果运行的测试没有使用该连接,则 mongoose 连接可能保持打开状态。
这个问题可能是因为 supertest 为连接优先,但是 jest 并不运行代码的 afterAll 部分。
提取相同测试步骤到辅助模块中
在很多地方使用的相同的测试步骤,最好提取到辅助模块中。
test('a valid note can be added', async () => {
const newNote = {
content: 'async/await simplifies making async calls',
important: true,
}
await api
.post('/api/notes')
.send(newNote)
.expect(200)
.expect('Content-Type', /application\/json/)
const response = await api.get('/api/notes')
const contents = response.body.map(r => r.content)
expect(response.body).toHaveLength(initialNotes.length + 1)
expect(contents).toContain(
'async/await simplifies making async calls'
)
})
test('note without content is not added', async () => {
const newNote = {
important: true
}
await api
.post('/api/notes')
.send(newNote)
.expect(400)
const response = await api.get('/api/notes')
expect(response.body).toHaveLength(initialNotes.length)
})
这两个测试都通过获取应用的所有便笺来检查保存操作之后存储在数据库中的状态。
const response = await api.get('/api/notes')
在 tests 目录下创建 test_helper.js 辅助函数,保存相同的测试步骤
const Note = require('../models/note')
const initialNotes = [
{
content: 'HTML is easy',
date: new Date(),
important: false
},
{
content: 'Browser can execute only Javascript',
date: new Date(),
important: true
}
]
const nonExistingId = async () => {
const note = new Note({ content: 'willremovethissoon',date: new Date() })
await note.save()
await note.remove()
return note._id.toString()
}
const notesInDb = async () => {
const notes = await Note.find({})
return notes.map(note => note.toJSON())
}
module.exports = {
initialNotes, nonExistingId, notesInDb
}
Methods
- afterAll(fn, timeout)
- afterEach(fn, timeout)
- beforeAll(fn, timeout)
- beforeEach(fn, timeout)
- describe(name, fn)
- describe.only(name, fn)
- describe.skip(name, fn)
- test(name, fn, timeout)
- test.only(name, fn, timeout)
- test.skip(name, fn)
- test.todo(name)
logs
- Your test suite must contain at least one test.