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

2025-06-04

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

作者:Godwin Ekuma✏️

测试有助于记录应用程序的核心功能。正确编写的测试可确保新功能不会引入导致应用程序崩溃的变更。

维护代码库的工程师不一定是编写初始代码的工程师。如果代码经过了适当的测试,其他工程师可以放心地添加新代码或修改现有代码,并且保证新的更改不会破坏其他功能,或者至少不会对其他功能造成副作用。

JavaScript 和 Node.js 拥有众多测试和断言库,例如JestJasmineQunitMocha。本文将介绍如何使用Mocha进行测试、如何使用Chai进行断言以及如何使用Sinon进行模拟、侦听和存根。

摩卡

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

Mocha 有很多有趣的功能:

  • 浏览器支持
  • 简单的异步支持,包括承诺
  • 测试覆盖率报告
  • 异步测试超时支持
  • before、、、钩子afterbeforeEachafterEach

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

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

西农

通常,被测试的方法需要与其他外部方法交互或调用。因此,您需要一个工具来监听、存根或模拟这些外部方法。Sinon 正是为您量身打造的。

当依赖代码发生变化或内部发生修改时,存根、模拟和间谍可以使测试更加健壮,并且更不容易被破坏。

间谍

间谍是一个伪函数,它跟踪所有调用的参数、返回值、值this和抛出的异常(如果有)

存根

存根(stub)是具有预定行为的间谍。

我们可以使用存根来:

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

嘲笑

模拟是一种伪函数(像间谍) 具有预先编程的行为(像存根)以及预先编程的期望。

我们可以使用模拟来:

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

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

LogRocket 免费试用横幅

编写测试

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

项目设置

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

mkdir mocha-unit-test && cd mocha-unit-test
mkdir src
Enter fullscreen mode Exit fullscreen mode

在源文件夹中创建一个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"
  }
}
Enter fullscreen mode Exit fullscreen mode

运行npm install以安装项目依赖项。

请注意,与测试相关的包mocha、、保存在 dev-dependencies 中。chaisinonfaker

test脚本使用自定义的glob( )来配置测试文件的文件路径。Mocha 将在文件夹的目录和子目录中./src/**/*.test.js查找测试文件(以 结尾的文件) .test.jssrc

存储库、服务和控制器

我们将使用控制器、服务和存储模式构建我们的应用程序,以便我们的应用程序被分解为存储库、服务和控制器。存储库-服务-控制器模式将应用程序的业务层分解为三个不同的层:

  • 存储库类负责处理数据从数据存储中存取。存储库位于服务层和模型层之间。例如,在 中,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;
Enter fullscreen mode Exit fullscreen mode

该类UserRepository有两个方法,creategetUsercreate方法将新用户添加到数据库,而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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

上面的代码正在测试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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

为了测试该getUser方法,我们还必须使用 stub UserModel.findone。我们使用expect(stub.calledOnce).to.be.truestub 断言该 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;
Enter fullscreen mode Exit fullscreen mode

该类UserService还包含两个方法creategetUser。 该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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

上面的代码正在测试该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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

我们再次对该方法进行存根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;
Enter fullscreen mode Exit fullscreen mode

该类还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);
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

在前三个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();
    });
  });
});
Enter fullscreen mode Exit fullscreen mode

为了进行getUser测试,我们对该方法进行了模拟。注意,在创建新实例时,json我们还必须使用间谍程序UserRepositoryUserService

结论

使用以下命令运行测试:

npm test
Enter fullscreen mode Exit fullscreen mode

您应该看到测试通过:

mocha chai 中通过的单元测试

我们已经了解了如何结合使用MochaChaiSinon来为 Node 应用程序创建健壮的测试。请务必查看它们各自的文档,以拓宽您对这些工具的了解。如有任何问题或意见,请在下方评论区留言。


编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本

插件:LogRocket,一个用于 Web 应用的 DVR

 
LogRocket 仪表板免费试用横幅
 
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
PREV
开发者如何摆脱拖延症 引言 1. 设定目标。 2. 找出一天中哪个时间段效率更高。 3. 排除干扰 4. 尝试番茄工作法 5. 养成晨间习惯 书籍推荐 结论
NEXT
React 中拖放的终极指南