使用Mocha,Chai和Sinon对Node.js应用程序进行单元测试

英文原文: https://blog.logrocket.com/unit-testing-node-js-applications-using-mocha-chai-and-sinon/

测试有助于记录应用程序的核心功能。正确编写的测试可确保新功能不会引入会破坏应用程序的更改。

维护代码库的工程师不一定与编写初始代码的工程师相同。如果对代码进行了正确的测试,则另一位工程师可以放心地添加新代码或修改现有代码,并期望新更改不会破坏其他功能,或者至少不会对其他功能造成副作用。

JavaScript和Node.js有许多测试和断言库,例如Jest,Jasmine,Qunit和Mocha。但是,在本文中,我们将研究如何使用Mocha进行测试,如何使用Chai进行断言,以及如何使用Sinon进行模拟,密探和存根。

Mocha

Mocha是功能丰富的JavaScript测试框架,可在Node.js和浏览器中运行。它将测试封装在测试套件(描述块)和测试用例(it-block)中。

摩卡咖啡具有许多有趣的功能:

  • 浏览器支持
  • 简单的异步支持,包括承诺
  • 测试覆盖率报告
  • 异步测试超时支持
  • before,after,beforeEach,afterEach钩等

Chai

为了进行相等性检查或将预期结果与实际结果进行比较,我们可以使用Node.js内置的断言模块。但是,当发生错误时,测试用例仍将通过。因此,Mocha建议使用其他断言库,在本教程中,我们将使用Chai。

Chai公开了三个断言接口:Expect(),assert()和should()。它们中的任何一个都可以用于断言。

Sinon

通常,正在测试的方法需要与其他外部方法进行交互或调用。因此,您需要一个实用程序来监视,存根或模拟那些外部方法。这正是Sinon为你做的。

存根,模拟和密探程序可以使测试更加健壮,并且在依赖代码不断发展或内部结构被修改的情况下,不易损坏。

Spy

一个Spy是假的函数,跟踪参数,返回值,值this和抛出异常(如果有的话)的所有calls。

Stub

stub是具有预定行为密探。

我们可以使用存根来执行以下操作:

  • 采取预定动作,例如引发异常
  • 提供预定的响应
  • 防止直接调用特定方法(尤其是当它触发诸如HTTP请求之类的不良行为时)

Mock

Mock是假的函数(如spy)用以及预编程的期望预编程的行为(如一个stub)。

我们可以使用Mock来:

  • 验证被测代码与其调用的外部方法之间的约定
  • 验证外部方法被调用的次数正确
  • 验证是否使用正确的参数调用了外部方法

模拟的经验法则是:如果您不打算为某个特定的调用添加断言,请不要模拟它。请改用存根。

Writing tests

为了演示我们上面解释的内容,我们将构建一个简单的节点应用程序来创建和检索用户。可以在CodeSandbox上找到本文的完整代码示例。

Project setup

让我们为用户应用程序项目创建一个新的项目目录:

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以安装项目依赖项。

请注意,测试相关的软件包mochachaisinon,并faker保存在dev-dependencies。

储存库,服务和控制器

我们将使用控制器,服务和仓库模式来构建应用程序,以便将我们的应用程序分解为仓库,服务和控制器。Repository-Service-Controller模式将应用程序的业务层分为三个不同的层:

  • 信息库类处理将数据移入和移出数据存储区的过程。在服务层和模型层之间使用存储库。例如,在中,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);
    });
  });
});

上面的代码测试了UserRepository的创建方法。注意,我们正在存根化UserModel。创建方法。存根是必需的,因为我们的目标是测试存储库,而不是模型。我们使用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方法,我们还必须存根UserModel.findone。我们使用expect .to.be (stub.calledOnce)。为真,以断言该存根至少被调用一次。其他断言检查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 repository方法,将新用户的姓名和电子邮件作为参数传递。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创建方法。我们已经为存储库创建方法创建了一个存根。下面的代码将测试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班有registergetUser方法为好。这些方法中的每一个都接受两个参数reqres对象。

// 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();
    });
  });
});

对于json方法模拟的getUser测试。请注意,在创建UserService的新实例时,我们还必须在适当的位置使用一个间谍UserRepository

结论

使用以下命令运行测试:

npm test

您应该看到测试通过:

我们已经看到了如何结合使用Mocha,Chai和Sinon来为节点应用程序创建可靠的测试。

英文原文: https://blog.logrocket.com/unit-testing-node-js-applications-using-mocha-chai-and-sinon/

李, 国轩