当前位置: 首页 > 科技观察

单元测试,只是测试?

时间:2023-03-15 01:03:35 科技观察

推动单元测试,仅仅实现单元测试覆盖是不够的。我们还需要学会编写“易于测试”的代码和“好的”测试,这样单元测试才能真正发挥作用。本文将分享作者对单元测试的思考和实践。首先回答一下题目提出的问题:单元测试除了是一种测试手段之外,还是一种改进代码设计的工具。易于编写单元测试的代码通常具有更好的设计。因此,它是任何自动化测试工具都无法替代的。当然,这并不是要一棍子打死自动化测试工具。自动化测试工具也有自己的使用场景,比如测试遗留代码,做长期链路测试等等。这里需要强调一下“工具”属性。工具可以放大人的智力或体力,工作时不会那么累。比如你去种树,带了一把铲子,你肯定不会把铲子当成负担,因为它是你的种树工具,如果你写Java,你肯定不会把它当成负担,因为IDEA上手时间比较长,因为IDEA也是你写Java的工具。很多人把写单测当成一种负担,往往只是没有意识到“单测”是一种工具,而简单地把它当成测试。ATaste在Taste一章中,让我们看看什么样的代码很容易单独测试。Mock工具的使用——毒药还是解药你可能马上会有和程序员A类似的疑惑:“无论代码写成什么,Mockito和PowerMock一定能写出单体测试?那么通过单体测试真的能改善代码结构吗?””。事实上,大量使用Mock工具的单元测试相当于买一盒还一粒珍珠。它只有测试能力,不能帮助代码设计。商店系统案例以一个很简单的程序为例,假设这是一个商店系统,有一个买面包的方法,会调用银行提供的信用卡服务creditCardService,从传入的信用卡中扣款。如果这个程序使用了Mockito,估计很快就能写出测试了。你只需要将creditCardService交给Mock,然后验证它传进来的参数就可以了。如果你总是这样想,单一的测试并不能帮助你改进你的代码设计。我们在为代码编写单元测试时,不应该考虑用什么工具来测试代码,而应该考虑如何重构代码,让代码更容易测试。还是上面的代码,我们换个角度想想怎么重构代码,让这个逻辑不用mocking就可以测试?返回执行计划而不是立即执行外部调用。上层拿到Payment实体后,可以选择立即执行,也可以稍后统一执行。其实一个很简单的方法就是返回一个计划,而不是立即执行外部调用。比如这里我们可以抽象出一个Payment实体,表示从银行卡转了多少钱,外部的take到达Payment实体后,决定是马上划掉钱,还是稍后再划掉钱.这时候可以不用Mock来测试这段逻辑,只要验证方法返回的Payment对象中的属性是正确的即可。说到这里,你可能又会有一个疑问,“为了写一个单元测试,花那么多时间重构代码,值得吗?”如果你有这个疑问,那么你可能还是把单元测试当成测试,我之所以要重构代码,让单测容易写,是因为单测代码容易写,而且有还有许多其他好处。易单测的代码就只有易单测吗?更多的性能优化机会以上面重构的代码为例,因为业务层返回Payment对象,我可以把这些Payments聚合起来,最后统一执行,比如用下图的代码,我可以扣除payment根据银行卡分组,这样可以减少rpc调用次数。如果需要的话,我什至可以直接把payment作为消息发送出去,在另外一个系统中执行,业务层不需要关心Payment最后是怎么执行的,只需要在支付的时候生成一个Payment即可。更健壮的核心代码更健壮的系统的另一个好处是,一个容易编写单元测试的系统往往比一个不容易编写单元测试的系统更健壮。如果一个系统的大部分代码都可以不用Mock单元测试就可以写出来,那么就像左图这样,外部调用只是薄薄的一层,可以随意更换。如果你的系统大部分代码都必须通过Mock来测试,或者根本无法测试,如右图,那说明你的业务根本没有自己的核心逻辑,而是纠缠在各种外部调用中。另外需要注意的是,图中红色部分是单体测试真正可以工作的场景,因为是比较稳定的业务逻辑,而且红色部分的单体测试也比较好,只需要通过即可几个参数进去,然后检查返回值就可以了。理论上,灰色的外部调用部分不写单元测试也无所谓,因为外部调用是不稳定的。即使你和对方约定了输入输出参数,他也有可能返回不符合约定的参数,或者直接出现网络错误,这部分就是集成测试的用武之地。为什么在我们的系统里,大家都觉得单测没用,其实我也觉得单测对我们现在的系统没有用,因为我们现在系统的主要代码就像右图,大部分是灰色的外部调用,单体测试能发挥作用的地方非常少。就算你写一个覆盖率80%的测试用例,你能测试什么?这里要补充一点,我上面提到的“稳定”的意思,我说红色部分“业务核心代码”的稳定,并不代表业务不变。业务必须不断变化,但其逻辑不会受到外部系统错误的影响。与灰色部分不同的是,外部系统可能会发生抖动。出错了,因为灰色部分不适合单独测试。Mock工具的定位刚刚喷了这么久的Mock工具,那么Mock工具真正的定位是什么呢?Mockito用于测试少量必须从外部调用的代码。PowerMock用于测试设计不当的遗留代码。PowerMock的文档中已经给出了警告,误用可能弊大于利,所以我们在写单个测试的时候,不应该考虑使用这些Mock工具,而应该考虑如何重构代码以避免使用这些工具。来自PowerMock官方文档的警告:将它(PowerMock)交到初级开发人员手中可能弊大于利。另外说一下单测自动化生成工具,我们只是要说明一下,不管是哪种单测生成工具,你会发现该工具生成的单元测试全是Mockito和PowerMock,这显然不符合单元测试的定位,但是这种工具也是有意义的,当系统中充满了不易编写单元测试的遗留代码时,使用这个工具生成它也可以帮助我们覆盖一小部分测试的一部分,对于我们系统目前的情况来说还是非常有必要的。重构的另一个例子是写一个静态方法,外部调用:最后的结果:为了加深大家的印象,这里再举一个例子。比如下面这个方法,我先在静态方法中调用,通过对Business对象的各种处理得到rpc调用的地址和版本号,然后用这个地址和版本号去加载一个初始化好的hsf(内部使用通过阿里)rpc框架)返回广义调用对象,这个方法的单体测试显然很难写,因为init会进行网络调用,导致测试失败。这个时候,我们不得不反思一下,为什么单体测试不好写,因为我们违背了一个编码的基本原则——“不能在静态方法中写外部调用”,如果你只是想在内部调用外部调用静态方法,那我该怎么办?还是像前面的例子一样返回一个plan,让外部调用,先把没有副作用的那部分代码保持不变,这部分没有外部调用,在静态方法里面执行没什么,然后放外部调用part封装在一个Operator中(比如这里的RpcLoader)返回给上层,上层选择立即调用还是稍后调用。这样做除了容易写单元测试还有什么好处呢?最明显的一点是代码变得可重用,更重要的一点是防腐。你会发现hsf的影响范围仅限于RpcLoader。更改API或更改为另一个框架非常容易。为什么单元测试可以验证代码结构的合理性?前面提到的关于代码结构的概念是不是很耳熟,在其他领域也经常听到,比如面向对象中提到的“高内聚、低耦合”,前面提到的“核心域”、“防腐层”DDD,和函数式编程所提倡的“孤立的副作用”,你会发现好的编程范式所提倡的东西是相似的。以上三种评估代码的方法其实都比较“主观”。什么样的代码才能称得上“高内聚”,可能每个人都不一样。但是至于写单测容易吗,大家的标准基本一致,写单测难的系统谁都难。容易写单元测试的代码一般都符合编程范式所提倡的原则,所以写单元测试的难度可以作为一个非常客观的代码质量评价指标。如果有人告诉你他的代码设计的很好,但是写单元测试并不容易,千万不要相信他。另外,让我提一下设计模式。如果只是照搬书上的代码,设计模式就很简单了。关键是使用正确的场景。一不小心,只会学“形”而学不到“神”。“形神兼备”的设计模式往往让代码更容易测试。如果你使用了设计模式,发现系统变得更难测试,那么很可能是设计模式没有被正确使用。如果一个程序员告诉你我的程序的性能达到了多少QPS,你肯定会马上拿起测试工具测试一下,看看能不能达到这个QPS。但是如果一个程序员画了一个框图,说他的代码分成了ABC模块,他怎么去验证他的代码真的分成了这些模块呢?很简单,看能不能每个模块都可以和其他模块分开测试。好吧,如果单独测试非常困难,这意味着模块并没有真正分开,而是或多或少地耦合在一起。一次性测试的难易程度现在我们可以总结一下一次性测试的难易程度。不像其他领域,你在其他领域使用的工具越高级,你可能越厉害,但是在单机测试领域,你使用的工具越高级,测试出来的效果就越差。另外,不要担心这些规则。这些只适用于具有丰富业务含义的代码。如果你写的是一些封装外部调用的代码,我觉得这部分代码不写单元测试是可行的。第一层容易测试:大部分代码无需Mock即可测试,少量外部调用代码需要Mockito。第二层,能够单测:一半以上的代码需要mock来测试,但是这些测试并不是特别难写。第三个层次是很难单独测试的:很多Mock,甚至用了很多PowerMock。第四层,无法单独测试:模块设计的复杂到连开发者自己都看不懂,更别说单独写测试了。两篇实践文章在上一篇学习了单测的正确概念后,接下来就来谈谈本篇单测的最佳实践。单元测试的运行速度重要吗?很多人认为单元测试无论如何都不是系统中的代码。.这样的话,就完全背离了单体测试的定位。单测的目的是为了便于快速迭代。改两行代码后,就可以在30秒到几分钟的时间内,在本地跑一个完整的单机测试来判断影响范围,而不是每次都得通读系统源码才能知道影响范围更改,让新人可以快速大胆改代码,而不是花几个月的时间通读系统源码,踩了几个坑才上手。对于那些需要几十分钟才能完整运行单个测试的系统,他的开发人员永远不会在本地运行完整的单个测试。他们会在一个人身上运行很长时间,然后才知道单人测试失败。这样的单一测试是没有用的。一个典型的违反这个原则的反例就是在单次测试中启动Spring。数据驱动测试(DataDrivenTest)糟糕的单元测试往往只用一组正常的测试数据进行测试。其实我们应该用多组数据,包括正常数据和异常数据,来输入模块,看看返回值是否符合预期。使用多组测试数据是否意味着要编写大量代码?不需要,我们只需要注意将测试用例的逻辑与数据分离即可。测试代码依次读取测试数据,验证是否符合预期。这种将逻辑和数据分离的测试一般称为“数据驱动测试”,常见的单元测试框架都会提供这种支持。“数据驱动测试”这个概念还是太抽象了。下面我们看两段代码。左图没有将数据和用例分开,而右图将它们分开。你可以看到明显的区别。右图是基于Spock单元测试框架不熟悉的人可能会觉得陌生。你可以把where标签下的代码看成一个表格,每一行都是一组测试数据。Spock框架会将其代入testAdd方法的参数中进行测试。测试数据没有从用例中分离出来。测试数据与用例分离。熟悉的junit框架也可以做,但是需要额外写一个内部类,添加@RunWith(Parameterized.class),写一个数据静态方法,然后返回需要的测试数据组,然后junit会填入依次读取该类属性中的数据,运行该类中的所有测试用例。基于Junit的数据驱动测试基于Spock的数据驱动测试如何测试私有方法人们在编写单元测试时经常会有的困惑之一就是如何测试私有方法?虽然理论上私有方法不需要写单元测试,但是有些私有方法逻辑复杂。单独写一个测试还是值得的。目前公认更好的做法是将修饰符从private改为protected。这也是很多开源项目给单体测试留下空隙的方式。如果你的项目刚好引入了guava,可以在方法上加上@VisibleForTesting注解,表明它只是一个修饰符,单元测试需要修改。一个典型的例子:三个TDD和BDD上一篇文章讲到你可能经常听到的一两个概念,TDD和BDD。个人认为这两个概念比较偏激,在实践中很难应用。励志意义大于实际意义,所以放在最后,希望能带来一些启发。TDDTDD强调写代码的过程形成一个循环。第一步,为你要做的功能写一个单元测试,跑起来发现失败了(毕竟你没有实现代码),也就是图中的TESTFAILS,俗称“红色”测试”。Light”,然后写出能通过所有测试的“最少代码”。之所以强调“最少代码”,是为了防止过度优化。在现实中,我们经常会因为代码过度优化或过度优化而导致很多遗留问题-设计。在这个阶段,用最快最脏的代码去实现就好了,不用太担心设计问题。这个阶段俗称“绿灯”。最重要的是下面的“REFACTOR”阶段.虽然之前的代码可能脏了,但至少是正确的,并且有足够的测试来保证逻辑的正确性。这时候可以大幅度重构代码。保证代码持续最优。这个启发给我们两点:单元测试一定要能跑得快,因为单元测试往往是在本地全量跑,只有跑得足够快,才能在TDD周期中快速迭代。好的代码不是设计一次,而是持续重构,单测是持续的前提麻烦的重构。BDD我经常抱怨产品经理提需求的时候没有想清楚,比如下图,如果产品经理也能写出可执行的测试用例,情况会好很多。BDD就是这样一个想法。产品经理提出了需求。不知道大家有没有在一些项目中看到过.story文件。它本质上是一个集成测试脚本,但它是用自然语言描述的。它包括叙事、场景和步骤三部分。例如,上图是一个书店管理应用的.story文件。文件中的Narrative和Scenario仅供思考,包含在测试用例的逻辑中。测试用例主要由以Given,When,Then开头的语句组成,含义如下:右图),可以定义各种Given、When、Then语句的实现,下图代码本质上图是一个基于Selenide的自动化界面点击测试,支持故事文件的执行。基于这个故事文件,我们可以像TDD循环一样,先测试失败(红灯),然后用最小的代码通过测试(绿灯),最后重构代码。只是这个周期可能需要几天,甚至几周。而一个TDD的周期可能只需要几个小时,所以BDD就是集成测试版本的TDD。JBehave框架是敏捷的。我们常常觉得TDD和BDD会严重拖慢迭代速度。讽刺的是,TDD和BDD只是敏捷开发实践的重要组成部分:图片来源维基百科敏捷软件开发我们在学习敏捷开发的时候,往往只学习它的“牢度”,而忽略了敏捷开发提出的质量保证方法。敏捷开发中所谓的“快”,是指代码质量得到充分保证时的“快”,而不是功能完成后直接上线。四如何学习写单试学习单试的关键是要多练习,看看别人是怎么写出好的单试的。比如你可以提交代码给一些公认的有优秀代码的开源项目。五、总结单机测试可以帮助我们验证代码设计的合理性。对于有核心业务的代码,首先应该思考如何让主要业务逻辑编写Mock-free单测。用例数据应尽可能与测试逻辑分开。参考资料参考资料[1]测试驱动的Java开发https://www.oreilly.com/library/view/test-driven-java-development/9781783987429/[2]Wiki敏捷软件开发https://en.wikipedia.org/wiki/Agile_software_development[3]PowerMockhttps://powermock.github.io/[4]JBehavehttps://jbehave.org/[5]Spockhttp://spockframework.org/[6]JUnithttps://junit.org/junit4/[7]LearningtoLoveTDDhttps://medium.com/swlh/learning-to-love-tdd-f8eb60739a69【本文为专栏作者《阿里巴巴官方技术》原创稿件,转载请联系原作者求转载】点这里,查看该作者更多好文