自动化测试是软件工程中的一个重要方面。在 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 的分支:
1 | git clone -b no-tests-or-jest git@github.com:geshan/typescript-jest.git |
进入目录 cd typescript-jest
并使用 npm install
安装所需的 NPM 模块。要以开发模式运行项目,可以运行:
1 | 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,可以执行以下命令:
1 | npm install -D jest @types/jest ts-jest |
上述命令安装了 Jest 及其类型文件。Jest 是主要的测试库,同时还添加了 Jest 的相关类型文件。它还安装了 ts-jest
,这是一个带有源映射支持的转换器,用于帮助你使用 Jest 测试 TypeScript 项目。如果你感兴趣,可以阅读更多关于 ts-jest 的文档.
你可以在 package.json
文件中添加一个名为 jest
的新配置键来配置 Jest,配置如下:
1 | "jest": { |
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
部分添加以下命令:
1 | "test": "jest", |
有三个命令。第一个是 test
命令,可以通过执行 npm t
或 npm test
来运行。它会使用 Jest 运行测试套件中的所有测试。第二个是 watch
命令,可以通过运行 npm run test:watch
启动。此命令会在文件发生更改时运行测试,但仅针对已保存的文件进行测试。你可以按下 A
键在监视模式下运行所有测试,退出监视模式时按 Ctrl-C
。
列表中的最后一个 npm 脚本是 test:cov
,用于测试覆盖率。它会运行所有测试并根据上述配置生成代码覆盖率报告。Jest 背后使用的是 Istanbul JS 来报告代码覆盖率。你将在本教程后续部分了解更多有关代码覆盖率的信息。在接下来的部分中,你将看到 Quotes 控制器的代码,并为该代码编写单元测试。
Quotes 控制器及其测试
作为单元测试的最佳实践,你应该始终为你编写的代码编写测试。因此,你需要为控制器和服务编写测试。虽然实例化 Express 并将其与控制器结合起来的测试不是必需的,但可以编写。以下是 QuotesController
的代码:
1 | import { Service } from 'typedi'; |
此类首先导入了 Service
,这是用作装饰器的工具,以便 QuotesController
类可以通过容器注入。接下来,导入了 QuotesService
和 Quote
类型。QuotesService
将从数据源获取引言,每个引言的类型为 Quote
,其结构如下:
1 | export type Quote = { |
它是一个简单的类型,包含一个数字类型的 id
,以及两个字符串类型的属性:quote
和 author
。
回到控制器,QuotesController
类通过 Service
装饰器定义。为了使该装饰器正常工作,你需要在 tsconfig.json
文件中将 experimentalDecorators
和 emitDecoratorMetadata
设置为 true
。
接着是 QuotesController
的构造函数,它将 QuotesService
作为依赖注入。随后定义了一个名为 getQuotes
的方法,它接收 page
参数,默认为 1
,并返回 Quote
类型的数组。在此方法中,你调用了 quotesService.getQuotes
方法,并传入页码进行分页。QuotesService
负责从适当的数据源(在此示例中是静态数组)获取引言数据。
你可以为 QuotesController
编写测试,甚至无需 QuotesService
,如下所示:
1 | import { QuotesController } from "../../../src/controllers/QuotesController"; |
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服务的代码:
1 | import { Service } from 'typedi'; |
Quotes服务首先从typedi
中导入Service
,以及从types/Quote
中导入Quote
类型。此类没有构造函数依赖,但如果使用真实的数据源,它可以通过构造函数传入Quotes存储库,以便从关系数据库等数据源获取数据。
接下来,定义了getQuotes
方法,它接收一个page
参数用于基本分页。方法首先检查page
参数是否小于1,如果是则抛出错误。然后,它使用slice
方法实现分页逻辑,每页显示10条数据。
接下来是对Quotes服务的单元测试代码:
1 | import { QuotesService } from "../../../src/services/QuotesService"; |
这个测试文件包含四个测试,其中三个用于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 服务——编写了单元测试。
除了学习测试的相关概念外,你还通过实际示例了解了代码覆盖率的工作原理,并掌握了单元测试中常用的测试术语。