自动化测试是软件工程中的一个重要方面。在 JavaScript 生态系统中,TypeScript 通过在 JavaScript 中添加类型提供了额外的保障。在测试方面,Jest 一直是 JavaScript 的标准测试框架。
本指南将向你展示如何使用 Jest 为 TypeScript 应用编写有效的单元测试。
什么是 TypeScript?
根据官网的描述,TypeScript 是“带有类型语法的 JavaScript”。由微软开发的 TypeScript 是 JavaScript 的一个超集,具有强类型、面向对象的特性,并且拥有更好的 IDE 支持。
TypeScript 代码不能直接运行;代码以 TypeScript 编写(.ts 文件),然后通过 TypeScript 编译器将其编译为 JavaScript(.js 文件),编译后的代码才能执行。由于这种编译过程,你可以根据需要将 TypeScript 编译为较旧版本的 JavaScript。近年来,TypeScript 的流行度显著上升。根据 2022 年的《State of JS》调查,28% 的受访者一直使用 TypeScript 编写代码,而始终使用 JavaScript 编写代码的仅有 11%。TypeScript 的另一个优势是,它可以用于前端和后端(如 Node.js)应用程序。
在 JavaScript 这种动态语言中引入类型,使得软件在执行时更加安全。借助 TypeScript,在 IDE 中对类型的支持可以让软件工程师清楚地了解可用的内容。例如,使用 GitHub API 的类型化 SDK 时,你将准确知道在请求中发送哪些参数以及响应中会有哪些字段。必填参数和可选参数都会在类型中定义,帮助你快速找到答案,而无需从文档中搜索。接下来,我们将讨论自动化测试的重要性。
为什么自动化测试很重要?
通常,任何编写的代码都会被测试,关键在于何时以及如何测试代码的输出。测试 Web 应用程序最基本的方法是通过 Web 浏览器访问 URL,然后通过目视验证输出是否正确。通常,软件工程师编写代码后会重新加载浏览器标签页,以查看结果是否符合预期。这是一种手动测试形式。
根据项目的规模和资源的分配,可能会有专门的质量保证(QA)工程师或部门在软件发布前进行这种类型的测试。使用功能开关可以帮助减少如果出错时的影响范围(功能开关将在另一篇文章中讨论)。
手动测试的问题在于反馈周期太长。工程师需要修改代码,切换到浏览器,刷新页面,查看代码修改是否生效。这时,自动化单元测试就非常有用。如果设置正确,并且软件工程师实践测试驱动开发(TDD),他们会先编写测试,然后编写代码。这也被称为 TDD 的“红绿重构”循环。
首先,作为软件工程师,你会编写一个失败的测试(红色),因为此时尚未编写执行工作的代码;接着编写最少的代码使测试通过(绿色)。然后,在不破坏单元测试的前提下重构代码并/或编写实际的实现。
需要注意的是,单元测试只测试一段代码。通常,这段代码是一个函数,因此测试非常专注、快速,并且不依赖于外部因素(如网络和文件系统)。如果测试以监听模式运行,当代码发生变化时会重新运行测试,反馈周期可以缩短到毫秒级。如果在 IDE 中运行测试,几乎不需要进行上下文切换。这不仅提高了开发者的生产力,还提升了软件质量。
编写测试的最重要原因是减少到达最终用户的 bug 数量。为实现这一主要目标,有多种形式和层次的测试可以帮助你。像 TDD 和单元测试这样的实践也有助于创建良好的开发者体验。自动化单元测试增加了代码按预期运行的信心。当你更改现有代码或添加新功能时,这也很有用,因为运行整个测试套件可以捕获引入的任何回归。
简而言之,自动化单元测试由于其快速的反馈循环更具优势,并且可以重复进行,且结果一致。如果你想提高软件质量,仅依赖手动测试是不可扩展的。通过自动化测试覆盖大部分用例,并通过手动测试验证主流程是否正常工作。
在 JavaScript 和 TypeScript 中,Jest 是使用最广泛的测试框架
示例应用
在本指南中,你将为一个使用 Express.js 构建的简单名言 API 编写测试。它将每页提供 10 条信息,最终输出将如下所示:
使用 Express.js 和 TypeScript 构建的 Quotes API 输出
这是一个简单的 API,它从数组中返回模拟的静态数据。由于本教程的重点是编写测试,因此它没有连接数据库。应用程序的结构如下,没有包含测试部分:
使用 Express.js 和 TypeScript 构建的 Quotes API 应用结构
TypeScript 的设置与本指南中进行的设置类似。大部分代码位于 src
文件夹中,其中包含 QuotesController
和 QuotesService
,分别位于 controllers
和 services
文件夹中。应用程序中有一个名为 Quote
的自定义类型,它包含 id
、quote
和 author
等属性。
应用程序的入口点是 index.js
,app.js
文件中有一个 App
类,它实例化了 Express 并将控制器和路由结合在一起。
所有代码都可以在此 GitHub 仓库 中查看,你也可以访问部署在 Render 上的工作示例应用程序。
此应用程序使用 TypeDI 进行依赖注入。此外,它还使用了 NPM 的 concurrently
包,以同时运行 TypeScript 编译器和 Nodemon 下的服务器,具体命令可以在 package.json
中查看。你可以通过运行以下命令克隆不带测试或 Jest 的分支:
git clone -b no-tests-or-jest git@github.com:geshan/typescript-jest.git
进入目录 cd typescript-jest
并使用 npm install
安装所需的 NPM 模块。要以开发模式运行项目,可以运行:
npm run dev
然后在浏览器的地址栏中输入 http://localhost:3000/api/quotes
,你将看到如下的结果:
单元测试与依赖注入的关系
单元测试专注于被测试的单元,即函数或类。单元测试中,任何外部的依赖都应该被模拟(mock)。这些依赖不仅包括网络调用和文件系统访问,还包括其他类的依赖或不同类的方法调用。这就是单元测试与依赖注入的交汇点。
依赖注入是一种设计模式,任何在类中使用的依赖项都不会在类内部实例化,而是从外部源注入。这个概念源自控制反转(IoC)范式,旨在创建松耦合的软件。通过依赖注入容器将任何依赖注入类中,可以轻松地为单元测试提供模拟类。
在下面的示例中,你将看到如何在测试 QuotesController
时注入并使用模拟的 QuotesService
类。
这就是为什么编写可测试代码是编写有用测试的基础。
依赖注入是编写可测试的面向对象代码的基石。在使用 Jest 和 TypeScript 的示例中,使用了 TypeDI 库进行依赖注入。你可以在这篇 TypeDI 教程 中学习更多相关知识。在下一部分中,你将安装、配置并使用 Jest 来为编写单元测试做准备。
安装 Jest
在代码层面上,你已经克隆了 no-tests-or-jest
分支。在此分支中没有任何测试,也没有安装 Jest。你将安装 Jest 并为 TypeScript 测试进行配置。
要安装支持 TypeScript 的 Jest,可以执行以下命令:
npm install -D jest @types/jest ts-jest
上述命令安装了 Jest 及其类型文件。Jest 是主要的测试库,同时还添加了 Jest 的相关类型文件。它还安装了 ts-jest
,这是一个带有源映射支持的转换器,用于帮助你使用 Jest 测试 TypeScript 项目。如果你感兴趣,可以阅读更多关于 ts-jest 的文档.
你可以在 package.json
文件中添加一个名为 jest
的新配置键来配置 Jest,配置如下:
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "./",
"testMatch": [
"/test/**/*.(spec|test).ts?(x)"
],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
],
"coverageReporters": [
"text",
"html"
],
"collectCoverageFrom": [
"**/src/**/**/*.{ts,tsx,js,jsx}",
"!app.ts",
"!index.ts",
"!dist/**"
],
"coverageDirectory": "/test/.coverage",
"testEnvironment": "node",
"setupFiles": ["/test/setup.ts"]
}
Jest 有许多配置选项。上面的配置首先定义了 moduleFileExtensions
,这是一个数组,用于定义应用模块中的文件扩展名。对于这个示例应用,.ts
、.js
和 .json
已经足够。接着,rootDir
被设置为项目的根目录 /
,Jest 配置文件和 package.json
都放在这里。之后,测试文件预计位于根目录的 /test
目录中,并带有 .spec.ts
或 .test.ts
后缀,同时支持 .tsx
文件扩展名。
Jest 会将项目的代码作为 JavaScript 运行,因此需要通过 ts-jest
将 TypeScript 代码编译为 JavaScript。之后,测试覆盖率报告将以文本和 HTML 格式提供。覆盖范围应用于 src
文件夹中的 .ts
、.tsx
、.js
和 .jsx
文件,排除了 app.ts
和 index.ts
文件以及 dist
文件夹。覆盖率报告将放置在 /test/.coverage
文件夹中。Jest 将在 Node 环境中运行,并在每个测试文件之前执行 /test/setup.ts
文件。
你还可以在 package.json
的 scripts
部分添加以下命令:
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage"
有三个命令。第一个是 test
命令,可以通过执行 npm t
或 npm test
来运行。它会使用 Jest 运行测试套件中的所有测试。第二个是 watch
命令,可以通过运行 npm run test:watch
启动。此命令会在文件发生更改时运行测试,但仅针对已保存的文件进行测试。你可以按下 A
键在监视模式下运行所有测试,退出监视模式时按 Ctrl-C
。
列表中的最后一个 npm 脚本是 test:cov
,用于测试覆盖率。它会运行所有测试并根据上述配置生成代码覆盖率报告。Jest 背后使用的是 Istanbul JS 来报告代码覆盖率。你将在本教程后续部分了解更多有关代码覆盖率的信息。在接下来的部分中,你将看到 Quotes 控制器的代码,并为该代码编写单元测试。
Quotes 控制器及其测试
作为单元测试的最佳实践,你应该始终为你编写的代码编写测试。因此,你需要为控制器和服务编写测试。虽然实例化 Express 并将其与控制器结合起来的测试不是必需的,但可以编写。以下是 QuotesController
的代码:
import { Service } from 'typedi';
import { QuotesService } from '../services/QuotesService';
import { Quote } from '../types/Quote';
@Service()
export class QuotesController {
private quotesService: QuotesService;
public constructor(quotesService: QuotesService) {
this.quotesService = quotesService;
}
public getQuotes(page: number = 1): Quote[] {
return this.quotesService.getQuotes(page);
}
}
此类首先导入了 Service
,这是用作装饰器的工具,以便 QuotesController
类可以通过容器注入。接下来,导入了 QuotesService
和 Quote
类型。QuotesService
将从数据源获取引言,每个引言的类型为 Quote
,其结构如下:
export type Quote = {
id: number;
quote: string;
author: string;
};
它是一个简单的类型,包含一个数字类型的 id
,以及两个字符串类型的属性:quote
和 author
。
回到控制器,QuotesController
类通过 Service
装饰器定义。为了使该装饰器正常工作,你需要在 tsconfig.json
文件中将 experimentalDecorators
和 emitDecoratorMetadata
设置为 true
。
接着是 QuotesController
的构造函数,它将 QuotesService
作为依赖注入。随后定义了一个名为 getQuotes
的方法,它接收 page
参数,默认为 1
,并返回 Quote
类型的数组。在此方法中,你调用了 quotesService.getQuotes
方法,并传入页码进行分页。QuotesService
负责从适当的数据源(在此示例中是静态数组)获取引言数据。
你可以为 QuotesController
编写测试,甚至无需 QuotesService
,如下所示:
import { QuotesController } from "../../../src/controllers/QuotesController";
import { QuotesService } from '../../../src/services/QuotesService';
import { Quote } from "../../../src/types/Quote";
describe('QuotesController', () => {
let controller: QuotesController;
const mockQuotesService = {
getQuotes: jest.fn()
} as QuotesService;
beforeEach(() => {
controller = new QuotesController(mockQuotesService);
});
it('should define quotes controller', () => {
expect(controller).toBeInstanceOf(QuotesController);
});
describe('getQuotes', () => {
it('should get quotes', () => {
mockQuotesService.getQuotes = jest.fn().mockReturnValueOnce([
{
id: 1,
quote: 'There are only two kinds of languages: the ones people complain about and the ones nobody uses.',
author: 'Bjarne Stroustrup'
},
{
id: 2,
quote: 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.',
author: 'Martin Fowler'
},
] as Quote[]);
const quotes: Quote[] = controller.getQuotes();
expect(quotes).toHaveLength(2);
expect(quotes[0].author).toBe('Bjarne Stroustrup');
expect(quotes[1].quote).toEqual(expect.stringContaining('Any fool can write code that'));
expect(quotes[1]).toEqual(expect.objectContaining({ id: 2 }));
expect(quotes[1]).toEqual({
id: 2,
quote: 'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.',
author: 'Martin Fowler'
});
expect(mockQuotesService.getQuotes).toHaveBeenCalledTimes(1);
expect(mockQuotesService.getQuotes).toHaveBeenCalledWith(1);
});
});
});
Jest 单元测试从导入 QuotesController
开始,这是被测试的系统(SUT)。你还需要导入 QuotesService
类(它是控制器的依赖)和 Quote
类型。然后,主 describe
块以待测试的类命名,即 QuotesController
。在 describe
下方定义了 controller
变量(类型为 QuotesController
)和 mockQuotesService
。模拟的引言服务有一个 getQuotes
函数,该函数被赋值为一个 Jest 函数。
在 beforeEach
钩子中,controller
被赋值为传入模拟服务的 QuotesController
实例。由于 controller
变量将在多个测试和函数中重复使用,因此它在外部作用域中的 beforeEach
函数内定义。接着是第一个测试:简单地测试 controller
变量是 QuotesController
类的实例。
随后,另一个 describe
块开始测试 getQuotes
方法。它包含一个 it
函数,用于测试控制器方法是否能返回一些引言。在这里,你设置了 QuotesService
的 getQuotes
方法,返回一对引言,作为 Quote
类型的数组。然后,你调用了控制器的 getQuotes
,并期望其长度为 2。接着,你断言第一个引言的作者为 Bjarne Stroustrup
,第二个引言的 quote
属性包含 "Any fool can write code that" 字符串。
测试中包含了多种断言,以便展示在 Jest 中如何使用 expect
函数。你可以宽松地只期望对象包含某个 id
,而忽略其他属性。另一方面,你也可以严格要求整个对象完全等于传入的值。
在测试的最后,你期望模拟服务的 getQuotes
方法被调用一次,并且参数为 1
。
如果你运行 npm t
或 npm test
,测试将通过,并显示以下输出:
Quotes服务及其测试
Quotes服务层负责通过查询数据源获取数据,并将数据源与调用方隔离。为简化本教程的范围,这里使用了一个包含17条引用的静态数组作为数据源。Quotes服务本可以通过对象关系映射器(ORM)与关系数据库或NoSQL数据库通信,但这些内容不在本教程测试的范围之内。以下是Quotes服务的代码:
import { Service } from 'typedi';
import { Quote } from '../types/Quote';
@Service()
export class QuotesService {
public getQuotes(page: number): Quote[] {
if (page < 1) {
throw new Error('Page number should be 1 or more');
}
const quotes:Quote[] = [
{
id: 1,
quote: 'There are only two kinds of languages: the ones people complain about and the ones nobody uses.',
author: 'Bjarne Stroustrup',
},
{
id: 2,
quote:
'Any fool can write code that a computer can understand. Good programmers write code that humans can understand.',
author: 'Martin Fowler',
},
{
id: 3,
quote: 'First, solve the problem. Then, write the code.',
author: 'John Johnson',
},
{
id: 4,
quote: 'Java is to JavaScript what car is to Carpet.',
author: 'Chris Heilmann',
},
{
id: 5,
quote:
'Always code as if the guy who ends up maintaining your code will be a violent psychopath who knows where you live.',
author: 'John Woods',
},
{
id: 6,
quote: "I'm not a great programmer; I'm just a good programmer with great habits.",
author: 'Kent Beck',
},
{
id: 7,
quote: 'Truth can only be found in one place: the code.',
author: 'Robert C. Martin',
},
{
id: 8,
quote:
'If you have to spend effort looking at a fragment of code and figuring out what it\'s doing, then you should extract it into a function and name the function after the "what".',
author: 'Martin Fowler',
},
{
id: 9,
quote:
'The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.',
author: 'Donald Knuth',
},
{
id: 10,
quote:
'SQL, Lisp, and Haskell are the only programming languages that I’ve seen where one spends more time thinking than typing.',
author: 'Philip Greenspun',
},
{
id: 11,
quote: 'Deleted code is debugged code.',
author: 'Jeff Sickel',
},
{
id: 12,
quote:
'There are two ways of constructing a software design: One way is to make it so simple that there are obviously no deficiencies and the other way is to make it so complicated that there are no obvious deficiencies.',
author: 'C.A.R. Hoare',
},
{
id: 13,
quote: 'Simplicity is prerequisite for reliability.',
author: 'Edsger W. Dijkstra',
},
{
id: 14,
quote: 'There are only two hard things in Computer Science: cache invalidation and naming things.',
author: 'Phil Karlton',
},
{
id: 15,
quote:
'Measuring programming progress by lines of code is like measuring aircraft building progress by weight.',
author: 'Bill Gates',
},
{
id: 16,
quote: 'Controlling complexity is the essence of computer programming.',
author: 'Brian Kernighan',
},
{
id: 17,
quote: 'The only way to learn a new programming language is by writing programs in it.',
author: 'Dennis Ritchie',
},
];
const itemsPerPage:number = 10;
const end:number = page * itemsPerPage;
let start:number = 0;
if(page > 1) {
start = (page - 1) * itemsPerPage;
}
return quotes.slice(start , end);
}
}
Quotes服务首先从typedi
中导入Service
,以及从types/Quote
中导入Quote
类型。此类没有构造函数依赖,但如果使用真实的数据源,它可以通过构造函数传入Quotes存储库,以便从关系数据库等数据源获取数据。
接下来,定义了getQuotes
方法,它接收一个page
参数用于基本分页。方法首先检查page
参数是否小于1,如果是则抛出错误。然后,它使用slice
方法实现分页逻辑,每页显示10条数据。
接下来是对Quotes服务的单元测试代码:
import { QuotesService } from "../../../src/services/QuotesService";
import { Quote } from "../../../src/types/Quote";
describe("QuotesService", () => {
let service: QuotesService;
beforeEach(() => {
service = new QuotesService();
});
it("should define quotes service", () => {
expect(service).toBeInstanceOf(QuotesService);
});
describe("getQuotes", () => {
it("should get mock fixed quotes", () => {
const quotes: Quote[] = service.getQuotes(1);
expect(quotes).toHaveLength(10);
expect(quotes[0]).toEqual({
author: "Bjarne Stroustrup",
id: 1,
quote:
"There are only two kinds of languages: the ones people complain about and the ones nobody uses.",
});
});
it("should get mock fixed quotes for page > 1", () => {
const quotes: Quote[] = service.getQuotes(2);
expect(quotes).toHaveLength(7);
});
it("should throw error for page number less than 0", () => {
expect(() => {
service.getQuotes(-1);
}).toThrow("Page number should be 1 or more");
});
});
});
这个测试文件包含四个测试,其中三个用于QuotesService
类的getQuotes
方法。与前面针对控制器的测试类似,文件中导入了Quote
类型,本次测试的系统被测对象(SUT)是Quotes服务。
第一个测试检查quotesService
变量是否是QuotesService
的实例。然后在getQuotes
的describe
块中,第一个测试验证如果传入页码为1,则返回10条引用,并且第一条引用与预期的对象匹配。
接下来的测试同样验证了传入的页码为2时的情况,期望返回7条相关的引用。最后一个测试处理的是页码小于1时抛出错误的场景,它传入页码-1,并期望抛出带有正确消息的错误。通过这些测试,该文件实现了完整的代码覆盖率。你可以通过运行npm test
来查看,测试脚本已配置在package.json
中,运行后会得到以下输出:
运行Quotes控制器和Quotes服务的测试结果
检查代码覆盖率
代码覆盖率是一个可能引发激烈讨论的指标。首先,拥有100%的代码覆盖率并不意味着代码没有缺陷,它仅表明软件工程师已经尽力编写了覆盖所有代码的测试。代码中仍然可能存在逻辑错误或与数据相关的错误,这些问题往往会揭示未曾预料到的边缘情况。
使用Jest来检查代码覆盖率,你可以执行jest --coverage
命令;它已在package.json
的scripts部分被包含为test:cov
。这意味着你可以运行npm run test:cov
来查看代码覆盖率,其输出如下:
如前所示,你已经编写了足够的测试来覆盖所有相关文件及其中的代码。由于配置中启用了HTML报告器,你可以在/test/.coverage/index.html
路径下查看包含代码覆盖率信息的HTML文件。打开该文件时,你将看到类似如下的内容:
例如,如果删除QuotesService.spec.ts
文件中描述为“should throw error for page number less than 0”的测试(见第31-34行),然后重新运行覆盖率检查,你会看到:
这意味着QuotesService
中的第10行代码没有被任何测试覆盖,这是因为删除了上述测试。这就是代码覆盖率的工作原理。
作为一个软件工程团队,追求较高的代码覆盖率是明智的做法,但100%覆盖率并不应成为技术文化的强制要求。与大多数事情一样,达到目标代码覆盖率所花费的时间和精力应该是合理、优化且合乎情理的。
结论
在本教程中,你学习了如何将 Jest 添加到现有的 TypeScript 项目中。随后你为两个重要的文件——Quotes 控制器和 Quotes 服务——编写了单元测试。
除了学习测试的相关概念外,你还通过实际示例了解了代码覆盖率的工作原理,并掌握了单元测试中常用的测试术语。