Тестирование Node.js с использованием Mocha, Chai и Sinon

от автора

Тесты помогают документировать основные функции приложения. Корректно написанные тесты гарантируют, что новые функции не внесут изменения, которые могут нарушить работу приложения.

Необязательно инженер, поддерживающий кодовую базу, сам писал исходный код. Если код хорошо протестирован, другой инженер сможет с уверенностью добавлять новый код или модифицировать существующий и при этом ожидать, что эти изменения не нарушат другие функции или, по крайней мере, не вызовут нежелательных побочных эффектов.

JavaScript и Node.js имеют множество библиотек тестирования и утверждений, таких как Jest, Jasmine, Qunit и Mocha. В этой статье мы рассмотрим, как использовать Mocha для тестирования, Chai для утверждений (ассертов) и Sinon для моков (mocks), шпионов (spies) и заглушек (stubs).

Содержание:

Что такое модульное тестирование?

Модульные (или юнит-) тесты проверяют, что функции работают, как ожидается, будучи изолированными от других компонентов приложения. Модульные тесты позволяют тестировать различные функции в приложении. Для их написания есть несколько причин:

  • Модульные тесты гарантируют, что код работает должным образом в различных условиях.

  • Модульные тесты помогают находить ошибки в коде на ранних стадиях разработки.

  • Поскольку любые тесты, заканчивающиеся ошибкой, выявляют дефектный код, написание модульных тестов способствует выработке доверия. Вы можете быть уверены, что ваш код функционален, если все тесты проходят.

Mocha

Mocha — это многофункциональный фреймворк для тестирования JavaScript, который работает как на Node.js, так и в браузере. Он структурирует тесты в тестовые наборы (блок describe) и тест-кейсы (блок it).

Mocha обладает множеством интересных возможностей:

  • Поддержка браузеров

  • Простая работа с асинхронностью, включая промисы

  • Отчёты о покрытии тестами

  • Поддержка таймаутов для асинхронных тестов

  • Хуки before, after, beforeEach, afterEach и другие

Зачем использовать Mocha?

Mocha используется уже много лет и является устоявшимся инструментом для тестирования. Она получает хорошую поддержку и имеет большую базу пользователей. Mocha немного сложнее в использовании по сравнению с некоторыми другими инструментами тестирования, но при правильном применении она может быть крайне мощной. Благодаря тому, что Mocha — это более зрелый продукт с более крупным сообществом разработчиков, она предлагает больше функциональности «из коробки».

Chai

Для проверки на равенство или сравнения ожидаемых результатов с фактическими, можно использовать встроенный модуль утверждений в Node.js. Однако при возникновении ошибки тесты всё равно могут пройти. Поэтому Mocha рекомендует использовать другие библиотеки для утверждений. В этом руководстве мы будем использовать Chai.

Chai предлагает три интерфейса для утверждений: expect(), assert(), и should(). Любой из них можно использовать для написания утверждений.

Sinon

Часто тестируемый метод должен взаимодействовать с другими внешними методами или вызывать их. Поэтому вам нужна утилита для шпионажа, заглушки или имитации этих внешних методов. Именно для этого и предназначен Sinon.

Стабы, моки и шпионы делают тесты более надёжными и менее подверженными поломкам, если код зависимостей изменится или будут изменены их внутренности.

Шпион

Шпион (spy) — это фейковая функция, которая для всех своих вызовов отслеживает:

  • Аргументы

  • Возвращаемое значение

  • Значение this

  • Исключение, если оно было выброшено

Стабы

Стаб (stub) — это шпион с заранее определённым поведением.

Стаб можно использовать для:

  • Выполнения заранее определённого действия — например, выбрасывания исключения.

  • Предоставления заранее определённого ответа.

  • Предотвращения прямого вызова конкретного метода (особенно когда это вызывает нежелательные действия, такие как HTTP-запросы).

Мок

Мок (mock) — это фейковая функция (как шпион) с заранее запрограммированным поведением (как у стаба), а также с заранее заданными ожиданиями. Мок можно использовать для:

  • Проверки контракта между тестируемым кодом и внешними методами, которые он вызывает.

  • Проверки того, что внешний метод был вызван нужное количество раз.

  • Проверки того, что внешний метод был вызван с правильными параметрами.

Общее правило для моков: если вы не собираетесь добавлять утверждение для конкретного вызова, не используйте мок. Вместо этого используйте стаб.

Создание примера приложения на Node.js

Для демонстрации того, что мы объяснили выше, мы создадим простое приложение на Node.js, которое создаёт и извлекает пользователя. Полный код для этой статьи можно найти на CodeSandbox.

Настройка проекта

Создадим новый каталог для нашего проекта приложения пользователя:

mkdir mocha-unit-test && cd mocha-unit-test mkdir src

Создайте файл package.json в папке src и добавьте в него следующий код:

// 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": {     "dotenv": "^6.2.0",     "express": "^4.18.2",     "jsonwebtoken": "^8.5.1",     "morgan": "^1.10.0",     "mysql2": "^2.3.3",     "pg": "^7.18.2",     "pg-hstore": "^2.3.4",     "sequelize": "^5.22.5"   },   "devDependencies": {     "chai": "^4.3.7",     "faker": "^4.1.0",     "mocha": "^10.2.0",     "sinon": "^15.0.1"   } }

Запустите команду npm install, чтобы установить зависимости проекта.

Обратите внимание, что пакеты для тестирования, такие как mocha, chai, sinon и faker, сохранены в разделе dev-dependencies.

Скрипт test использует кастомный шаблон поиска (glob) ./src/**/*.test.js для настройки пути к файлам тестов. Mocha будет искать тестовые файлы (файлы, заканчивающиеся на .test.js) в каталогах и подкаталогах папки src.

Репозитории, сервисы и контроллеры

Мы будем структурировать наше приложение с использованием паттерна контроллер-сервис-репозиторий, чтобы наше приложение было разделено на репозитории, сервисы и контроллеры. Этот паттерн разделяет бизнес-слой приложения на три отдельных слоя:

  1. Класс репозитория отвечает за извлечение данных из хранилища и запись в него. Репозиторий используется между слоем сервиса и слоем модели. Например, в UserRepository вы создадите методы для записи и чтения данных пользователя в/из базы данных.

  2. Класс сервиса вызывает класс репозитория и может комбинировать их данные для создания новых, более сложных бизнес-объектов. Это абстракция между контроллером и репозиторием. Например, UserService будет отвечать за выполнение логики, необходимой для создания нового пользователя.

  3. Контроллер содержит минимальное количество логики и используется для вызова сервисов. Контроллер редко вызывает репозитории напрямую, если на то нет уважительной причины. Контроллер будет выполнять базовые проверки данных, полученных от сервисов, чтобы отправить ответ обратно клиенту.

Разбиение приложения таким образом упрощает тестирование.

Класс 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, нам также нужно «заглушить» метод UserModel.findOne. Мы используем expect(stub.calledOnce).to.be.true для утверждения того, что стаб был вызван хотя бы один раз. Остальные утверждения проверяют, что значение, возвращённое методом 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 репозитория, передавая имя и email нового пользователя в качестве аргументов. Метод 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);     });  //Тестирование случая, когда пользователь отсутствует.     it("should return an empty object if no user matches the provided id", async function() {       const stubValue = {};       const userRepo = new UserRepository();       const stub = sinon.stub(userRepo, "getUser").returns(stubValue);       const userService = new UserService(userRepo);       const user = await userService.getUser(1);       expect(stub.calledOnce).to.be.true;       expect(user).to.deep.equal({})     });   }); });

В коде выше мы тестируем метод create сервиса UserService. Мы создаём стаб для метода 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);     });   }); });

Здесь мы снова используем стаб для метода getUser репозитория UserRepository. Мы также проверяем, что стаб был вызван хотя бы один раз и что возвращённое значение корректно.

Класс UserController

/ 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 мы тестируем, что пользователь не будет создан, если один или оба обязательных параметра (email и name) не переданы. Обратите внимание, что мы используем стаб для 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

Вы должны увидеть, что тесты прошли успешно.

unit tests passing in mocha chai

Модульные тесты успешно проходят в Mocha Chai.

Итак, мы рассмотрели, как можно использовать комбинацию Mocha, Chai и Sinon для создания надёжного теста для Node-приложения. Обязательно ознакомьтесь с их документацией, чтобы расширить свои знания о этих инструментах. 


В заключение упомянем, что тестировщикам-автоматизаторам могут быть полезны следующие открытые уроки:

  • 5 декабря: Основы Mocha Chai.
    Рассмотрим принципы написания тестов и узнаем, как использовать Chai для создания автоматизированных тестов на JavaScript. Напишем несколько Unit и API тестов. Записаться

  • 17 декабря: Перспективы и преимущества профессии автоматизатора тестирования на JavaScript в 2024 году. Записаться


ссылка на оригинал статьи https://habr.com/ru/articles/863094/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *