使用 TypeScript 编写结构良好的单元测试
这篇文章的目的是探索在Sequelize和TypeScript项目中使用JavaScript 测试框架Jest编写单元测试的实现。
设置项目
让我们使用NPM和Git Versioning创建一个新的品牌项目。
mkdir my-project
cd /my-project
git init
npm init
然后我们将安装一些依赖项,我们将使用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
由于我们使用 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',
],
};
好的,现在我们开始写代码。
编写代码
我们将遵循一个设计模式,包含一个模型、一个存储库、一个数据库库和一个服务。它会尽可能简单,以便我们能够编写简单且覆盖面广的单元测试。项目结构如下:
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 创建一个扩展了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
}
);
太棒了,接下来我们将创建一个存储库层。它是一种抽象数据访问的策略。它提供了几种与模型交互的方法。
//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 扩展模型 - https://sequelize.org/docs/v6/core-concepts/model-basics/#extending-model
- 存储库和服务层之间的区别 - https://stackoverflow.com/questions/5049363/difference-between-repository-and-service-layer
- 单元测试和安排、操作和断言 (AAA) 模式 - https://medium.com/@pjbgf/title-testing-code-ocd-and-the-aaa-pattern-df453975ab80