过于关注实现细节的测试
在为前端项目编写测试用例的时候,你也许和我一样,曾遇到过以下困扰:
明明进行了功能正确的改动,测试却挂了。修复测试有时候得认真阅读各种mock的细节,或者去了解很多本没有必要知道的代码逻辑。最后修测试花的时间比进行业务改动花的时间还要长(甚至长很多)。
对代码进行提取抽象之后,为各个组件或函数添加测试,实际上是用测试工具的API去重复 业务代码的内部实现逻辑(有时候还很麻烦!)。任何正常的重构都会导致测试失败,你本来希望测试能告诉你什么样的修改是对的,结果现在测试只能告诉你代码确实有被修改。
测试写好,覆盖率提高,本应信心十足地认为代码变得健壮了,可是扪心自问,你知道自己写的这个测试弱点在什么地方,或者说还有多少细节没有涵盖。你精心模拟了一个条件,去触发逻辑流程,并且测试通过,可是在真实的浏览器交互中用户也许并不能触发这个条件。因此,同样的道理,你在自己的代码通过了他人写的测试之后,也不能确定真实场景下没有问题,只好把后续的重任交给QA。
造成上面三个问题的原因不止一个,但测试过于关注实现细节在我看来是最主要的。
第一个问题,明明是正确的改动,可是测试不止是验证业务功能,还对实现细节提出了不该提出的要求,比如要求你的函数接受跟以前一样的参数,返回值必须是字符串而不能是数组等等。可是这个函数只是实现流程中一个小小的环节,也许在下次重构时就会不复存在。
第二个问题很类似,如果测试代码去重复实现细节,不管进行正确还是错误的重构,你都得把测试改一遍,那原先的测试又能提供什么价值呢?
第三个问题有时发生在,测试的实现细节,不能覆盖整个真实交互流程的时候。用户点击的是屏幕上的button按钮,而测试的起点是onClick事件被触发。后面的逻辑被验证成功,可问题偏偏发生在点击环节,真实的点击也许因为按钮状态而无法触发onClick事件。
因此,才会有人提出前端的测试应尽量去模拟真实的用户行为,Testing-Library就在其官网的“指导原则”章节,鼓励使用者尽量仿照应用真实的使用方式去编写测试,并明确提出,你的测试越接近用户的真实使用方式,它就能给你越多的信心。换句话说,你的测试应该尽量少用函数去手动触发,而要尽量多地利用测试框架给你的API,去模拟Input框的输入,按钮的点击,表单的提交等等。
如此一来,有的函数,你也无需写测试证明它的返回值如你所愿,需要写的,是页面显示了期待的文字,发生了预期的变化,进行了对应的跳转。你会发现,这时的测试就像写在卡里的AC一样。只要测试是通过的,你就有理由相信主体功能没有破坏,而不只是函数工作正常。
没有独立业务含义的测试单元
看到上面的方案,你可能会立马会想到一些问题。
首先就是测试流程可能会很长,从用户填完表单,点击提交,到期待的变化出现,当中可能经历了好几个函数的执行,连带着一系列的副作用。模拟这一系列行为,似乎是集成测试与E2E测试该干的事情。如果项目中大部分逻辑都是由这种测试去覆盖,看起来与测试金字塔所说的由单元测试作为地基是矛盾的。
我认为,当真实遇到的问题碰到了某种教条规范时,后者该适当地让步。
鼓励多写单元测试的原因在于它们成本低,有针对性。可是在前端项目里面,很多形式上的单元并没有独立的业务含义。
拿React项目举例,好多函数只是因为它们在形式上可以被抽取出来,就被拎到一个单独的文件里,从而降低主函数的复杂度。如果给它写单元测试,你就不得不手动触发它的参数变化,或者检测它的参数函数是否有被调用。
我们写的React hook尤其如此。很多时候抽取自定义的hook是出于逻辑上的原因,把相关的逻辑和数据聚合到一起,减轻UI组件的负担,但这些hook往往没有一个可以轻易解释清楚的业务含义,而且它们也不会被其它地方使用。
所以这类 “单元”只是长得像单元而已,它们其实只是一个实现环节。这里完整的UI操作流程,才更像一个有价值的单元,尽管它们在形式上可能超越了单个函数的范畴。
但我不想矫枉过正,确实有不少情况下,一个util函数,一个hook,一个很小的公共组件,都是有独立存在的价值的,因此,它们也应当被视为真正的单元,确实“有资格”拥有自己的专属测试。
testing-library下面有一个单独的库,叫react-hooks-testing-library,让你无需通过UI行为层面,而是直接以hook的方式去测试它们。它的GitHub页面上,明确提出了使用以及不使用它的场景:当你的hook不与组件强相关,拥有独立含义时可以使用;当你的hook只被一个组件使用,且和它的定义强相关时,则不建议使用。
【插入一段: 尽管存在react-hooks-testing-library这样的工具,但像SWR这样优秀的三方库,在用testing-library为自己的hook API做测试的时候,依然选择在UI层面进行。方法是,把自己的hook置于一个临时的div标签里进行render,把数据的变化映射成html文字的变化,最后对文字内容做断言。其实对于独立性强的函数,个人觉得放置在UI里面做测试倒没有太大区别,但SWR的例子体现了对“仿照真实使用场景去测试”这一原则的尊重。】
将上面的规律套用到Angular项目中,也是类似的。对于独立性和通用性不强的pipe,directive,reducer,effect,service,都可以认为它们是实现流程的一部分,从UI行为层面写好测试即可。
总之,在构思前端测试的时候,与其死守“单元测试”的字面含义,不如结合实际场景,重新思考什么才是真正有价值的“单元”,因地制宜地去写。换种角度表述,与其在意我们写的测试是不是“单元测试”,不如追求更核心的东西——我们的测试有没有以合适的方式去校验逻辑。
另外,当我们的“单元”过大,一些逻辑可能就会覆盖不上。像sonar这类工具,不仅会检查你的行数覆盖率,还会检查你的各项条件语句是否有被测试执行。当一套测试的行为流程囊括了多个函数,而且每个函数都有好几个if…else语句时,想要在UI操作与mock数据上把所有情况都覆盖到,成本就会变得非常高昂。
对于此,我们得承认,无论用什么方式组织测试,覆盖所有的条件分支都是不太现实的,而且价值也不大。对于“满足条件A就执行XXX”之类的语句,条件为非A时没有业务上的规定,如果为了刻意覆盖函数的所有条件,就强行测它在非A的情况下返回一个undefined,则没有太多价值。对这类情况,用UI行为测试主要条件即可,如果你实在觉得有重要的逻辑没有被覆盖,不妨回过头来想想,是不是漏掉了某种输入条件,例如特定的用户键入或者特殊的API mock返回值。但是,当有过多的条件分支很难用业务场景去表述和模拟的时候,我们可能需要重新思考代码的实现逻辑是否合理了。
当然,即使按上面这样做,有时候还是会发现要覆盖的条件组合太多,从行为流程上写测试太复杂,这时就不得不做一定的妥协,为那些没有独立性的部分去单独写测试。如果这类测试不太好写,可以参照刚才提到的SWR官方测试用到的技巧,把要测的函数或者是对象放置在一个临时的UI组件下,以最小的成本做UI行为测试。
最后
总结一下上面谈到的几个原则:
- 从真实用户的行为流程去测试,往往比测函数本身,能给你带来更多的信心。
- 对于没有独立性和通用性的函数或对象,把它们视作实现的一部分,一般没有必要为它们去写单独的测试。不要拘泥于对“单元测试”的字面理解,不要被形式上的规律所束缚。
- 不要把测试覆盖率视为太过重要的指标,它的目的还是帮助提升代码的稳定。有的代码没有覆盖也没关系,有的代码值得你覆盖好多遍。毕竟,我们不是为了写测试而写测试。
文/Thoughtworks钟立
原文链接:https://insights.thoughtworks.cn/front-end-testing/
更多精彩洞见,请关注微信公众号Thoughtworks洞见。