时不时地,关于类型化 JavaScript 的价值就会引发争论。“多写一些测试!”一些反对者喊道。“用类型替换单元测试!”其他人则尖叫道。两者在某些方面都是正确的,在其他方面都是错误的。Twitter 容不下细致入微的讨论。但在本文中,我们可以尝试阐述一个合理的论点,说明两者如何以及应该如何共存。
正确性:我们真正想要的
最好从最终目标开始。我们从所有这些元工程最终真正想要的是**正确性**。我指的不是严格的理论计算机科学定义,而是程序行为与其规范的更普遍的遵循:我们脑海中有一个关于程序如何工作的想法,而编程过程则组织位和字节,将这个想法变成现实。因为我们并不总是精确地知道我们想要什么,并且因为我们希望确信我们的程序在进行更改时没有出现故障,所以我们在现有的原始代码之上编写类型和测试,仅仅是为了让事情一开始就能正常工作。
因此,如果我们接受正确性是我们想要的东西,并且类型和测试只是实现正确性的自动化方式,那么最好有一个关于类型和测试如何帮助我们实现正确性的可视化模型,从而理解它们在哪里重叠以及在哪里互补。
程序正确性的可视化模型
如果我们将程序可能执行的所有操作的整个无限图灵完备的可能空间——**包括故障**——想象成一个广阔的灰色区域,那么我们希望我们的程序执行的操作,我们的规范,就是这个可能空间的一个非常非常小的子集(下面的绿色菱形,为了显示而夸大了尺寸)。

我们在编程中的工作是尽可能地将我们的程序与规范相匹配(当然,我们知道我们是不完美的,并且我们的规范不断变化,例如,由于人为错误、新功能或未指定的行为;因此,我们永远无法完全实现精确的重叠)。

请注意,为了我们在这里的讨论,我们程序行为的边界**也包括计划内和计划外的错误**。我们对“正确性”的含义包括计划内的错误,但不包括计划外的错误。
测试与正确性
我们编写测试以确保我们的程序符合我们的预期,但有许多选择可以进行测试。

理想的测试是图中的橙色点——它们准确地测试了我们的程序是否与规范重叠。在此可视化中,我们并没有真正区分测试类型,但您可以将单元测试想象成非常小的点,而集成/端到端测试则是大的点。无论哪种方式,它们都是点,因为没有一个测试可以完全描述程序中的每一条路径。(事实上,即使您拥有 100% 的代码覆盖率,也**仍然**无法测试每一条路径,因为存在组合爆炸!)
此图中的蓝色点是一个糟糕的测试。当然,它测试了我们的程序是否有效,但它实际上并没有将其与底层规范(我们最终真正想要从程序中获得的东西)联系起来。当我们修复程序以使其更接近规范时,此测试就会失败,给我们一个误报。
紫色点是一个有价值的测试,因为它测试了我们认为程序应该如何工作,并确定了程序当前无法工作的区域。以紫色测试为先,并相应地修复程序实现,也称为**测试驱动开发**。
此图中的红色测试是一个罕见的测试。与测试“快乐路径”(包括计划的错误状态)的普通(橙色)测试不同,此测试期望并验证“不快乐路径”会失败。如果此测试在应该“失败”的地方“通过”,则这是一个巨大的早期预警信号,表明某些地方出了问题——但基本上不可能编写足够的测试来覆盖绿色规范区域之外存在的庞大可能的“不快乐路径”。人们很少发现测试那些不应该工作的东西不工作有价值,所以他们不这样做;但当事情出错时,它仍然可以成为一个有用的早期预警信号。
类型与正确性
如果测试是程序可能执行的操作的可能性空间中的单个点,那么类型则表示从整个可能空间中划分出的类别。我们可以将它们可视化为矩形。

我们选择一个矩形来对比表示程序的菱形,因为仅使用类型系统本身无法完全描述程序的行为。(举一个简单的例子,一个应该始终为正整数的id
是number
类型,但number
类型也接受分数和负数。除了非常简单的数字文字联合之外,没有办法将number
类型限制在特定范围内。)

类型充当您在编写代码时程序可以到达位置的约束。如果我们的程序开始超出程序类型的指定边界,我们的类型检查器(如 TypeScript 或 Flow)将简单地拒绝让我们编译程序。这很好,因为在像 JavaScript 这样的动态语言中,很容易意外地创建一个崩溃的程序,这肯定不是您想要的结果。最简单的增值是自动空值检查。如果foo
没有名为bar
的方法,则调用foo.bar()
将导致我们非常熟悉的undefined is not a function
运行时异常。如果foo
被完全类型化,那么这可以在编写时由类型检查器捕获,并具体指出有问题的代码行(以及自动完成作为伴随的好处)。这是测试无法做到的。
我们可能希望为程序编写严格的类型,就好像我们试图编写尽可能小的仍然符合我们规范的矩形一样。但是,这需要一个学习曲线,因为充分利用类型系统涉及学习一整套新的语法和运算符以及泛型类型逻辑,这些逻辑需要对 JavaScript 的完整动态范围进行建模。手册和速查表有助于降低此学习曲线,并且这里需要更多投资。
幸运的是,这种采用/学习曲线不必阻止我们。由于类型检查是 Flow 中的可选过程,并且 TypeScript 中的可配置严格性(能够有选择地忽略
有问题的代码行),因此我们可以从类型安全性的范围内进行选择。我们甚至可以对此进行建模。

较大的矩形,例如上图中的红色大矩形,表示对代码库中类型系统的非常宽松的采用——例如,允许implicitAny
并完全依赖类型推断来仅仅限制程序避免最糟糕的编码错误。
中等严格性(如中等大小的绿色矩形)可以表示更忠实的类型化,但有很多逃生舱口,例如在整个代码库中使用any
的显式实例和手动类型断言。尽管如此,即使进行这种轻量级的类型化工作,也不符合我们规范的有效程序的可能表面积也会大大减少。
最大严格性,如紫色矩形,使程序与规范保持如此紧密的联系,以至于它有时会发现程序中不符合规范的部分(这些通常是程序行为中的计划外错误)。从将普通 JavaScript 代码库转换为类型化代码库的团队那里,发现现有程序中的错误是一个非常常见的故事。但是,要从类型检查器中获得最大的类型安全,可能需要利用泛型类型和特殊运算符,这些运算符旨在细化和缩小每个变量和函数的可能类型空间。
请注意,我们并不一定非得先编写程序才能编写类型。毕竟,我们只是希望我们的类型与规范紧密匹配,所以实际上我们可以先编写类型,然后以后再填充实现。理论上,这将是**类型驱动开发**;在实践中,很少有人真正以这种方式进行开发,因为类型与我们的实际程序代码紧密交织在一起。
将它们结合起来
我们最终要构建的是一个直观的可视化,说明类型和测试如何在保证程序的**正确性**方面相互补充。

我们的**测试**断言程序在选定的关键路径中按预期执行(尽管如上所述,测试还有一些其他变体,但绝大多数测试都是这样做的)。用我们开发的可视化语言来说,它们将程序的深绿色菱形“固定”到规范的浅绿色菱形上。程序的任何偏移都会破坏这些测试,从而导致测试发出警报。这很好!测试也无限灵活且可配置,适用于最自定义的用例。
我们的类型断言我们的程序不会偏离我们的控制,因为它不允许超出我们划定的边界的潜在故障模式,希望尽可能紧密地围绕我们的规范。用我们可视化的语言来说,它们“包含”了我们的程序偏离我们规范的可能漂移(因为我们总是不完美的,我们犯的每一个错误都会给我们的程序增加额外的故障行为)。类型也是一种直截了当但功能强大的工具(因为类型推断和编辑器工具),它受益于一个强大的社区,提供您无需从头编写的类型。
简而言之
- 测试最擅长确保正常路径有效。
- 类型最擅长防止异常路径出现。
根据它们的优势将它们结合使用,以获得最佳效果!
如果您想了解更多关于类型和测试如何交织的信息,Gary Bernhardt关于边界的精彩演讲和Kent C. Dodds的测试奖杯对本文的思考产生了重大影响。