我们继续讨论TDD的一些理论知识,然后下一节我们就开始进入到单元测试的实践部分。理论知识是最佳实践充分不必要的条件,即便没有理论知识,也能进行最佳实践;但掌握了理论知识将能帮助你做到事半功倍。所以,这一节,不要轻易跳过哦,并且它的内容也很有意思。
构造-操作-检验模式
不知大家发现没,编程开发有一个大道至简的道理,这个道理很简单,也很平常,符合我们传统文件所说的“万变不离其宗”这一理念。
大家应该都有印象,在最初学习编程时,使用C++写一个main函数的代码,其实是分为三部分的:输入、处理和输出。后来,进入实际项目开发后,随着系统的复杂度越来越高,规模越来越大,可能对这观念就麻木了。其实,不管是一段程序,还是一个模块,一个框架,一个系统,本质上都可以划分为输入、输出和处理这三部分。这就是大道至简。
还是拿PHP开源框架来说,框架要解决参数输入的问题,这些参数可能是通过php-fpm运行方式传递进来的GET、POST、多媒体数据等,也可能是通过php-cli运动方式传递的命令行参数、流、输入参数等;输出可以是常见的页面渲染返回,也可能是JSON接口数据结果,或者是异常失败情况下的4xx和5xx页面;处理环节则是前面介绍的动态调用,以及前后穿插的事件侦听、钩子函数、回调函数等。
那么输入、处理和输出,与构造-操作-检验模式之间的关系又是什么呢?也许,细心的读者已经发现了其中的奥妙。这里暂时不给出答案,给大家留一个念想。因为,你思考收获的,与你看到学习的,深刻程度是不一样的。
构造-操作-检验(BUILD-OPERATE-CHECK)模式,可以理解成:“当… 做…,应该…”。其中,构造包括测试环境的搭建、测试数据前期的准备;操作是指对被测试对象的调用, 以及被测试对象之间的通信和协助交互;最后检验则是对业务规则的断言、对功能需求的验证。
例如,对于1+2=3,简单实现的代码和对应的单元测试代码如下,其中可以按构造-操作-检验模式分为三步编写单元测试用例。第一步,构造必要的参数,即上下文场景信息;第二步进行操作,调用计数器的相加方法;第三步,对相加的结果进行自我断言,核对1加2的结果是否等于3。
<?php use PHPUnit\Framework\TestCase; class CalculatorTest extends TestCase { public function testAdd() { // Step 1. 构造 $left = 1; $right = 2; // Step 2. 操作 $calculator = new Calculator(); $sum = $calculator->add($left, $right); // Step 3. 检验 $this->assertSame(3, $sum); } } class Calculator { public function add($left, $right) { return $left + $right; } }
这是编写测试用例的基本模式,如果对于编写单元测试代码没有什么头绪时,可以参考此模式。
F.I.R.S.T.原则
在编写单元测试代码时,除了要遵循构成-操作-检验模式外,还要坚持F.I.R.S.T.原则。这五个原则分别是:
Fast 快速
Independent 独立
Repeatable 可重复
Self-validating 自我验证
Timely 及时
首先,单元测试的执行速度一定要是快速的,能快速运行、快速反馈。不能是运行一个测试,要等上好几分钟,或者需要好长一段时间,这些都是不友好,而且不要说别人甚至连自己也无法忍受这蜗牛般的速度。通常,较好的速度就是“秒杀”,即能在1秒钟内完成一个单元测试用例。对于整个测试套件,可以稍微放宽一点时间限制,总的运行时间一般不要超过去饮水间打杯水的时间。在我们曾经推行的一键测试里,就是这个时间要求。能让开发人员运行一键测试后,打杯水回来就能看到哪里有问题,哪里不对。快速这一特性,非常重要。
其次是独立性。在编写代码,设计系统时,我们都会要求能做到“高内聚、低耦合”,把类与类之间的依赖关系简单化,避免不必要的认识关系。同样地,在编写单元测试代码时,也需要遵循独立原则,即各个测试用例之间是相互独立的。你失败了,不会影响我的测试;我异常了,也不会对你的测试造成干扰。虽然xUnit家族里是可以支持指定测试用例之间的前后顺序和依赖关系,但那是有意识的设计,有意而为的动作,与这里所说的情况不一样。
可重复性是指同一个单元测试用例,应该是可以反复运行并能得到相同的结果。这一点很好理解,就等于对于特定的一个函数,我每次提供的参数都是一样的,那么每次这个函数给我返回的结果应该是一样的。但为什么这点很简单,甚至我们已经是这样做了,却还要再提一次呢?这是因为,和前面独立性的原则类似,很多时候我们都是在无意识层面下犯了错误。接触过命令查询职责分离(CQRS)模式的同学都知道,接口可以分为两种,分别是命令操作和查询操作。命令操作是会产生副作用的,而查询操作则是幂等操作。当待校验的操作是命令操作时,那么就会改变内部状态或数据的值,当重复执行单元测试时,有可能就会得到不一样的结果。因为对象的方法通常都不是一个纯函数。为了能重复执行,不断验证,遵循这个原则很重要。
自我验证,是单元测试的一大特色,基本上我们编写单元测试都是为了这一原则。具备了自我验证的单元测试,组合起来就是自动化单元测试。传统的开发方式,基本是写代码、手工测试与调试,其中在调试这一过程花费的时间是非常多的。至于手工测试,可以想象得到的就是,一个开发人员写好代码后,打开浏览器,然后通过页面点击、输入一系列表单,或者通过在链接后面手动加上必要的参数,然后进入到刚才编写开发模块,进行测试验证。正所谓,机关重重,想要穿过十面埋伏,进行到待测试的代码,已经是历经千山万水,困难重重了。更别提,还要重复测试,还要在一个月后再回来重复验证。别提有多痛苦。自我验证,是不了做到自动化,一旦实现自动化,离高效就不远矣。因为开发人员可以从重复繁锁的人工操作抽身出来去做一些更有价值的事情。
最后一点是及时性。这一点很简单理解,但也是很重要的,特别在测试驱动开发时。如果有测试不通过的情况,就应该及时发现、及时报告出来,以便能及时修正,形成一个快速反馈的闭环机制。
关于这五个原则,简单介绍到这里。
三角验证
接下来是三角验证。熟练掌握这个思维工具,对于中型项目、乃至核心底层模块、大型系统都非常有启发性。一般在辩论某个观点时,都会从正反两面来论述。在进行单元测试时,则要从多个维度进行验证、判断、检测,不要轻易放过任何一个可疑之处。
测试路径也可以分为两大类,一类是正常情况的Happy Path快乐路径,另一类是快乐路径的反面,如:Sad Path悲伤路径、Bad Path错误路径或者Exception Path异常路径。所以在构造测试用例时,要对期望输入的参数进行等价类的划分,并且为这几类不同的路径准备相应的测试数据。我们可以结合示例来加深这块的理解。
回到上面两数相加的单元测试,刚才只是测试了正常快乐路径,1加2等于3。但如果客户端(这里所说的客户端是指使用Calculator计数器的客户端代码)传递过来的是非数字而是字符串呢,这时会发生什么,或者我需要怎样去验证?通过主动设想可能的场景并提前为之设计和开发,而不是等到最后发生了问题再来被动弥补,前者的优势会更大。对于字符串相加会发生什么,这取决于我们对于计数器的目标和定位,以及它所处的上下文约束。如果我们要实现的就是一个小学生使用的数字计算器,那么字符串相加是不允许的,是应该禁止的。那最终呢?我是该断言有异常抛出,还是断言是否返回一个默认值,还是直接代码因参数类型错误而结束运行?通过这样一系列的自我提问或者集体智慧讨论,从不同的维度开始,到提出问题,到如何断言,最后可以引出具体的实现方式。
还记得我们前面说过的吗?在面向过程世界里,如果一个函数处理失败了,按照惯例是返回一个FALSE默认值,并伴随一个错误编码。而在面向对象的世界里,如果失败,按照惯例是抛出一个特定的异常。
因此,为了使得计数器的代码更健壮,可以处理非法的参数,可以添加一个针对错误路径的测试用例。
class CalculatorTest extends TestCase { /** * @expectedException IllegalParameterException */ public function testAddString() { // Step 1. 构造 $left = "逆流而上"; $right = "PHP企业级系统开发"; // Step 2. 操作 $calculator = new Calculator(); $sum = $calculator->add($left, $right); } }
这里使用了PHPUnit的expectedException注解,表示期望抛出的异常是IllegalParameterException。为使得此测试通过,再来根据约束实现代码就很容易了,以下是一个参考的实现版本。
class Calculator { public function add($left, $right) { if (!is_numeric($left) || !is_numeric($right)) { throw new IllegalParameterException('参数非法'); } return $left + $right; } } class IllegalParameterException extends Exception { }
现在,正反两面我们都验证了,但这里说的是三角验证,最起码还有第三个维度的测试。那第三个维度的测试又是什么呢?三,历来都是一个泛数,三角验证并不就只是三个不同维度的验证,还可以是四角、五角、八角验证。我们要懂得举一反三,变通。第三个维度,可以说是一个开放式的验证,你可以根据当前的情况、关注点和项目的需求,进行补充。其中一个方向就是对未来可预见的扩展性的测试。
例如在这里的计数器,如果我们想实现复数的相加呢?现在不支持,如果以后需要支持时,是否可以在不影响原来的设计、原来已经提供的接口基础上完美过渡、顺利升级、完全兼容呢?这是一个值得思考的问题。提前考虑可预见的扩展性,不是让大家过度设计,而是要求我们编写交付的代码能具备一定的生存能力,能在不断变化的复杂场景下具备快速迭代和演进的更新能力。
简单加顾一下,构造-操作-检验模式是针对单个测试用例而提出来,目的是为了编写的测试代码更有条理性。F.I.R.S.T.这五个原则,不仅适用于单个测试用例,还适用于大的测试套件,在整体上都是有指导意义的。最后,三角验证则是从断言层面提出更多出启发性的测试要求。