单元测试优化的实践(Generative Testing)
首先为什么要写单元测试?
因为对于任何一个软件来说,“满足需求”是他存在的必要条件,也是软件的价值体现。单元测试一定是为它服务的。所以很容易知道写单元测试的两个动机:驱动(如:TDD)和验证功能实现。另外,软件需求“易变”的特征决定了修改代码成为必然,在这种情况下,单元测试能保护已有的功能不被破坏。
基于以上两点共识,我们看看传统的单元测试有什么特征?
基于用例的测试(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呢?答案是让程序自动生成入参并验证结果。这也就引出“生成式测试”的概念——我们先声明传入数据可能的情况,然后使用生成器生成符合入参情况的数据,调用待测方法,最后进行验证。
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才具有普遍意义。
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),我深以为然。
小结
回到我们写单元测试的动机上:
1、驱动和验证功能实现;
2、保护已有的功能不被破坏。
基于用例的单元测试和生成式测试在这两点上是相辅相成的。我们可以借助它们尽可能早地发现更多的缺陷,避免它们逃逸到生产环境。
Clojure.spec是Clojure内置的一个新特性,它允许开发人员将数据结构用类型和其他验证条件(例如允许的取值范围)进行封装。这种数据结构一旦建立,Clojure就能利用这种规格来为程序员提供大量的便利:自动生成的测试代码、合法性验证、析构数据结构等等。Clojure.spec提供方法很有前景,它可以让开发者在需要的时候,就能从类型和取值范围中获益。
另外,除了Clojure,其它语言也有相应的生成式测试的框架,你不妨在自己的项目中试一试。
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
教你如何在Angular中使用better-scroll插件
本文主要介绍了Angular中使用better-scroll插件的方法,小编觉得挺不错的,现在分享给大家。 better-scroll的使用 由于需要在一个固定的的高度做无限滚动,本来css的overflow-y也可以完成的,奈何安卓不是很流畅,还很生硬,就是用了第三方库better-scroll,配合angular的ng-content。angular的ng-content和vue的插槽很像,里面一些不确定的内容可以通过ng-content投影进去。 安装better-scroll 1: npm install better-scroll --save 2: 安装types npm install better-scroll @types/better-scroll --save 3:在angular-cli里面引入 listscroll组件的编写 根据官方的文档可以看出,better-scroll对dom的结构是有要求的,最外层的wrapper那一层是需要固定高度的,里面那一层content是根据内容的高度来撑起的。 html部分: <div class="scroll" #...
- 下一篇
深入解析vue.js响应式原理与实现
vue.js响应式原理解析与实现。angularjs是通过脏检查来实现数据监测以及页面更新渲染。之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新并且重新渲染页面。vue.js响应式原理解析与实现 Object.defineProperty es5新增了Object.defineProperty这个api,它可以允许我们为对象的属性来设定getter和setter,从而我们可以劫持用户对对象属性的取值和赋值。比如以下代码: const obj = { }; let val = 'cjg'; Object.defineProperty(obj, 'name', { get() { console.log('劫持了你的取值操作啦'); return val; }, set(newVal) { console.log('劫持了你的赋值操作啦'); val = newVal; } }); console.log(obj.name); obj.name = 'cwc'; console.log(obj.name); //欢迎加入全栈开发交流圈一起学习交流:86430586...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- Docker安装Oracle12C,快速搭建Oracle学习环境
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- Windows10,CentOS7,CentOS8安装MongoDB4.0.16
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题
- Mario游戏-低调大师作品
- SpringBoot2整合Redis,开启缓存,提高访问速度
- SpringBoot2整合Thymeleaf,官方推荐html解决方案
- 2048小游戏-低调大师作品
- CentOS7编译安装Cmake3.16.3,解决mysql等软件编译问题
- CentOS7,CentOS8安装Elasticsearch6.8.6