Написание хорошо структурированных модульных тестов на TypeScript

Цель этой заметки — рассказать о реализации написания модульных тестов с помощью 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

Оцените статью
devanswers.ru
Добавить комментарий