Цель этой заметки — рассказать о реализации написания модульных тестов с помощью Jest, JavaScript-фреймворка для тестирования, в проекте Sequelize и TypeScript.
Настройка проекта
Давайте создадим новый проект бренда, используя NPM и Git Versioning.
mkdir my-project
cd /my-project
git init
npm init
Затем мы установим некоторые зависимости, мы будем использовать babel для запуска Jest с помощью TypeScript
npm install --save sequelize pg pg-hstore
npm install --save-dev typescript ts-node jest babel-jest @types/sequelize @types/jest @babel/preset-typescript @babel/preset-env @babel/core
Поскольку мы используем TypeScript, нам нужно создать tsconfig.json
, чтобы указать, как транскрибировать файлы TypeScript из папок src в dist.
//tsconfig.json
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"target": "es2017",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": false,
"strict": true,
"baseUrl": ".",
"typeRoots": ["node_modules/@types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.test.ts"]
}
Затем нам нужно добавить babel.config.js
в папку проекта, чтобы мы могли запускать юнит-тест напрямую.
//babel.config.js
module.exports = {
presets: [
['@babel/preset-env', {targets: {node: 'current'}}],
'@babel/preset-typescript',
],
};
Хорошо, теперь давайте начнем писать код.
Написание кода
Мы будем следовать шаблону проектирования с моделью, хранилищем, базой данных lib и сервисом. Все будет как можно проще, чтобы мы могли написать простые модульные тесты с полным покрытием. Структура проекта будет выглядеть следующим образом
my-project/
├──src/
| ├──bookModel.ts
| ├──bookRepo.test.ts
| ├──bookRepo.ts
| ├──bookService.test.ts
| ├──bookService.ts
| └──database.ts
├──babel.config.js
├──package.json
└──tsconfig.json
Во-первых, нам нужно создать database.ts
, это либа подключения к базе данных в Sequelize.
//database.ts
import { Sequelize } from 'sequelize';
export const db: Sequelize = new Sequelize(
<string>process.env.DB_NAME,
<string>process.env.DB_USER,
<string>process.env.DB_PASSWORD,
{
host: <string>process.env.DB_HOST,
dialect: 'postgres',
logging: console.log
}
);
Теперь давайте определим модель. Модели — это суть Sequelize. Модель — это абстракция, которая представляет таблицу в вашей базе данных. В Sequelize это класс, который расширяет Model. Мы создадим одну модель с помощью Sequelize, расширяющего класс Model, представляющий модель книги.
//bookModel.ts
import { db } from './database';
import { Model, DataTypes, Sequelize } from 'sequelize';
export default class Book extends Model {}
Book.init(
{
id: {
primaryKey: true,
type: DataTypes.BIGINT,
autoIncrement: true
},
title: {
type: DataTypes.STRING,
allowNull: false
},
author: {
type: DataTypes.STRING,
allowNull: false
},
page: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
publisher: {
type: DataTypes.STRING
},
quantity: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0
},
created_at: {
type: DataTypes.DATE,
defaultValue: Sequelize.fn('now'),
allowNull: false
},
updated_at: {
type: DataTypes.DATE,
defaultValue: Sequelize.fn('now'),
allowNull: false
}
},
{
modelName: 'books',
freezeTableName: true,
createdAt: false,
updatedAt: false,
sequelize: db
}
);
Далее мы создадим слой репозитория. Это стратегия абстрагирования доступа к данным. Он предоставляет несколько методов для взаимодействия с моделью.
//bookRepo.ts
import Book from './bookModel';
class BookRepo {
getBookDetail(bookID: number): Promise<Book | null> {
return Book.findOne({
where: {
id: bookID
}
});
}
removeBook(bookID: number): Promise<number> {
return Book.destroy({
where: {
id: bookID
}
});
}
}
export default new BookRepo();
Затем мы создадим слой сервисов. Он состоит из бизнес-логики приложения и может использовать хранилище для реализации определенной логики, связанной с базой данных.
Лучше иметь отдельный слой хранилища и слой сервиса. Наличие отдельных слоев делает код более модульным и отделяет базу данных от бизнес-логики.
//bookService.ts
import BookRepo from './bookRepo';
import Book from './bookModel';
class BookService {
getBookDetail(bookId: number): Promise<Book | null> {
return BookRepo.getBookDetail(bookId);
}
async removeBook(bookId: number): Promise<number> {
const book = await BookRepo.getBookDetail(bookId);
if (!book) {
throw new Error('Book is not found');
}
return BookRepo.removeBook(bookId);
}
}
export default new BookService();
Итак, мы закончили с бизнес-логикой. Мы не будем писать контроллер и маршрутизатор, потому что хотим сосредоточиться на том, как писать модульные тесты.
Написание модульного теста
Теперь мы напишем модульный тест для хранилища и сервисного уровня. Для написания модульного теста мы будем использовать паттерн AAA (Arrange-Act-Assert).
Паттерн AAA предполагает, что мы должны разделить наш метод тестирования на три секции: arrange, act и assert. Каждый из них отвечает только за ту часть, в честь которой он назван. Следование этому паттерну действительно делает код достаточно хорошо структурированным и легким для понимания.
Давайте напишем модульный тест. Мы передразним метод из bookModel, чтобы изолировать и сосредоточиться на тестируемом коде, а не на поведении или состоянии внешних зависимостей. Затем мы утвердим модульный тест в некоторых случаях, таких как: должен быть равен, должен быть вызван несколько раз и должен быть вызван с некоторыми параметрами.
//bookRepo.test.ts
import BookRepo from './bookRepo';
import Book from './bookModel';
describe('BookRepo', () => {
beforeEach(() =>{
jest.resetAllMocks();
});
describe('BookRepo.__getBookDetail', () => {
it('should return book detail', async () => {
//arrange
const bookID = 1;
const mockResponse = {
id: 1,
title: 'ABC',
author: 'John Doe',
page: 1
}
Book.findOne = jest.fn().mockResolvedValue(mockResponse);
//act
const result = await BookRepo.getBookDetail(bookID);
//assert
expect(result).toEqual(mockResponse);
expect(Book.findOne).toHaveBeenCalledTimes(1);
expect(Book.findOne).toBeCalledWith({
where: {
id: bookID
}
});
});
});
describe('BookRepo.__removeBook', () => {
it('should return true remove book', async () => {
//arrange
const bookID = 1;
const mockResponse = true;
Book.destroy = jest.fn().mockResolvedValue(mockResponse);
//act
const result = await BookRepo.removeBook(bookID);
//assert
expect(result).toEqual(mockResponse);
expect(Book.destroy).toHaveBeenCalledTimes(1);
expect(Book.destroy).toBeCalledWith({
where: {
id: bookID
}
});
});
});
});
Затем мы напишем модульный тест для сервисного уровня. Как и для слоя хранилища, в тесте для сервисного слоя мы будем имитировать слой хранилища, чтобы изолировать и сосредоточиться на тестируемом коде.
//bookService.test.ts
import BookService from './bookService';
import BookRepo from './bookRepo';
describe('BookService', () => {
beforeEach(() =>{
jest.resetAllMocks();
});
describe('BookService.__getBookDetail', () => {
it('should return book detail', async () => {
//arrange
const bookID = 1;
const mockResponse = {
id: 1,
title: 'ABC',
author: 'John Doe',
page: 1
};
BookRepo.getBookDetail = jest.fn().mockResolvedValue(mockResponse);
//act
const result = await BookService.getBookDetail(bookID);
//assert
expect(result).toEqual(mockResponse);
expect(BookRepo.getBookDetail).toHaveBeenCalledTimes(1);
expect(BookRepo.getBookDetail).toBeCalledWith(bookID);
});
});
describe('BookService.__removeBook', () => {
it('should return true remove book', async () => {
//arrange
const bookID = 2;
const mockBookDetail = {
id: 2,
title: 'ABC',
author: 'John Doe',
page: 1
};
const mockResponse = true;
BookRepo.getBookDetail = jest.fn().mockResolvedValue(mockBookDetail);
BookRepo.removeBook = jest.fn().mockResolvedValue(mockResponse);
//act
const result = await BookService.removeBook(bookID);
//assert
expect(result).toEqual(mockResponse);
// assert BookRepo.getBookDetail
expect(BookRepo.getBookDetail).toHaveBeenCalledTimes(1);
expect(BookRepo.getBookDetail).toBeCalledWith(bookID);
//assert BookRepo.removeBook
expect(BookRepo.removeBook).toHaveBeenCalledTimes(1);
expect(BookRepo.removeBook).toBeCalledWith(bookID);
});
it('should throw error book is not found', () => {
//arrange
const bookID = 2;
const mockBookDetail = null;
const errorMessage = 'Book is not found';
BookRepo.getBookDetail = jest.fn().mockResolvedValue(mockBookDetail);
//act
const result = BookService.removeBook(bookID);
//assert
expect(result).rejects.toThrowError(errorMessage);
expect(BookRepo.getBookDetail).toHaveBeenCalledTimes(1);
expect(BookRepo.getBookDetail).toBeCalledWith(bookID);
});
});
});
Итак, мы закончили написание модульного теста.
Перед запуском теста мы добавим скрипт теста в наш package.json следующим образом:
//package.json
...
"scripts": {
"build": "tsc",
"build-watch": "tsc -w",
"test": "jest --coverage ./src"
},
...
Наконец, мы можем запустить тест с помощью этой команды в терминале:
npm test
После запуска мы получим вот такой результат, говорящий о том, что наш юнит-тест прошел успешно и имеет полное покрытие 🎉.
Прекрасно! ✨
Ссылки:
- Sequelize Extending Model — https://sequelize.org/docs/v6/core-concepts/model-basics/#extending-model
- Разница между репозиторием и сервисным уровнем — https://stackoverflow.com/questions/5049363/difference-between-repository-and-service-layer
- Юнит-тестирование и паттерн Arrange, Act and Assert (AAA) — https://medium.com/@pjbgf/title-testing-code-ocd-and-the-aaa-pattern-df453975ab80