Psaní dobře strukturovaného testu jednotek v TypeScriptu

Účelem tohoto příspěvku je objevit implementaci testu jednotek pomocí Jest, testovacího rámce JavaScriptu, v projektu Sequelize a TypeScript.

Nastavení projektu

Pojďme vytvořit nový projekt značky pomocí NPM a Git Versioning.

mkdir my-project
cd /my-project
git init
npm init

Poté nainstalujeme nějaké závislosti, použijeme babel pro spuštění Jestu pomocí TypeScriptu

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

Protože používáme TypeScript, musíme vytvořit tsconfig.json označující, jak přepisovat soubory TypeScript z src vzdálit složky.

//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"]
}

Potom musíme přidat babel.config.js ve složce projektu, takže můžeme spustit test jednotky přímo.

//babel.config.js
module.exports = {
    presets: [
        ['@babel/preset-env', {targets: {node: 'current'}}],
        '@babel/preset-typescript',
    ],
};

Dobře, teď začneme psát kód.

Napište kód

Budeme postupovat podle vzoru návrhu s modelem, úložištěm, databázovou knihovnou a službou . Bude to co nejjednodušší, takže bychom mohli napsat jednoduchý unit test s plným pokrytím. Struktura projektu bude taková

my-project/
├──src/
|   ├──bookModel.ts
|   ├──bookRepo.test.ts
|   ├──bookRepo.ts
|   ├──bookService.test.ts
|   ├──bookService.ts
|   └──database.ts
├──babel.config.js
├──package.json
└──tsconfig.json

Nejprve musíme vytvořit database.ts , je to knihovna pro připojení k databázi v 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
    }
);

Nyní definujme model. Modely jsou podstatou Sequelize . Model je abstrakce, která představuje tabulku ve vaší databázi. V Sequelize je to třída, která rozšiřuje Model. Vytvoříme jeden model pomocí Sequelize rozšiřujícího Class Model reprezentujícího Book 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
    }
);

Super, dále vytvoříme vrstvu úložiště. Je to strategie pro abstrakce přístupu k datům . Poskytuje několik metod pro interakci s modelem.

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

Poté vytvoříme servisní vrstvu. Skládá se z obchodní logiky aplikace a může použít úložiště k implementaci určité logiky zahrnující databázi.
Je lepší mít oddělenou vrstvu úložiště a vrstvu služeb. Díky samostatným vrstvám je kód modulárnější a odděluje databázi od obchodní logiky.

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

Dobře, skončili jsme s obchodní logikou. Nebudeme psát řadič a router, protože se chceme zaměřit na to, jak napsat test jednotky.

Test jednotky zápisu

Nyní napíšeme unit test pro repozitář a servisní vrstvu. Pro psaní unit testu použijeme vzor AAA (Arrange-Act-Assert).
Vzor AAA naznačuje, že bychom měli naši testovací metodu rozdělit do tří částí:uspořádat, jednat a prosadit . Každý z nich je zodpovědný pouze za tu část, po které je pojmenován. Podle tohoto vzoru je kód docela dobře strukturovaný a snadno srozumitelný.

Napíšeme unit test. Budeme se vysmívat metodě z bookModel, abychom izolovali a zaměřili se na testovaný kód a ne na chování nebo stav externích závislostí. Potom v některých případech uplatníme test jednotek, jako je mělo by se rovnat , mělo být voláno vícekrát a měly být volány s některými parametry .

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

Poté napíšeme unit test pro servisní vrstvu. Stejně jako vrstvu úložiště budeme v testu servisní vrstvy zesměšňovat vrstvu úložiště, abychom izolovali a zaměřili se na testovaný kód.

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

Dobře, dokončili jsme psaní testu jednotky.
Před spuštěním testu přidáme test skriptu do našeho package.json jak následuje:

//package.json
...
"scripts": {
    "build": "tsc",
    "build-watch": "tsc -w",
    "test": "jest --coverage ./src"
},
...

Super, konečně můžeme spustit test pomocí tohoto příkazu v našem terminálu:

npm test

Po spuštění získáme tento výsledek, že náš test jednotky je úspěšný a plně pokrytý 🎉


Krásná! ✨

Odkazy:

  • Sequelize Extending Model – https://sequelize.org/docs/v6/core-concepts/model-basics/#extending-model
  • Rozdíl mezi úložištěm a vrstvou služby – https://stackoverflow.com/questions/5049363/difference-between-repository-and-service-layer
  • Unit Testing and the Arrange, Act and Assert (AAA) Pattern – https://medium.com/@pjbgf/title-testing-code-ocd-and-the-aaa-pattern-df453975ab80