使用 TypeScript 编写结构良好的单元测试

2025-05-27

使用 TypeScript 编写结构良好的单元测试

这篇文章的目的是探索在SequelizeTypeScript项目中使用JavaScript 测试框架Jest编写单元测试的实现。

设置项目

让我们使用NPMGit Versioning创建一个新的品牌项目。



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


Enter fullscreen mode Exit fullscreen mode

然后我们将安装一些依赖项,我们将使用babel通过 TypeScript 运行 Jest



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


Enter fullscreen mode Exit fullscreen mode

由于我们使用 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"]
}


Enter fullscreen mode Exit fullscreen mode

然后,我们需要添加babel.config.js项目文件夹,这样我们就可以直接运行单元测试。



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


Enter fullscreen mode Exit fullscreen mode

好的,现在我们开始写代码。

编写代码

我们将遵循一个设计模式,包含一个模型、一个存储库、一个数据库库和一个服务。它会尽可能简单,以便我们能够编写简单且覆盖面广的单元测试。项目结构如下:



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


Enter fullscreen mode Exit fullscreen mode

首先,我们需要创建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
    }
);


Enter fullscreen mode Exit fullscreen mode

现在,让我们定义模型。模型是Sequelize 的精髓。模型是一种抽象概念,它代表数据库中的表。在 Sequelize 中,它是一个扩展了 Model 的类。我们将使用 Sequelize 创建一个扩展了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
    }
);


Enter fullscreen mode Exit fullscreen mode

太棒了,接下来我们将创建一个存储库层。它是一种抽象数据访问的策略。它提供了几种与模型交互的方法。



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


Enter fullscreen mode Exit fullscreen mode

然后我们将创建一个服务层。它包含应用程序的业务逻辑,并可能使用存储库来实现涉及数据库的某些逻辑。
最好将存储库层和服务层分开。使用单独的层可以使代码更加模块化,并将数据库与业务逻辑分离。



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


Enter fullscreen mode Exit fullscreen mode

好了,业务逻辑部分已经写完了。我们不会再写控制器和路由器了,因为我们想专注于编写单元测试。

编写单元测试

现在我们将为存储库和服务层编写单元测试。我们将使用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
                }
            });
        });
    });
});


Enter fullscreen mode Exit fullscreen mode

然后,我们将为服务层编写单元测试。与存储库层相同,我们将在服务层测试中模拟存储库层,以隔离并专注于被测代码。



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


Enter fullscreen mode Exit fullscreen mode

好了,我们已经完成了单元测试的编写。
在运行测试之前,我们将在package.json中添加脚本测试,如下所示:



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


Enter fullscreen mode Exit fullscreen mode

太棒了,最后我们可以在终端中用这个命令运行测试:



npm test


Enter fullscreen mode Exit fullscreen mode

运行后,我们将得到这个结果,告诉我们单元测试成功并且完全覆盖🎉

单元测试结果
漂亮!✨

链接:

文章来源:https://dev.to/arifintahu/writing-well-structed-unit-test-in-typescript-2hal
PREV
Web 浏览器的工作原理 - 解析 CSS(第 4 部分,附插图)⏳🌐
NEXT
API 项目模板,以 Typescript 编写