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
。正如我们在
Chapter
1
中讨论的,拆分对于应用 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
书单不同状态的测试
摘要
有时,我们可能会发现为代码编写测试很复杂:可能有很多外部依赖。在这种情况下,我们需要首先重构,提取出依赖项,然后添加测试。
六、实现图书详细视图
对于图书列表中的每本书,我们希望将其名称显示为超链接,这样当用户单击它时,浏览器将导航到详细页面。详细页面将包含特定于每本书的内容,包括书名、封面图片、描述、评论等等。
验收测试
在我们的bookish.spec.js
中,我们可以将这个需求描述为验收测试:
it('Goes to the detail page', () => {
cy.visit('http://localhost:3000/');
cy.get('div.book-item').contains('View Details').eq(0).click();
cy.url().should('include', '/books/1');
});
运行测试,它会失败。
链接到详细页面
那是因为我们还没有一条/books
路线,我们也没有链接。为了使测试通过,在BookList
组件中添加一个超链接:
{
books.map(book => (<div className='book-item' key={book.id}>
<h2 className='title'>{book.name}</h2>
+ <a href={`/books/${book.id}`}>View Details</a>
</div>))
}
验证详细页面上的图书标题
然后,为了确保页面在导航后显示预期的内容,我们需要在bookish.spec.js
中添加一行:
it('Goes to the detail page', () => {
cy.visit('http://localhost:3000/');
cy.get('div.book-item').contains('View Details').eq(0).click();
cy.url().should('include', '/books/1');
+ cy.get('h2.book-title').contains('Refactoring');
});
该检查页面有一个.book-title
部分,它的内容是Refactoring
。测试再次失败;让我们通过在应用中添加客户端路由来解决这个问题。
正如您所看到的,这里有一个页面导航:当用户点击一个button
时,将能够跳转到detail page
。这意味着我们需要某种机制来维护路由。
前端路由
我们需要添加react-router
和react-router-dom
作为依赖项,它们为我们提供了客户端路由机制:
npm install react-router react-router-dom
在index.js
中,我们导入BrowserRouter
并将其包裹在<App />
周围。这意味着整个应用可以共享全局Router
配置。
+import {BrowserRouter as Router} from 'react-router-dom'
+
-ReactDOM.render(<App />, document.getElementById('root'));
+ReactDOM.render(<Router>
+ <App />
+</Router>, document.getElementById('root'));
然后我们在App.js
中定义两条路线:
+import {Route, Switch} from 'react-router-dom';
import BookListContainer from './BookListContainer';
+import BookDetailContainer from './BookDetailContainer';
const App = () => {
return (
@@ -8,7 +10,10 @@ const App = () => {
<Typography variant='h2' component='h2' data-test='heading'>
Bookish
</Typography>
- <BookListContainer/>
+ <Switch>
+ <Route exact path='/' component={BookListContainer} />
+ <Route path='/books/:id' component={BookDetailContainer} />
+ </Switch>
</div>
);
}
通过这些路径,当用户访问根路径/
时,组件BookListContainer
将被呈现。当访问/books/123
时,将显示BookDetailContainer
。
BookDetailContainer
组件
最后,我们需要创建一个新文件BookDetailContainer.js
。它将与第一版BookListContainer.js
非常相似,除了这本书的id
将作为match.params.id
通过react-router
。一旦我们有了图书 id,我们就可以发送一个 HTTP 请求来加载图书的详细信息:
```js BookDetailContainer.js
import React, {useEffect, useState} from ‘react’
import axios from ‘axios’
const BookDetailContainer = ({match}) => { const [id, _] = useState(match.params.id); const [book, setBook] = useState({});
useEffect(() => { const fetchBook = async () => { const book = await axios.get(http://localhost:8080/books/${id}); setBook(book.data); };
fetchBook();
}, [id]);
return (
<h2 className='book-title'>{book.name}</h2>
) }
export default BookDetailContainer ```jsx
很好,功能测试现在通过了(图 6-1 )。
图 6-1
图书详细页的验收测试
一般化useRemoteService
钩子
然而,数据获取过程可以改进。是时候让我们重构useRemoteService
来适应新的需求了。因为我们已经准备好了更高级别的测试,所以我们可以自信地做出一些改变。
-export const useRemoteService = (initial) => {
+export const useRemoteService = (url, initialData) => {
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
setLoading(true);
try {
- const res = await axios.get('http://localhost:8080/books');
+ const res = await axios.get(url);
setData(res.data);
} catch (e) {
setError(true);
我们将硬编码的url
作为参数移出,在调用位置,简单地说
const {data, loading, error} = useRemoteService('http://localhost:8080/books', []);
用新挂钩简化BookDetailContainer
对于BookDetailContainer
,它可以简化为
import React from 'react'
import {useRemoteService} from './hooks';
const BookDetailContainer = ({match}) => {
const {data} = useRemoteService(`http://localhost:8080/books/${match.params.id}`, {});
return (<div className='detail'>
<h2 className='book-title'>{data.name}</h2>
</div>)
};
export default BookDetailContainer
代码现在看起来干净多了。
单元测试
在端到端测试中,我们只需确保细节页面中有一个title
。如果我们在页面上添加更多的细节,比如description
和book cover
,我们会在更低层次的测试——单元测试中检查它们。单元测试运行速度快,比端到端测试检查更多的具体细节,如果出现问题,开发人员更容易调试。
重构
提取表示组件
尽管在BookDetailContainer
中只有一行来呈现细节,但是将该行提取到一个单独的组件中是一个好主意——我们称之为BookDetail
:
import React from 'react';
const BookDetail = ({book}) => {
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
</div>)
}
export default BookDetail;
BookDetailContainer
那么可以简化为
const BookDetailContainer = ({match}) => {
const {data} = useRemoteService(`http://localhost:8080/books/${match.params.id}`, {});
return (<BookDetail book={data}/>);
};
现在让我们检查所有的测试,功能测试都通过了,但是根据您使用的 react-router 和 react-testing-library 的版本,您的单元测试可能会显示以下错误消息:
● BookList › render books
Invariant failed: You should not use <Link> outside a <Router>
MemoryRouter
用于测试
为了解决这个问题,我们需要通过提供一个<MemoryRouter>
来稍微修改一下BookList.test.js
:
import BookList from './BookList';
+import {MemoryRouter as Router} from 'react-router-dom';
+
+const renderWithRouter = (component) => {
+ return {...render(<Router>
+ {component}
+ </Router>)}
+};
+
我们在render
中添加了一个包装器。这将把您传入的任何组件包装在一个MemoryRouter
中。然后我们可以在所有需要渲染Link
的测试中调用renderWithRouter
而不是render
:
it('render books', () => {
const props = {
books: [
{ 'name': 'Refactoring', 'id': 1 },
{ 'name': 'Domain-driven design', 'id': 2 },
]
};
const { container } = renderWithRouter(<BookList {...props} />);
const titles = [...container.querySelectorAll('h2')].map(x => x.innerHTML);
expect(titles).toEqual(['Refactoring', 'Domain-driven design']);
})
图书详细信息页面
书名
现在,我们可以在文件BookDetail.test.js
中快速添加单元测试,以便驱动实现:
describe('BookDetail', () => {
it('renders title', () => {
const props = {
book: {
name: 'Refactoring'
}
};
const {container} = render(<BookDetail {...props} />);
const title = container.querySelector('.book-title');
expect(title.innerHTML).toEqual(props.book.name);
})
});
这个测试将会通过,因为我们已经呈现了name
字段。
书籍描述
让我们再添加一些字段:
it('renders description', () => {
const props = {
book: {
name: 'Refactoring',
description: "Martin Fowler's Refactoring defined core ideas and techniques " +
"that hundreds of thousands of developers have used to improve " +
"their software."
}
};
const { container } = render(<BookDetail {...props} />);
const description = container.querySelector('p.book-description');
expect(description.innerHTML).toEqual(props.book.description);
})
一个简单的实现如下所示:
const BookDetail = ({book}) => {
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
+ <p className='book-description'>{book.description}</p>
</div>)
}
所有测试现在都以漂亮的绿色通过了!让我们后退一步,看看我们是否能把代码库做得更好一点。我注意到的一件事是,随着我们创建更多的文件,整个项目结构有点爆炸。
文件结构
我们的文件结构非常扁平——根本没有层次结构,因为所有文件都在一个文件夹中。那是代码的味道。很难找到我们想要的东西。让我们重组。
目前,我们的文件如下所示:
src
├── App.js
├── BookDetail.jsx
├── BookDetail.test.jsx
├── BookDetailContainer.jsx
├── BookList.jsx
├── BookList.test.jsx
├── BookListContainer.jsx
├── hooks.js
└── index.js
有多种方法可以将应用分割成模块并组织它们。在尝试了各种项目的不同组合后,我发现用feature
分割应用对我来说是最有意义的。
模块化
所以现在,让我们定义两个独立的文件夹:BookDetail
和BookList
分别用于特性一和特性二。
src
├── App.js
├── BookDetail
│ ├── BookDetail.jsx
│ ├── BookDetail.test.jsx
│ └── BookDetailContainer.jsx
├── BookList
│ ├── BookList.jsx
│ ├── BookList.test.jsx
│ └── BookListContainer.jsx
├── hooks.js
└── index.js
这是很有条理的,读者很容易找到需要更改的组件。
测试数据
您可能会发现为功能测试清理所有数据有点棘手。而当你想手动检查应用在浏览器中的样子时,根本没有数据。
让我们通过为json-server
引入另一个database
文件来解决这个问题:
{
"books": [
{"name": "Refactoring", "id": 1, "description": "Martin Fowler's Refactoring defined core ideas and techniques that hundreds of thousands of developers have used to improve their software."},
{"name": "Domain-driven design", "id": 2, "description": "Explains how to incorporate effective domain modeling into the software development process."},
{"name": "Building Microservices", "id": 3, "description": "Author Sam Newman provides you with a firm grounding in the concepts while diving into current solutions for modeling, integrating, testing, deploying, and monitoring your own autonomous services."},
{"name": "Acceptance Test Driven Development with React", "id": 4, "description": "This book describes how to apply the Acceptance Test Driven Development when developing a Web Application named bookish with React / Redux and other tools in react ecosystem."}
]
}
并将内容作为books.json
保存在stub-server
文件夹中。现在,更新package.json
中的stub-server
脚本:
json-server --watch books.json --port 8080
并运行服务器(图 6-2 ): npm run stub-server
。
图 6-2
用假数据运行我们的存根服务器
记住在这里也要运行端到端测试。当我们改变书单中的预期数据时,我们也需要改变测试的预期。由于服务器正在模拟所有的数据,您会注意到此时我们不需要 beforeEach 和 afterEach。
用户界面优化
我们现在已经完成了两个令人兴奋和具有挑战性的功能。不过用户界面有点平淡(图 6-3);让我们添加一些造型。
图 6-3
Bookish 的用户界面草稿
Material-UI 提供了许多基本的和更高级的 UI 组件,以及其他助手,比如一个responsive
网格系统。
使用Grid
系统
在我们的例子中,让我们为我们的BookList
实现Grid
和Card
组件:
import React from 'react';
+ import {
+ Button,
+ Card,
+ CardActionArea,
+ CardActions,
+ CardContent,
+ Grid,
+ Typography,
+ } from '@material-ui/core';
import { Link } from 'react-router-dom';
const BookList = ({ loading, error, books }) => {
const classes = useStyles();
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>
- <Link to={`/books/${book.id}`}>View Details</Link>
- </div>))
- }
+ return <div data-test='book-list' className={classes.root}>
+ <Grid container spacing={3}>
+ {
+ books.map(book => (<Grid item xs={4} sm={4} key={book.id} className='book-item' >
+ <Card>
+ <CardActionArea>
+ <CardContent>
+ <Typography gutterBottom variant='h5' component='h2' className={classes.name}>
+ {book.name}
+ </Typography>
+ <Typography variant='body2' color='textSecondary' component='p' className={classes.description}>
+ {book.description}
+ </Typography>
+ </CardContent>
+ </CardActionArea>
+ <CardActions>
+ <Button size='small' color='primary'>
+ <Link to={`/books/${book.id}`}>View Details</Link>
+ </Button>
+ </CardActions>
+ </Card>
+ </Grid>))
+ }
+ </Grid>
</div>;
}
这可能看起来有点多,但这些实际上只是标记——想想适合我们应用的 HTML 标记。
为组件创建样式
为了做到这一点,我们需要使用 Material-UI 的makeStyles
函数,它将使用钩子模式将样式表与函数组件链接起来(图 6-4 )。
图 6-4
带有材质的用户界面-UI
const useStyles = makeStyles(theme => ({
root: {
flexGrow: 1,
},
paper: {
padding: theme.spacing(2),
textAlign: 'center',
color: theme.palette.text.secondary,
},
name: {
maxHeight: 30,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
description: {
maxHeight: 40,
overflow: 'hidden',
textOverflow: 'ellipsis',
}
}));
在组件的开始,我们调用useStyles
来生成可以用作className
的类名:
const classes = useStyles();
处理默认值
现在,我们有一个需求调整:后端服务提供的数据可能在一些字段中包含一些意外的 null 值,我们需要优雅地处理这些值。例如,不能保证description
字段总是存在(它可能是空字符串或空值)。在这种情况下,我们需要使用图书名称作为描述后备。
使用undefined
的失败测试
我们可以添加一个测试来描述这种情况,注意 props 对象根本不包含description
字段:
it('displays the book name when no description was given', () => {
const props = {
book: {
name: 'Refactoring'
}
}
const { container } = render(<BookDetail {...props} />);
const description = container.querySelector('p.book-description');
expect(description.innerHTML).toEqual(props.book.name);
})
然后我们的测试又失败了(图 6-5 )。
图 6-5
数据不完整时测试失败
我们可以用一个条件运算符来解决这个问题:
const BookDetail = ({book}) => {
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
<p className='book-description'>{book.description ? book.description : book.name}</p>
</div>)
}
这里值得注意的是conditional operator
。就目前而言,这很简单。但它可能会很快变得复杂。一个更好的选择是将该表达式作为一个单独的函数提取出来。例如,我们可以使用提取函数将潜在变化隔离到一个纯计算函数中。
const getDescriptionFor = (book) => {
return book.description ? book.description : book.name;
}
const BookDetail = ({book}) => {
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
<p className='book-description'>{getDescriptionFor(book)}</p>
</div>)
}
这样,我们将rendering
和computing
分开,这可以带来更好的可测试性和可读性。
最后一次?变化
现在让我们考虑另一个变化:如果description
的长度大于 300 个字符,我们需要在 300 个字符处截断内容,并显示一个show more...
链接。当用户单击该链接时,将显示完整的内容。
我们可以为这种情况添加一个新的测试:
it('Shows *more* link when description is too long', () => {
const props = {
book: {
name: 'Refactoring',
description: 'The book about how to do refactoring ....'
}
};
const { container } = render(<BookDetail {...props} />);
const link = container.querySelector('a.show-more');
const title = container.querySelector('p.book-description');
expect(link.innerHTML).toEqual('Show more');
expect(title.innerHTML).toEqual('The book about how to do refactoring ....');
})
这促使我们以满足需求的方式编写或修改代码。一旦所有的测试都通过了,我们就可以进行重构:提取方法,创建新文件,移动方法或类,重命名变量或改变文件夹结构,等等。
这是一种无休止的过程。我们总有进步的空间。当我们有足够的时间时,我们可以重复这个过程,直到代码变得干净并且自文档化。
摘要
在这一章中,我们已经通过应用验收测试驱动的开发方法实现了 Book Detail 特性,并学习了如何迭代地将其重构到理想状态。让我们在下一章更深入地讨论用存根技术进行测试。
七、按关键字搜索
我们的第三个特性将允许用户通过书名搜索一本书。当图书列表变得很长时,这很有用——当内容超过一个屏幕或页面时,用户可能很难找到他们要找的内容。
接收试验
如前所述,我们首先编写一个acceptance test
:
it('Searches for a title', () => {
cy.visit('http://localhost:3000/');
cy.get('div.book-item').should('have.length', 4);
cy.get('[data-test="search"] input').type('design');
cy.get('div.book-item').should('have.length', 1);
cy.get('div.book-item').eq(0).contains('Domain-driven design');
});
这个测试试图在search
输入框中键入关键字design
,并期望只有Domain-driven design
会出现在图书列表中。
实现这个特性最简单的方法是通过添加一个来自material-ui
的TextField
来修改BookListContainer
:
return (<>
<TextField
label='Search'
value={term}
data-test='search'
onChange={(e) => setTerm(e.target.value)}
margin='normal'
variant='outlined'
/>
<BookList books={data} loading={loading} error={error}/>
);
我们需要将state
引入组件——在 return 语句之前,添加下面一行,记住从react
导入useState
:
const [term, setTerm] = useState('');
当term
(搜索词)改变时,我们想要触发新的搜索。我们可以利用useEffect
钩子,就像
useEffect(() => {
performSearch(`http://localhost:8080/books?q=${term}`)
}, [term]);
我们可以在这里重新编写每一个axios.get
、error
和loading
步骤,但是更明智的做法是重用我们已经定义的现有的useRemoteService
。让我们先稍微调整一下:
-export const useRemoteService = (url, initialData) => {
+export const useRemoteService = (initialUrl, initialData) => {
const [data, setData] = useState(initialData);
+ const [url, setUrl] = useState(initialUrl);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
@@ -22,7 +23,7 @@
fetchBooks();
- }, []);
+ }, [url]);
- return {data, loading, error};
+ return {data, loading, error, setUrl};
}
通过输出setUrl
,我们给了外界一个改变url
的机会。因为我们将[url]
定义为fetchBooks
效果的依赖,所以抓取将被触发。
也就是说我们只需要使用BookListContainer
中的setUrl
,剩下的工作由钩子来完成(图 7-1 ):
图 7-1
寻找一本书
const [term, setTerm] = useState('');
const {data, loading, error, setUrl} = useRemoteService('http://localhost:8080/books', );
useEffect(() => {
setUrl(`http://localhost:8080/books?q=${term}`)
}, [term]);
注意,我们使用books?q=${e.target.value}
作为获取数据的 URL。有json-server
提供的全文搜索 API 你可以把books?q=domain
发送到后端,它会返回所有包含该域名的内容。
您可以像这样在命令行上尝试:
curl http://localhost:8080/books?q=domain
现在,我们的测试又变绿了。让我们跳到Red-Green-Refactoring
的下一步。
更进一步
假设有人想使用我们刚刚在本页完成的搜索框;怎么才能再利用呢?这很难,因为目前搜索框与BookListContainer
中的其余代码紧密耦合,但是我们可以将其提取到另一个组件中,称为SearchBox
:
import React from 'react';
import TextField from '@material-ui/core/TextField/TextField';
const SearchBox = ({term, onSearch}) => {
return (<TextField
label='Search'
value={term}
data-test='search'
onChange={onSearch}
margin='normal'
variant='outlined'
/>)
};
export default SearchBox;
提取之后,BookListContainer
变成
const onSearch = (event) => setTerm(event.target.value);
return (
<SearchBox term={term} onSearch={onSearch}/>
<BookList books={data} loading={loading} error={error}/>
);
现在让我们添加一个单元测试:
import React from 'react';
import {render} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchBox from './SearchBox';
describe('SearchBox', () => {
it('renders input', () => {
const props = {
term: '',
onSearch: jest.fn()
}
const {container} = render(<SearchBox {...props} />);
const input = container.querySelector('input[type="text"]');
userEvent.type(input, 'domain');
expect(props.onSearch).toHaveBeenCalled();
});
})
请注意,为了能够使用user-event
,如果您还没有安装它,您必须安装:
yarn add @testing-library/user-event --dev
我们使用jest.fn()
来创建一个spy
对象,它可以记录调用的轨迹。我们使用userEvent.type
API 模拟一个change
事件,以domain
作为有效负载。然后我们可以期待onChange
方法已经被调用。
让我们在这里增加一个需求:当执行搜索时,我们不希望white-space
成为请求的一部分。所以我们在字符串被发送到服务之前对其进行了处理。让我们先写一个测试:
it('trim empty strings', () => {
const props = {
term: '',
onSearch: jest.fn()
}
const {container} = render(<SearchBox {...props} />);
const input = container.querySelector('input[type="text"]');
userEvent.type(input, ' ');
expect(props.onSearch).not.toHaveBeenCalled();
})
它会失败,因为我们当前将所有的values
发送给了books
API。为了解决这个问题,我们可以在SearchBox
中定义一个函数,在事件到达上层之前intercept
:
const protect = (event) => {
const value = clone(event.target.value);
if(!isEmpty(value.trim())) {
return onSearch(event)
}
}
你会注意到我们使用了一些你以前可能没见过的函数——clone
和isEmpty
。这些将需要安装和从洛达什进口。
yarn add lodash.isempty lodash.clone
不要直接调用onSearch
而是使用函数onChange
,如图 7-2 所示,所有测试都应该通过:
return (<TextField
label='Search'
value={term}
data-test='search'
onChange={protect}
margin='normal'
variant='outlined'
/>)
图 7-2
搜索框的单元测试
我们做了什么?
太好了,我们已经完成了所有三个功能!让我们快速回顾一下我们得到的信息:
-
三个纯组件(BookDetail、BookList、SearchBox)及其单元测试
-
两个容器组件(BookDetailContainer、BookListContainer)
-
一个用于数据获取的定制钩子
-
涵盖最有价值路径的四个验收测试(列表、细节和搜索)
走向
也许你已经在我们的end-to-end
测试中注意到了一些代码味道。我们利用了许多新奇的commands
,但没有准确表达我们在商业价值方面的所作所为:
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'])
})
});
通过引入一些函数,我们可以显著提高可读性:
const gotoApp = () => {
cy.visit('http://localhost:3000/');
}
const checkAppTitle = () => {
cy.get('h2[data-test="heading"]').contains('Bookish');
}
在测试案例中,我们可以像这样使用它们:
it('Visits the bookish', () => {
gotoApp();
checkAppTitle();
});
对于复杂的函数,我们可以抽象得更多:
const checkBookListWith = (expectation = []) => {
cy.get('div[data-test="book-list"]').should('exist');
cy.get('div.book-item').should((books) => {
expect(books).to.have.length(expectation.length);
const titles = [...books].map(x => x.querySelector('h2').innerHTML);
expect(titles).to.deep.equal(expectation)
})
}
像这样使用它:
const checkBookList = () => {
checkBookListWith(['Refactoring', 'Domain-driven design', 'Building Microservices', 'Acceptance Test Driven Development with React']);
}
或者
const checkSearchedResult = () => {
checkBookListWith(['Domain-driven design'])
}
在我们提取了几个函数之后,一些模式出现了。我们可以做一些进一步的重构:
describe('Bookish application', () => {
beforeEach(() => {
feedStubBooks();
gotoApp();
});
afterEach(() => {
cleanUpStubBooks();
});
it('Visits the bookish', () => {
checkAppTitle();
});
it('Shows a book list', () => {
checkBookListWith(['Refactoring', 'Domain-driven design', 'Building Microservices']);
});
it('Goes to the detail page', () => {
gotoNthBookInTheList(0);
checkBookDetail();
});
it('Search for a title', () => {
checkBookListWith(['Refactoring',
'Domain-driven design',
'Building Microservices',
'Acceptance Test Driven Development with React']);
performSearch('design');
checkBookListWith(['Domain-driven design']);
});
});
这看起来更整洁、更简洁。除了干净之外,我们还分离了业务价值和实现细节,这在将来可能会对我们有所帮助(例如,如果我们想要迁移到另一个测试框架或者重写它的某些部分,那么机构对读者来说是显而易见的)。
摘要
在前三章中,我们已经开发了应用Bookish
的三个特性,并且我们已经了解了如何在实际项目中应用 ATDD。我们已经学习了如何快速设置react
环境,以及如何使用模拟服务器来启动模拟服务。
我们引入了Cypress
来写acceptance tests
。一旦我们有了测试,我们就编写简单的代码使它通过,并在代码中发现代码味道时进行重构。在整个过程中,我们一直使用经典的Red-Green-Refactor
循环。当我们重构时,我们根据职责和提取方法来拆分代码,重命名类,并重构文件夹,以使代码更加紧凑,更易于阅读和维护。
此外,我们已经为json-server
添加了一些扩展,使我们能够在运行测试用例之前准备一些数据,并在测试完成后清理。这使得测试本身更具可读性和独立性。
最后,我们学习了如何将cypress
命令重构为有意义的functions
来提高可读性。
八、状态管理
很长一段时间,前端开发都是关于处理不同组件的状态同步。页面上两个搜索框中的关键字(一个在顶部,另一个在底部)、选项卡的活动状态、路由和 URL 中的散列、show more...
链接等等。所有这些状态管理可能会令人难以置信地困惑。即使发明了像Backbone
这样的MVVM
库或双向数据绑定(一种在应用中共享数据的方式,使用它来监听事件并在父组件和子组件之间同时更新值),如果您必须管理不同组件之间的状态,事情仍然很有挑战性(如果有组件的话——在 jQuery 的世界中,没有真正的component
,只有 DOM 片段)。
今天,web 开发是一个完全不同的场景。在典型的网页中,交互和数据转换变得更加复杂。处理这些并发症的方式也发生了变化。
典型的用户界面场景
让我们来看看图 8-1 中这个简单的页面。
图 8-1
许多组件共享相同的数据模型
右边是一个树组件,中间是一个图形组件。现在,当您单击树上的一个节点时,该节点应该根据其以前的状态折叠或展开,并且状态变化也应该同步到图表中。
如果您不想使用任何外部库,只使用来自 DOM API 的自定义事件可能会导致一个dead-loop
——当您必须在graph
上注册一个监听器来监听对tree
的更改时,也要对树做同样的事情。当一个事件被触发时,它将在这两个组件之间来回切换。当你有两个以上的组件时,事情很快会变得更糟。
更可靠的方法是提取底层数据并使用发布-订阅模式:树和图都在监听数据的变化;一旦数据改变,组件应该重新呈现自己。
这种模式的实现现在很普遍;你几乎可以在每个网页上找到它。你可以实现自己的发布订阅库;然而,您可能会发现它很乏味,很难维护。幸运的是,我们有选择。
每当底层数据发生变化——无论是浏览器上的用户事件、计时器还是异步服务调用——我们都需要一种简单的方法来管理这些变化,并确保数据模型总是反映在所有组件的最新数据中。
Redux 简介
Redux
是一个流行的 JavaScript 状态管理工具。
正如redux
文档所述:
Redux 是 JavaScript 应用的可预测状态容器。
通过使用它,测试和调试您的应用变得简单明了,并且您可以轻松地跟踪它的状态。它没有绑定到任何库或框架,所以虽然您不必将它与React
一起使用,但这是它最常见的实现。
冗余的三个原则
-
真理的单一来源
-
状态为只读
-
变化是由纯函数产生的
在Redux
世界中,所有状态都存储在一个全局数据源中。在任何时候,这个数据源都可以映射到视图。当发生变化时——例如,用户点击一个按钮,发生超时,或者后端异步消息到达——将创建一个action
,以描述发生了什么的对象的形式。
只是 JavaScript 对象形式的信息负载,将信息从我们的应用传输到我们的状态存储中。
一旦被创建,action
将通过一个名为reducer
的纯函数。reducer
将指定应用状态如何响应动作而改变,这可能会触发视图的另一次重新呈现。它接受先前的状态和action
并返回新的状态。图 8-2 清晰地展示了这一过程。
图 8-2
使用或不使用 redux 的应用。来源:Danny Huang(kuanhsuh。github。io/2017/09/28/What-s-Redux-and-how-to-use-it/
由于React
提供的virtual dom
机制,UI 会以最小的努力重新渲染。
解耦数据和视图
如果你仔细看看我们的useRemoteService
钩子,你会注意到它实际上做了很多事情:
-
它向外部服务发出数据请求。
-
它负责 url 的更改。
-
它管理几种状态,包括
loading
和error
。
export const useRemoteService = (initialUrl, initialData) => {
const [data, setData] = useState(initialData);
const [url, setUrl] = useState(initialUrl);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
const fetchBooks = async () => {
setError(false);
setLoading(true);
try {
const res = await axios.get(url);
setData(res.data);
} catch (e) {
setError(true);
} finally {
setLoading(false);
}
};
fetchBooks();
}, [url]);
return {data, loading, error, setUrl};
}
其中一些状态将总是一起更新,例如:
{
data: [],
loading: false,
error: false
}
或者
{
error: true
}
然而,在前面讨论的代码片段中,乍一看并不明显。
理想情况下,我们可以重写容器对象来触发一些数据获取动作,如BookDetailContainer
中所示:
const BookDetailContainer = ({match}) => {
const book = fetchBookById(match.params.id);
// that will fetch data with `match.params.id`
return (<BookDetail book={book}/>);
};
fetchBookById
可以是同步函数调用,也可以是同步远程调用,但对于BookDetailContainer
来说,关系不大。如前所述,在全局空间中,除了所有组件之外,还有一个维护应用状态的存储(类似于数据库)。每当在 UI 中的某个地方触发一个动作,并且发生一些修改时,相应的更新数据就会被发送到需要重新呈现的组件。
这就是状态管理容器可以帮助我们的方式。容器可以为我们处理细节,包括监听变化、分派动作、减少状态和广播变化。
视图= f(状态)
在React
社区中有一个众所周知的公式(有趣的是,这种模式似乎很久以前就已经在桌面 GUI 环境中讨论过了,更多内容请阅读底部的“Further Reading
”部分):view = f(state)
,这意味着view
只是state
的一个函数。state
这里展示了我们的应用状态的快照。例如,当用户打开Bookish
主页时,该时间点的数据快照可能是
const state = {
books: [
{'name': 'Refactoring', 'id': 1, 'description': 'Refactoring'},
{'name': 'Domain-driven design', 'id': 2, 'description': 'Domain-driven design'},
{'name': 'Building Microservices', 'id': 3, 'description': 'Building Microservices'}
],
term: ''
}
当用户在搜索框中键入Domain
时,快照变成
const state = {
books: [
{'name': 'Domain-driven design', 'id': 2, 'description': 'Domain-driven design'}
],
term: 'Domain'
}
这两段数据(状态)可以在某一点上代表整个应用。由于view = f(state)
,对于任何给定的state
,view
总是可预测的,所以应用开发人员唯一关心的是如何操作数据,因为UI
将自动呈现。
我知道这听起来很简单,但它只是最近才出现在现实世界的应用中(第一次发布redux
是在 2015 年 6 月,仅仅五年前)。
实施状态管理
为了使用 redux 处理应用的状态管理,我们需要处理所有三个组件:动作、reducer 和全局存储。让我们首先通过安装一些依赖项来设置环境。
环境设置
首先,我们需要添加一些包来使我们能够使用redux
:
npm install redux redux-thunk history react-router-redux reselect --save
从Action
开始
在redux
中,动作是将数据从应用发送到商店的有效信息负载。它类似于其他 GUI 应用中的事件。要将此信息应用到商店,您必须dispatch
(发送)它。
action
是一个很好的切入点——它将促使我们考虑组件之间的交互方式,以及每个组件如何与外界交互。
以BookListContainer
为例。我们期望它有能力设置搜索的关键字。
创建一个名为redux
的文件夹,在名为actions
的子文件夹中,添加一个名为actions.test.js
的文件:
import { setSearchTerm } from './actions'
describe('BookListContainer related actions', () => {
it('Sets the search keyword', () => {
const term = ''
const expected = {
type: 'SET_SEARCH_TERM',
term
}
const action = setSearchTerm(term)
expect(action).toEqual(expected)
})
})
这个测试断言,当一个搜索词被提供给setSearchTerm
动作创建者时,该动作将被创建。
顾名思义,动作创建者将创建一个动作,并且通常将与来自用户交互(鼠标点击、键盘)的事件绑定。
所以目前,setSearchTerm
只是actions.js
中的一个空函数,但是在这里实现非常简单:
export const setSearchTerm = (term) => {
return { type: 'SET_SEARCH_TERM', term }
}
动作有一个type
属性,表示正在执行的动作的类型,但是除此之外,动作对象的结构由我们来定义。
setSearchTerm
接受一个搜索词,并返回一个类型为SET_SEARCH_TERM
的动作,以及作为搜索词提供的任何字符串。
小菜一碟!
请注意,虽然我们在这里的商店中保存了 term,但我们不必这样做。这实际上是由您——开发人员——来决定将这些状态放在哪里。一个很好的经验法则是让store
尽可能的简单和平坦。任何可以由其他字段计算的数据都不应该放在那里,而且在大多数情况下,其他人不关心的内部状态也应该放在组件内部。
异步操作
对于异步操作来说,事情变得有点棘手。为了让这些工作,我们需要配置redux-thunk
并创建一个模拟store
(用于测试)。
Redux-thunk
是一个中间件(基本上,一个中间件可以拦截你发送的所有动作,并基于某种条件来存储和操作它们,例如,做一些审计或日志记录),它允许动作创建者返回一个函数而不是一个动作。这意味着我们可以延迟调度动作,或者只基于条件逻辑进行调度,从而允许我们处理异步动作。
让我们先将redux-mock-store
添加到依赖项中:
npm install redux-mock-store --save-dev
在我们编写测试之前,我们将在actions.test.js
中创建一个mockStore
,如下所示:
import configureMockStore from 'redux-mock-store'
import thunk from 'redux-thunk'
const middlewares = [thunk]
const mockStore = configureMockStore(middlewares)
然后,让我们定义一条快乐的路径,假设网络正在运行,我们可以检索正在获取的数据(记住还要导入axios
,因为我们将它用于网络请求):
it('Fetches data successfully', () => {
const books = [
{ id: 1, name: 'Refactoring' },
{ id: 2, name: 'Domain-driven design' },
];
axios.get = jest
.fn()
.mockImplementation(() => Promise.resolve({ data: books }));
const expectedActions = [
{ type: 'FETCH_BOOKS_PENDING' },
{ type: 'FETCH_BOOKS_SUCCESS', books },
];
const store = mockStore({ books: [] });
return store.dispatch(fetchBooks('')).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
这里,我们期望fetchBooks
将创建两个actions
:一个表示请求已经发送,另一个表示响应已经收到。
因为请求在底层使用了axios
,我们可以使用jest.fn().mockImplementation()
来存根它。它将拦截对axios.get
的调用,并调用我们定义的任何函数,所以我们不会在测试中发送真正的 HTTP 请求。
axios.get = jest.fn().mockImplementation(
() => Promise.resolve({data: books}))
下面是actions.js
里面的实现:
import axios from 'axios'
export const fetchBooks = () => {
return (dispatch) => {
dispatch({type: 'FETCH_BOOKS_PENDING'})
return axios.get(`http://localhost:8080/books`).then((res) => {
dispatch({type: 'FETCH_BOOKS_SUCCESS', books: res.data})
})
}
}
首先,我们dispatch
一个FETCH_BOOKS_PENDING
动作并调用axios.get
。当承诺被解析时,我们可以用响应作为有效负载来dispatch
?? 动作。
失败场景
对于网络故障的情况(例如,超时),我们可以在单元测试中再次使用jest.fn().mockImplementation()
:
axios.get = jest.fn().mockImplementation(
() => Promise.reject({message: 'Something went wrong'}))
然后,验证失败的操作是否按预期调度:
it('Fetch data with error', () => {
axios.get = jest
.fn()
.mockImplementation(() =>
Promise.reject({ message: 'Something went wrong' })
);
const expectedActions = [
{ type: 'FETCH_BOOKS_PENDING' },
{ type: 'FETCH_BOOKS_FAILED', err: 'Something went wrong' },
];
const store = mockStore({ books: [] });
return store.dispatch(fetchBooks('')).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
我们可以在 promise rejected
分支中添加一个catch
案例来使我们的测试绿色化:
export const fetchBooks = (term) => {
return (dispatch) => {
dispatch({type: 'FETCH_BOOKS_PENDING'})
return axios.get(`http://localhost:8080/books?q=${term}`).then((res) => {
dispatch({type: 'FETCH_BOOKS_SUCCESS', books: res.data})
}).catch((err) => {
dispatch({type: 'FETCH_BOOKS_FAILED', err: err.message})
})
}
}
搜索动作
我们期望动作fetchBooks
可以在发送请求时使用来自store
的term
值作为关键字,这将启用过滤器功能。
请注意,我们正在将mockStore
中的term
设置为domain
,并且需要更新 fetchBooks 操作以考虑所提供的查询参数:
it('Search data with term', () => {
const books = [
{ id: 1, name: 'Refactoring' },
{ id: 2, name: 'Domain-driven design' },
];
axios.get = jest
.fn()
.mockImplementation(() => Promise.resolve({ data: books }));
const store = mockStore({ books: [] });
return store.dispatch(fetchBooks('domain')).then(() => {
expect(axios.get).toHaveBeenCalledWith(
'http://localhost:8080/books?q=domain'
);
});
});
重构
在action
测试和实现中有很多硬编码和“神奇”的字符串。我们可以将它们提取到某个公共位置,这样就可以从那里引用它们。让我们创建一个名为types.js
的文件:
export const SET_SEARCH_TERM = 'SET_SEARCH_TERM'
export const FETCH_BOOKS_PENDING = 'FETCH_BOOKS_PENDING'
export const FETCH_BOOKS_SUCCESS = 'FETCH_BOOKS_SUCCESS'
export const FETCH_BOOKS_FAILED = 'FETCH_BOOKS_FAILED'
并将它作为变量types
导入到我们想要使用它的地方:
import * as types from './types'
然后,我们可以用types.FETCH_BOOKS_PENDING
来引用它:
const expectedActions = [
{ type: types.FETCH_BOOKS_PENDING},
{ type: types.FETCH_BOOKS_SUCCESS, books }
]
还原剂
在redux
中,reducer
只是一个纯函数——如果输入是确定的,那么输出总是可预测的。reducer
负责阐明应用的状态将如何改变,以响应发送到商店的任何动作。
实现缩减器非常简单。比如FETCH_BOOKS_PENDING
和FETCH_BOOK_SUCCESS
可以这样测试,在reducers/reducer.test.js
里面:
import reducer from './reducer';
import * as types from '../types';
describe('Reducer', () => {
it('Show loading when request is sent', () => {
const initState = { loading: false };
const action = { type: types.FETCH_BOOKS_PENDING };
const state = reducer(initState, action);
expect(state.loading).toBeTruthy();
});
it('Add books to state when request successful', () => {
const books = [
{ id: 1, name: 'Refactoring' },
{ id: 2, name: 'Domain-driven design' },
];
const action = {
type: types.FETCH_BOOKS_SUCCESS,
books
};
const state = reducer([], action);
expect(state.books).toBe(books);
});
});
我们期望当FETCH_BOOKS_PENDING
动作被发送到 reducer 时,它会将loading
设置为true
,并且FETCH_BOOKS_SUCCESS
会附加响应(图书列表)来声明请求何时成功。
import * as types from '../types';
const reducer = (state = [], action) => {
switch (action.type) {
case types.FETCH_BOOKS_PENDING:
return { ...state, loading: true };
case types.FETCH_BOOKS_SUCCESS:
return { books: action.books };
default:
return state;
}
};
export default reducer;
测试 action creator 就像 Java 中的值-对象测试一样,测试reducer
相当于测试静态util
类。在React
社区,人们倾向于将action+reducer+store
作为集成测试一起测试。就我个人而言,我根本不直接测试那些代码。他们甚至在我的package.json
的modulePathIgnorePatterns
部分被明确忽略。
我们将在下一节详细讨论这一点。
Redux 的集成测试Store
在 src 文件夹中,创建store.test.js
:
import axios from 'axios';
import * as actions from './redux/actions/actions';
import store from './store';
describe('Store', () => {
const books = [
{id: 1, name: 'Refactoring'}
]
it('Fetch books from remote', () => {
axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: books}))
return store.dispatch(actions.fetchBooks()).then(() => {
const state = store.getState()
expect(state.books.length).toEqual(1)
expect(state.books).toEqual(books)
})
})
})
然后,我们创建store.js
。我们导入前面定义的actions
,创建一个真正的store
来执行dispatch
,并期望它返回correct
响应。我们导入真实的reducers
,并使用redux
提供的createStore
函数创建一个商店:
import { applyMiddleware, createStore, compose } from 'redux';
import thunk from 'redux-thunk';
import reducer from './redux/reducers/reducer';
const initialState = {};
const middlewares = [thunk]
const composedEnhancers = compose(
applyMiddleware(...middlewares)
)
const store = createStore(
reducer,
initialState,
composedEnhancers
)
export default store
这是我们的集成测试——它将action + reducer + store
连接在一起。这种测试比其他单元测试稍微重一点,但是它提供了独特的价值:它证明了每个元素可以一起工作来提供预期的结果。
既然我们已经将动作和 reducers 连接到了状态,那么让我们更新我们的fetchBooks
动作来使用状态。
-export const fetchBooks = (term) => {
+export const fetchBooks = () => {
- return (dispatch) => {
+ return (dispatch, getState) => {
dispatch({ type: types.FETCH_BOOKS_PENDING });
+ const state = getState();
- return axios.get(`http://localhost:8080/books?q=${term || ''}`).then((res) => {
+ return axios.get(`http://localhost:8080/books?q=${state.term || ''}`).then((res) => {
dispatch({ type: types.FETCH_BOOKS_SUCCESS, books: res.data });
}).catch((err) => {
dispatch({type: types.FETCH_BOOKS_FAILED, err: err.message})
});
};
};
您还需要在Search data with term
测试中更新 mockStore。
现在,我们可以为我们的searching
功能添加另一个集成测试:
it('Performs a search', () => {
axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: books}))
store.dispatch(actions.setSearchTerm('domain'))
return store.dispatch(actions.fetchBooks()).then(() => {
const state = store.getState()
expect(state.term).toEqual('domain')
expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/books?q=domain')
})
})
迁移应用
我们的下一步是将我们的应用迁移到redux
。由于我们有足够的验收测试,我们不需要担心破坏任何功能。
首先,我们需要添加react-redux
作为依赖项:
npm install react-redux
我们将store
传递给index.js
中的Provider
组件。这意味着整个组件树可以在任何时候共享这个store
:
+import { Provider } from 'react-redux';
+import store from './store';
-ReactDOM.render(<Router>
- <App />
-</Router>, document.getElementById('root'));
+const root = <Provider store={store}>
+ <Router>
+ <App />
+ </Router>
+</Provider>
+
+ReactDOM.render(root, document.getElementById('root'));
由于presentational
组件是无状态的,我们让它们保持原样——我们的迁移应该只影响container
组件。对于BookListContainer
,将会有很多变化,因为数据获取被委托给actions
:
const dispatch = useDispatch();
useEffect(() => {
dispatch(actions.fetchBooks(term))
}, [term]);
useDispatch
随react-redux
一起提供,可用于分派我们之前定义的动作—fetchBooks
。
每当BookListContainer
中的term
状态发生变化,就会触发fetchBooks
,一旦服务器端返回数据,我们就可以用一个selector
从状态中导出我们需要的数据。因为我们想让我们的商店尽可能精简,符合良好 redux 架构的原则,所以我们使用了selector
。selector
函数接受 Redux 存储状态作为参数,并根据该状态返回数据。
我们可以选择用来自redux
的useSelector
来做这件事,就像
const books = useSelector(state => state.books);
或者定义一个函数来完成所有的映射。我更喜欢第二种选择,使用一个名为reselect
的库,因为它提供了一个可组合的选择器和可缓存的结果(这意味着它将在内部保存计算出的值,除非值的依赖关系已经改变),以记忆的形式。这样,我们的应用可以更有性能,特别是对于具有相对较大存储空间的应用。让我们先将它安装到项目中:
npm install reselect
然后,我们在redux/selector
中定义一个selector
:
import { createSelector } from 'reselect';
const bookListSelector = createSelector([
state => state.books,
state => state.loading,
state => state.error,
], (books, loading, error) => ({books, loading, error}));
export default bookListSelector;
createSelector
接受两个参数,一个输入选择器数组和一个转换函数,并返回一个记忆的选择器。并不是说我们的 transform 函数现在不做太多的转换,只是直接从 state 返回值。
最后,将它们连接起来:
import bookListSelector from '../../redux/selectors/selector';
const BookListContainer = () => {
const [term, setTerm] = useState();
const dispatch = useDispatch();
useEffect(() => {
dispatch(actions.fetchBooks());
}, [term, dispatch]);
const onSearch = (event) => {
dispatch(actions.setSearchTerm(event.target.value));
dispatch(actions.fetchBooks());
};
const { books, loading, error } = useSelector(bookListSelector);
return (
<SearchBox term={term} onSearch={onSearch} />
<BookList books={books} loading={loading} error={error} />
);
};
测试容器
如果你仔细看看BookListContainer
,你会发现在单元测试级别测试是相对困难的。这么说,我的意思是它依赖于一些外部组件,如actions
甚至网络。
我们不想使用真实的网络,所以我们需要想出一种方法来模拟网络。幸运的是,axios-mock-adapter
可以为我们做到这一点。
npm install axios-mock-adapter --save-dev
在BookListContainer
.test
中,导入新的依赖项并创建一个新的 mock。我们可以通过调用onGet
并在reply
中提供预期的结果来定义模拟。在我们的例子中,我们需要从下游返回的两本书:
it('renders', async () => {
const mock = new MockAdapter(axios);
mock.onGet('http://localhost:8080/books?q=').reply(200, [
{'name': 'Refactoring', 'id': 1},
{'name': 'Acceptance tests driven development with React', 'id': 2},
]);
const {findByText} = renderWithProvider(<BookListContainer/>);
const book1 = await findByText('Refactoring');
const book2 = await findByText('Acceptance tests driven development with React');
expect(book1).toBeInTheDocument();
expect(book2).toBeInTheDocument();
});
注意,我们使用了另一个包装函数——renderWithProvider
——来避免在provider
之外调用useDispatch
所导致的错误。本质上,react-redux
期望钩子在<Provider>
内部被调用。
const renderWithProvider = (component) => {
return {...render(<Provider store={store}>
<Router>
{component}
</Router>
</Provider>)}
};
在这里,我们使用与实际应用中相同的存储。当然,您也可以为测试定义一些静态存储。
此外,我们使用来自@testing-library/jest-dom
的toBeInTheDocument
断言:
npm install @testing-library/jest-dom --save-dev
由于我们已经在cypress
测试中介绍了这个功能,您可能想知道在这里测试这个功能有什么意义。这是因为我们可以用更快的反馈测试更多的案例。例如,如果我们想确保当网络故障发生时,我们应该在页面上看到一条error
消息。由于网络故障的原因多种多样,在慢速cypress
测试中测试每一种可能性并不理想。
相反,一个简单的integration test
将为我们工作:
it('something went wrong', async () => {
const mock = new MockAdapter(axios);
mock.onGet('http://localhost:8080/books?q=').networkError();
const {findByText} = renderWithProvider(<BookListContainer/>);
const error = await findByText('Error...');
expect(error).toBeInTheDocument();
})
通过使用axios-mock-adapter
,您可以很容易地模拟不同的网络问题,甚至是数据形状改变的情况以及组件将如何处理它。
我们还需要向我们的 reducer 添加一个案例,以确保将错误状态添加到状态中。
case types.FETCH_BOOKS_FAILED:
return { ...state, loading: false, error: true };
获取图书详细信息
为了完成我们的迁移,我们需要为我们的BookDetailContainer
创建一个action
,而我们只需要一本书:
it('Fetch book by id', () => {
const book = {id: 1, name: 'Refactoring'}
axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: book}))
const store = mockStore({list: { books: [], term: '' }})
return store.dispatch(fetchABook(1)).then(() => {
expect(axios.get).toHaveBeenCalledWith('http://localhost:8080/books/1')
})
})
我们可以复制fetchBooks
,稍加修改就可以创造出fetchABook
。它需要一个id
参数来发送请求:
export const fetchABook = (id) => {
return (dispatch) => {
dispatch({type: types.FETCH_BOOK_PENDING})
return axios.get(`http://localhost:8080/books/${id}`).then((res) => {
dispatch({type: types.FETCH_BOOK_SUCCESS, book: res.data})
}).catch((err) => {
dispatch({type: types.FETCH_BOOK_FAILED, err: err.message})
})
}
}
store
中的集成测试类似:
it('Fetch a book from remote', () => {
axios.get = jest.fn().mockImplementation(() => Promise.resolve({data: books[0]}))
return store.dispatch(actions.fetchABook(1)).then(() => {
const state = store.getState()
expect(state.book).toEqual(books[0])
})
})
和BookDetailContainer
可以简化为
const BookDetailContainer = ({match}) => {
const dispatch = useDispatch();
useEffect(() => {
dispatch(actions.fetchABook(match.params.id))
}, []);
const book = useSelector(state => state.detail);
return (<BookDetail book={book}/>);
};
export default BookDetailContainer
由于这部分代码是由redux
验证的,我们就不用测试了。我们的工作是确保BookDetailContainer
正常工作:
describe('BookDetailContainer', () => {
it('renders', async () => {
const props = {
match: {
params: {
id: 2
}
}
};
const mock = new MockAdapter(axios);
mock.onGet('http://localhost:8080/books/2').reply(200, {
'name': 'Acceptance tests driven development with React', 'id': 2
});
const {findByText} = renderWithProvider(<BookDetailContainer {...props} />);
const book = await findByText('Acceptance tests driven development with React');
expect(book).toBeInTheDocument();
})
});
我们现在已经成功迁移到redux
,测试覆盖看起来如图 8-3 。
图 8-3
测试覆盖报告
让我们看看我们在这里做了什么:
-
动作、减速器的单元测试。
-
action + reducer + store
的集成测试。 -
验收测试保持绿色。
这真是一个伟大的成就。
摘要
在本章中,我们引入了redux
作为状态管理机制。通过对action
和reducer,
进行单元测试,我们已经为我们的应用推出了必要的redux
组件。经过一些重构,我们已经将我们的container
代码迁移到了redux
。
在移植之后,我们发现我们的container
易于测试,所以我们为它添加了一些单元测试。
这个test-last
看起来有点奇怪,特别是在强调了首先编写我们的单元测试的重要性之后,但是如果你把它当作refactoring,
的一部分,那就没问题了。当处理遗留代码时,我们总是会面临类似的问题。有时候,代码太难测试了;为了编写测试,您可能需要进行许多更改。在这种情况下,我们可以编写一个高级(验收)测试来确保业务需求总是得到满足。之后,我们可以refactor
拆分当前的实现,然后我们添加适当的单元测试。
这些单元测试不仅验证功能,还作为文档,使其他团队成员通过查看组件的测试来理解如何使用组件成为可能。
进一步阅读
虽然为您的应用设计商店的形状具有挑战性,但您可能希望在这里获得一些关于如何以一种易于扩展和操作的方式来塑造它的见解: https://medium.com/javascript-scene/10-tips-for-better-redux-architecture-69250425af44
。
在他关于 GUI 架构的文章中,Martin Fowler 描述了观察者同步模式,这很像我们在网络世界中所做的: https://martinfowler.com/eaaDev/uiArchs.html
。
九、管理书评
在任何现实世界的项目中,您通常都必须处理某种类型的资源管理。广告管理系统通过在某种业务限制下创建、修改或删除项目来管理schedule
或campaign
。人力资源系统将通过创建(当公司有新员工时)、修改(被提升)和删除(退休)来帮助人力资源管理employee records
。如果你观察这些系统试图解决的问题,你会发现一个相似的模式:它们都在一些资源上应用CRUD
(创建、读取、更新、删除)操作。
然而,并不是所有的系统都必须包括所有的四种操作;对于一个关键系统,不会删除任何数据——程序员只会在记录中设置一个标志,将它们标记为已删除。记录仍然在那里,但是用户不能再从 GUI 中检索它们。
在这一章中,我们将学习如何通过扩展我们的应用bookish
,在review
资源上实现一组经典的CRUD
操作,当然也应用了ATDD
。
业务需求
在图书详细信息页面中,有一些关于图书的关键信息,包括标题、描述和封面图片。然而,我们想要一些可以帮助最终用户找到更多关于这本书的东西——比如来自其他用户的reviews
。一般来说,一本书可以有不止一个review
。对这本书有强烈意见的读者会提供评论。评论可以是正面的,也可以是负面的。有时,也有一个评级与审查。
让我们从没有评论的最简单的场景开始。我们需要渲染一个空容器——我们称之为reviews-container
。
从一个空列表开始
import React from 'react';
import ReviewList from './ReviewList';
import { render } from '@testing-library/react';
import toBeInTheDocument from '@testing-library/jest-dom';
describe('ReviewList', () => {
it('renders an empty list', () => {
const props = {
reviews: []
};
const {container} = render(<ReviewList {...props}/>);
const reviews = container.querySelector('[data-test="reviews-container"]');
expect(reviews).toBeInTheDocument();
})
});
通过测试应该很简单:
import React from 'react';
const ReviewList = () => {
return (<div data-test='reviews-container'></div>)
};
export default ReviewList;
呈现静态列表
我们的第二个测试案例可能涉及一些模拟数据:
it('renders a list when data is passed', () => {
const props = {
reviews: [
{ name: 'Juntao', date: '2018/06/21', content: 'Excellent work, really impressed by your efforts'},
{ name: 'Abruzzi', date: '2018/06/22', content: 'What a great book'}
]
};
const {container} = render(<ReviewList {...props}/>);
const reviews = container.querySelectorAll('[data-test="reviews-container"] .review');
expect(reviews.length).toBe(2);
})
在这里,我们演示了如何从外部使用组件(传入一组评论,每个评论都有用于name date
和content
的字段)。其他程序员有可能不查看我们的实现就重用我们的组件。
一个简单的map
应该对我们有用。由于地图要求key
属性具有惟一的身份,所以让我们将name
和date
组合起来形成一个键;在下一节中,我们将在与后端 API 集成时创建一个id
。
import React from 'react';
const ReviewList = ({reviews}) => {
return (<div data-test='reviews-container'>
{
reviews.map(review =>
<div key={review.name + review.date} className='review'>{review.name}</div>)
}
</div>)
};
export default ReviewList;
我们需要确保内容正确呈现:
+
+ expect(reviews[0].innerHTML).toEqual('Juntao');
使用检查组件
对于我们的第一次集成,让我们将ReviewList
放在BookDetail
中。您现在可能已经知道,我们将首先实现测试。
我们可以在BookDetail.test.js
中添加一个新的测试用例,因为我们想要验证 BookDetail 上是否有一个ReviewList
。
it('renders reviews', () => {
const props = {
book: {
name: 'Refactoring',
description: 'Martin Fowler’s Refactoring defined core ideas and techniques that hundreds of thousands of developers have used to improve their software.',
reviews: [
{ name: 'Juntao', date: '2018/06/21', content: 'Excellent work, really impressed by your efforts'}
]
}
};
const {container} = render(<BookDetail {...props} />);
const reviews = container.querySelectorAll('[data-test="reviews-container"] .review');
expect(reviews.length).toBe(1);
expect(reviews[0].innerHTML).toEqual('Juntao');
});
注意这里的props
包含一个reviews
属性。对于实现,我们引入了ReviewList
组件,由于组件化,就是这样:
import React from 'react';
import ReviewList from './ReviewList';
const BookDetail = ({book}) => {
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
<p className='book-description'>{book.description}</p>
{book.reviews && <ReviewList reviews={book.reviews}/>}
</div>)
}
export default BookDetail;
填写书评表格
我们可以生成一些静态数据来显示在 BookDetail 组件中,但是如果我们能够显示一些来自最终用户的真实数据会更好。我们需要一个简单的形式让用户交流他们对这本书的观点。现在,我们可以提供两个输入框和一个提交按钮。第一个输入是用户名(或电子邮件地址),第二个输入(文本区域)是评论内容。
我们可以在BookDetail
组件中添加一个新的测试用例:
it('renders review form', () => {
const props = {
book: {
name: 'Refactoring',
description: 'Martin Fowler’s Refactoring defined core ideas and techniques that hundreds of thousands of developers have used to improve their software.'
}
};
const {container} = render(<BookDetail {...props} />);
const form = container.querySelector('form');
const nameInput = container.querySelector('input[name="name"]');
const contentTextArea = container.querySelector('textarea[name="content"]');
const submitButton = container.querySelector('button[name="submit"]');
expect(form).toBeInTheDocument();
expect(nameInput).toBeInTheDocument();
expect(contentTextArea).toBeInTheDocument();
expect(submitButton).toBeInTheDocument();
});
确保<form>
显示在描述部分的下方和reviews
的上方。TextField
和Button
组件都可以从 material-ui 导入;
<form noValidate autoComplete='off'>
<TextField
label='Name'
name='name'
margin='normal'
variant='outlined'
/>
<TextField
name='content'
label='Content'
margin='normal'
variant='outlined'
multiline
rowsMax='4'
/>
<Button variant='contained' color='primary' name='submit'>
Submit
</Button>
</form>
现在,我们必须将它连接到state
:
+ const [name, setName] = useState('');
+ const [content, setContent] = useState('');
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
<p className='book-description'>{book.description}</p>
<form noValidate autoComplete='off'>
<TextField
label='Name'
name='name'
margin='normal'
variant='outlined'
+ value={name}
+ onChange={e => setName(e.target.value)}
/>
<TextField
name='content'
label='Content'
margin='normal'
variant='outlined'
multiline
rowsMax='4'
+ value={content}
+ onChange={e => setContent(e.target.value)}
/>
<Button variant='contained' color='primary' name='submit'>
Submit
</Button>
</form>
{book.reviews && <ReviewList reviews={book.reviews}/>}
</div>)
}
端到端测试
您可能已经注意到,当我们处理这个函数时,我们是从 ReviewList 组件的单元测试开始的。这是因为目前所有的变化都是静态的——在这一点上没有行为上的相互作用。在这种情况下,您可以从端到端测试开始——从上到下——或者从下到上。我更喜欢从组件本身开始,因为它能更快地提供反馈,帮助我们推动实现。
端到端测试可以这样描述:转到详细页面,找到输入字段,填写一些内容,然后单击 submit 按钮。最后,我们希望提交的内容将显示在页面上:
it('Write a review for a book', () => {
gotoNthBookInTheList(0);
checkBookDetail('Refactoring');
cy.get('input[name="name"]').type('Juntao Qiu');
cy.get('textarea[name="content"]').type('Excellent work!');
cy.get('button[name="submit"]').click();
cy.get('div[data-test="reviews-container"] .review').should('have.length', 1);
});
点击后测试将失败(图 9-1 ),因为它既不发送数据到服务器,也不接收响应和重新渲染。
图 9-1
尝试提交评论
为了让测试通过,我们需要回到redux
并定义一个新类型的action
。
Redux 中的操作
我们已经知道,所有的网络活动和其他杂务都由redux
中的action
处理。所以让我们首先定义一个动作来创建一个review
:
it('Saves a review for a book', () => {
const review = {
name: 'Juntao Qiu',
content: 'Excellent work!'
}
axios.post = jest.fn().mockImplementation(() => Promise.resolve({}))
const store = mockStore({ books: [], term: '' })
return store.dispatch(saveReview(1, review)).then(() => {
expect(axios.post).toHaveBeenCalledWith('http://localhost:8080/books/1', review)
})
})
我们假设当我们将一些数据POST
到端点http://localhost:8080/books/1
时,将为 id 为1
的书创建一个新的评论:
{
"name": "Juntao Qiu",
"content": "Excellent work!"
}
现在,使用axios
创建异步动作对我们来说应该很容易:
export const saveReview = (id, review) => {
return (dispatch) => {
dispatch({type: types.SAVE_BOOK_REVIEW_PENDING})
return axios.post(`http://localhost:8080/books/${id}`, review).then((res) => {
dispatch({type: types.SAVE_BOOK_REVIEW_SUCCESS, payload: res.data})
}).catch((err) => {
dispatch({type: types.SAVE_BOOK_REVIEW_FAILED, err: err.message})
})
}
}
然后,我们在表单中的BookDetail
组件中添加一个onClick
事件处理程序:
<Button
variant='contained'
color='primary'
name='submit'
onClick={() => dispatch(actions.saveReview(book.id, {name, content}))}
>
Submit
</Button>
因为useDispatch
只能在Provider
中使用,所以BookDetail
的单元测试现在失败了。我们可以通过以下方式解决这个问题
import store from '../../store';
import {Provider} from 'react-redux';
const renderWithProvider = (component) => {
return {...render(<Provider store={store}>
{component}
</Provider>)}
};
在使用render
的地方使用renderWithProvider
:
const { container } = renderWithProvider(<BookDetail {...props} />);
JSON-服务器定制
我们一直在使用json-server
来为我们简化后端 API 工作。我们需要对它进行更多的定制,以适应我们的新要求。我们期望review
是一本书的子资源,这允许我们通过请求/books/1/reviews
来访问属于某本书的所有评论。
此外,我们希望/books/1
在响应中携带所有的reviews
作为嵌入资源。这将使图书详细信息页面的呈现变得简单方便。为了做到这一点,我们需要像这样定义json-server
中的route
:
server.use(jsonServer.rewriter({
'/books/:id': '/books/:id?_embed=reviews'
}))
server.use(router)
然后,无论何时访问/books/1
,它都会返回所有评论和回复。
像这样的请求
curl http://localhost:8080/books/1
会得到这样的回应
{
"name": "Refactoring",
"id": 1,
"description": "Refactoring",
"reviews": [
{
"name": "Juntao",
"content": "Very great book",
"bookId": 1,
"id": 1
}
]
}
干得好!同样,当我们将一些数据POST
到http://localhost:8080/books/1/reviews
时,它会在 id 为1
的书下创建一个review
。
现在,我们可以通过表单创建评论。请注意,存根服务器返回 201,表示审查已被接受(图 9-2 )。
图 9-2
提交第一本书的评论
当然,我们需要在提交后刷新页面,以查看新创建的评论:
export const saveReview = (id, review) => {
const config = {
headers: { 'Content-Type': 'application/json' }
}
return (dispatch) => {
dispatch({type: types.SAVE_BOOK_REVIEW_PENDING})
return axios.post(`http://localhost:8080/books/${id}/reviews`, JSON.stringify(review), config).then((res) => {
dispatch({type: types.SAVE_BOOK_REVIEW_SUCCESS, payload: res.data})
dispatch(fetchABook(id));
}).catch((err) => {
dispatch({type: types.SAVE_BOOK_REVIEW_FAILED, err: err.message})
})
}
}
注意这里我们在成功回调中添加了dispatch (fetchABook(id))
。它为我们刷新了reviews
。然而,当您重新运行测试时,review
的创建将会失败,因为我们没有在测试用例执行后进行清理。
为了解决这个问题(重复 id),首先,我们需要在server.js
中定义一个 map:
const relations = {
'books': 'reviews'
}
和一个生成embed
定义的函数,所以由给定的relations
动态生成一个route
:
const buildRewrite = (relations) => {
return _.reduce(relations, (sum, embed, resources) => {
sum[`/${resources}/:id`] = `/${resources}/:id?_embed=${embed}`
return sum;
}, {})
}
server.use(jsonServer.rewriter(buildRewrite(relations)))
现在,我们可以通过在DELETE
中增加一个额外的步骤来清理嵌入式资源。首先,我们检查需要删除的资源是否有任何embedded
资源。如果有,我们会把它们和资源一起清理掉。
server.use((req, res, next) => {
if (req.method === 'DELETE' && req.query['_cleanup']) {
const db = router.db
db.set(req.entity, []).write()
if (relations[req.entity]) {
db.set(relations[req.entity], []).write()
}
res.sendStatus(204)
} else {
next()
}
})
然后,我们可以使用afterEach
来完成所有的清理工作,就像之前一样:
afterEach(() => {
return axios.delete('http://localhost:8080/books?_cleanup=true').catch(err => err)
})
现在,我们不必担心一个失败的测试会导致另一个测试的问题。
重构
我们现在已经完成了Review
的创建和检索。我们的测试覆盖率仍然很高,这很好。有了这些测试,我们就可以自信无畏地重构了。对于BookDetail
组件,form
是独立的,应该有自己的文件:
const ReviewForm = ({id}) => {
const [name, setName] = useState('');
const [content, setContent] = useState('');
const dispatch = useDispatch();
return (<form noValidate autoComplete='off'>
<TextField
label='Name'
name='name'
margin='normal'
variant='outlined'
value={name}
onChange={e => setName(e.target.value)}
/>
<TextField
name='content'
label='Content'
margin='normal'
variant='outlined'
multiline
rowsMax='4'
value={content}
onChange={e => setContent(e.target.value)}
/>
<Button variant='contained' color='primary' name='submit' onClick={() => dispatch(actions.saveReview(id, {name, content}))}>
Submit
</Button>
</form>)
}
export default ReviewForm;
提取之后,BookDetail
就干净多了:
const BookDetail = ({book}) => {
return (<div className='detail'>
<h2 className='book-title'>{book.name}</h2>
<p className='book-description'>{book.description}</p>
<ReviewForm id={book.id} />
{book.reviews && <ReviewList reviews={book.reviews}/>}
</div>)
}
而对于cypress
中的功能测试,我们可以提取一些辅助函数来简化测试用例:
it('Write a review for a book', () => {
gotoNthBookInTheList(0);
checkBookDetail();
composeReview('Juntao Qiu', 'Excellent work!');
checkReview();
});
功能composeReview
和checkReview
定义如下
export const composeReview = (name, content) => {
cy.get('input[name="name"]').type(name);
cy.get('textarea[name="content"]').type(content);
cy.get('button[name="submit"]').click();
};
export const checkReview = () => {
cy.get('div[data-test="reviews-container"] .review').should('have.length', 1);
}
现在,审查表单应该类似于图 9-3 。
图 9-3
评论页面
添加更多字段
如果你仔细看看这篇评论,你会发现遗漏了一些重要信息:用户名和创建时间。我们需要完成这些字段:
expect(reviews.length).toBe(2);
- expect(reviews[0].innerHTML).toEqual('Juntao');
+ expect(reviews[0].querySelector('.name').innerHTML).toEqual('Juntao');
+ expect(reviews[0].querySelector('.date').innerHTML).toEqual('2018/06/21');
+ expect(reviews[0].querySelector('.content').innerHTML).toEqual('Excellent work, really impressed by your efforts');
})
实施应该毫不费力:
return (<div data-test='reviews-container'>
{
reviews.map((review, index) =>
- <div key={index} className='review'>{review.name}</div>)
+ <div key={index} className='review'>
+ <span className='name'>{review.name}</span>
+ <span className='date'>{review.date}</span>
+ <p className='content'>{review.content}</p>
+ </div>)
}
</div>)
随着map
中的代码不断增长,我们可以将其提取到一个单独的文件—Review
:
import React from 'react';
const Review = ({review}) => (<div className='review'>
<span className='name'>{review.name}</span>
<span className='date'>{review.date}</span>
<p className='content'>{review.content}</p>
</div>);
export default Review;
并将其作为一个纯粹的presentational
组件:
import Review from './Review';
const ReviewList = ({reviews = []}) => {
return (<div data-test='reviews-container'>
{
reviews.map((review, index) => <Review key={index} review={review}/>)
}
</div>)
};
由于所有呈现审查的逻辑都被移到了它自己的组件中,我们也可以移动相应的测试。
import Review from './Review';
describe('Review', () => {
it('renders', () => {
const props = {
review: {
name: 'Juntao',
date: '2018/06/21',
content: 'Excellent work, really impressed by your efforts'
},
};
const {container} = render(<Review {...props}/>);
const review = container.querySelector('.review');
expect(review.querySelector('.name').innerHTML).toEqual('Juntao');
expect(review.querySelector('.date').innerHTML).toEqual('2018/06/21');
expect(review.querySelector('.content').innerHTML)
.toEqual('Excellent work, really impressed by your efforts');
})
});
这样,我们测试不同的数据变量就容易多了。例如,如果明天产品所有者决定他们想要以relative
的方式显示日期,例如Posted 5 mins ago
或Posted yesterday
,而不是absolute
日期,我们根本不需要触摸ReviewList
。
所有测试都顺利通过——太棒了!我们的代码更简洁,更有凝聚力,责任更明确。此外,我们的高测试覆盖率意味着我们在重构时不必担心破坏现有的功能。
审阅编辑
Review
组件现在提供了基本的表示。然而,在现实世界中,用户可能会在他们的评论中留下一个错别字,或者完全重写内容。我们需要允许用户编辑他们已经发布的Review
。
我们需要添加一个Edit
按钮,点击后会变成一个Submit
按钮(等待用户提交)。当用户点击Submit
时,文本再次变成Edit
。所以第一个测试可能是
it('editing', () => {
const props = {
review: {
name: 'Juntao',
date: '2018/06/21',
content: 'Excellent work, really impressed by your efforts'
},
};
const {getByText} = render(<Review {...props}/>);
const button = getByText('Edit');
expect(button.innerHTML).toEqual('Edit');
userEvent.click(button);
expect(button.innerHTML).toEqual('Submit');
});
通过使用userEvent.click
,我们可以模拟Edit
按钮上的点击事件,并验证按钮上的文本变化。我们可以通过在组件中引入state
来实现这一点:
const [editing, setEditing] = useState(false);
我们需要做的就是切换editing
的状态。对于渲染,我们可以通过editing
状态来决定显示哪个文本,如下所示:
<Button variant='contained' color='primary' name='submit' onClick={() => setEditing(!editing)}>
{!editing ? 'Edit' : 'Submit'}
</Button>
我们希望有一个当用户点击Edit
时显示的textarea
,并将所有评论内容复制到textarea
中进行编辑:
it('copy content to a textarea for editing', () => {
const props = {
review: {
name: 'Juntao',
date: '2018/06/21',
content: 'Excellent work, really impressed by your efforts'
},
};
const {getByText, container} = render(<Review {...props}/>);
const button = getByText('Edit');
const content = container.querySelector('p.content');
const editingContent = container.querySelector('textarea[name="content"]');
expect(content).toBeInTheDocument();
expect(container.querySelector('textarea[name="content"]')).not.toBeInTheDocument();
userEvent.click(button);
expect(content).not.toBeInTheDocument();
expect(container.querySelector('textarea[name="content"]')).toBeInTheDocument();
expect(container.querySelector('textarea[name="content"]').innerHTML)
.toEqual('Excellent work, really impressed by your efforts');
});
})
为了实现这一点,我们还必须维护state
中的内容:
const [content, setContent] = useState(review.content);
并根据editing
状态渲染textarea
和static text
:
{!editing ? <p className='content'>{review.content}</p> : (<TextField
name='content'
label='Content'
margin='normal'
variant='outlined'
multiline
rowsMax='4'
value={content}
onChange={e => setContent(e.target.value)}
/>)}
现在,评审有两种不同的状态:viewing
和editing
,可以通过点击.edit
按钮进行切换。为了将实际内容保存到后端,我们需要定义一个action
。
保存审核-行动
就像创建评论的过程一样,为了保存评论,我们需要向后端发送一个请求。好消息是json-server
已经提供了这个功能。我们向http://localhost:8080/reviews/{id}
发送PUT
请求来更新评论。当然,我们必须首先为 redux 操作编写一个测试:
it('Update a review for a book', () => {
const config = {
headers: { 'Content-Type': 'application/json' }
}
const review = {
name: 'Juntao Qiu',
content: 'Excellent work!'
}
axios.put = jest.fn().mockImplementation(() => Promise.resolve({}))
const store = mockStore({list: { books: [], term: '' }})
return store.dispatch(updateReview(1, review)).then(() => {
expect(axios.put).toHaveBeenCalledWith('http://localhost:8080/reviews/1', JSON.stringify(review), config)
})
})
注意,我们在这里嘲讽了axios.put
。一般来说,当您更新一些现有的资源时,您使用PUT
作为 HTTP 动词。
export const updateReview = (id, review) => {
const config = {
headers: { 'Content-Type': 'application/json' }
}
return (dispatch) => {
dispatch({type: types.SAVE_BOOK_REVIEW_PENDING})
return axios.put(`http://localhost:8080/reviews/${id}`, JSON.stringify(review), config).then((res) => {
dispatch({type: types.SAVE_BOOK_REVIEW_SUCCESS, payload: res.data})
}).catch((err) => {
dispatch({type: types.SAVE_BOOK_REVIEW_FAILED, err: err.message})
})
}
}
注意,我们在这里重用了SAVE_BOOK_REVIEW
类型。
综合
既然编辑评论的所有部分都准备好了,就该把它们放在一起了。我们需要确保当点击Submit
时,调用save action
:
//...
const props = {
bookId: 123,
review: {
name: 'Juntao',
date: '2018/06/21',
content: 'Excellent work, really impressed by your efforts'
},
};
const {getByText, container} = renderWithProvider(<Review {...props}/>);
userEvent.click(getByText('Edit'));
const content = container.querySelector('textarea[name="content"]');
userEvent.type(content, 'Fantastic work');
userEvent.click(getByText('Submit'));
//...
现在,剩下的唯一问题是,无论何时单击按钮,我们如何验证调用了正确的操作。jest
提供了设置mock
或stub
的各种方式。在我们这里的例子中,我们可以import
真实的动作,然后override
它的行为,所以我们不发送真实的网络请求:
import * as actions from '../redux/actions/actions';
const fakeUpdateReview = () => {
return () => {
return Promise.resolve({})
}
};
jest.spyOn(actions, 'updateReview').mockImplementation(() => fakeUpdateReview);
最后,我们可以验证已经调用了updateReview
:
it('send requests', async () => {
const fakeUpdateReview = () => {
return () => {
return Promise.resolve({})
}
};
jest.spyOn(actions, 'updateReview').mockImplementation(() => fakeUpdateReview);
//...
const {getByText, container} = renderWithProvider(<Review {...props}/>);
userEvent.click(getByText('Edit'));
const content = container.querySelector('textarea[name="content"]');
userEvent.type(content, 'Fantastic work');
userEvent.click(getByText('Submit'));
expect(actions.updateReview).toHaveBeenCalledWith(123, {content: 'Fantastic work'});
})
因为updateReview
的正确性已经在action
测试中得到验证,所以我们可以对它的功能充满信心。现在让我们尝试编写实现:
+import {useDispatch} from 'react-redux';
+import * as actions from '../redux/actions/actions';
+const Review = ({review}) => {
const [editing, setEditing] = useState(false);
const [content, setContent] = useState(review.content);
+ const dispatch = useDispatch();
+
+ const clickHandler = () => {
+ if(editing) {
+ dispatch(actions.updateReview(review.id, {content}))
+ }
+
+ setEditing(!editing);
+ };
+
return (<div className='review'>
我们用useDispatch
React 钩子从react-redux
生成一个dispatch
,然后用它触发一个真实的action
(图 9-4 )。
图 9-4
Review page 现在可以很好地使用 redux 了
摘要
太棒了,我们已经完成了整个Review
部分。这是一个相对较大的组件,有一个Reviews
列表,在每个部分,我们允许用户添加一个新的Review
,以及编辑现有的。
在这个过程中,我们尝试了一种不同的方法来处理TDD
——首先编写unit tests
来驱逐一个分离的Component
,然后是分离的actions
,最后是一个集成测试,它可以确保我们将它们连接在一起。
十、行为驱动开发
行为驱动开发(BDD)是由 Dan North 创建的。他的目标是改善业务和技术团队之间的交流,以帮助创建具有商业价值的软件。业务和技术团队之间的沟通不畅通常是软件项目交付的最大瓶颈,开发人员通常会误解业务目标,业务团队无法掌握技术团队的能力。BDD 是一个通过改善工程师和商业专家之间的交流来帮助管理和交付软件开发项目的过程。在这样做的时候,BDD 确保所有的开发项目都集中在交付业务实际需要的东西上,同时满足用户的所有需求。
—康斯坦丁·库德良绍夫,阿利斯泰尔·施泰德,丹·诺斯来自博文《BDD 入门指南》
这个概念是从已建立的敏捷实践中发展而来的,并且有不同的实践用于实现 BDD,但是它的核心是用人类可读的语言编写我们的自动化测试,以一种业务和开发团队都可以很容易理解的方式。这鼓励了跨角色的协作,以创建对他们试图解决的问题的共同理解,并产生系统文档,该文档根据实际的系统行为被自动测试。
你可能听说过在进行 BDD 时使用的一些实践,包括举例说明和现场文档。这些实践提供了特定的技术,可以改善团队中不同角色之间的协作。它们可以帮助开发人员理解业务目标,并帮助他们针对业务限制做出更好的决策。当由于业务需求的更新而实施变更时,可以确保软件的行为符合预期。它旨在防止出现所有测试都通过,但系统行为不正确的情况。
当你试图在你的团队中采用BDD
作为实践时,有很多工具可用。我们在这里要演示的是cucumber
,这是一个强大的工具,它使用一种DSL
(特定领域语言)让开发人员首先编写一个人类可读的文档,并产生可执行代码作为副作用(通过一些纯魔法,我们将很快解决)。
它可以用作业务分析师和用代码编写业务规则的开发人员之间的交流工具。正如我们所知,大多数错误来自沟通失误,因此拥有一个专门的工具来处理这个过程将非常有帮助。在某些情况下,cucumber
写的Live Document
是不可执行的,或者太昂贵而不能定期运行。它仍然是一个有效的工具,可以在QA
过程中提供帮助,作为进行手工测试的指南。
理论够了,开始吧。
玩黄瓜
好消息是cypress
有一个极好的cucumber
插件;这意味着我们可以一起使用它们。
安装并配置Cucumber
插件
只需要几个步骤就可以配置并让它们正常工作:
npm install --save-dev cypress-cucumber-preprocessor
在文件cypress/plugins/index.js
中,我们需要启用cucumber
:
const cucumber = require('cypress-cucumber-preprocessor').default
module.exports = (on, config) => {
on('file:preprocessor', cucumber())
}
在项目根文件夹的cypress.json
文件中,添加这一行,让cypress
加载以 feature 结尾的文件(通过使用 wildcast *。特征)文件改为:
{
"testFiles": "**/*.feature"
}
最后一步是在package.json
中添加一个用于定制plugin
的新部分(我们稍后会添加更多):
"cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true
}
酷,配置就这么多了。我们现在可以开始用简单的英语编写一些测试。
带cucumber
的 Live 文档
文件结构
默认情况下,cypress-cucumber-preprocessor
在cypress/integration
文件夹下寻找feature
文件:
cypress/integration
├── Bookish
│ ├── heading.js
│ ├── index.js
├── Bookish.feature
所以在运行时,cypress-cucumber-preprocessor
会加载*.feature
并尝试执行它们。
第一个特性规范
因为您可以用简单的英语描述您的测试,所以将我们在第三章中描述的验收标准转换成cucumber
想要的格式应该很简单:
Feature: Book List
As a reader
I want to see books that are trending
So I know what to read next
Scenario: Heading
Given I am a bookish user
When I open the list page
Then I can see the title "Bookish" is listed
注意缩进和关键字,如Scenario
、Given
、When
和Then
。早期的一些文字只是针对人类的。例如,翻译对As a <role>, I want to <do something>, So that<business value>
部分不感兴趣。那部分就像任何其他编程语言中的注释一样,不会被cucumber
拾取。相反,它将从下面的Scenario
部分开始。
定义步骤
一个Scenario
部分中的所有句子被称为一个step
定义,需要在幕后以某种方式翻译成可执行代码。cucumber
使用正则表达式匹配句子。它试图从句子中提取一些参数,然后传递给step
函数。
通过逐步定义来解释句子
我们可以用来自cypress-cucumber-preprocessor
的Given
、When
和Then
函数定义正则表达式,并在这些函数中做一些有趣的事情。
例如:
import {checkAppTitle, gotoApp} from '../../helpers';
import {Given, Then, When} from 'cypress-cucumber-preprocessor/steps';
Given(`I am a bookish user`, () => {
//
});
When(`I open the list page`, () => {
gotoApp();
});
Then(`I can see the title {string} is showing`, (title) => {
checkAppTitle(title);
});
传递给Given
、When
和Then
函数的parameters
非常相似;第一个是正则表达式,用于匹配.feature
文件中的一个句子。第二个是类似的正则表达式,它返回一个回调,一旦有匹配就调用这个回调。如果正则表达式中有一些模式,值将被提取并传递给回调函数(参见Then
示例)。这是一个简单但强大的机制,允许我们做一些有趣的工作——包括启动浏览器和检查特定元素是否显示在页面上。
现在让我们运行npm run e2e
(正如你在下面的图 10-1 中看到的)来验证所有东西都被正确链接了。
图 10-1
运行特征文件中定义的测试
所以,我们的Feature
被正确解释,参数被提取并相应地传递给方法。注意,我们可以重用在前面章节中提取的函数,比如gotoApp()
和checkAppTitle
。
书单
连接好每一部分后,我们现在可以开始用现有的helper
函数定义一个步骤。
定义图书列表Scenario
Scenario: Book List
Given I am a bookish user
When I open the list page
And there is a book list
| name |
| Refactoring |
| Domain-driven design |
| Building Microservices |
如果您使用过markdown
来编写文档,您将会认识到我们刚刚在前面定义的table
。没错,你可以使用table
在feature
文件中定义更复杂的数据结构:管道|
包围的结构。这是在测试中组织可重复数据的一种更好的方式,既便于人类阅读,也便于代码解析。
使用数据表接口
每行将被视为表中的一行,实际上您可以为每行定义许多列:
And there is
| name | price |
| Refactoring | $100 |
| Domain-driven design | $120 |
| Building Microservices | $80 |
cucumber
提供了一个引人注目的DTI
(数据表接口)来帮助开发者解析和使用数据表。例如,如果我们想在step
内获取feature
文件中定义的书目,只需使用table.rows
:
And(`there are a book list`, table => {
console.log(table.rows())
});
您将在控制台中以此形状显示数据:
[ [ 'Refactoring' ],
[ 'Domain-driven design' ],
[ 'Building Microservices' ] ]
或者,如果您更喜欢 JSON,您可以调用table.hashes()
来代替:
[ { name: 'Refactoring' },
{ name: 'Domain-driven design' },
{ name: 'Building Microservices' } ]
因此,在我们的步骤定义中,我们可以使用DTI
来做断言:
And(`there is a book list`, table => {
const actual = table.rows().map(x => x[0]);
checkBookListWith(actual);
});
Before
和After
挂钩
正如我们在raw
cypress 测试中所做的,我们需要set up/tear down
通过使用Before
和After
钩子来固定数据:
import {feedStubBooks} from '../../helpers';
import {cleanUpStubBooks} from '../../helpers';
import {Before, After} from 'cypress-cucumber-preprocessor/steps';
Before(() => {
feedStubBooks();
});
After(() => {
cleanUpStubBooks();
});
这对你来说应该很简单,因为我们已经在cypress
中看到了类似的东西。
搜索
我们可以测试的下一个场景是searching
特性。我们可以用简单的英语描述业务需求:
Scenario: Search by keyword
Given I am a bookish user
When I open the list page
And I typed "design" to perform a search
Then I should see "Domain-driven design" is matched
步骤定义
只要我们有了所有的辅助函数,实现这些步骤是很容易的:
import {checkBookListWith, performSearch} from '../../helpers';
import {And, Then} from 'cypress-cucumber-preprocessor/steps';
And(`I typed {string} to perform a search`, (term) => {
performSearch(term);
});
Then(`I should see {string} is matched`, (book) => {
checkBookListWith([book]);
});
整洁!step
函数几乎是不言自明的。注意我们如何在步骤定义中重用现有的helper
函数。
评论页面
类似地,我们可以用英语在下面的句子中重写review
特性测试:
Scenario: Write a review
Given I am a bookish user
When I open the book detail page for the first item
And I add a review to that book
| name | content |
| Juntao Qiu | Excellent work! |
Then I can see it displayed beneath the description section with the text "Excellent works!"
同样,我们可以重用之前定义的许多步骤,注意我们使用Data Table Interface
来提取传入的多个参数:
import {When, And, Then} from 'cypress-cucumber-preprocessor/steps';
import {checkBookDetail, checkReview, composeReview, gotoNthBookInTheList} from '../../helpers';
When(`I open the book detail page for the first item`, () => {
gotoNthBookInTheList(0);
});
And(`I add a review to that book`, table => {
const reviews = table.hashes();
const review = reviews[0];
composeReview(review.name, review.content);
});
Then(`I can see it displayed beneath the description section with the text {string}`, (content) => {
checkReview(content);
});
正如您在这里看到的,通过将行为提取到助手函数中,我们可以使step
函数中的文本更加简洁和有意义。将所有相关的代码放在一起还会使将来的任何更改更加易读和易于维护。例如,如果有任何 UI 元素更改,我们可以轻松地导航到相应的文件,并在不影响其他页面的情况下修改它。
实验报告
cypress-cucumber-preprocessor
提供了一种生成不同格式报告的奇妙方式;我喜欢的格式是json
,因为它允许我们以任何我们选择的方式可视化数据。
再配置一些 cypress-cumber-preprocessor
为了在json
中输出测试结果,您可以简单地在package.json
中指定一些选项:
"cypress-cucumber-preprocessor": {
"nonGlobalStepDefinitions": true,
"cucumberJson": {
"generate": true,
"outputFolder": "cypress/cucumber-json",
"filePrefix": "",
"fileSuffix": ".cucumber"
}
}
无论何时运行命令npm run e2e
,文件夹cypress/cucumber-json
下都会生成一个json
文件。
Bookish.cucumber.json
应该是这样的:
[
{
"description": " As a reader\n I want to see books in the trend\n So I can learn what to read next",
"keyword": "Feature",
"name": "Book List",
"line": 1,
"id": "book-list",
"tags": [],
"uri": "Bookish.feature",
"elements": []
}
]
而在每个element
中,都有scenario
的执行结果,像这样:
{
"id": "book-list;heading",
"keyword": "Scenario",
"line": 6,
"name": "Heading",
"tags": [],
"type": "scenario",
"steps": [
{
"arguments": [],
"keyword": "Given ",
"line": 7,
"name": "I am a bookish user",
"result": {
"status": "passed",
"duration": 57000000
}
},
//...
]
}
这些元数据可以用来生成最终的HTML
报告(或者您选择的其他格式)。
使用 HTML 报告程序
在我们的例子中,要生成 HTML 报告,我们需要将cucumber-html-reporter
作为cucumber
的插件安装:
npm install cucumber-html-reporter --save-dev
我们可以编写一个简单的脚本来生成基于 json 的 HTML 报告:
var reporter = require('cucumber-html-reporter');
var options = {
theme: 'bootstrap',
jsonFile: 'reports/report.json',
output: 'reports/report.html',
reportSuiteAsScenarios: true,
launchReport: true
};
reporter.generate(options);
最后,我们运行以下命令来生成报告:
node report.js
最终的 HTML 将如图 10-2 所示。
图 10-2
HTML 报告已生成
摘要
行为驱动开发的美妙之处在于它允许你为非技术人员编写可读的功能测试。传统上,人们认为代码,甚至测试代码,只由开发人员或测试人员编写和维护,而不是业务分析师或团队中其他感兴趣的人。然而,BDD
正试图消除这一障碍,让不同的角色无缝、高效地协作。
在本章中,我们详细讨论了如何使用cypress-cucumber-preprocessor
和cypress
来编写和运行live documents
。在这个过程中,我们重用了我们在前面章节中完成的大部分helpers
来转换我们应用的关键路径。