使用 Mocha、Chai 和 Sinon 对 Node.js 应用程序进行单元测试
作者:Godwin Ekuma✏️
测试有助于记录应用程序的核心功能。正确编写的测试可确保新功能不会引入导致应用程序崩溃的变更。
维护代码库的工程师不一定是编写初始代码的工程师。如果代码经过了适当的测试,其他工程师可以放心地添加新代码或修改现有代码,并且保证新的更改不会破坏其他功能,或者至少不会对其他功能造成副作用。
JavaScript 和 Node.js 拥有众多测试和断言库,例如Jest、Jasmine、Qunit和Mocha。本文将介绍如何使用Mocha进行测试、如何使用Chai进行断言以及如何使用Sinon进行模拟、侦听和存根。
摩卡
Mocha是一个功能丰富的 JavaScript 测试框架,可在 Node.js 和浏览器中运行。它将测试封装在测试套件(describe-block)和测试用例(it-block)中。
Mocha 有很多有趣的功能:
- 浏览器支持
- 简单的异步支持,包括承诺
- 测试覆盖率报告
- 异步测试超时支持
before
、、、钩子等after
beforeEach
afterEach
柴
为了进行相等性检查或将预期结果与实际结果进行比较,我们可以使用 Node.js 内置的断言模块。但是,即使发生错误,测试用例仍然会通过。因此,Mocha 建议使用其他断言库,在本教程中,我们将使用Chai。
Chai 公开了三个断言接口:expect()、assert() 和 should()。它们中的任何一个都可以用于断言。
西农
通常,被测试的方法需要与其他外部方法交互或调用。因此,您需要一个工具来监听、存根或模拟这些外部方法。Sinon 正是为您量身打造的。
当依赖代码发生变化或内部发生修改时,存根、模拟和间谍可以使测试更加健壮,并且更不容易被破坏。
间谍
间谍是一个伪函数,它跟踪所有调用的参数、返回值、值this
和抛出的异常(如果有)。
存根
存根(stub)是具有预定行为的间谍。
我们可以使用存根来:
- 采取预定的操作,例如抛出异常
- 提供预定的响应
- 防止直接调用特定方法(尤其是当它触发 HTTP 请求等不良行为时)
嘲笑
模拟是一种伪函数(像间谍) ,具有预先编程的行为(像存根)以及预先编程的期望。
我们可以使用模拟来:
- 验证被测代码与其调用的外部方法之间的契约
- 验证外部方法调用次数是否正确
- 验证是否使用正确的参数调用了外部方法
模拟的经验法则是:如果你不打算为某个特定的调用添加断言,就不要模拟它。改用存根 (Stub)。
编写测试
为了演示上面的内容,我们将构建一个简单的 Node 应用程序来创建和检索用户。本文的完整代码示例可以在CodeSandbox上找到。
项目设置
让我们为我们的用户应用程序项目创建一个新的项目目录:
mkdir mocha-unit-test && cd mocha-unit-test
mkdir src
在源文件夹中创建一个package.json
文件并添加以下代码:
// src/package.json
{
"name": "mocha-unit-test",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "mocha './src/**/*.test.js'",
"start": "node src/app.js"
},
"keywords": [
"mocha",
"chai"
],
"author": "Godwin Ekuma",
"license": "ISC",
"dependencies": {
"body-parser": "^1.18.3",
"dotenv": "^6.2.0",
"express": "^4.16.4",
"jsonwebtoken": "^8.4.0",
"morgan": "^1.9.1",
"pg": "^7.12.1",
"pg-hstore": "^2.3.3",
"sequelize": "^5.19.6"
},
"devDependencies": {
"chai": "^4.2.0",
"mocha": "^6.2.1",
"sinon": "^7.5.0",
"faker": "^4.1.0"
}
}
运行npm install
以安装项目依赖项。
请注意,与测试相关的包mocha
、、和保存在 dev-dependencies 中。chai
sinon
faker
该test
脚本使用自定义的glob( )来配置测试文件的文件路径。Mocha 将在文件夹的目录和子目录中./src/**/*.test.js
查找测试文件(以 结尾的文件) 。.test.js
src
存储库、服务和控制器
我们将使用控制器、服务和存储库模式构建我们的应用程序,以便我们的应用程序被分解为存储库、服务和控制器。存储库-服务-控制器模式将应用程序的业务层分解为三个不同的层:
- 存储库类负责处理数据从数据存储中存取。存储库位于服务层和模型层之间。例如,在 中,
UserRepository
您可以创建将用户信息写入数据库或从数据库读取用户信息的方法。 - 服务类调用存储库类,并可以组合它们的数据以形成新的、更复杂的业务对象。它是控制器和存储库之间的抽象。例如,服务类
UserService
将负责执行创建新用户所需的逻辑。 - 控制器包含很少的逻辑,用于调用服务。除非有正当理由,否则控制器很少直接调用存储库。控制器将对服务返回的数据执行基本检查,以便将响应发送回客户端。
通过这种方式分解应用程序可以使测试变得容易。
UserRepository 类
让我们首先创建一个存储库类:
// src/user/user.repository.js
const { UserModel } = require("../database");
class UserRepository {
constructor() {
this.user = UserModel;
this.user.sync({ force: true });
}
async create(name, email) {
return this.user.create({
name,
email
});
}
async getUser(id) {
return this.user.findOne({ id });
}
}
module.exports = UserRepository;
该类UserRepository
有两个方法,create
和getUser
。create
方法将新用户添加到数据库,而getUser
方法从数据库中搜索用户。
让我们测试userRepository
以下方法:
// src/user/user.repository.test.js
const chai = require("chai");
const sinon = require("sinon");
const expect = chai.expect;
const faker = require("faker");
const { UserModel } = require("../database");
const UserRepository = require("./user.repository");
describe("UserRepository", function() {
const stubValue = {
id: faker.random.uuid(),
name: faker.name.findName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
updatedAt: faker.date.past()
};
describe("create", function() {
it("should add a new user to the db", async function() {
const stub = sinon.stub(UserModel, "create").returns(stubValue);
const userRepository = new UserRepository();
const user = await userRepository.create(stubValue.name, stubValue.email);
expect(stub.calledOnce).to.be.true;
expect(user.id).to.equal(stubValue.id);
expect(user.name).to.equal(stubValue.name);
expect(user.email).to.equal(stubValue.email);
expect(user.createdAt).to.equal(stubValue.createdAt);
expect(user.updatedAt).to.equal(stubValue.updatedAt);
});
});
});
上面的代码正在测试create
的方法UserRepository
。请注意,我们正在对该UserModel.create
方法进行存根。存根是必要的,因为我们的目标是测试存储库而不是模型。我们使用faker
以下测试夹具:
// src/user/user.repository.test.js
const chai = require("chai");
const sinon = require("sinon");
const expect = chai.expect;
const faker = require("faker");
const { UserModel } = require("../database");
const UserRepository = require("./user.repository");
describe("UserRepository", function() {
const stubValue = {
id: faker.random.uuid(),
name: faker.name.findName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
updatedAt: faker.date.past()
};
describe("getUser", function() {
it("should retrieve a user with specific id", async function() {
const stub = sinon.stub(UserModel, "findOne").returns(stubValue);
const userRepository = new UserRepository();
const user = await userRepository.getUser(stubValue.id);
expect(stub.calledOnce).to.be.true;
expect(user.id).to.equal(stubValue.id);
expect(user.name).to.equal(stubValue.name);
expect(user.email).to.equal(stubValue.email);
expect(user.createdAt).to.equal(stubValue.createdAt);
expect(user.updatedAt).to.equal(stubValue.updatedAt);
});
});
});
为了测试该getUser
方法,我们还必须使用 stub UserModel.findone
。我们使用expect(stub.calledOnce).to.be.true
stub 断言该 stub 至少被调用一次。其他断言用于检查该getUser
方法的返回值。
UserService类
// src/user/user.service.js
const UserRepository = require("./user.repository");
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async create(name, email) {
return this.userRepository.create(name, email);
}
getUser(id) {
return this.userRepository.getUser(id);
}
}
module.exports = UserService;
该类UserService
还包含两个方法create
和getUser
。 该create
方法调用create
存储库方法,并将新用户的姓名和电子邮件作为参数传递。getUser
调用存储库getUser
方法。
让我们测试userService
以下方法:
// src/user/user.service.test.js
const chai = require("chai");
const sinon = require("sinon");
const UserRepository = require("./user.repository");
const expect = chai.expect;
const faker = require("faker");
const UserService = require("./user.service");
describe("UserService", function() {
describe("create", function() {
it("should create a new user", async function() {
const stubValue = {
id: faker.random.uuid(),
name: faker.name.findName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
updatedAt: faker.date.past()
};
const userRepo = new UserRepository();
const stub = sinon.stub(userRepo, "create").returns(stubValue);
const userService = new UserService(userRepo);
const user = await userService.create(stubValue.name, stubValue.email);
expect(stub.calledOnce).to.be.true;
expect(user.id).to.equal(stubValue.id);
expect(user.name).to.equal(stubValue.name);
expect(user.email).to.equal(stubValue.email);
expect(user.createdAt).to.equal(stubValue.createdAt);
expect(user.updatedAt).to.equal(stubValue.updatedAt);
});
});
});
上面的代码正在测试该UserService create
方法。我们为存储库方法创建了一个存根create
。下面的代码将测试getUser
服务方法:
const chai = require("chai");
const sinon = require("sinon");
const UserRepository = require("./user.repository");
const expect = chai.expect;
const faker = require("faker");
const UserService = require("./user.service");
describe("UserService", function() {
describe("getUser", function() {
it("should return a user that matches the provided id", async function() {
const stubValue = {
id: faker.random.uuid(),
name: faker.name.findName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
updatedAt: faker.date.past()
};
const userRepo = new UserRepository();
const stub = sinon.stub(userRepo, "getUser").returns(stubValue);
const userService = new UserService(userRepo);
const user = await userService.getUser(stubValue.id);
expect(stub.calledOnce).to.be.true;
expect(user.id).to.equal(stubValue.id);
expect(user.name).to.equal(stubValue.name);
expect(user.email).to.equal(stubValue.email);
expect(user.createdAt).to.equal(stubValue.createdAt);
expect(user.updatedAt).to.equal(stubValue.updatedAt);
});
});
});
我们再次对该方法进行存根UserRepository getUser
。我们还断言该存根至少被调用一次,然后断言该方法的返回值是正确的。
UserContoller 类
/ src/user/user.controller.js
class UserController {
constructor(userService) {
this.userService = userService;
}
async register(req, res, next) {
const { name, email } = req.body;
if (
!name ||
typeof name !== "string" ||
(!email || typeof email !== "string")
) {
return res.status(400).json({
message: "Invalid Params"
});
}
const user = await this.userService.create(name, email);
return res.status(201).json({
data: user
});
}
async getUser(req, res) {
const { id } = req.params;
const user = await this.userService.getUser(id);
return res.json({
data: user
});
}
}
module.exports = UserController;
该类还UserController
具有register
和getUser
方法。每个方法都接受两个参数req
和res
对象。
// src/user/user.controller.test.js
describe("UserController", function() {
describe("register", function() {
let status json, res, userController, userService;
beforeEach(() => {
status = sinon.stub();
json = sinon.spy();
res = { json, status };
status.returns(res);
const userRepo = sinon.spy();
userService = new UserService(userRepo);
});
it("should not register a user when name param is not provided", async function() {
const req = { body: { email: faker.internet.email() } };
await new UserController().register(req, res);
expect(status.calledOnce).to.be.true;
expect(status.args\[0\][0]).to.equal(400);
expect(json.calledOnce).to.be.true;
expect(json.args\[0\][0].message).to.equal("Invalid Params");
});
it("should not register a user when name and email params are not provided", async function() {
const req = { body: {} };
await new UserController().register(req, res);
expect(status.calledOnce).to.be.true;
expect(status.args\[0\][0]).to.equal(400);
expect(json.calledOnce).to.be.true;
expect(json.args\[0\][0].message).to.equal("Invalid Params");
});
it("should not register a user when email param is not provided", async function() {
const req = { body: { name: faker.name.findName() } };
await new UserController().register(req, res);
expect(status.calledOnce).to.be.true;
expect(status.args\[0\][0]).to.equal(400);
expect(json.calledOnce).to.be.true;
expect(json.args\[0\][0].message).to.equal("Invalid Params");
});
it("should register a user when email and name params are provided", async function() {
const req = {
body: { name: faker.name.findName(), email: faker.internet.email() }
};
const stubValue = {
id: faker.random.uuid(),
name: faker.name.findName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
updatedAt: faker.date.past()
};
const stub = sinon.stub(userService, "create").returns(stubValue);
userController = new UserController(userService);
await userController.register(req, res);
expect(stub.calledOnce).to.be.true;
expect(status.calledOnce).to.be.true;
expect(status.args\[0\][0]).to.equal(201);
expect(json.calledOnce).to.be.true;
expect(json.args\[0\][0].data).to.equal(stubValue);
});
});
});
在前三个it
代码块中,我们测试当未提供一个或两个必需参数(电子邮件和姓名)时,将不会创建用户。请注意,我们正在存根res.status
并监视res.json
:
describe("UserController", function() {
describe("getUser", function() {
let req;
let res;
let userService;
beforeEach(() => {
req = { params: { id: faker.random.uuid() } };
res = { json: function() {} };
const userRepo = sinon.spy();
userService = new UserService(userRepo);
});
it("should return a user that matches the id param", async function() {
const stubValue = {
id: req.params.id,
name: faker.name.findName(),
email: faker.internet.email(),
createdAt: faker.date.past(),
updatedAt: faker.date.past()
};
const mock = sinon.mock(res);
mock
.expects("json")
.once()
.withExactArgs({ data: stubValue });
const stub = sinon.stub(userService, "getUser").returns(stubValue);
userController = new UserController(userService);
const user = await userController.getUser(req, res);
expect(stub.calledOnce).to.be.true;
mock.verify();
});
});
});
为了进行getUser
测试,我们对该方法进行了模拟。注意,在创建新实例时,json
我们还必须使用间谍程序。UserRepository
UserService
结论
使用以下命令运行测试:
npm test
您应该看到测试通过:
我们已经了解了如何结合使用Mocha、Chai和Sinon来为 Node 应用程序创建健壮的测试。请务必查看它们各自的文档,以拓宽您对这些工具的了解。如有任何问题或意见,请在下方评论区留言。
编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本。
插件:LogRocket,一个用于 Web 应用的 DVR
LogRocket是一款前端日志工具,可让您重播问题,就像它们发生在您自己的浏览器中一样。无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 让您重播会话,快速了解问题所在。它可与任何应用程序完美兼容,不受框架限制,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的额外上下文。
除了记录 Redux 操作和状态外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
免费试用。
使用 Mocha、Chai 和 Sinon 对 Node.js 应用程序进行单元测试一文首先出现在LogRocket 博客上。
文章来源:https://dev.to/bnevilleoneill/unit-testing-node-js-applications-using-mocha-chai-and-sinon-3658