在本书的开头,我们阐述了干净代码的基本原则。 其中包括可靠性。 要确认代码的可靠性,最好的方法就是将代码库公开给持续的多变量使用。 这意味着让真正的用户坐在你的软件前,真正地使用它。 只有通过这种方式,我们才能理解我们的代码是否真正实现了它的目的。 然而,经常在现实生活中进行这样的测试通常是不合理的,甚至可能是危险的。 如果代码被更改,用户所依赖的功能部分可能会出现停顿或倒退。 为了防止这种情况的发生,并通常确认我们的期望得到了满足,我们编写了测试。 没有一套好的测试,我们只能被动地、傲慢地闭上眼睛,希望一切顺利。
在本章中,我们将涵盖以下主题:
软件测试是一个自动化的过程,它对一段代码作出断言,然后向您报告这些断言的成功。 测试可以对从单个函数到整个特性的行为的任何东西进行断言。
测试,很像我们的其他代码,处理抽象层和粒度。 如果我们要抽象地测试一辆汽车,我们可以简单地断言以下属性:
显然,对于汽车工程师来说,这不是一组非常有用的断言,因为这些属性要么非常明显,要么描述得不够充分。 它所驱动的断言很重要,但如果没有额外的细节,它所表达的只是一个通用的面向业务的目标。 这类似于项目经理要求软件工程师确保用户登录门户能够允许用户成功登录。 工程师的工作不仅是实现用户登录门户,而且还要生成工作测试,成功地调查用户能够成功登录的断言的真实性。 从通用语句中推导出好的测试并不总是容易的。
为了正确地设计测试,我们必须将通用的和抽象的需求提取为颗粒状的和非抽象的细节。 例如,我们断言我们的汽车有一个正常工作的喇叭,我们可以这样提取它:
When the driver raises at least one hand and directs the hand to depress by 2 cm the center of the steering wheel for a period of 1 second, a loud sound of fixed frequency at 400 Hz will be emitted by the car at approximately 107 decibels for 1 second.
当我们开始为断言添加关键的细节时,它们就会对我们有用。 我们可以使用它们作为实现和功能确认的指南。 即使添加了这些细节,我们的声明也只是一个断言或要求。 这样的需求在软件设计中是一个有用的步骤。 事实上,我们应该非常不情愿开始实现软件,直到我们有这样的特异性水平。
例如,如果客户要求您实现一个支付表单,明智的做法是收集确切的需求:它应该接受什么类型的支付? 还有哪些客户信息需要收集? 在存储这些数据时,我们应遵守哪些规定或约束? 这些扩展的需求将成为我们和客户衡量完整性的标准。 很自然地,我们可以将这些需求作为单独的测试来实现,以确认它们在软件中存在。
一个好的测试方法包括对代码库的所有不同部分进行测试,并提供以下好处:
一个好的测试方法也有许多二阶效应。 您的同事对代码库的信心的增加将意味着您可以更高效,更快地进行更重大的更改,从长远来看减少成本和痛苦。 知识的共享可以使您的同事和用户更快地执行他们的操作,更多的理解和更少的时间和费用开销。 证明实现的能力使团队和个人能够更好地与涉众、经理和用户沟通他们工作的价值。
既然我们已经讨论了测试的明显好处,现在我们可以讨论如何编写测试了。 在每个测试的核心是一组断言,所以我们现在将探讨断言的含义以及如何使用断言来编码我们的期望。
有许多测试工具、术语和范例。 如此复杂的存在似乎令人生畏,但重要的是要记住,在核心上,测试实际上只是对某些东西如何工作的断言。
根据特定的结果,断言可以通过编程方式表达SUCCESS
或FAILURE
,如下例所示:
if (sum(100, 200) !== 300) {
console.log('SUCCESS! :) sum() is not behaving correctly');
} else {
console.log('FAILURE! :( sum() is behaving correctly');
}
在这里,如果我们的sum
函数没有给出预期的输出,我们将收到一个FAILURE!
日志。 我们可以通过执行assert
函数抽象出成功和失败的模式,如下所示:
function assert(assertion, description) {
if (assertion) {
console.log('SUCCESS! ', description);
} else {
console.log('FAILURE! ', description);
}
}
这可以用来创建一系列带有附加说明的断言:
assert(sum(1, 2) === 3, 'sum of 1 and 2 should be 3');
assert(sum(5, 60) === 65, 'sum of 60 and 5 should be 65');
assert(isNaN(sum(0, null)), 'sum of null and any number should be NaN');
这是任何测试框架或库的基本核心。 它们都有一个机制来做出断言并报告这些断言的成功和失败。 测试库提供一种机制来封装或包含相关的断言,并将它们统称为测试或测试用例,这也是正常的。 我们可以做一些类似的事情,通过提供一个测试函数,允许你传递一个描述和一个函数(包含断言):
function test(description, assertionsFn) {
console.log(`Test: ${description}`);
assertionsFn();
}
我们可以这样使用它:
test('sum() small numbers', () => {
assert(sum(1, 2) === 3, 'sum of 1 and 2 should be 3');
assert(sum(0, 0) === 0, 'sum of 0 and 0 should be 0');
assert(sum(1, 8) === 9, 'sum of 1 and 8 should be 9');
});
test('sum() large numbers', () => {
assert(
sum(1e6, 1e10) === 10001000000,
'sum of 1e6 and 1e10 should be 10001e6'
);
});
运行此命令生成的测试日志如下:
> Test: sum() small numbers
> SUCCESS! sum of 1 and 2 should be 3
> SUCCESS! sum of 0 and 0 should be 0
> SUCCESS! sum of 1 and 8 should be 9
> Test: sum() large numbers
> SUCCESS! sum of 1e6 and 1e10 should be 10001e6
从技术角度来看,编写断言和简单测试的纯操作并不太具有挑战性。 编写一个奇异函数的测试并不难。 然而,要编写完整的测试套件并彻底测试代码库的所有部分,我们必须利用一些更复杂的测试机制和方法来帮助我们。
让我们回想一下汽车的类比,假设我们面前有一辆车,我们想测试它的喇叭。 牛角不是一个独立的机械部件。 它被嵌入汽车内部,依靠独立的电源。 事实上,我们可能会发现,我们必须首先通过点火来启动汽车,然后喇叭才会工作。 点火的成功本身取决于几个其他组件,包括工作的点火开关,油箱中的燃料,工作的燃料过滤器,和一个不耗尽的电池。 因此,喇叭的功能依赖于一系列的许多运动部件。 因此,我们对喇叭的测试不仅是对喇叭本身的测试,而且实际上是对几乎整个汽车的测试! 这并不理想。
为了解决这个问题,我们可以将喇叭连接到一个单独的电源,只是为了测试目的。 通过这样做,我们隔离了喇叭,使测试只反映喇叭本身的功能。 在测试世界中,我们使用的这个替代电源可能被称为stub或mock。
In the software world, both stubs and mocks are a type of stand-in abstraction for the real abstraction that provides appropriate outputs without carrying out the real work of the replaced abstraction. An example would be a makeCreditCardPayment
stub, which returns SUCCESS
without creating a real-world payment. This would be used in the context of testing e-commerce functionality, possibly.
不幸的是,我们隔离喇叭电源的方法存在缺陷。 即使我们的测试成功了,喇叭也能正常工作,但我们不能保证在连接到车内真正的电源后喇叭还能正常工作。 独立测试喇叭仍然是有用的,因为它告诉我们喇叭的特定电路和机制的任何故障,但它本身是不够的。 我们需要测试,当它嵌入到必须依赖其他部件的现实情况下,喇叭将如何工作。 在软件中,我们称这种实际测试为集成测试或端到端测试,而隔离测试通常称为单元测试。 一个有效的测试方法总是包括以下两种类型:
在隔离各个部分进行测试时,可能会产生一个不现实的场景,在这个场景中,您最终并没有实际测试代码库的真正功能,而是测试模拟的有效性。 在这里,以我们的汽车为例,通过提供一个模拟电源来隔离喇叭,使我们能够纯粹地测试喇叭的电路和发声机制,并在测试失败时为我们提供一个明确的调试问题的路径。 但是我们需要用几个集成测试来补充这个测试,这样我们就可以确信整个系统能够正确地工作。 即使我们对系统的所有部分都进行了上千个单元测试,如果不测试所有这些部分的集成,也无法保证系统能够工作。
为了确保一个经过彻底测试的代码库,我们必须进行不同类型的测试。 已经提到了,*单位测试使我们能够测试孤立的部分,而部分可以进行测试的各种组合通过集成,功能,或*E2E 测试。 首先,当我们谈论部分或单元*时,理解我们的意思是很有用的。***
当我们谈论一个代码单元时,不可否认,这个概念是模糊的。 通常,它是在系统中具有单一职责的一段代码。 当用户希望通过我们的软件执行某个操作时,实际上,他们将激活我们的代码的一系列部分,所有这些部分共同工作,为用户提供他们想要的输出。 考虑一个用户可以创建和分享图片的应用。 典型的用户体验(流程或旅程)可能涉及几个不同的步骤,这些步骤都涉及代码库的不同部分。 用户*执行的每一个动作(通常他们不知道)都会封装一系列代码动作:
(用户)上传保存在桌面上的照片,创建新图片:
<form>
上传照片<canvas>
内的位图,以便应用过滤器(用户)对图像应用滤镜:
<canvas>
像素操作应用过滤器(用户)与朋友分享图片:
总之,所有这些步骤,以及用户可能采取的所有其他步骤,都可以被视为一个系统。 和全面测试系统可能包括单元测试为每个单独的一步,集成为每一对测试步骤,和功能或端到端(【显示】E2E)测试每一个步骤的组合在一起形成一个用户流或【病人】用户之旅。 我们可以将可能需要作为系统的一部分存在的测试类型可视化如下:
在这里,我们可以看到一个开始点和两个结束点,表示两个不同的用户旅程。 每个点可以被认为是一个单独的责任区域或单元,作为这些旅程的一部分被激活。 正如您所看到的,单元测试只关心单个职责区域。 集成测试涉及两个(或更多)相邻区域的集成。 端到端或功能测试涉及单个用户旅程中涉及的所有领域。 前我们的图片共享应用的例子,我们可以想象,我们可能有特定的单元测试等操作上传照片到 CDN 或发送推送通知,集成测试,集成测试的朋友数据库,和一个 E2E 测试,测试整个流程从创建到共享一个新形象。 每一种测试方法对于确保一个真正经过良好测试的系统都是至关重要的,并且每一种方法都有其独特的优点以及需要克服的缺陷和挑战。
正如我们在汽车类比中所描述的,单元测试是处理独立的单元代码的测试。 这通常是一个单独的函数或模块,它将对代码的操作做出一个或多个简单的断言。
下面是一些单一单元测试场景的例子:
Button
组件,它应该包含值Submit My Data
,并且应该有一个btn_success
类。 您可以通过一个简单的单元测试断言这些特征,该单元测试检查生成的 DOM 元素的属性。/todo/list/item/{ID}
,它从数据库中检索特定的项。 您可以通过模拟数据库抽象(提供虚假数据)来断言路由正确工作,然后断言请求 URL 正确返回您的数据。测试独立的代码单元有几个好处:
Completeness here is similar to the popular concept of *test covera*ge. The crucial difference is that while coverage is about maximizing the amount of code within a code base that is tested, completeness is about maximizing the coverage of each individual unit, so that the entire input space of the unit is expressed. Test coverage, as a metric, only tells us whether things are tested, not whether they're well-tested.
然而,单元测试也有一些挑战:
单元测试作为最细粒度的测试类型,对任何代码库都是至关重要的。 最简单的方法可能是把它看作是一种复式记帐系统。 当您进行更改时,必须通过断言反映该更改。 这个实现然后测试的周期最好是在接近的情况下完成—一个接一个—也许是通过 TDD,这将在后面讨论。 单元测试是您确认自己确实编写了想要编写的代码的方法。 它提供了一定程度的确定性和可靠性,您的团队和利益相关者将非常感激。
集成测试,顾名思义,处理不同的单元代码的集成。 与简单的单元测试相比,集成测试将提供关于软件在生产中如何运行的更有用的信号。 以汽车为例,集成测试可能会基于喇叭如何使用汽车自己的电源而不是提供模拟电源来判断喇叭的功能。 不过,这可能仍然是一个部分隔离的测试,以确保它不涉及汽车内的所有组件。
以下是几个可能的集成测试示例:
Button
组件,当单击时应该将一个项目添加到列表中。 一个可能的集成测试是在真实的 DOM 中呈现组件,并检查模拟的click
事件是否正确地将项目添加到列表中。 这将测试Button
组件、DOM 和决定何时将项目添加到列表中的逻辑之间的集成。/users/get/{ID}
,它应该从数据库返回用户配置文件数据。 一个可能的集成测试是创建一个 ID 为456
的真实数据库条目,然后通过/users/get/456
请求返回该数据。 这将测试 HTTP 路由抽象和数据库层之间的集成。集成模块和测试它们的行为有很多好处:
因此,虽然单元测试为我们提供了特定模块和函数的输入和输出的狭窄而详细的视图,但集成测试让我们看到所有这些模块是如何一起工作的,并通过这样做,让我们了解集成的潜在问题。 这是非常有用的,但编写集成测试也存在陷阱和挑战:
集成测试在接口和 I/O 的关键点上提供了重要的洞察力,这些关键点控制了代码库的所有独立部分如何作为一个系统一起工作。 集成测试通常提供关于系统中潜在故障的最多信号,因为它们通常都能快速运行,并且在出现故障时高度透明(不像可能很笨拙的端到端测试)。 当然,集成测试只能告诉您关于它们封装的集成点的事情。 为了更充分地信任系统的功能,采用端到端测试总是一个好主意。
端到端测试是集成测试的一种更极端的形式,我们将测试整个系统,而不是测试模块之间的单个集成,通常通过执行一系列实际操作来产生给定的结果。 这些测试有时也被称为功能测试,因为它们从用户的角度对功能领域的测试感兴趣。 构造良好的端到端测试让我们相信整个系统都在正常工作,但当与更细粒度的单元和集成测试结合使用时最有价值,这样可以更快、更精确地识别故障。
下面是编写 E2E 测试的好处:
然而,在制作端到端加密测试时存在一些挑战:
端到端测试虽然很难正确进行,但可以提供仅通过单元和集成测试难以获得的洞察力和信心。 就自动化测试过程而言,端到端测试是我们能够合理地将我们的软件呈现在真实用户面前的最接近的方法。 这是识别我们的软件是否按照用户期望的方式工作的最细粒度和最系统的方法,毕竟,这是我们最感兴趣的。
TDD 是我们在实现之前编写测试的范例。 在这样做的过程中,我们的测试最终通知并影响我们的实现及其接口的设计。 通过这样做,我们开始将测试不仅视为一种文档形式,而且视为一种规范形式。 通过我们的测试,我们可以指定我们希望某些东西如何工作,就像功能存在一样编写断言,然后我们可以迭代地构建实现,这样我们所有的测试最终都会通过。
为了说明 TDD,让我们假设我们希望实现一个单词计数函数。 在实现它之前,我们可以开始写一些关于我们希望它如何工作的断言:
assert(
wordCount('Lemonade and chocolate') === 3,
'"Lemonade and chocolate" contains 3 words'
);
assert(
wordCount('Never-ending long-term') === 2,
'Hyphenated words count as singular words'
);
assert(
wordCount('This,is...a(story)') === 4,
'Punctuation is treated as word boundaries'
);
这是一个相当简单的函数,因此我们可以用三个断言来表达它的大部分功能。 当然还有其他的边缘情况,但我们已经拼凑了足够的期望,我们可以开始实现这个功能。 这是我们的第一次尝试:
function wordCount(string) {
return string.match(/[\w]+/g).length;
}
立即通过我们的小测试套件运行这个实现,我们收到以下结果:
SUCCESS! "Lemonade and chocolate" contains 3 words
FAILURE! Hyphenated words count as singular words
SUCCESS! Punctuation is treated as word boundaries
Hyphenated words
测试失败。 TDD,就其本质而言,期望迭代失败和重构将实现与测试套件相结合。 鉴于这个特殊的失败,我们可以简单地在正则表达式的字符类中添加一个连字符(在[...]
分隔符之间):
function wordCount(string) {
return string.match(/[\w-]+/g).length;
}
这会产生以下测试日志:
SUCCESS! "Lemonade and chocolate" contains 3 words
SUCCESS! Hyphenated words count as singular words
SUCCESS! Punctuation is treated as word boundaries
成功! 通过增量迭代,尽管为了演示而简化了,我们还是通过 TDD 实现了一些东西。
正如你可能已经观察到的,TDD 不是一种特殊的测试类型或风格,而是一种范例,用于何时、如何、为什么进行测试。 将测试视为事后考虑的传统观点是有限的,经常会迫使我们陷入没有时间编写好的测试套件的境地。 然而,TDD 迫使我们使用一个可靠的测试套件,这给了我们一些显著的好处:
当开始进行测试时,TDD 是一个特别有用的范例,因为它会迫使你在实现一些东西之前后退一步,真正考虑你想要做什么。 这个计划阶段确实有助于确保我们的代码完全符合用户的期望。
在本章中,我们介绍了测试的概念及其与软件的关系。 虽然这些概念很简单,但如果我们要以可靠性和可维护性为目标进行测试,那么这些基本概念是至关重要的。 测试,就像软件世界中的许多其他问题一样,可能会受到“货物崇拜”的影响,所以保持对我们编写的测试背后的基本原理和理论的观点是至关重要的。 测试的核心是证明预期和防止错误。 我们已经讨论了单元测试、集成测试和端到端测试之间的区别,讨论了它们各自固有的优点和挑战。
在下一章中,我们将研究如何利用这些知识,并将其应用于制作干净的测试和真实的例子。 具体来说,我们将介绍我们可以使用哪些度量和指导原则来确保我们的测试和其中的断言是可靠的、直观的和最大限度地有用的。*