八、测试

在整本书中,我们已经多次断言纯函数更容易测试;是我们证明它的时候了。在本章中,我们将首先介绍一个关于该主题的小词汇表,以确保我们使用一种通用语言。然后,我们将继续介绍功能性方法如何帮助传统测试。最后,我们将学习一种不同的测试代码的方法,称为基于属性的测试

本章的主题均不严格限于函数式编程;您将能够使用任何遗留代码库中的任何内容。另外,这不是一本关于测试的书,所以我们不会深入到每一个细节。还假设您对 PHP 中的代码测试有一些先验知识。

在本章中,我们将介绍以下主题:

  • 小型测试术语表
  • 测试纯函数
  • 测试并行化作为一种加速技术
  • 基于属性的测试

测试词汇

我不会声称给你一个完整的词汇表,包括所有与测试相关的术语,也不会解释每个术语的细微差别和解释。本节的想法只是为了奠定一些共同点。

术语表不会按字母顺序排列,而是按类别对术语进行分组。此外,它决不能被视为一个完整的词汇表。与测试相关的术语和技术比这里介绍的要多得多,特别是如果您包括所有与性能、安全性和可用性相关的测试方法:

  • 单元测试:针对每个单独组件分别进行的测试。被认为是单元的内容各不相同——一个函数/方法、一个完整的类、一个完整的模块。通常,模拟对其他单元的依赖关系,以清晰地隔离每个部分。
  • 功能测试:将软件作为黑盒进行测试,以确保其符合规范要求。外部依赖通常被嘲笑。
  • 集成测试:针对整个应用及其依赖项(包括外部依赖项)进行的测试,以确保所有内容都正确集成。
  • 验收测试:由最终客户/最终用户根据一套商定的标准进行的测试。
  • 回归测试:在进行一些更改后重复测试,以确保过程中没有引入任何问题。
  • 模糊测试/模糊化:通过输入大量(半)随机数据使其崩溃而进行的测试。这有助于发现编码错误或安全问题。
  • 特别测试:在没有正式框架或计划的情况下进行的测试。
  • 组件测试:参见单元测试
  • 黑盒测试:参见功能测试
  • 行为测试:见功能测试
  • 用户验收测试UAT】:见验收测试
  • 阿尔法版本:通常是作为黑盒测试的第一个版本。它可能不稳定并导致数据丢失。
  • 测试版:通常情况下,第一个功能完整且状态良好的版本可以发布给外部人员。它仍然可能存在严重问题,不应在生产环境中使用。
  • 发布候选RC】:被认为足够稳定的版本,可以发布给公众进行最终测试。通常最后一个 RC 作为发布版本“升级”。
  • 模拟(mock):创建模拟软件或外部服务的其他部分的组件,以仅测试手头的内容。
  • 存根存根:见模拟
  • 代码覆盖率:测试覆盖的应用代码或功能的百分比。它可以有不同的粒度:按行、按函数、按组件等等。
  • 检测:向应用添加代码以测试和监视行为或覆盖的过程。它可以手动完成,也可以在源代码、编译形式或内存中使用工具完成。
  • 同行评审:一个或多个同事检查产生的工作的过程,如代码、文档或与发布相关的任何内容。
  • 静态分析:在不运行应用的情况下分析应用,通常由工具完成。它可以提供有关覆盖范围、复杂性、编码风格甚至发现问题的信息。
  • 静态测试:在不执行应用的情况下执行的所有测试和审查。参见同行评审静态分析
  • 冒烟测试:表面上测试应用的主要部分,以确保核心功能正常工作。
  • 技术评审:见同行评审
  • 决策点:代码中可能发生控制流更改的语句,通常为if条件。
  • 路径:从函数开始到结束执行的语句序列。一个函数可以有多条路径,这取决于它的决策点。
  • 圈复杂度:一段代码复杂度的度量。有各种各样的算法来计算它;一是“决策点数+1”。
  • 缺陷故障问题、错误:任何在应用中无法正常工作的情况。
  • 假阳性:测试结果被视为缺陷,而事实上一切正常。
  • 假阴性:当事实上存在缺陷时,测试结果被视为成功。
  • 测试驱动开发TDD】:一种开发方法,您可以从编写测试开始,然后在重复该过程之前使用最少的代码量使其通过。
  • 行为驱动开发BDD】:一种基于 TDD 的开发方法,您可以使用特定领域的语言来描述行为,而不是编写传统的测试。
  • 类型驱动开发:功能世界中的一个流行笑话,你用一个强大的类型系统替换测试。取决于你问谁,这个想法可能会被或多或少地认真对待。
  • X 驱动开发:每周都会创建一个新的最佳开发方法;网站http://devdriven.by/ 尝试引用它们。

测试纯函数

正如我们刚才在词汇表中看到的,有很多潜在的方法来测试应用。但是,在本节中,我们将仅限于功能级别的测试;或者换句话说,我们将进行单元测试。

那么,是什么让纯函数更容易测试呢?原因是多方面的,;让我们从列举它们开始,然后我们将了解真实测试用例的原因:

  • 模拟被简化,因为您只需要提供输入参数。没有要创建的外部状态,没有要存根的单例。
  • 对于给定的参数列表,重复调用将产生完全相同的结果,无论是在一天中的什么时间还是之前运行的测试。无需将应用置于特定状态。
  • 函数式编程鼓励较小的函数只做一件事。这通常需要更容易编写和理解的测试用例。
  • 引用透明性通常意味着您需要更少的测试来获得代码中相同级别的信任。
  • 无副作用保证您的测试不会对任何其他后续测试产生影响。这意味着您可以按任意顺序运行它们,而无需担心重置每个测试之间的状态或单独运行它们。

其中一些说法对你来说可能有点大胆,或者你不确定我为什么这么说。让我们花些时间用例子来验证为什么它们是正确的。我们将把我们的例子分成四个不同的部分,以便更容易理解。

所有输入都是显式的

正如我们之前发现的,纯函数需要将其所有输入作为参数。您不能依赖某个单例的静态方法、生成随机数或从外部源获取任何类型的数据。

由此推论,您可以在一天中的任何时间、任何环境和任何给定的参数列表上运行测试,并且输出将保持不变。这个简单的事实使得写作和阅读测试都变得容易多了。

假设您必须测试以下功能:

<?php 

function greet() 
{ 
  $hour = (int) date('g'); 

  if ($hour >= 5 && $hour < 12) { 
    return "Good morning!"; 
  } elseif ($hour < 18) { 
    return "Good afternoon!"; 
  } elseif ($hour < 22) { 
    return "Good evening!"; 
  } 
  return "Good night!"; 
} 

问题是,当调用函数时,需要知道现在是什么时间,以便检查返回值是否正确。这一事实导致了一些问题:

  • 您基本上必须在测试中重新实现函数逻辑,因此测试和函数中可能存在相同的 bug。
  • 在计算期望值和函数再次获得返回结果的时间之间,有一个很小的可能性,即经过了一分钟,改变了当前小时,从而改变了函数结果。这些类型的假阳性真是令人头疼的调试问题。
  • 在不操纵系统时钟的情况下,无法测试所有可能的输出。
  • 由于对当前时间的依赖关系被隐藏,读取测试的人只能推断函数正在做什么。

通过简单地移动$hour变量作为参数,我们解决了前面提到的所有问题。

此外,如果您使用允许您为测试创建数据提供程序的测试运行程序,例如PHPUnitatoum,那么测试函数就变得非常简单,只需创建一个提供程序,创建与预期回报相关联的小时列表,并将时间提供给函数并检查结果。这个测试的编写、理解和扩展比您之前需要编写的任何其他测试都要简单得多。

参考透明,无副作用

引用透明性确保您可以在代码中的任何位置用计算结果替换函数调用(使用某些参数)。这对于测试来说也是一个有趣的属性,因为它主要意味着您将需要更少的测试来获得相同数量的信任。让我解释一下。

通常,当您进行单元测试时,您会尝试选择尽可能最小的单元,以满足您希望在代码中放置的信任。通常,您将在模块、类或方法级别进行测试。显然,在进行函数编程时,您将在函数级别进行测试。

您的函数显然会调用其他函数。在传统的测试设置中,为了确保只测试当前单元的功能,并且不受其他功能中可能的错误的影响,您将尝试模拟尽可能多的功能。

虽然并非不可能,但在 PHP 中模拟函数很麻烦,所以在我们的例子中这变得有点困难。对于组合函数(如$title = compose('strip_tags', 'trim', 'capitalize');)尤其如此,因为组合是在 PHP 中使用闭包实现的。

那我们该怎么办?几乎没什么。单元测试的目标是在代码以预期方式工作的事实上获得信心。在传统的命令式方法中,由于以下原因,您可以模拟尽可能多的依赖项:

  • 每个依赖项都可能取决于您需要提供的某些状态,从而使您的工作更加困难。更糟糕的是,依赖项可以有自己的依赖项,也需要一些状态,等等。
  • 命令式代码可能有副作用,这可能会导致函数或某些依赖项出现问题。这意味着如果没有 mock,您不仅要测试您的函数,还要测试所有其他依赖项以及它们之间的交互;换句话说,您正在进行集成测试。
  • 控制结构引入决策点,使函数推理变得复杂;这意味着,如果您将移动块的数量减少到严格的最小值,那么您的功能就更容易测试。模拟其他函数调用可以降低这种复杂性。

在进行函数式编程时,第一个问题是没有意义的,因为没有全局状态。依赖项所需的所有内容要么已经包含在测试函数的参数中,要么将在此过程中进行计算。因此,模拟依赖项将使您做更多的工作,而不是更少。

由于我们的函数是纯的且引用透明的,因此没有副作用对计算结果产生任何影响的风险,这意味着即使我们具有依赖性,我们也不会进行集成测试。当然,被调用的函数中的一个 bug 会导致错误,但希望它也会被另一个测试更早地捕获到,从而清楚发生了什么。

关于复杂性,如果我们回到组合函数$title = compose('strip_tags', 'trim', 'capitalize');,我认为任何人都很容易理解正在发生的事情。如果这三个函数都已经过测试,那么就不会有什么问题,即使我们在没有compose命令的情况下重写它:

<?php 

function title(string $string): string 
{ 
  $stripped = strip_tags($string); 
  $trimmed = trim($stripped); 
  return capitalize($trimmed); 
} 

这里没有什么可测试的。显然,我们必须编写一些测试,以确保我们将正确的临时值传递给每个函数,并确保管道按预期工作,但是如果我们对所有三个调用的函数都有信心,我们就可以非常有信心该函数也会工作。

这条推理路线之所以可能,是因为我们知道,由于引用透明的特性,这三个函数中没有一个会以某种微妙的方式对其他函数产生任何影响,这意味着它们自己的单元测试给了我们足够的信任,因为它们不会破坏。

所有这些的结果是,通常您会为功能代码编写更少的测试,因为您会更快地获得信任。然而,这并不意味着title函数不需要测试,因为您可能在某个地方犯了一个小错误。每个组件仍应进行测试,但在正确隔离所有组件时可能会少一点谨慎。

显然,我们这里谈论的不是数据库访问、第三方 API 或服务;由于与任何测试套件中相同的原因,应该始终模拟这些测试。

简化模拟

这可能已经很清楚了,但我真的想强调一点,你必须做的任何嘲弄都将大大简化。

首先,您只需要创建被测试函数的输入参数。在某些情况下,这表示创建一些相当大的数据结构或实例化复杂的类,但至少您不必模拟外部状态或注入依赖项中的大量服务。

此外,这可能并非在所有情况下都是正确的,但通常您的函数在较小的规模上运行,因为它们是较大对象的一小部分,这意味着任何一个函数都只接受一些真正精确和简洁的参数。

显然,会有例外,但不会有那么多,正如我们前面所讨论的,因为构成全局的所有部分都将经过测试。然后,您的信心程度应该已经高于在更迫切的应用中通常的情况。

积木

函数式编程鼓励创建小的构建块,这些构建块可以作为更大函数的一部分重用。这些小功能通常只做一件事。这使它们更容易理解,也更容易测试。

函数的决策点越多,就越难找到一种方法来测试每个可能的执行路径。一个小型的专用函数通常最多有两个决策点,这使得测试相当容易。

较大的函数通常不执行任何类型的控制流,它们只是以简单的方式由较小的块组成。因为这意味着只有一条可能的执行路径,这也意味着它们很容易测试。

结束语

当然,我并不是说你不会遇到一些难以测试的纯函数。这比一般情况下,您编写测试的困难更少,您也会更快地获得对代码的信任。

随着行业越来越接近 TDD 等方法,这意味着函数式编程确实非常适合现代应用。当您意识到编写“可测试代码”所需的大多数建议都已经通过仅使用函数式编程技术来实现时,这一点尤其重要。

使用并行化加速

如果您曾经寻找过加快测试套件速度的解决方案,那么您很可能找到了一些关于测试并行化的东西。例如,PHPUnit 的用户通常会找到ParaTest实用程序。

其主要思想是同时运行多个 PHP 进程,以充分利用计算机的所有处理能力。这种方法之所以有效,主要有两个原因:

  • 单个测试运行有瓶颈,例如源文件解析或数据库访问的磁盘速度。
  • PHP 是单线程的,一个多核 CPU,就像现在几乎所有的计算机一样,不能通过一次测试来充分发挥其潜力。

通过并行运行多个测试,这两个问题都可以得到解决。但是,这样做的能力受到以下事实的限制:每个测试套件都是独立于其他测试套件的,这是一个已经由功能代码库中的引用透明性强制实现的属性。

这意味着,如果要测试的功能遵循功能原则,则可以并行运行所有测试,而无需进行任何调整。在某些情况下,这可以除以整个测试套件所花费的时间的 10,从而大大改善了在开发过程中的反馈循环。

如果您使用的是 PHPUnit 实用程序,那么前面提到的 ParaTest 实用程序是最简单的入门方法之一。您可以在 GitHub 的上找到它 https://github.com/brianium/paratest 。我建议您使用-functional命令行参数,以便可以同时测试每个功能,而不仅仅是测试用例。

PHPUnit 用户还有一个全新的实用程序,名为PHPChunkIt。我还没有机会测试它,但我听说它很有趣。您可以在 GitHub 的上找到它 https://github.com/jwage/phpchunkit

另一个更灵活的选择是使用最快的,可在上找到 https://github.com/liuggio/fastest 。工具文档中显示的示例是针对 PHPUnit 的,但理论上它可以并行运行任何东西。

如果您使用的是 atoum 实用程序,那么默认情况下,您的测试已经处于所谓的并发模式,这意味着它们并行运行。您可以使用处的执行引擎文档中所述的注释来修改每个测试的此行为 https://atoum-en.rtfd.org/en/latest/engine.html

behat框架用户可以使用Parallel Runner扩展,也可以在 GitHub 的上找到 https://github.com/shvetsgroup/ParallelRunner 。如果您使用的是CodeCeption框架,那么遗憾的是实现起来有点困难;文件(http://codeception.com/docs/12-ParallelExecution 为您提供了多种可能的解决方案。

我强烈建议您考虑并行化您的测试,因为这将是值得花费的时间。即使你每次跑步只能节省几秒钟,这种增益也会迅速累积。更快的测试意味着您将更频繁地运行它们,这通常是提高代码质量的好方法。

基于属性的测试

约翰·休斯(JohnHughes)和科恩·克莱森(KoenClaessen)厌倦了花时间枯燥乏味地编写测试用例,决定是时候做出改变了。15 年多前,他们撰写并发表了一篇关于一种新工具的论文,称为快速检查

其主要思想是,不要定义一个可能的输入值列表,然后断言结果是我们期望的结果,而是定义一个描述函数特性的属性列表。然后,该工具自动生成所需数量的测试用例,并验证该属性是否有效。

默认操作模式为快速检查生成随机值,并将其提供给您的函数。然后根据属性检查结果。如果检测到故障,工具将尝试将输入减少到产生问题的最小输入集。

有一个工具可以生成您想要的测试值,这对于发现需要花费您数小时思考的边缘案例是非常宝贵的。然后将测试用例简化为它的最小形式,这一事实也很容易确定出哪里出了问题以及如何修复它。碰巧的是,随机值并不总是测试某事物的最佳方法。这就是为什么您还可以提供将要使用的生成器。

此外,将测试视为一组需要保持为真的属性,这是一种更好的方式,可以更清楚地关注系统应该做什么,而不是专注于寻找测试值。这在执行 TDD 时尤其有用,因为您的测试更接近于规范。

如果您想了解更多关于这种方法的信息,可在网上获取原始论文 http://www.cs.tufts.edu/~nr/cs257/archive/john hughes/quick.pdf。作者在论文中使用了 Haskell,但内容相当容易阅读和理解。

什么是财产?

属性是函数必须遵守的规则,才能正确确定。它可以是非常简单的事情,比如一个函数加上一个要求也是整数的整数的结果,或者任何更复杂的事情,比如验证单子定律。

您通常希望创建其他属性或语言尚未强制执行的属性。例如,如果我们使用 PHP7 引入的标量类型系统,则不需要前面的整数示例。

作为一个例子,我们将从论文中得到一些东西。假设我们刚刚编写了一个函数,它反转数组中元素的顺序。作者建议该函数应具有以下属性:

  • reverse([x]) == [x]属性使用单个元素反转数组,并应生成完全相同的数组
  • reverse(reverse(x)) == x属性将数组反转两次,并应产生完全相同的数组
  • reverse(array_merge(x, y)) == array_merge(reverse(y), reverse(x))属性,反转两个合并的数组应该产生与将第二个反转数组合并到第一个反转数组相同的结果

前两个属性将保证我们的函数不会弄乱值。如果我们只拥有这两个属性,那么一个函数除了返回其参数之外什么也不做,它将以优异的成绩通过测试。这就是第三个属性发挥作用的地方。它的编写方式确保了我们的函数实现了我们对它的期望,因为没有其他方式可以保存该属性。

这些属性的有趣之处在于,它们在任何时候都不会执行任何类型的计算。它们易于实现和理解,这意味着几乎不可能在其中引入 bug。如果你想通过某种方式重新实现函数所做的计算来测试你的函数,这会有点挫败整个要点。

虽然非常简单,但这个例子完美地表明,要找到既有意义又足够简单的有价值的属性以确保它们不会有 bug 是不容易的。如果您很难找到好的属性,我建议您进行概述,并根据您试图实现的业务逻辑来思考您的功能。不要从投入和产出的角度去思考,而是要看到更广阔的前景。

实现添加功能

关于为什么基于属性的测试是一个有价值的工具,可以在的在线幻灯片中找到一个很好的解释 http://www.slideshare.net/ScottWlaschin/an-introduction-to-property-based-testing 。在上还有一篇附带的博文,提供了更多信息 http://fsharpforfunandprofit.com/posts/property-based-testing-2/ 。我将尝试在这里快速总结它们。

要求开发人员编写一个函数,通过一些测试添加两个值。他写了两个测试,预期结果是 4;一切都很好。要求功能的人要求进行更多测试;它们失败的原因是函数总是返回值 4,而不是执行任何有意义的操作。

开发人员重写函数,使测试再次通过,但新一轮测试继续失败。真正做的是将结果合并到新测试中,作为原始函数中的特殊情况。开发人员提出的借口是,他们遵循 TDD 最佳实践,说您需要编写使测试通过的最小代码。

对于这样一个简单的函数来说,正在发生的事情可能看起来很愚蠢,但如果你用某种需要实现的复杂业务逻辑来代替它,那么这样的故事可能比你想象的更常见,而且也是 TDD 的对手所说的陷阱之一。如果您严格遵循 TDD,您的代码将永远不会比您的测试更好。

幻灯片继续介绍测试,其中每个值都是随机整数,通过将结果与x + y进行比较来测试函数。在这种情况下,开发人员无法在其功能中使用特殊情况进行欺骗。显然还有另一个问题,但是,您在测试中重新实现了该函数以验证结果。

输入基于属性的测试。实现的第一个属性是add(x, y) == add(y, x)。开发人员将add属性实现为x * y,正确通过测试。

这意味着我们需要第二个属性,例如,add(add(x, 1), 1) == add(x, 2)属性。这也可以通过实施x - y来克服,但在这种情况下,第一次测试将失败。这就是为什么开发人员的最新实现只是返回0

此时,添加了最后一个属性add(x, 0) == x。开发人员最终被迫为我们的函数编写一个正确的实现,因为他这次找不到一种方法来欺骗它。

如果我们回到最后三个性质,并将它们与我们所知道的数学加法性质进行比较,我们可以得出以下比较:

  • add(x, 0) == x属性中,0 是加法的标识
  • add(x, y) == add(y, x)属性中,加法是可交换的
  • add(add(x, 1), 1) == add(x, 2)属性中,加法是关联的

这三个属性实际上都是我们试图实现的操作的众所周知的属性。正如我们前面所说的,退后一步,思考一下什么而不是谁,这对开发房产非常有帮助。

幻灯片的其余部分是一本非常有趣的读物,但由于我不想剽窃整个内容,我宁愿鼓励你上网阅读。我将从他们那里再接受三条建议,因为我发现它们非常棒,而且很容易记住:

  • 不同的路径,相同的目的地:使用测试中的函数想出两种不同的方法来获得相同的结果,就像我们对reverse的第三个属性所做的那样。
  • 反复:如果你的函数有一个倒数,试着应用这两个倒数,看看你是否得到了初始值,就像我们对reverse的第二个属性所做的那样。
  • 有些东西永远不会改变:如果函数没有改变输入的某些属性,请测试它们,例如数组长度或数据类型。

有了这些,您现在应该对如何为函数找到好的属性有了很好的了解。这仍然是一项困难的任务,但最终您可能会节省大量时间,因为您不必在找到边缘案例时添加它们。

如果您想了解一个通过基于属性的测试发现的真实错误的好例子,John Hughes 本人在上做了一次很棒的演讲,并给出了一些好例子 https://vimeo.com/68383317

PhpQuickCheck 测试库

在大体上了解了基于属性的测试的理论方面之后,我们现在可以将注意力转移到特定于 PHP 的实现PhpQuickCheck库。源代码可在 GitHub 的上获得 https://github.com/steos/php-quickcheck 使用composer命令可以安装和库:

composer require steos/php-quickcheck -stability dev

您可能需要在composer.json文件中将minimum-stability设置更改为 dev,或者按照 GitHub 页面上的说明手动添加依赖项,因为目前没有稳定版本的库。

该项目于 2014 年 9 月启动,大部分开发工作一直持续到同年 11 月。从那个以后,并没有添加多少新特性,主要是编码风格的改进和一些小的改进。

虽然我们不能说这个项目今天真的还活着,但它是第一次认真尝试在 PHP 中创建一个QuickCheck库,它有一些主要竞争者尚未提供的功能,稍后将讨论这些功能。

但我们不要超越自己;让我们回到我们的第一个例子,反向函数。想象一下,我们用 PHP 编写了array_reverse函数,需要对其进行测试。这就是PhpQuickCheck库的外观:

<?php 

use QCheck\Generator; 
use QCheck\Quick; 

$singleElement = Quick::check(1000, Generator::forAll( 
    [Generator::ints()], 
    function($i) { 
        return array_reverse([$i]) == [$i]; 
    } 
), ['echo' => true]); 

$inverse = Quick::check(1000, Generator::forAll( 
    [Generator::ints()->intoArrays()], 
    function($array) { 
        return array_reverse(array_reverse($array)) == $array; 
    } 
), ['echo' => true]); 

$merge = Quick::check(1000, Generator::forAll( 
    [Generator::ints()->intoArrays(), Generator::ints()- >intoArrays()], 
    function($x, $y) { 
        return 
            array_reverse(array_merge($x, $y)) == 
            array_merge(array_reverse($y), array_reverse($x)); 
    } 
), ['echo' => true]); 

check静态方法接受它需要生成的测试数据量作为第一个参数。第二个参数是Generator函数的一个实例;通常,您将在示例中使用Generator::forAll创建它。最后一部分是一组选项,您可以传入随机生成器seed变量、生成数据的max_size函数(该值的含义取决于使用的生成器),或者最后是echo选项,这些选项将为每个通过的测试显示一个点(.

forAll实例接受一个数组,该数组表示测试的参数和测试本身。在我们的例子中,对于第一个测试,我们生成随机整数,对于另外两个,生成随机整数数组。测试必须返回一个布尔值:true表示通过,否则返回false

如果您运行我们的小示例,它将为生成的每个随机数据显示一个点,因为我们通过了echo选项。结果变量包含有关测试结果本身的信息。在我们的例子中,如果您显示$merge,它将显示:

array(3) { 
  ["result"]=> bool(true) 
  ["num_tests"]=> int(1000) 
  ["seed"]=> int(1478161013564) 
} 

seed实例在每次运行时都会不同,除非您将一个实例作为参数传递。重用seed实例允许您创建完全相同的测试数据。这对于检查在发现特定边缘情况后是否正确修复非常有用。

一个有趣的特性是根据类型注释自动确定要使用哪个生成器。您可以使用Annotation类上的方法执行此操作:

<?php 

/** 
 * @param string $s 
 * @return bool 
 */ 
function my_function($s) { 
    return is_string($s); 
} 

Annotation::check('my_function'); 

但是,此功能现在只能用于注释,并且类型提示将被忽略。

从这些小例子中可以看出,PhpQuickCheck库严重依赖于静态函数。代码库本身有时也有点难以理解,而且该库缺乏良好的文档和活跃的社区。

总而言之,我不认为我会推荐使用这个选项,而不是我们接下来将看到的选项。我只是想把图书馆作为一种可能的替代品呈现给大家,谁知道,它的地位将来可能会改变。

埃里斯

Eris开发始于 2014 年 11 月,大约在PhpQuickCheck库引入其最后一个大功能时。正如我们将看到的,编码风格无疑更加现代。所有内容都在名称空间中清晰地组织起来,助手采用函数的形式,而不是静态方法。

通常,您可以使用composer命令获取 ERI:

composer require giorgiosironi/eris

该文件可在线访问http://eris.rtfd.org/ 非常完整。我对它唯一的不满是,唯一的例子是使用 PHPUnit 运行测试套件的人。与其他测试运行程序一起使用它应该是可行的,但这是目前没有文档记录的。

如果我们想使用 Eris 测试我们为array_reduce定义的属性,我们的测试用例将如下所示:

<?php 

use Eris\Generator; 

class ArrayReverseTest extends \PHPUnit_Framework_TestCase 
{ 
    use Eris\TestTrait; 

    public function testSingleElement() 
    { 
        $this->forAll(Generator\vector(1, Generator\nat())) 
             ->then(function ($x) { 
                 $this->assertEquals($x, array_reverse($x)); 
             }); 
    } 

    public function testInverse() 
    { 
      $this->forAll(Generator\seq(Generator\nat())) 
           ->then(function ($x) { 
               $this->assertEquals($x,  array_reverse(array_reverse($x))); 
           }); 
    } 

    public function testMerge() 
    { 
      $this->forAll( 
               Generator\seq(Generator\nat()), 
               Generator\seq(Generator\nat()) 
           ) 
           ->then(function ($x, $y) { 
               $this->assertEquals( 
                   array_reverse(array_merge($x, $y)), 
                   array_merge(array_reverse($y),  array_reverse($x)) 
               ); 
           }); 
    } 
} 

代码有点类似于我们为PhpQuickCheck库编写的代码,但利用了提供的特性添加到测试用例和生成器函数中的方法,而不是静态方法。forAll方法接受表示测试函数参数的生成器列表。随后可以使用then关键字定义函数。您可以访问 PHPUnit 提供的所有资产。

文档详细解释了如何配置库的各个方面,例如生成的测试数据量、限制执行时间等。每个生成器还详细介绍了各种示例和用例。

让我们看看当我们有一个失败的测试用例时会发生什么。假设我们想证明没有字符串也是一个数值;我们可以编写以下测试:

<?php 

class StringAreNotNumbersTest extends \PHPUnit_Framework_TestCase 
{ 
    use Eris\TestTrait; 

    public function testStrings() 
    { 
        $this->limitTo(1000) 
             ->forAll(Generator\string()) 
             ->then(function ($s) { 
        $this->assertFalse(is_numeric($s),"'$s' is a numeric  value.");}); 
    } 
} 

您可以看到我们如何使用limitTo函数将迭代次数从默认的 100 次提高到 1000 次。这是因为很多字符串实际上不是数值,如果没有这个提升,我只能在三次测试中得到一次失败。即使有了这个更高的限制,有时所有测试数据仍有可能通过测试而不出现故障。

这是您将获得的输出类型:

PHPUnit 5.6.2 by Sebastian Bergmann and contributors. 
F 1 / 1 (100%) 
Reproduce with: 
ERIS_SEED=1478176692904359 vendor/bin/phpunit --filter  StringAreNotNumbersTest::testStrings 

Time: 42 ms, Memory: 4.00MB 

There was 1 failure: 

1) StringAreNotNumbersTest::testStrings 
'9' is a numeric value. 
Failed asserting that true is false. 

./src/test.php:55 
./src/Quantifier/Evaluation.php:51 
./src/Quantifier/ForAll.php:154 
./src/Quantifier/ForAll.php:180 
./src/test.php:57 

FAILURES! 
Tests: 1, Assertions: 160, Failures: 1\. 

使用字符串"9"进行 160 次迭代后,测试失败。如果您希望通过手动设定随机生成器的种子来准确再现此失败测试,Eris 还将为您提供要运行的命令:

ERIS_SEED=1478176692904359 vendor/bin/phpunit -filter StringAreNotNumbersTest::testStrings".

如您所见,当您的测试是为 PHPUnit 编写的时,该库相当容易使用。否则,你可能需要做一些调整,但我认为这是值得你的时间。

结束语

在严格类型的函数式编程语言中,QuickCheck库更易于使用,因为它足以为某些类型和函数的某些属性声明生成器,并且几乎所有其他操作都可以自动完成。PhpQuickCheck库试图模拟这种行为,但结果使用起来有点乏味。

然而,这并不意味着不能在 PHP 中有效地使用基于属性的测试!一旦您创建了生成器,框架将使用它生成尽可能多的测试数据,可能会发现您从未想到的边缘情况。例如,DateTime方法在 PHP 中的实现中有一个 bug,它出现在闰年,在手动创建测试数据时很容易被忽略。参见中的测试语言部分 http://www.giorgiosironi.com/2015/06/property-based-testing-primer.html (由 Eris 的创建者提供)了解有关该问题的更多详细信息。

编写属性可能很有挑战性,尤其是在开始时。但通常情况下,它会帮助您对正在实现的功能进行推理,并且可能会产生更好的代码,因为您花时间从不同的角度考虑它。

总结

在本章中,我们快速了解了当您使用更具功能性的编程方法时,在测试方面可以做些什么。正如我们所看到的,函数式代码通常更容易测试,因为在执行命令式编码时,它强制执行被认为是测试的最佳实践。

通过没有副作用和明确的依赖关系,您可以避免编写测试时通常遇到的大多数问题。这样可以减少测试时间,并有更多的时间专注于应用。

我们还发现了基于属性的测试,这是发现与边缘案例相关问题的一种很好的方法。它还允许我们退一步,考虑要为函数强制执行的属性,这类似于为它们创建规范。这种方法在进行 TDD 时特别有效,因为它迫使您思考您想要什么,而不是如何去做。

既然我们已经讨论了测试以确保我们的函数完成它们应该做的事情,那么我们将在下一章中学习代码优化,以便考虑应用性能。经过良好测试的代码库将帮助您进行必要的重构,以获得更好的速度。