单元测试优化的实践(Generative Testing)

首先为何要写单元测试?

由于对于任何一个软件来讲,“知足需求”是他存在的必要条件,也是软件的价值体现。单元测试必定是为它服务的。因此很容易知道写单元测试的两个动机:驱动(如:TDD)和验证功能实现。另外,软件需求“易变”的特征决定了修改代码成为必然,在这种状况下,单元测试能保护已有的功能不被破坏。node

基于以上两点共识,咱们看看传统的单元测试有什么特征?

基于用例的测试(By Example)

单元测试最多见的套路就是Given、When、Then三部曲。程序员

  • Given:初始状态或前置条件
  • When:行为发生
  • Then:断言结果

编写时,咱们会精心准备(Given)一组输入数据,而后在调用行为后,断言返回的结果与预期相符。这种基于用例的测试方式在开发(包括TDD)过程当中十分好用。由于它清晰地定义了输入输出,并且大部分状况下体量都很小、容易理解。面试

但这样的测试方式也有坏处。安全

  • 第一点在于测试的意图。用例太过具体,咱们就很容易忽略本身的测试意图。好比我曾经看过有人在写计算器kata程序的时候,将其中的一个测试命名为“return 3 when add 1 and 2”,这样的命名其实掩盖了测试用例背后的真实意图——传入两个整型参数,调用add方法以后获得的结果应该是二者之和。咱们常说测试即文档,既然是文档就应该明确描述待测方法的行为,而不是陈述一个例子。
  • 第二点在于测试完备性。由于省事省心而且回报率高,咱们更乐于写happy path的代码。尽管出于职业道德,咱们也会找一个明显的异常路径进行测试,不过这还远远不够。

为了辅助单元测试改善这两点。我这里介绍另外一种测试方式——生成式测试(Generative Testing,也称Property-Based Testing)。这种测试方式会基于输入假设输出,而且生成许多可能的数据来验证假设的正确性。数据结构

生成式测试

对于第一个问题,咱们换种思路思考一下。假设咱们不写具体的测试用例,而是直接描述意图,那么问题也就迎刃而解了。想法很美好,但如何实践Given、When、Then呢?答案是让程序自动生成入参并验证结果。这也就引出“生成式测试”的概念——咱们先声明传入数据可能的状况,而后使用生成器生成符合入参状况的数据,调用待测方法,最后进行验证。app

Given阶段

Clojure 1.9(Alpha)新内置的Clojure.spec能够很轻松地作到这点:框架

;;  定义输入参数的可能状况:两个整型参数

(s/def  ::add-operators  (s/cat  :a  int?  :b  int?))

;;  尝试生成数据

(gen/generate  (s/gen  ::add-operators))

;;  生成的数据

->  (1  -122)

首先,咱们尝试声明两个参数可能出现的状况或者称为规格(specification),即参数a和b都是整数。而后调用生成器产生一对整数。整个分析和构造的过程当中,都没有涉及具体的数据,这样会强制咱们揣摩输入数据可能的模样,并且也能避免测试意图被掩盖掉——正如前面所说,return 3 when add 1 and 2并不表明什么,return the sum of two integers才具备广泛意义。ide

Then阶段

数据是生成了,待测方法也能够调用,可是Then这个断言阶段又让人头疼了,由于咱们根本无法预知生成的数据,也就没法知道正确的结果,怎么断言?性能

拿定义好的加法运算为例:单元测试

(defn add  [a  b]

(+  a  b))

咱们尝试把断言改为一个全称命题: 任取两个整数a、b,a和b加起来的结果老是a、b之和。 借助test.check,咱们在Clojure能够这样表达:

(def test-add

(prop/for-all  [a  (gen/int)

              b  (gen/int)]

 (=  (add  a  b)  (+  a  b))))

不过,咱们把add方法的实现(+ a b)写到了断言里,这几乎丧失了单元测试的基本意义。换一种断言方式,咱们使用加法的逆运算进行描述: 任取两个整数,把a和b加起来的结果减去a总会获得b。

(def test-add

(prop/for-all  [a  (gen/int)

            b  (gen/int)]

 (=  (-  (add  a  b)  a)  b))))

咱们经过程序陈述了一个已知的真命题。变换之后,就可使用quick-check对多组生成的整数进行测试。

;;  随机生成100组数据测试add方法

(tc/quick-check  100  test-add)

;;  测试结果

->  {:result true,  :num-tests  100,  :seed  1477285296502}

测试结果代表,刚才运行了100组测试,而且都经过了。理论上,程序能够生成无数的测试数据来验证add方法的正确性。即使不能穷尽,咱们也得到一组统计上的数字,而不只仅是几个纯手工挑选的用例。

至于第二个问题,首先得明确测试是没法作到完备的。不少指导方法保证使用较少的用例作到有效覆盖,好比:等价类、边界值、断定表、因果图、pairwise等等。可是在实际使用过程中,依然存在问题。举个例子,假如咱们有一个接收天然数并直接返回这个参数的方法identity-nat,那么对于输入参数而言,全体天然数都互为等价类,其中的一个有效等价类能够是天然数1;假定入参被限定在整数范围,咱们很容易找到一个无效等价类,好比-1。 用Clojure测试代码表现出来:

(deftest test-with-identity-nat

(testing  "identity of natural integers"

 (is  (=  1  (identity-nat  1))))

(testing  "throw exception for non-natural integers"

(is  (thrown?  RuntimeException  (identity-nat  -1)))))

不过若是有人修改了方法identity-nat的实现,单独处理入参为0的状况,这个测试仍是可以照常经过。也就是说,实现发生改变,基于等价类的测试有可能起不到防御做用。固然你彻底能够反驳:规则改变致使等价类也须要从新定义。道理确实如此,可是反过来想一想,咱们写测试的目的不正是构建一张安全网吗?咱们信任测试能在代码变更时给予警告,但此处它失信了,这就尴尬了。

若是使用生成式测试,咱们规定:

任取一个天然数a,在其上调用identity-nat的结果老是返回a。

(def test-identity-nat

(prop/for-all  [a  (s/gen nat-int?)]

 (=  a  (identity-nat  a))))

(tc/quick-check  100  test-identity-nat)

->  {:result false,

:seed  1477362396044,

:failing-size  0,

:num-tests  1,

:fail  [0],

:shrunk  {:total-nodes-visited  0,

    :depth  0,

    :result false,

    :smallest  [0]}}

这个测试尝试对100组生成的天然数(nat-int?)进行测试,但首次运行就发现代码发生过变更。失败的数据是0,并且还给出了最小失败集[0]。拿着这个最小失败集,咱们就能够快速地重现失败用例,从而修正。

固然也存在这样的可能:在一次运行中,咱们的测试没法发现失败的用例。可是,若是100个测试用例都经过了,至少代表咱们程序对于100个随机的天然数都是正确的,和基于用例的测试相比,这就如同编织出一道更加紧密的安全网——网孔越小,漏掉的状况也越少。

Clojure语言之父Rich Hickey推崇Simple Made Easy哲学,受其影响生成式测试在Clojure.spec中有更为简约的表达。以上述为例:

(s/fdef identity-nat

 :args  (s/cat  :a  nat-int?)  ;  输入参数的规格

 :ret nat-int? ;  返回结果的规格

 :fn  #(= (:ret %) (-> % :args :a))) ; 入参和出参之间的约束

(stest/check  `identity-nat)

fdef宏定义了方法identity-nat的规格,默认状况下会基于参数的规格生成1000组数据进行生成式测试。除了这一好处,它还提供部分类型检查的功能。

再谈TDD

若是对软件测试、接口测试、自动化测试、性能测试、LR脚本开发、面试经验交流。感兴趣能够175317069,群内会有不按期的发放免费的资料连接,这些资料都是从各个技术网站搜集、整理出来的,若是你有好的学习资料能够私聊发我,我会注明出处以后分享给你们。

TDD(测试驱动开发)是一种驱动代码实现和设计的过程。咱们说要先有测试,再去实现;保证明现功能的前提下,重构代码以达到较好的设计。整个过程就比如演绎推理,测试就是其中的证实步骤,而最终实现的功能则是证实的结果。

对于开发人员而言,基于用例的测试方式是友好的,由于它能简单直接地表达实现的功能并保证其正确性。一旦进入红、绿、重构的节(guai)奏(quan),开发人员根本停不下来,仿佛遁入一种心流状态。只不过问题是,基于用例驱动出来的实现可能并非刚好经过的。咱们经常会发现,在写完上组测试用例的实现以后,无需任何改动,下组测试照常能运行经过。换句话说,实现代码可能作了多余的事情而咱们却浑然不知。在这种状况下,咱们能够利用生成式测试准备大量符合规格的数据探测程序,以此检查程序的健壮性,让缺陷无处遁形。

凡是想到的状况都能测试,可是想不到状况也须要测试,这才是生成式测试的价值所在。有人把TDD概念化为“展现你的功能”(Show your work),而把生成式测试概括为“检查你的功能“(Check your work),我深觉得然。

小结

回到咱们写单元测试的动机上:

一、驱动和验证功能实现;

二、保护已有的功能不被破坏。

基于用例的单元测试和生成式测试在这两点上是相辅相成的。咱们能够借助它们尽量早地发现更多的缺陷,避免它们逃逸到生产环境。

Clojure.spec是Clojure内置的一个新特性,它容许开发人员将数据结构用类型和其余验证条件(例如容许的取值范围)进行封装。这种数据结构一旦创建,Clojure就能利用这种规格来为程序员提供大量的便利:自动生成的测试代码、合法性验证、析构数据结构等等。Clojure.spec提供方法颇有前景,它可让开发者在须要的时候,就能从类型和取值范围中获益。

另外,除了Clojure,其它语言也有相应的生成式测试的框架,你不妨在本身的项目中试一试。

相关文章
相关标签/搜索