React 测试驱动开发教程(一)
一、测试驱动开发的简短历史
我写这一章的意图不是复制和粘贴博客中的陈词滥调(下面的摘录除外),或者假装我是历史事件的一部分(比如敏捷宣言或极限编程活动),这些事件导致了测试驱动开发作为一种方法论的诞生——相信我,我还没那么老。
但我确实认为,给你一些我们在本书中将要讨论的内容的背景是有益的。我将谈论测试驱动开发(TDD)的基本工作流程和实践中的不同流派。如果您想直接进入代码,请随意操作,并导航到下一章。
测试驱动开发
TDD 是一种软件开发方法,通过编写测试来驱动应用的开发。它是由肯特·贝克在 20 世纪 90 年代末开发/重新发现的,作为极限编程的一部分,并在他的名著测试驱动开发:举例中进行了充分的讨论。
肯特·贝克在他的书中描述了两条基本规则:
-
只有当你第一次有一个失败的自动化测试时,才编写新的代码
-
消除重复
这就引出了红绿重构的步骤,我们很快就会讨论到。这两个规则的最终目标是编写(正如 Ron Jeffries 所描述的)干净有效的代码。
红绿重构循环
有一个著名的图表解释了如何实际应用 TDD 它被称为红绿重构循环(图 1-1 )。
图 1-1
测试驱动开发
通常,这个图会有一个简短的描述,被表述为 TDD 的三个原则:
-
写一个测试,看它失败。
-
编写足够通过测试的代码。
-
如果检测到任何代码味道,则进行重构。
乍一看,这很容易理解。这里的问题——和许多原则一样——是它们对初学者不太适用。这些原则非常高级,很难应用,因为它们缺乏细节。例如,仅仅知道原理并不能帮助你回答这样的问题
-
我该如何编写我的第一个测试呢?
-
足够的代码实际上意味着什么?
-
我应该何时以及如何重构?
近距离观察红绿重构
图 1-2 仔细观察该过程。
图 1-2
测试驱动开发。来源:维基百科(en . Wikipedia . org/wiki/Test-driven _ development)
传统上,TDD 包含两个部分:快速实现和重构。实际上,快速实现的测试并不局限于单元测试。它们也可以是验收测试——这些是更高层次的测试,更关注商业价值和最终用户的旅程,而不太担心技术细节。首先实现验收测试可能是一个更好的主意。
从验收测试开始确保了正确的事情被优先考虑,并且当开发人员想要在后期清理和重构代码时,它为他们提供了信心。验收测试旨在从最终用户的角度来编写;通过验收测试可以确保代码满足业务需求。此外,它可以保护开发人员不在错误的假设或无效的需求上浪费时间。
极限编程中有一个叫做 YAGNI 的原则,否则你不会需要它。YAGNI 对于防止开发人员浪费他们宝贵的时间非常有用。开发人员非常善于围绕潜在的需求变化做出假设,基于这些假设,他们可能会提出一些不必要的抽象或优化,从而使代码更加通用或可重用。问题是这些假设很少被证明是正确的。YAGNI 强调,除非万不得已,否则你不应该这么做。
然而,在重构阶段,您可以实现那些抽象和优化。既然您已经有了测试覆盖,那么做清理就安全多了。诸如修改类名、提取方法或者将一些类提取到更高层次之类的小重构——任何有助于使代码更加通用和可靠的事情——现在变得更加安全和容易进行。
TDD 的类型
TDD 是一个大而复杂的概念。它有许多变种和不同的学校,如 UTDD,BDD,ATDD,等等。传统上,TDD 意味着单元测试驱动开发或 UTDD。然而,我们在本书中讨论的 TDD 是 ATDD(验收测试驱动开发),这是传统概念的扩展版本,强调从业务角度编写验收测试,并使用它来驱动生产代码。
在不同的层进行不同的测试可以确保我们总是在正确的轨道上,并且拥有正确的功能。
验收测试驱动的开发
简而言之,ATDD 从最终用户的角度描述了软件的行为,关注于应用的商业价值,而不是实现细节。它不是验证在特定时间调用的函数是否具有正确的参数,而是确保当用户下订单时,他们能够按时收到订单。
我们可以将 ATDD 和 UTDD 合并成一个图,如图 1-3 所示。
图 1-3
验收测试驱动的开发
该图描述了以下步骤:
-
写一个验收测试,看它失败。
-
写一个单元测试,看它失败。
-
编写代码使单元测试通过。
-
重构代码。
-
重复 2–4,直到验收测试通过。
当您仔细观察这个过程时,您会发现在开发阶段,验收测试可能会失败很长一段时间。反馈循环变得非常长,并且存在一个风险,即总是失败的测试意味着根本没有测试(保护)。开发人员可能会对实现中是否有缺陷,或者是否有任何实现感到困惑。
为了解决这个问题,您必须以相对较小的块来编写验收测试,一次测试需求的一小部分。或者,你可以使用假的它,直到你让它接近,就像我们将在本书中使用的那样。
步骤保持不变;只增加了一个额外的fake步骤:
-
写一个失败的验收测试。
-
让它以最直接的方式通过(一个伪实现)。
-
基于任何代码味道(如硬编码数据、幻数等)进行重构。).
-
基于新的需求添加一个新的测试(如果我们需要一个新的验收测试,回到步骤 1;否则,过程就像传统的 TDD 一样)。
请注意,在第二步中,您可以使用硬编码或静态 HTML 片段来使测试通过。乍一看,这似乎是多余的,但是在接下来的几章中你将会看到假的力量。
这种变化的好处是,当开发人员进行重构时,总会有一个通过的验收测试来保护您不破坏现有的业务逻辑。这种方法的缺点是,当开发人员没有足够的经验时,他们可能很难提出干净的代码设计——他们可能会以某种方式保持虚假(例如,幻数、缺乏抽象等)。).
行为驱动开发
行为驱动开发是一种敏捷实践,它鼓励不同角色、开发人员、质量工程师、业务分析师,甚至软件项目中其他相关方之间的协作。
尽管 BDD 在某种程度上是关于软件开发应该如何被商业利益和技术洞察力管理的一般概念,但是 BDD 的实践涉及一些专门的工具。例如,特定于领域的语言(DSL)用于用自然语言编写测试,这些语言可以被非技术人员容易地理解,并且可以由代码解释并在后台执行。
例如,清单 1-1 展示了如何描述一个需求。
Given there are `10` books in the library
When a user visits the homepage
Then they would see `10` books on the page
And each book would contain at least `name`, `author`, `price` and `rating`
Listing 1-1An example of BDD test case
TDD 的先决条件
对于开发人员来说,TDD 有一个严格而关键的先决条件:如何检测代码气味,以及如何将它们重构为好的设计。例如,当您发现一些糟糕的代码(例如,缺乏抽象或幻数)并且不确定如何使其变得更好时,那么单靠 TDD 无法帮助您。即使您被迫使用传统的 TDD 工作流,除了低质量的代码之外,您可能还会遇到一些不可维护的测试。
意识到代码味道和重构
在他的书重构:改进现有代码的设计中,马丁·福勒列出了 68 种重构。对于任何重视干净代码和高质量代码的人来说,我会推荐这本书作为强制性的先决条件。但是不用太担心;他提到的一些重构你可能在日常工作中已经用过了。
如前所述,典型的 TDD 工作流有三个步骤:
-
一个测试用例描述需求(规范)。
-
一些使测试通过的代码。
-
重构实现和测试。
一个常见的误解是测试代码是第二层的,或者不一定和生产代码一样重要。我认为它和产品代码一样重要。可维护的测试对于那些必须在以后做出改变或者添加新的测试的人来说是至关重要的。每次重构时,确保产品代码中所做的更改在测试代码中得到反映。
先测试还是后测试
在您的日常工作流程中应用 TDD 最困难的部分是,您必须在开始编写任何生产代码之前编写测试。对于大多数开发人员来说,这不仅仅是与众不同和违反直觉,而且还极大地破坏了他们自己的工作方式。
然而,应用 TDD 的关键是您应该首先建立快速反馈机制。一旦有了,写测试是先还是后就没多大关系了。所谓快速反馈,我的意思是一个方法或者一个 if-else 分支可以用一种非常轻量级和轻松的方式进行测试。如果您在所有的功能都已经完成之后添加测试,您无论如何都不是在进行 TDD。因为你错过了必要的快速反馈循环——被视为开发中最重要的事情——你也可能错过了 TDD 承诺的好处。
通过实施快速反馈循环,TDD 确保您始终安全地处于正确的轨道上。这也给你足够的信心去做进一步的代码清理。适当的代码清理可以带来更好的代码设计。当然,清理不会自动进行;这需要额外的时间和努力。然而,TDD 是一种很好的机制,可以保护您在进行任何更改时不会破坏整个应用。
可以帮助实现 TDD 的技术
对于初学者来说,在应用 TDD 时可能会有挑战性,因为首先测试有时感觉是违反直觉的。实际上,抵制 TDD 有一些常见的原因:
-
对于简单的任务,他们不需要 TDD。
-
对于复杂的任务,建立 TDD 机制本身可能太困难了。
有很多教程和文章描述了你应该用来做 TDD 的技术,有些甚至涉及到在实现 TDD 之前如何分割任务。然而,这些教程中讨论的东西往往过于简单,很难直接应用到现实世界的项目中。例如,在 web 应用中,交互和相当一部分业务逻辑现在都存在于前端:UI。如何编写单元测试来驱动后端逻辑的传统技术已经过时了。
任务分配
TDD 需要的另一个关键技能是通过任务分配将一个大的需求分割成更小的块。我建议每个开发人员在开始编写他们的第一个测试之前就应该学会如何分解需求。
有一个经典笑话:“把一头大象放进冰箱需要几个步骤?”答案是三个步骤:
-
打开冰箱。
-
把大象放进去。
-
关上它。
当我们接下一项任务,当我们开始思考或讨论所有细节时,我们可能很快就会发现我们被堆积如山的技术细节所困,不知道从哪里开始。我们的大脑喜欢明确和具体的东西,讨厌抽象——不可见或隐含的东西。
通过利用一些简单的工具,我们可以让工作更容易被我们的大脑消化,任务分配就是这些工具之一。它可以帮助我们把一个大任务分成更小的任务,然后我们可以一个接一个地完成。
将一个相对较大的任务分成较小的部分,一个广泛使用的原则是投资。
分离原则——投资
助记投资代表
-
自主的
-
可以商量
-
有价值的
-
可估计的
-
小的
-
可试验的
当将一个大的需求分解成更小的任务时,您需要确保每个任务都满足这些特性。首先,对于任何给定的任务,你应该使它尽可能的独立,这样它就可以和其他任务一起并行完成。可协商意味着它不应该固定为合同,任务的范围可以根据时间和成本的权衡而变化。为了有价值,每个任务必须提供一些商业价值;为之付出的努力应该是可衡量的,或者有一个估计。小意味着任务应该相对较小——大意味着更多未知的特性,可能会使估计不太准确。最后,testable 通过验证一些关键的检查点来确保我们知道 done 是什么样子的。
例如,当我们想为一个电子商务系统开发一个搜索功能时,我们可以使用 INVEST 原则来指导我们进行分析。搜索可以分为几个故事或任务:
-
用户可以按名称搜索产品。
-
用户可以通过品牌搜索产品。
-
用户可以通过名称和品牌搜索产品。
对于用户可以通过名称搜索产品,我们可以继续使用 INVEST 从开发人员的角度将一个故事分成几个任务:
-
在内存中维护搜索结果(ArrayList + Java Stream API)。
-
区分大小写的支持。
-
通配符(正则表达式)支持。
我们甚至可以继续使用相同的原则来进一步拆分每个项目:
-
编写验收测试。
-
编写代码使测试通过。
-
重构。
-
编写一个单元测试。
-
编写代码使测试通过。
-
重构。
-
等等。
这将引导我们完成一个定义明确的任务,并允许我们清楚地验证每一步。
带便利贴的待办事项列表
通常,我们可以在第二轮拆分时停止,因为红绿重构在任务分配方面过于详细。过于细化的任务意味着更多的管理工作(跟踪这些任务需要更多的精力)。为了让任务可见,我们可以把它写在便利贴上,并在完成后做一个简单的记号(图 1-4 )。
图 1-4
任务分配
通过使用这个简单的工具,您可以专注于您将要做的事情,并在您想要向其他团队成员更新进度时(例如,在每日站立会议中)使进度更加准确。通过说一项任务完成了 50%,清单上的一半项目在你之前做的清单上被勾掉了。
摘要
我们浏览了测试金字塔和敏捷测试象限,并介绍了验收测试驱动的开发,作为我们通过这本书编写代码的方式。在做 ATDD 时,我们将继续做经典的红绿重构循环。
重构依赖于识别代码气味的感觉和经验。一旦你发现了代码的味道,你就可以应用相应的重构技术。然后我们可能会实现可维护的、人类可读的、可扩展的、干净的代码。
TDD 总是伴随着另一个强大的实践——任务。你可以使用投资原则来帮助你把一个大任务分成几个小部分。适当拆分后,可以逐步完善基础版本,迭代完成大任务。
在下一章,我们将介绍一个具体的例子来演示如何一步一步地应用 TDD。除了这个例子,我们还将介绍实现 TDD 所需的基本技能,包括如何使用 jest 测试框架以及如何使用真实的例子来完成任务。
进一步阅读
围绕 TDD 有广泛的争论——时不时地,你会看到人们在争论我们是否需要 TDD 或者实现 TDD 的正确方法。我发现下面的文章对理解这些论点很有帮助:
-
鲍勃大叔有一篇很棒的文章讨论
test-first或test-last临近。如果你还没有读过,我强烈建议你读一读。 -
关于
TDD最新最著名的争论来自于David Heinemeier Hansson (DHH)(Ruby on Rails的作者)Kent Beck,和Martin Fowler;你可以在这里找到更多信息。
我也强烈推荐阅读这些书籍,为实施 TDD 打下坚实的基础。即使你决定不使用 TDD,那些书仍然被强烈推荐。
-
罗伯特·c·马丁的《干净的代码:敏捷软件工艺手册》
-
马丁·福勒的《重构:改进现有代码的设计》
二、从笑话开始
在这一章中,我们将学习一些关于 jest 的概念和特性——一个 JavaScript 测试框架——比如不同类型的matchers,强大灵活的expect,对于单元测试极其有用的mock,等等。此外,我们将学习如何以易于维护的方式安排我们的测试套件,并利用来自真实项目的最佳实践。
首先,您将看到如何设置您的环境来编写我们的第一个测试。在本书中,我们将使用ES6作为主要的编程语言。
所以,事不宜迟,我们开始吧。
设置环境
安装 Node.js
我们将利用node.js作为本书中几乎所有场景的平台。如果您的计算机上还没有安装node,您可以简单地运行下面的命令,将它安装到带有homebrew的MacOS上:
brew install node
或者,如果你运行不同的操作系统,或者只是想要另一个选项,可以在这里下载。
一旦您在本地安装了它,您就可以使用npm(节点包管理器)来安装节点包——这是一个随node运行时提供的二进制程序。
安装和配置 Jest
Jest是来自Facebook的一个测试框架,它允许开发人员以更易读的语法编写可靠和快速运行的测试。它可以观察测试/源文件中的变化,并自动重新运行必要的测试。这可以让你快速得到反馈,这是TDD的一个关键因素。反馈的速度甚至可以决定TDD对你是否管用。简单地说,测试运行得越快,开发人员的效率就越高。
让我们首先为我们的实验创建一个文件夹,并用一个package.json初始化该文件夹,以维护所有下面的包安装:
mkdir jest-101
cd jest-101
npm init -y # init the current folder with default settings
将jest作为development dependency安装,因为我们不想将jest包含在生产包中:
npm install --save-dev jest
安装完成后,可以运行jest --init来指定一些默认设置,比如jest应该在哪里找到测试文件和源代码,jest应该在哪个环境(有很多)下运行(浏览器或节点为后端)等等。你要回答一些问题,让jest明白你的要求;现在,让我们接受所有的默认设置,对所有的问题说Yes。
注意,如果你已经全局安装了jest(带有npm install jest -g,你可以使用下面的命令直接init配置:
jest --init
否则,您必须通过npx使用本地安装,它从node_modules/.bin/中寻找jest二进制文件并调用它:
npx jest --init
为了简单起见,我们使用node作为测试环境,没有coverage report,所有其他默认设置如下:
npx jest --init
The following questions will help Jest to create a suitable configuration for your project:
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? ... no
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls and instances between every test? ... no
在/Users/juntaoqiu/learn/jest-101/jest . config . js 创建的配置文件
乍一看是笑话
酷,我们已经准备好编写一些测试来验证所有部分现在可以一起工作了。让我们创建一个名为src的文件夹,并将两个文件放在calc.test.js和calc.js中。
该文件以*.test.js结尾,这意味着这是一种模式,jest将识别它们并将其视为tests,如我们之前生成的jest.config.js中所定义的:
//The glob patterns Jest uses to detect test files
testMatch: [
"**/__tests__/**/*.js?(x)",
"**/?(*.)+(spec|test).js?(x)"
],
现在,让我们在calc.test.js中放一些代码:
var add = require('./calc.js')
describe('calculator', function() {
it('add two numbers', function() {
expect(add(1, 2)).toEqual(3)
})
})
如果你从未尝试过用jasmine(在jest时代之前非常流行的测试框架)编写测试,这里有一些新东西:函数describe和it继承自jasmine。describe是一个可以用来创建测试套件的函数,你可以在其中定义测试用例(通过使用it函数)。正确的做法是将人类可读的文本作为第一个参数,将可执行的回调函数作为第二个参数。另一方面,对于函数it,您可以编写实际的测试代码。
实际的断言是语句expect(add(1, 2)).toEqual(3),它声明我们期望函数调用add(1, 2)等于3。
add从另一个文件导入,实现如下:
function add(x, y) {
return x + y;
}
module.exports = add
然后,让我们运行测试,看看结果如何:
npx jest
或者,您可以通过以下方式运行测试
npm test
其中调用了罩下的node_modules/.bin/jest,如图 2-1 所示。
图 2-1
首次测试
太好了,我们进行了第一次测试。
笑话中的基本概念
在这一节中,我们将讨论Jest中的一些基本概念。我们使用describe来定义一个测试块。我们可以使用这种机制来安排不同的测试用例,并将相关的测试用例集合成一个组。
Jest API:描述和它
例如,我们可以将所有的arithmetic放入一个组中:
describe('calculator', () => {
it('should perform addition', () => {})
it('should perform subtraction', () => {})
it('should perform multiplication', () => {})
it('should perform division', () => {})
})
更重要的是,我们可以这样下一个describe函数:
describe('calculator', () => {
describe('should perform addition', () => {
it('adds two positive numbers', () => {})
it('adds two negative numbers', () => {})
it('adds one positive and one negative numbers', () => {})
})
})
基本的想法是确保相关的测试被分组在一起,以便测试描述对维护它们的人更有意义。如果您可以在业务上下文中使用领域语言描述description(函数describe和it的第一个参数),那就更有帮助了。
友好地组织你的测试
例如,当您开发一个酒店预订应用时,测试应该是这样的:
describe('Hotel Sunshine', () => {
describe('Reservation', () => {
it('should make a reservation when there are enough rooms available', () => {})
it('should warn the administrator when there are only 5 available rooms left', () => {})
})
describe('Checkout', () => {
it('should check if any appliance is broken', () => {})
it('should refund guest when checkout is earlier than planned', () => {})
})
})
您可能偶尔会发现一些分散在测试用例中的重复代码,例如,在每个测试中设置一个主题并不罕见:
describe('addition', () => {
it('adds two positive numbers', () => {
const options = {
precision: 2
}
const calc = new Calculator(options)
const result = calc.add(1.333, 3.2)
expect(result).toEqual(4.53)
})
it('adds two negative numbers', () => {
const options = {
precision: 2
}
const calc = new Calculator(options)
const result = calc.add(-1.333, -3.2)
expect(result).toEqual(-4.53)
})
})
安装和拆卸
为了减少重复,我们可以使用jest提供的beforeEach函数来定义一些可重用的对象实例。在jest运行每个测试用例之前,它会被自动调用。在我们的例子中,calculator实例可以在同一个describe块中的所有测试用例中使用:
describe('addition', () => {
let calc = null
beforeEach(() => {
const options = {
precision: 2
}
calc = new Calculator(options)
})
it('adds two positive numbers', () => {
const result = calc.add(1.333, 3.2)
expect(result).toEqual(4.53)
})
it('adds two negative numbers', () => {
const result = calc.add(-1.333, -3.2)
expect(result).toEqual(-4.53)
})
})
当然,你可能想知道是否有一个名为afterEach的对应函数或负责清理工作的东西:有!
describe('database', () => {
let db = null;
beforeEach(() => {
db.connect('localhost', '9999', 'user', 'pass')
})
afterEach(() => {
db.disconnect()
})
})
这里,我们在每个测试用例之前建立一个数据库连接,然后关闭它。在实践中,您可能希望在afterEach步骤中添加一个函数来回滚数据库更改或其他清理。
此外,如果您希望在所有测试用例开始之前建立一些东西,并在所有测试用例完成之后拆除,那么beforeAll和afterAll可以提供帮助:
beforeAll(() => {
db.connect('localhost', '9999', 'user', 'pass')
})
afterAll(() => {
db.disconnect()
})
使用 ES6
默认情况下,你只能在node.js中使用ES5(JavaScript 的一个相对较老的版本)(有趣的是,到我开始写这一章的时候,默认情况下,我不能在node运行时中直接使用ES6中的大部分特性)。然而,既然我们已经到了 2021 年,ES6应该是你的前端项目应该选择的默认编程语言。好消息是你不必等到所有的浏览器都实现了规范;你可以用babel把ES6代码翻译编译成ES5。
安装和配置 Babel
这很容易设置;只需安装几个包就可以让它正常工作:
npm install --save-dev babel-jest babel
-core regenerator-runtime @babel/preset-env
安装完成后,在项目根目录下创建一个.babelrc,内容如下
{
"presets": [
"@babel/preset-env"
]
}
就这样!现在,您应该能够在源代码和测试代码中编写 ES6 了,剩下的工作将由 babel 来完成:
import {add} from './calc'
describe('calculator', () => {
it('adds two numbers', () => {
expect(add(1, 2)).toEqual(3)
})
})
和
const add = (x, y) => x + y
export {add}
使用箭头函数和单行匿名函数(比如add函数)会更加简洁明了。此外,我更喜欢import和export,因为我觉得它比旧的modules.export约定更具可读性。
当您重新运行npm test时,所有测试都应该通过。
开玩笑时使用火柴
为开发人员提供了大量的帮助函数(匹配器),用于编写测试时的断言。这些匹配器可用于在不同的场景中断言各种数据类型。
让我们先看看一些基本的用法,然后再看一些更高级的例子。
平等
toEqual和toBe可能是你在几乎每个测试用例中会发现和使用的最常见的匹配器。顾名思义,它们用于断言值是否彼此相等(实际值和期望值)。
例如,它可以用于string、number,或复合对象:
it('basic usage', () => {
expect(1+1).toEqual(2)
expect('Juntao').toEqual('Juntao')
expect({ name: 'Juntao' }).toEqual({ name: 'Juntao' })
})
而对于toBe
it('basic usage', () => {
expect(1+1).toBe(2) // PASS
expect('Juntao').toBe('Juntao') // PASS
expect({ name: 'Juntao' }).toBe({ name: 'Juntao' }) //FAIL
})
最后一次测试会失败。对于像strings、numbers和booleans这样的原语,可以使用toBe来测试相等性。而对于Objects,内部jest使用Object.is校验,比较严格,按内存地址比较对象。所以如果你想确保所有的字段都匹配,使用toEqual。
。反向匹配的 not 方法
Jest还提供了.not,可以用来断言相反的值:
it('basic usage', () => {
expect(1+2).not.toEqual(2)
})
有时,您可能不希望完全匹配。假设您希望一个字符串匹配某个特定的模式。那么你可以用toMatch来代替:
it('match regular expression', () => {
expect('juntao').toMatch(/\w+/)
})
事实上,您可以编写任何有效的正则表达式:
it('match numbers', () => {
expect('185-3345-3343').toMatch(/^\d{3}-\d{4}-\d{4}$/)
expect('1853-3345-3343').not.toMatch(/^\d{3}-\d{4}-\d{4}$/)
})
Jest使得使用strings变得非常容易。但是,您也可以使用数字进行比较:
it('compare numbers', () => {
expect(1+2).toBeGreaterThan(2)
expect(1+2).toBeGreaterThanOrEqual(2)
expect(1+2).toBeLessThan(4)
expect(1+2).toBeLessThanOrEqual(4)
})
数组和对象的匹配器
Jest还为Array和Object提供匹配器。
toContainEqual 和 toContain
例如,测试一个元素是否包含在一个Array中是很常见的:
const users = ['Juntao', 'Abruzzi', 'Alex']
it('match arrays', () => {
expect(users).toContainEqual('Juntao')
expect(users).toContain(users[0])
})
注意toContain和toContainEqual是有区别的。基本上,toContain通过使用===严格比较元素来检查项目是否在列表中。另一方面,toContainEqual只是检查值(不是内存地址)。
例如,如果您想检查一个对象是否在列表中
it('object in array', () => {
const users = [
{ name: 'Juntao' },
{ name: 'Alex' }
]
expect(users).toContainEqual({ name: 'Juntao' }) // PASS
expect(users).toContain({ name: 'Juntao' }) // FAIL
})
第二个断言会失败,因为它使用了更严格的比较。由于对象只是其他 JavaScript 原语的组合,我们可以使用dot符号并测试字段的existence,或者使用对象中字段的早期匹配器。
it('match object', () => {
const user = {
name: 'Juntao',
address: 'Xian, Shaanxi, China'
}
expect(user.name).toBeDefined()
expect(user.age).not.toBeDefined()
})
强大的功能Expect
在前面的章节中,我们已经尝过了matcher的味道;让我们来看看Jest提供的另一个超级武器:expect。
有几个有用的辅助函数附加在expect对象上:
-
expect . string 包含
-
expect . array 包含
-
expect . object 包含
通过使用这些函数,您可以定义自己的matcher。例如:
it('string contains', () => {
const givenName = expect.stringContaining('Juntao')
expect('Juntao Qiu').toEqual(givenName)
})
这里的变量givenName不是一个简单的值;这是一个新的匹配器,匹配包含Juntao的字符串。
类似地,您可以使用arrayContaining来检查数组的子集:
describe('array', () => {
const users = ['Juntao', 'Abruzzi', 'Alex']
it('array containing', () => {
const userSet = expect.arrayContaining(['Juntao', 'Abruzzi'])
expect(users).toEqual(userSet)
})
})
乍一看,这看起来有点奇怪,但是一旦你理解了,这种模式将帮助你构建更复杂的匹配器。
例如,假设我们从后端 API 中检索一些数据,其有效负载如下所示
const user = {
name: 'Juntao Qiu',
address: 'Xian, Shaanxi, China',
projects: [
{ name: 'ThoughtWorks University' },
{ name: 'ThoughtWorks Core Business Beach'}
]
}
不管什么原因,在我们的测试中,我们根本不关心address。我们确实关心name字段是否包含Juntao,以及 project.name 是否包含ThoughtWorks。
Containing家庭功能
所以让我们通过使用stringContaining、arrayContaining和objectContaining来定义一个匹配器,如下所示:
const matcher = expect.objectContaining({
name: expect.stringContaining('Juntao'),
projects: expect.arrayContaining([
{ name: expect.stringContaining('ThoughtWorks') }
])
})
这个表达式准确地描述了我们所期望的,然后我们可以使用toEqual来做断言:
expect(user).toEqual(matcher)
如您所见,这种模式非常强大。基本上,你可以像在自然语言中一样定义一个匹配器。它甚至可以用在前端和后端服务之间的contract中。
制造你的火柴
Jest也允许你扩展expect对象来定义你自己的匹配器。这样,您可以增强默认的匹配器集,并使测试代码更具可读性。
让我们来看一个具体的例子。如您所知,jsonpath是一个允许开发人员使用 JavaScript 对象的库——类似于 XML 中的xpath。
示例:jsonpath 匹配器
如果尚未安装,请先安装jsonpath:
npm install jsonpath --save
然后像这样使用它:
import jsonpath from 'jsonpath'
const user = {
name: 'Juntao Qiu',
address: 'Xian, Shaanxi, China',
projects: [
{ name: 'ThoughtWorks University' },
{ name: 'ThoughtWorks Core Business Beach'}
]
}
const result = jsonpath.query(user, '$.projects')
console.log(JSON.stringify(result))
你会得到这样的结果:
[[{"name":"ThoughtWorks University"},{"name":"ThoughtWorks Core Business Beach"}]]
并查询$.projects[0].name
const result = jsonpath.query(user, '$.projects[0].name')
会得到
["ThoughtWorks University"]
如果路径不匹配,那么query将返回一个空数组([]):
const result = jsonpath.query(user, '$.projects[0].address')
扩展Expect功能
让我们使用函数expect.extend定义一个名为toMatchJsonPath的匹配器作为扩展:
import jsonpath from 'jsonpath'
expect.extend({
toMatchJsonPath(received, argument) {
const result = jsonpath.query(received, argument)
if (result.length > 0) {
return {
pass: true,
message: () => 'matched'
}
} else {
return {
pass: false,
message: () => `expected ${JSON.stringify(received)} to match jsonpath ${argument}`
}
}
}
})
所以在内部,Jest将传递两个参数给定制匹配器;第一个是实际结果——您传递给函数expect()的结果。另一方面,第二个是传递给匹配器的期望值,在我们的例子中是toMatchJsonPath。
对于返回值,它是一个简单的 JavaScript 对象,包含pass,这是一个布尔值,指示测试是否通过,以及一个message字段,分别描述通过或失败的原因。
一旦定义好,就可以像其他内置匹配器一样在测试中使用它:
describe('jsonpath', () => {
it('matches jsonpath', () => {
const user = {
name: 'Juntao'
}
expect(user).toMatchJsonPath('$.name')
})
it('does not match jsonpath', () => {
const user = {
name: 'Juntao',
address: 'ThoughtWorks'
}
expect(user).not.toMatchJsonPath('$.age')
})
})
很酷,对吧?当您想通过使用一些特定领域的语言使matcher更具可读性时,这有时非常有用。
例如:
const employee = {}
expect(employee).toHaveName('Juntao')
expect(employee).toBelongToDepartment('Product Halo')
模拟和存根
在大多数情况下,您只是不想在单元测试中真正调用底层外部函数。你想要——就假装我们在调用真实的东西。例如,当您只想测试电子邮件模板功能时,您可能不想向客户端发送电子邮件。相反,您希望看到生成的 HTML 是否包含正确的内容,或者您只是验证它是否向特定的地址发送了电子邮件。除此之外,连接到产品数据库来测试删除 API 在大多数情况下是不可接受的。
jest.fn进行间谍活动
因此,作为开发人员,我们需要建立一种机制来实现这一点。Jest提供了多种方式来做到这一点mock。最简单的一个是功能jest.fn为一个功能设置一个间谍:
it('create a callable function', () => {
const mock = jest.fn()
mock('Juntao')
expect(mock).toHaveBeenCalled()
expect(mock).toHaveBeenCalledWith('Juntao')
expect(mock).toHaveBeenCalledTimes(1)
})
您可以使用jest.fn()创建一个函数,该函数可以像其他常规函数一样被调用,只是它提供了被审计的能力。一个mock可以跟踪对它的所有调用。它可以记录调用次数和每次调用传入的参数。这可能非常有用,因为在许多情况下,我们只想确保特定的函数是用指定的参数,以正确的顺序调用的——我们不必进行真正的调用。
模拟实现
在前一个例子中看到的虚拟mock对象没有做任何有趣的事情。下面这个更有意义:
it('mock implementation', () => {
const fakeAdd = jest.fn().mockImplementation((a, b) => 5)
expect(fakeAdd(1, 1)).toBe(5)
expect(fakeAdd).toHaveBeenCalledWith(1, 1)
})
您也可以自己定义一个实现,而不是定义一个静态的mock。真正的实现可能非常复杂;也许它会根据一个复杂的公式,对一些给定的参数进行计算。
存根远程服务调用
此外,假设我们有一个调用远程 API 调用来获取数据的函数:
export const fetchUser = (id, process) => {
return fetch(`http://localhost:4000/users/${id}`)
}
在测试代码中,特别是在单元测试中,我们不想执行任何远程调用,所以我们使用mock来代替。在这个例子中,我们测试我们的函数fetchUser将调用全局函数fetch:
describe('mock API call', () => {
const user = {
name: 'Juntao'
}
it('mock fetch', () => {
// given
global.fetch = jest.fn().mockImplementation(() => Promise.resolve({user}))
const process = jest.fn()
// when
fetchUser(111).then(x => console.log(x))
// then
expect(global.fetch).toHaveBeenCalledWith('http://localhost:4000/users/111')
})
})
我们期望http://localhost:4000/users/111调用fetch;注意我们在这里使用的id。我们可以看到用户信息在控制台上打印出来:
PASS src/advanced/matcher.test.js
• Console
console.log src/advanced/matcher.test.js:152
{ user: { name: 'Juntao' } }
这是非常有用的东西。Jest也提供了其他的mock机制,但是我们不打算在这里讨论它们。除了我们之前提到的,我们在本书中没有使用任何高级特性。
如果您有兴趣,请查看jest帮助或主页了解更多信息。
摘要
我们在本章开始时学习了如何设置ES6和jest,然后浏览了jest测试框架的一些基本概念,以及不同类型的matchers和如何使用它们。我们自己定义了一个jsonpath匹配器,并学习了它如何在我们的测试中简化匹配过程,使测试更加简洁和可读。
三、测试驱动开发 101
在本章中,我们将通过一步一步的指导来学习如何在我们的日常开发程序中应用TDD。通过这个演示,您将了解如何将一个大任务分割成相对较小的任务,并在学习一些重构技术的同时通过一系列测试来完成每个任务。在深入研究代码之前,让我们对如何编写一个合适的测试有一个基本的了解。
写作测试
那么,如何开始编写测试呢?一般来说,需要三个步骤(一如既往,甚至把大象放进冰箱)。首先,做一些准备工作,比如建立数据库,初始化被测对象,或者加载一些夹具数据。其次,调用要测试的方法或函数,通常将结果赋给某个变量。最后做一些断言,看看结果是否如预期。
使用给定的时间来安排测试
通常描述为Given、When、Then或3A s 格式,其中A s 代表Arrange、Act和Assert。两者描述了相同的过程。
在Given子句中,你描述了所有的准备工作,包括建立依赖关系。在When中,你触发动作或者改变一个被测对象的状态,通常是一个带有准备好的参数的函数调用。最后,在Then中,您检查结果,看它是否在某些方面与预期的结果匹配(确切地等于某个值,或者包含特定的模式或者抛出一个错误,等等)。
例如,假设我们有以下代码片段:
// given
const user = User.create({ name: 'Juntao', address: 'ThoughtWorks Software Technologies (Melbourne)' })
// when
const name = user.getName()
const address = user.getAddress()
// then
expect(name).toEqual('Juntao')
expect(address).toEqual('ThoughtWorks Software Technologies (Melbourne)')
通常,您会将带有许多断言的测试用例分割成几个独立的用例,并让每个用例有一个单独的断言,比如
it('creates user name', () => {
// given
const user = User.create({ name: 'Juntao', address: 'ThoughtWorks Software Technologies (Melbourne)' })
// when
const name = user.getName()
// then
expect(name).toEqual('Juntao')
});
it('creates user address', () => {
// given
const user = User.create({ name: 'Juntao', address: 'ThoughtWorks Software Technologies (Melbourne)' })
// when
const address = user.getAddress()
// then
expect(address).toEqual('ThoughtWorks Software Technologies (Melbourne)')
});
三角形法
有几种方法可以编写测试并驱动实现。一种普遍接受的方法叫做triangulation。让我们用一些例子来仔细看看如何去做。
示例:函数addition
假设我们正在用TDD实现一个计算器。对addition的测试可能是一个很好的起点。
第一项测试为addition
addition的规格可以是
describe('addition', () => {
it('returns 5 when adding 2 and 3', () => {
const a = 2, b = 3
const result = add(a, b)
expect(result).toEqual(5)
})
})
一个Simple实现
最简单的实现可以是
const add = () => 5
乍一看,像这样写函数似乎很奇怪。但是它有几个好处。例如,对于开发人员来说,这是验证所有东西是否都连接正确的好方法。只需将前面显示的值5修改为3,以查看测试是否失败。当测试和实现没有恰当地联系起来时,您可能会得到一个误导性的绿色测试。
使我们的实现不那么具体的第二个测试用例
我们可以为我们的add函数创建另一个测试:
it('returns 6 when adding 2 and 4', () => {
const a = 2, b = 4
const result = add(a, b)
expect(result).toEqual(6)
})
为了通过测试,最简单的解决方案变成了
const add = (a, b) => 2 + b
这个想法是在每一步中编写一个失败但具体的测试来驱动实现代码变得更加通用。所以现在,实现比第一步更通用。然而,仍然有一些改进的空间。
最终简单的实现
第三个测试可能是这样的
it('returns 7 when adding 3 and 4', () => {
const a = 3, b = 4
const result = add(a, b)
expect(result).toEqual(7)
})
这一次测试数据中没有模式可循,所以我们必须编写一些更复杂的东西来使它通过。实现变成了
const add = (a, b) => a + b
现在,实现更加通用,将覆盖大多数附加场景。将来,我们的计算器可能需要支持虚数的addition;我们可以通过添加更多的测试来以同样的方式推出解决方案。
这种写测试的方法被称为Triangulation:你写一个失败的测试,然后写足够的代码使测试通过,然后你写另一个测试从另一个角度驱动变化。反过来,这将使您的实现更加通用。您继续以这种方式一步一步地工作,直到代码变得足够通用,能够支持业务需求中的大多数情况。
乍一看,这似乎太简单太慢,不是编写软件的有效方法,但是它是您可以并且应该依赖的坚实基础。无论是简单的任务还是复杂的任务,你都可以应用相同的过程。这又回到了TDD的一个关键部分,那就是能够简化任务,将较大的任务分成较小的部分。
好了,让我们更进一步,看看如何在一个更复杂的例子中应用TDD。
如何用 TDD 实现任务
在我目前从事的项目中,我们的团队使用一种非常简单的方式来跟踪投入到每个用户故事中的工作(一小块可以独立完成的工作)。通常,在一个敏捷项目中,随着生命周期的进展,每个卡片或标签可以有以下状态之一:analysis、doing或testing、done。当它所依赖的东西不完整或者还没有准备好的时候,它也可以是blocked。
我们使用的对故事的努力的测量是非常简单的。基本上,我们跟踪在编码上花了多少天,或者有多少天被阻塞。这样,项目经理就有机会了解项目的进展情况,项目的整体健康状况,以及可能采取的进一步改进措施。
我们在卡片的标题中用小写字母d表示已经在development下半天,用大写字母D表示一整天。不出意外,q半天Quality Assurance,Q一整天QA。这意味着在任何给定的时刻,你都会在卡片的标题上看到类似这样的内容:[ddDQbq] Allow users to login to their profile page—b代表被阻止。
用于跟踪进度的表达式解析器
让我们构建一个解析器,它可以读取跟踪标记ddDQbq并将其翻译成人类可读的格式,如下所示:
{
"Dev days": 2.0,
"QA days": 1,
"Blocked": 0.5
}
看起来很简单,对吧?迫不及待地开始编写代码?等等,让我们先从一个测试开始,感受一下在这种情况下如何应用TDD。
将解析器分解为子任务
因此,第一个问题可能是:我们如何将这样的任务分解成更小的任务 **以便于实现和验证?**虽然有多种方法,但合理的分割可以是
-
编写一个测试来确保我们可以将
d转换为半开发日。 -
编写一个测试来确保我们可以将
D转换为一个开发日。 -
像
dD一样编写一个测试来处理多个标记。 -
编写一个测试来处理
q。 -
编写一个测试来处理
qQ。 -
编写一个测试来处理
ddQ。正如我们在
Chapter1中讨论的,拆分对于应用 TDD 是必不可少的。小任务应该以不同的方式吸引和鼓励你: -
这很有趣(已经证明,当我们经历少量成就时,我们的大脑会释放多巴胺,多巴胺与快乐、学习和动力的感觉有关)。
-
它确保快速反馈。
-
它可以让您在任何给定的时间轻松了解任务的进度。
好了,一旦我们定义了这些步骤,我们就可以用 TDD 一个接一个地实现它们了。
逐步应用 TDD
因为我们已经有了任务分割,我们只需要将它们转化为相应的单元测试。让我们从第一个测试开始。
第一项测试——解析并计算分数d
好了,理论够了,让我们把手弄脏吧。根据tasking步骤的输出,第一次测试应该是
it('translates d to half a dev day', () => {
expect(translate('d')).toEqual({'Dev': 0.5})
})
非常简单,实现可以简单到
const translate = () => ({'Dev': 0.5})
它忽略输入并返回一个哑元{'Dev': 0.5},但是你不得不佩服它满足了当前子任务的要求。又快又脏,但很管用。
第二项测试——针对马克D
让我们划掉任务清单上的第一个待办事项,继续前进:
it('translates D to one dev day', () => {
expect(translate('D')).toEqual({'Dev': 1.0})
})
你能想到的最直接的解决方案是什么?也许是这样的:
const translate = (c) => (c === 'd' ? {'Dev': 0.5}: {'Dev': 1.0})
我知道用这种方式写代码看起来很傻。然而,正如您所看到的,我们的实现是由相关的测试驱动的。只要测试通过——这意味着需求得到满足——我们就可以称之为满意。毕竟,我们编写代码的唯一原因是为了满足某些业务需求,对吗?
现在测试已经通过了,如果你发现一些可以改进的地方,比如magic numbers,或者方法体太长,你可以做一些重构。现在,我认为我们可以继续。
音符的组合d和D
第三个测试可能是
it('translates dD to one and a half dev days', () => {
expect(translate('dD')).toEqual({'Dev': 1.5})
})
嗯,现在事情变得更复杂了;我们必须单独解析字符串并对结果求和。下面的代码片段应该可以完成这个任务:
const translate = (input) => {
let sum = 0;
input.split('').forEach((c) => sum += c === 'd' ? 0.5: 1.0)
return {'Dev': sum}
}
现在我们的程序可以毫无问题地处理所有的d或D组合序列,比如ddd或DDdDd。接下来是任务四:
it('translates q to half a qa day', () => {
expect(translate('q')).toEqual({'QA': 0.5})
})
似乎我们需要为每种状态设置一个sum函数,例如,Dev中的sum,QA中的sum。如果我们能稍微重构一下代码,使更改变得更容易,那就更方便了。因此,TDD 最漂亮的部分出现了——您不必担心意外破坏任何现有的功能,因为您有测试来覆盖它们。
重构——提取函数
让我们将解析部分提取出来作为一个函数本身,并在translate中使用该函数。
重构后的translate函数可能是这样的:
const parse = (c) => {
switch(c) {
case 'd': return {status: 'Dev', effort: 0.5}
case 'D': return {status: 'Dev', effort: 1}
}
}
const translate = (input) => {
const state = {
'Dev': 0,
'QA': 0
}
input.split('').forEach((c) => {
const {status, effort} = parse(c)
state[status] = state[status] + effort
})
return state
}
现在,通过新的测试应该不难了。我们可以在parse中增加一个新的case:
const parse = (c) => {
switch(c) {
case 'd': return {status: 'Dev', effort: 0.5}
case 'D': return {status: 'Dev', effort: 1}
case 'q': return {status: 'QA', effort: 0.5}
}
}
保持重构——将函数提取到文件中
对于包含不同字符的任务,根本不需要修改代码。然而,作为一个负责任的程序员,我们可以不断清理代码,直到达到理想的状态。例如,我们可以将解析提取到一个查找字典中:
const dict = {
'd': {
status: 'Dev',
effort: 0.5
},
'D': {
status: 'Dev',
effort: 1.0
},
'q': {
status: 'QA',
effort: 0.5
},
'Q': {
status: 'QA',
effort: 1.0
}
}
这将把parse函数简化为类似于
const parse = (c) => dict[c]
为了清晰起见,您甚至可以将dict作为数据提取到一个名为constants的单独文件中,并将其导入到translator.js中。对于translate中的forEach功能,我们可以使用Array.reduce使其更短:
const translate = (input) => {
const items = input.split('')
return items.reduce((accumulator, current) => {
const { status, effort } = parse(current)
accumulator[status] = (accumulator[status] || 0) + effort
return accumulator
}, {})
}
又漂亮又干净,对吧?正如你在图 3-1 中看到的,现在所有的测试都通过了。
图 3-1
翻译器的所有测试用例均通过
请注意,重构过程可以一直进行下去,直到您对代码感到满意为止。注意不要对潜在的变化做过多的假设,或者将代码抽象到超出有用的水平,从而过度设计。
摘要
我们学习了编写一个合适的测试的三个基本步骤,现在了解了如何使用Triangulation在测试中推出不同的路径。我们还学习了如何执行tasking来帮助我们编写测试。接下来,我们一步一步地按照TDD的方式完成了一个相当小的程序,最终在现实生活中得到一些有用的东西。
四、项目设置
在我们进入本书的主要内容之前,我们需要建立几个基础设施。我们将用create-react-app和 install/config Material-UI框架建立项目代码库和框架代码,以简化用户界面开发;最后但同样重要的是,我们将建立端到端的 UI 测试框架Cypress。
应用要求
在本书中,我们将从头开始开发一个 web 应用。我们将称之为Bookish;这是一个关于books的简单应用——顾名思义。在应用中,用户可以有一个图书列表,可以通过关键字搜索图书,用户可以导航到图书的详细页面,并查看图书的description、review,和ranking。我们将以迭代的方式完成一些特性,在这个过程中应用ATDD。
在应用中,我们将开发几个典型的功能,包括图书列表和图书详情页面,以及搜索和评论功能。
功能 1–书目
在现实世界中,一个特性的粒度要比我们在本书中描述的大得多。通常,在一个特性中会有许多用户故事,比如图书列表、分页、图书列表的样式等等。让我们假设这里每个特征只有一个故事。
- 出示书单。
我们可以用这种形式描述用户故事:
作为一个用户,我希望看到一个书单,这样我就可以学到一些新东西
这是一种非常流行的描述用户故事的格式,这是有充分理由的。通过描述As a <role>,它强调了谁将从这个特性中受益,通过说I want to <do something>,你解释了用户将如何与系统交互。最后,So that <value>一句话描述了这一功能背后的商业价值。
这种格式迫使我们从利益相关者的角度考虑问题,并希望告诉业务分析师和开发人员他们正在处理的用户故事中最重要的(有价值的)点是什么。
验收标准是
-
假设系统中有
ten本书,用户应该在页面上看到十个项目。 -
在每个项目中,应该显示以下信息:书名、作者、价格和评级。
验收标准有时可以用以下方式书写:
Given there are `10` books in the library
When a user visits the homepage
Then he/she would see `10` books on the page
And each book would contain at least `name`, `author`, `price` and `rating`
given子句解释了应用的当前状态,当它意味着用户触发一些动作时,例如,点击一个按钮或导航到一个页面,而then是一个断言,陈述了应用的预期性能。
功能 2–图书详情
- 显示图书详细信息。
作为一个用户,我希望看到一本书的细节,这样我就可以快速了解它的内容。
验收标准是
-
用户单击图书列表中的一个项目,然后被重定向到详细信息页面。
-
详细信息页面显示书名、作者、价格、描述和任何评论。
功能 3–搜索
- 按书名搜索
作为一个用户,我想按书名搜索一本书,这样我就可以快速找到我感兴趣的内容。
验收标准是
-
用户键入
Refactoring作为搜索词。 -
图书列表中只显示名称中带有
Refactoring的图书。
功能 4–评论
- 除了详细页面上的其他信息之外
作为一名用户,我希望能够给我以前读过的一本书添加评论,以便有相同兴趣的人可以决定是否值得阅读。
相应的验收标准是
-
用户可以在详细页面上阅读评论。
-
用户可以对某本书发表评论。
-
用户可以编辑他们发布的评论。
定义好所有这些需求后,我们就可以开始项目设置了。
创建项目
让我们首先从一些基本的软件包安装和配置开始。确保本地安装了node(至少需要节点> = 8.10 和 npm > = 5.6)。之后,您可以使用npm来安装构建我们的Bookish应用所需的工具(我们已经在前一章中介绍了这一部分;万一你还没看过,就去看看吧)。
使用创建-React-应用
安装完成后,我们可以使用create-react-app包来创建我们的项目:
npx create-react-app bookish-react
create-react-app会默认安装react、react-dom和一个名为react-scripts的命令行工具。此外,它会自动下载这些库及其依赖项,包括webpack、babel等。通过使用create-react-app,我们不需要任何配置就可以启动并运行应用。
在创建过程之后,正如控制台日志所建议的,我们可以跳转到bookish-react文件夹并运行npm start,然后您应该能够看到它像图 4-1 那样启动:
cd bookish-react
npm start
图 4-1
在终端中启动您的应用
会有一个新的浏览器标签页自动打开在这个地址:http://localhost:3000。用户界面应该如图 4-2 所示。
图 4-2
在浏览器中运行的应用
项目文件结构
我们不需要由create-react-app生成的所有文件,所以让我们先做一些清理工作。我们可以删除src文件夹中所有不相关的文件,给我们留下以下文件:
src
├── App.css
├── App.js
├── index.css
└── index.js
修改App.js文件内容,如下所示:
import React from 'react';
import './App.css';
function App() {
return (
<div className='App'>
<h1>Hello world</h1>
</div>
);
}
export default App;
而index.js是这样的:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
那么我们的用户界面看起来应该如图 4-3 所示。
图 4-3
清理后
材料-用户界面库
为了让我们在这里演示的应用看起来更真实,同时减少代码片段中的css技巧,我们将使用Material-UI。这个库包含许多现成的可重用组件,比如Tabs、ExpandablePanel等等。这将帮助我们更快、更容易地构建我们的bookish应用。
安装非常简单;再来一个npm install就可以了:
npm install @material-ui/core @material-ui/icons --save
之后,让我们在我们的public/index.html中放置一些字体来改善外观和感觉。
字体和图标
注意第二行是用于svg图标的:
<link rel='stylesheet' href='https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap' />
<link rel='stylesheet' href='https://fonts.googleapis.com/icon?family=Material+Icons' />
这就是我们目前所需要的。
以Typography为例
我们可以在代码中使用来自material-ui的Component,像这样在App.js中导入模块:
import { Typography } from '@material-ui/core';
然后将h1改为<Typography>:
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
通过使用Material-UI,我们不再需要为css准备一个单独的文件,因为它利用了css-in-js方法来使组件被封装和独立。然后我们可以删除所有的.css文件,确保删除对它们的任何引用。
现在,项目结构只剩下两个文件:
src
├── App.js
└── index.js
index.js应该是这样的:
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
并且App.js这样:
import React from 'react';
import Typography from '@material-ui/core/Typography';
function App() {
return (
<div>
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
</div>
);
}
export default App;
柏树
在本书的第一版中,我使用了木偶师作为 UI 功能测试的引擎,这是一个非常好的工具。但是,我发现它的 API 对大多数初学者来说水平太低了。从最终用户的角度来看,当查询页面上的元素时,您必须记住许多不必要的细节,比如async/await对。而且它不提供基本的助手,比如fixtures或者stubs,这些助手在TDD中被广泛使用。
所以这一次,我将使用 Cypress 想法几乎是一样的,Cypress给了我们更多的选择和更好的机制来减少编写测试的工作量。像fixture和route这样的功能是工具自带的,可以让我们的生活变得更加轻松。
好消息是安装很简单,您根本不需要配置它。
树立柏树
让我们运行以下命令来启动:
npm install cypress --save-dev
安装完成后,确保应用正在运行,然后我们可以运行cypress命令来启动 GUI 以创建我们的第一个测试套件,如图 4-4 所示:
npx cypress open
图 4-4
赛普拉斯的介绍页
这将在我们的项目代码之外创建一个名为cypress的新文件夹。
现在,让我们去掉大部分生成的代码,在ui文件夹下创建一个文件bookish.spec.js,这个文件在cypress/integration下,用于我们的第一个端到端测试。文件夹结构应该如图 4-5 所示。
图 4-5
柏树的折叠结构
目前,我们唯一需要关心的是bookish.spec.js。我们将在接下来的章节中研究fixtures。
编写我们的第一个端到端测试
你还记得我们讨论过TDD最具挑战性的部分可能是从哪里开始以及如何编写第一个测试吗?
我们第一个测试的可行选项是
- 确保页面上有一个
Heading元素,内容是Bookish。
这个测试乍看起来可能毫无意义,但实际上,它可以确保
-
前端代码可以编译翻译。
-
浏览器可以正确地呈现我们的页面(没有任何脚本错误)。
所以,在我们的bookish.spec.js中,简单地说
describe('Bookish application', function() {
it('Visits the bookish', function() {
cy.visit('http://localhost:3000/');
cy.get('h2[data-test="heading"]').contains('Bookish')
})
})
cy是cypress中的全局对象。它几乎包含了我们编写测试所需的一切:导航到浏览器,查询页面上的元素,以及执行断言。我们刚刚编写的测试试图访问http://localhost:3000/,然后确保将data-test标志作为heading的h2的内容等于字符串:Bookish(图 4-6 )。
图 4-6
运行我们的第一个测试
在日常的开发工作流中,尤其是当有几个端到端的测试正在运行时,您可能不希望看到所有的细节(填写表单字段、滚动页面或一些通知),因此您可以使用以下命令将其配置为在 headless 模式下运行:
npx cypress run
定义快捷命令
只需在package.json中的scripts部分下定义一个新任务:
"scripts": {
"e2e": "cypress run"
},
确保应用正在运行(npm start),然后从另一个终端运行npm run e2e。这将为您完成所有的脏工作,并在所有测试完成后给您一份详细的报告(图 4-7 )。
图 4-7
在终端中运行端到端测试
另外,您也可以在 CI 环境中使用这个command。
将代码提交到版本控制
太美了!我们现在有了验收测试及其相应的实现,我们可以将代码提交给版本控制,以防将来需要回顾。我将在本书中使用git,因为它是最受欢迎的一个,你会发现现在几乎每个开发人员的计算机中都安装了它。
运行以下命令会将当前文件夹初始化为git存储库:
git init
然后在本地犯。当然,您可能还想将其推送到 GitHub 或 GitLab 之类的远程存储库,以便与同事共享:
git add .
git commit -m "make the first e2e test pass"
要忽略的文件
如果你有不想发布或分享给他人的东西,在根目录下创建一个.gitignore文本文件,把不想分享的文件名放进去,像这样:
*.log
.idea/
debug/
前面提到的列表将忽略任何带有log扩展名和文件夹.idea的文件(由 JetBrains IDEs 如 WebStorm 自动生成)。
摘要
现在看看我们得到了什么:
-
运行验收测试套件
-
可以将
Bookish渲染为heading的页面
这是一个伟大的成就。现在,我们已经建立了所有必要的机制,我们可以专注于业务需求的实现。
五、实现图书列表
我们的第一个要求是制定一个书单。从验收测试的角度来看,我们所要做的就是确保页面包含图书列表——我们不需要担心将使用什么技术来实现页面。而且不管页面是动态生成的还是只是静态 HTML,只要页面上有图书列表就行。
书单的验收测试
(书的)清单
首先,让我们在describe块的bookish.spec.js中添加一个测试用例:
it('Shows a book list', () => {
cy.visit('http://localhost:3000/');
cy.get('div[data-test="book-list"]').should('exist');
cy.get('div.book-item').should('have.length', 2);
})
我们期望有一个容器具有book list的data-test属性,并且这个容器有几个.book-item元素。如果我们现在运行测试(npm run e2e),它将悲惨地失败。按照TDD的步骤,我们需要实现尽可能简单的代码来通过测试:
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
+ <div data-test='book-list'>
+ <div className='book-item'>
+ </div>
+ <div className='book-item'>
+ </div>
+ </div>
</div>
);
}
验证图书名称
太好了,测试通过了。如你所见,我们已经通过测试驱动了HTML 结构。现在让我们为测试添加另一个期望:
cy.get('div[data-test="book-list"]').should('exist');
- cy.get('div.book-item').should('have.length', 2);
+ cy.get('div.book-item').should((books) => {
+ expect(books).to.have.length(2);
+
+ const titles = [...books].map(x => x.querySelector('h2').innerHTML);
+ expect(titles).to.deep.equal(['Refactoring', 'Domain-driven design'])
+ })
})
为了通过这个测试,我们可以再次对我们期望的 html 进行硬编码:
<div data-test='book-list'>
<div className='book-item'>
+ <h2 className='title'>Refactoring</h2>
</div>
<div className='book-item'>
+ <h2 className='title'>Domain-driven design</h2>
</div>
</div>
太棒了。我们的测试再次通过(图 5-1 )。
图 5-1
通过硬编码书名的测试
现在是时候审查代码,检查是否有任何代码味道,然后进行任何必要的重构。
重构——提取函数
首先,将所有的.book-item元素放在render方法中可能并不理想。相反,我们可以使用一个forloop来生成 HTML 内容。
对于关心干净代码的开发人员来说,静态重复是不可接受的,对吗?所以我们可以把它作为一个变量(books)提取出来,然后执行一个map:
function App() {
+ const books = [{ name: 'Refactoring' }, { name: 'Domain-driven design' }];
+
return (
<div>
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
<div data-test='book-list'>
- <div className='book-item'>
- <h2 className='title'>Refactoring</h2>
- </div>
- <div className='book-item'>
- <h2 className='title'>Domain-driven design</h2>
- </div>
+ {
+ books.map(book => (<div className='book-item'>
+ <h2 className='title'>{book.name}</h2>
+ </div>))
+ }
</div>
</div>
);
之后,我们可以将map块提取到一个函数中,该函数负责通过任意数量的给定的book对象来呈现书籍:
const renderBooks = (books) => {
return <div data-test='book-list'>
{
books.map(book => (<div className='book-item'>
<h2 className='title'>{book.name}</h2>
</div>))
}
</div>;
}
注意这里复习了提取功能, https://refactoring.com/catalog/extractFunction.html
每当调用该方法时,我们可以传递一组书籍,如下所示:
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
- <div data-test='book-list'>
- {
- books.map(book => (<div className='book-item'>
- <h2 className='title'>{book.name}</h2>
- </div>))
- }
- </div>
+ {renderBooks(books)}
</div>
);
我们的测试仍然通过。我们改进了内部实现,而没有修改外部行为。这很好地展示了TDD提供的好处之一:更容易、更安全的清理。
重构——提取组件
现在,代码更加简洁明了,但还可以做得更好。一个可能的变化是进一步模块化代码;抽象的粒度应该基于component,而不是基于function。例如,我们使用函数renderBooks将解析后的数组呈现为图书列表,我们可以抽象一个名为BookList的组件来做同样的事情。创建一个文件BookList.js,将函数renderBooks移入其中。
从 React 16 开始,在大多数情况下,我们在创建组件时不需要class。通过使用一个纯函数,它可以更容易地完成(并且代码更少)。
import React from 'react';
const BookList = ({books}) => {
return <div data-test='book-list'>
{
books.map(book => (<div className='book-item'>
<h2 className='title'>{book.name}</h2>
</div>))
}
</div>;
}
export default BookList;
现在,我们可以像使用任何React内置组件一样使用这个定制组件(例如div或h1):
function App() {
const books = [
{ name: 'Refactoring' },
{ name: 'Domain-driven design' }
];
return (
<div>
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
<BookList books={books} />
</div>
);
}
通过这种重构,我们的代码变得更具声明性,也更容易理解。此外,我们的测试仍然是green。你可以无所畏惧地修改代码,而不用担心破坏现有的功能。它给你信心去改变现有的代码并提高内部质量。
与图书服务器交谈
一般来说,书单的数据千万不要硬编码在代码里。在大多数实际项目中,这些数据存储在远程服务器上的某个地方,需要在应用启动时获取。为了让我们的应用以这种方式工作,我们需要做以下事情:
-
配置一个存根服务器来提供我们需要的图书数据。
-
使用客户端网络库
axios从存根服务器获取数据。 -
使用获取的数据呈现我们的组件。
虽然我们可以简单地使用原生 API fetch与服务器端进行通信,但在这种情况下我更喜欢使用axios,因为它提供了语义 API ( axios.get、axios.put等等),并且它具有抽象和垫片来阻止不同浏览器之间的差异(以及不同版本的同一浏览器)。
所以我们先来看看stub server。
存根服务器
存根服务器通常用在开发过程中。这里,我们将使用一个叫做json-server的工具。这是一个非常轻量级且易于上手的节点包。
设置json-服务器
首先,我们需要将它安装到全球空间中,就像我们安装其他工具一样:
npm install json-server --global
然后,我们将创建一个名为stub-server的空文件夹:
mkdir -p stub-server
cd stub-server
之后,我们创建一个包含以下内容的db.json文件:
{
"books": [
{ "name": "Refactoring" },
{ "name": "Domain-driven design" }
]
}
该文件定义了一个route和该route的数据。现在,我们可以使用以下命令启动服务器:
json-server --watch db.json --port 8080
如果您打开浏览器并导航到http://localhost:8080/books,您应该能够看到如下内容:
[
{
"name": "Refactoring"
},
{
"name": "Domain-driven design"
}
]
当然,您可以使用curl从命令行获取它。
确保存根服务器正在工作
为了验证存根服务器是否如预期的那样工作,我们可以像这样运行 curl 来测试它,我们应该能够看到我们在前面的部分中设置的响应:
$ curl http://localhost:8080/books
[
{
"name": "Refactoring"
},
{
"name": "Domain-driven design"
}
]
让我们添加一个脚本,让生活变得简单一点。在我们的package.json中的scripts下,增加scripts部分:
"scripts": {
"stub-server": "json-server --watch db.json --port 8080"
},
我们可以从根目录运行npm run stub-server来启动并运行我们的存根服务器。太好了。让我们尝试对 bookish 应用进行一些更改,以便通过 HTTP 调用获取这些数据。
应用中的异步请求
回到应用文件夹:bookish-react。为了发送请求和获取数据,我们需要一个 HTTP 客户端。在这种情况下,我们将使用axios。
在我们的项目中安装axios很容易:
npm install axios --save
然后,我们可以用它来获取我们的App.js中的数据,如下所示:
-import React from 'react';
+import React, { useState, useEffect } from 'react;
import Typography from '@material-ui/core/Typography';
+import axios from 'axios';
import BookList from './BookList';
-function App() {
- const books = [{ name: 'Refactoring' }, { name: 'Domain-driven design' }];
+const App = () => {
+ const [books, setBooks] = useState([]);
+
+ useEffect(() => {
+ const fetchBooks = async () => {
+ const res = await axios.get('http://localhost:8080/books');
+ setBooks(res.data);
+ };
+
+ fetchBooks();
+ }, []);
return (
<div>
你可能注意到了,当我们这么做的时候,我们将 App 组件重构为一个功能组件,而不是一个类组件。这允许我们使用 react-hooks API:useState和useEffect。useState类似于this.setState API,而useEffect用于副作用,如setTimeout或async远程调用。在回调中,我们定义了一个向localhost:8080/books发送异步调用的effect,一旦获取数据,将用该数据调用setBooks,最后用来自状态的books调用BookList。
当我们现在运行我们的应用时,当到达books API 时,您可以在控制台中看到来自存根服务器的一些输出(图 5-2 )。
图 5-2
启动存根服务器
安装和拆卸
让我们仔细看看我们的代码和测试。如你所见,这里隐含的假设是测试知道实现将返回two本书。这个假设的问题是它让测试变得有点神秘:为什么我们期待expect(books.length).toEqual(2),为什么不期待3?还有为什么那两本书是Refactoring和Domain-Driven Design?这种假设应该避免,或者应该在测试中的某个地方解释清楚。
一种方法是创建一些 fixture 数据,这些数据将在每次测试前设置,并在每次测试完成后清除。
json-server提供了一种可编程的方式来做到这一点。我们可以用一些代码来定义存根服务器的行为。
用Middleware扩展存根簿服务
对于这一步,我们需要在本地安装json-server,所以从命令行运行npm install json-server --save-dev。
在stub-server文件夹中,创建一个名为server.js的文件,并在其中添加一些middleware:
const jsonServer = require('json-server')
const server = jsonServer.create()
const router = jsonServer.router('db.json')
const middlewares = jsonServer.defaults()
server.use((req, res, next) => {
if (req.method === 'DELETE' && req.query['_cleanup']) {
const db = router.db
db.set('books', []).write()
res.sendStatus(204)
} else {
next()
}
})
server.use(middlewares)
server.use(router)
server.listen(8080, () => {
console.log('JSON Server is running')
})
该函数将根据收到的请求方法和查询字符串执行一些操作。如果请求是一个DELETE请求,并且查询字符串中有一个_cleanup参数,我们将通过将req.entity设置为空数组来清理实体。所以当你发送一个DELETE到http://localhost:8080/books?_cleanup=true时,这个函数会将books数组置空。
有了这些代码,您可以使用以下命令启动服务器:
node server.js
完整版的存根服务器代码托管在这里: https://github.com/abruzzi/react-tdd-mock-server
一旦我们有了这个中间件,我们就可以在我们的测试设置和拆卸挂钩中使用它。在bookish.spec.js的顶部,在describe模块内,添加
before(() => {
return axios
.delete('http://localhost:8080/books?_cleanup=true')
.catch((err) => err);
});
afterEach(() => {
return axios
.delete('http://localhost:8080/books?_cleanup=true')
.catch(err => err)
})
beforeEach(() => {
const books = [
{ 'name': 'Refactoring', 'id': 1 },
{ 'name': 'Domain-driven design', 'id': 2 }
]
return books.map(item =>
axios.post('http://localhost:8080/books', item,
{ headers: { 'Content-Type': 'application/json' } }
)
)
})
确保在文件顶部也导入axios。
在所有测试运行之前,我们将通过向这个端点'http://localhost:8080/books?_cleanup=true'发送一个DELETE请求来删除数据库中的任何内容。然后,在运行每个测试之前,我们将两本书插入存根服务器,并对 URL: http://localhost:8080/books发出POST请求。最后,在每次测试后,我们会清理它们。
在存根服务器运行的情况下,运行测试并观察控制台中发生的情况。
每个挂钩之前和之后
现在,我们可以随意修改设置中的数据。例如,我们可以添加另一本名为Building Microservices的书:
beforeEach(() => {
const books = [
{ 'name': 'Refactoring', 'id': 1 },
{ 'name': 'Domain-driven design', 'id': 2 },
{ 'name': 'Building Microservices', 'id': 3 }
]
return books.map(item =>
axios.post('http://localhost:8080/books', item,
{ headers: { 'Content-Type': 'application/json' } }
)
)
})
并期待three本书在测试:
it('Shows a book list', () => {
cy.visit('http://localhost:3000/');
cy.get('div[data-test="book-list"]').should('exist');
cy.get('div.book-item').should((books) => {
expect(books).to.have.length(3);
const titles = [...books].map(x => x.querySelector('h2').innerHTML);
expect(titles).to.deep.equal(
['Refactoring', 'Domain-driven design', 'Building Microservices']
)
})
});
添加装载指示器
我们的应用正在远程获取数据,不能保证数据会立即返回。我们希望有一些加载时间的指标,以改善用户体验。此外,当根本没有网络连接(或超时)时,我们需要显示一些错误消息。
在我们将它添加到代码中之前,让我们想象一下如何模拟这两个场景:
-
缓慢的请求
-
失败的请求
不幸的是,这两个场景都不容易模拟,即使我们可以模拟,我们也必须将测试与代码紧密耦合。让我们仔细反思一下我们想要做什么:组件有三种状态(加载、错误、成功),所以如果我们能够以隔离的方式测试这三种状态的行为,那么我们就可以确保我们的组件是功能性的。
首先重构
为了让测试更容易编写,我们需要先做一点重构。看一看App.js:
import BookList from './BookList';
const App = () => {
const [books, setBooks] = useState([]);
useEffect(() => {
const fetchBooks = async () => {
const res = await axios.get('http://localhost:8080/books');
setBooks(res.data);
};
fetchBooks();
}, []);
return (
<div>
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
<BookList books={books} />
</div>
);
}
目的现在看起来很清楚,但是如果我们想增加更多的州,责任可能是混合的。
添加更多状态
如果我们想处理有loading或error状态的情况,我们需要向组件引入更多的状态:
const App = () => {
const [books, setBooks] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(false);
useEffect(() => {
const fetchBooks = async () => {
- const res = await axios.get('http://localhost:8080/books');
- setBooks(res.data);
+ setError(false);
+ setLoading(true);
+
+ try {
+ const res = await axios.get('http://localhost:8080/books');
+ setBooks(res.data);
+ } catch (e) {
+ setError(true);
+ } finally {
+ setLoading(false);
+ }
};
fetchBooks();
}, []);
由于我们不一定需要显示整个页面的loading和error,我们可以将它移到自己的组件BookListContainer.js中。
重构:提取组件
import React, {useEffect, useState} from 'react';
import axios from 'axios';
import BookList from './BookList';
const BookListContainer = () => {
const [books, setBooks] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
const fetchBooks = async () => {
setError(false);
setLoading(true);
try {
const res = await axios.get('http://localhost:8080/books');
setBooks(res.data);
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
};
fetchBooks();
}, []);
return <BookList books={books} />
}
export default BookListContainer;
然后这个应用就变成了
const App = () => {
return (
<div>
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
<BookListContainer/>
</div>
);
}
嗯,可行。但是缺点是我们仍然将网络请求和渲染耦合在一起。这使得单元测试非常复杂。所以我们把网络和渲染分开。
定义一个 React 钩子
幸运的是,React 允许我们以非常灵活的方式定义钩子。我们可以将网络部分提取到一个hooks.js文件中的一个hook中,并允许组件像使用其他hook一样使用它。
export const useRemoteService = (initial) => {
const [data, setData] = useState(initial);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
const fetchBooks = async () => {
setError(false);
setLoading(true);
try {
const res = await axios.get('http://localhost:8080/books');
setData(res.data);
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
};
fetchBooks();
}, []);
return {data, loading, error};
}
这里,我们将所有与网络相关的代码分解到一个钩子中。在BookListContainer中,我们可以这样调用它:
const BookListContainer = () => {
const {data, loading, error} = useRemoteService([]);
// if(loading) {
// return <p>Loading...</p>
// }
// if(error) {
// return <p>Error...</p>
// }
return <BookList books={data} />
}
看起来很酷,对吧?useRemoteService唯一需要的参数是BookList渲染的默认值。代码现在很好很干净,最重要的是,功能测试仍然通过。
使用useRemoteService挂钩
此外,我更喜欢将所有的 UI 元素放在一起,这可以使单元测试更加方便:
const BookListContainer = () => {
const {data, loading, error} = useRemoteService([]);
return <BookList books={data} loading={loading} error={error}/>
}
我们将loading和error状态传递给BookList组件,让它决定显示什么。在我们直接进入实现之前,让我们为这些场景编写一些单元测试。
使用 React 测试库进行单元测试
在我们添加任何单元测试之前,我们需要添加一些包:
npm install @testing-library/react --save-dev
测试加载状态
现在,在src中创建一个名为BookList.test.js的测试文件:
import React from 'react'
import {render} from '@testing-library/react'
import BookList from './BookList';
describe('BookList', () => {
it('loading', () => {
const props = {
loading: true
};
const {container} = render(<BookList {...props} />)
const content = container.querySelector('p');
expect(content.innerHTML).toContain('Loading');
});
});
用npm test运行测试。因为我们还没有代码,所以测试会失败。
我们可以实施一个快速解决方案:
const BookList = ({loading, books}) => {
if(loading) {
return <p>Loading...</p>
}
return <div data-test='book-list'>
{
books.map(book => (<div className='book-item'>
<h2 className='title'>{book.name}</h2>
</div>))
}
</div>;
}
测试错误状态
测试网络错误的情况,你可以看到现在所有的测试都通过了图 5-3
it('error', () => {
const props = {
error: true
};
const {container} = render(<BookList {...props} />);
const content = container.querySelector('p');
expect(content.innerHTML).toContain('Error');
})
图 5-3
关于错误状态的测试现在通过
测试预期数据
最后,我们可以添加一个happy path来确保我们的组件在成功场景中呈现:
it('render books', () => {
const props = {
books: [
{ 'name': 'Refactoring', 'id': 1 },
{ 'name': 'Domain-driven design', 'id': 2 },
]
};
const { container } = render(<BookList {...props} />);
const titles = [...container.querySelectorAll('h2')].map(x => x.innerHTML);
expect(titles).toEqual(['Refactoring', 'Domain-driven design']);
})
您可能想知道这是不是重复——我们不是已经在验收test中测试过这个案例了吗?嗯,是和否。单元测试中的案例可以用作文档;它指定组件需要什么参数、字段名称和类型。例如,在props中,我们明确显示了BookList需要一个带有图书字段的对象,这是一个数组。
运行测试时,我们将在控制台中看到一条警告:
console.error node_modules/react/cjs/react.development.js:172
Warning: Each child in a list should have a unique 'key' prop.
Check the render method of "BookList." See https://fb.me/react-warning-keys for more information.
in div (at BookList.jsx:14)
in BookList (at BookList.test.jsx:32)
这告诉我们,当呈现一个列表时,React要求每个条目都有一个唯一的key,比如id。我们可以通过为循环中的每一项添加一个key来快速修复它。在我们的例子中,由于每本书都有唯一的ISBN(国际标准书号),我们可以在存根服务器中使用它。现在,我们的BookList的最终版本看起来是这样的:
import React from 'react';
const BookList = ({loading, error, books}) => {
if(loading) {
return <p>Loading...</p>
}
if(error) {
return <p>Error...</p>
}
return <div data-test='book-list'>
{
books.map(book => (<div className='book-item' key={book.id}>
<h2 className='title'>{book.name}</h2>
</div>))
}
</div>;
}
export default BookList;
单元测试全部通过(图 5-4 ),太好了!
图 5-4
书单不同状态的测试
摘要
有时,我们可能会发现为代码编写测试很复杂:可能有很多外部依赖。在这种情况下,我们需要首先重构,提取出依赖项,然后添加测试。