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

前端测试反模式分析

时间:2023-03-17 10:20:00 科技观察

过分关注实现细节的测试在编写前端项目的测试用例时,你可能和我一样遇到过以下问题:(1)明明做了正确的功能改动,测试挂了。修复测试有时需要仔细阅读各种mock的细节,或者理解很多没必要知道的代码逻辑。最终修复测试所花费的时间比进行业务更改所花费的时间更长(甚至更长)。(2)对代码进行抽取和抽象后,为每个组件或功能添加测试,实际上是在利用测试工具的API重复业务代码的内部实现逻辑(有时很麻烦!)。任何正常的重构都会导致测试失败。你本来希望测试能告诉你什么样的修改是正确的,现在测试只能告诉你代码确实修改了。(3)测试写得好,覆盖率提高。你应该确信代码已经变得健壮了,但是问问你自己,你知道你写的测试的弱点在哪里,或者有多少细节没有被覆盖。你仔细模拟了一个触发逻辑流程的条件,测试通过了,但是用户在真实的浏览器交互中可能无法触发这个条件。所以同样的道理,你的代码通过了别人写的测试之后,你也不能确定在真实场景中没有问题,所以你要把后续的任务交给QA。造成以上三个问题的原因不止一个,但最重要的一个是测试过于关注实现细节。第一个问题显然是一个正确的改动,但是测试不仅仅是为了验证业务功能,还会在实现细节上提出不应该提出的要求,比如要求你的函数接受和之前一样的参数,以及返回值必须是字符串,不能是数组等。但是这个函数只是实现过程中的一个小环节,在接下来的重构中可能就不存在了。第二个问题非常相似。如果测试代码重复了实现细节,不管重构是正确的还是错误的,都得重新改测试。原始测试能提供什么价值?第三个问题有时会出现在测试的实现细节无法覆盖整个真实交互过程的时候。用户点击屏幕上的button按钮,测试的起点是触发onClick事件。以下逻辑验证成功,但问题出现在点击链接。真正的点击可能因为按钮状态的原因不会触发onClick事件。因此,有人建议前端测试应该尽可能模拟真实的用户行为。Testing-Library在其官网的“GuidingPrinciples”部分鼓励用户编写模仿应用程序真实使用情况的测试,并明确指出,你的测试越接近用户的实际使用方式,就越有信心给你。也就是说,你的测试应该尽可能少的使用手动触发的函数,而是尽可能的使用测试框架提供的API来模拟输入框的输入,按钮的点击,表单的提交,等等。这样,对于某些函数,你不需要写测试来证明它的返回值如你所愿。需要写的是页面上显示预期的文字,发生预期的变化,并进行相应的跳转。你会发现此时的考试就像写在卡片上的AC。只要测试通过,您就有理由相信主要功能没有损坏,而不仅仅是该功能运行良好。一个没有独立业务意义的测试单元看到上面的方案,你可能会马上想到一些问题。首先,测试过程可能会很长。从用户填写表单,点击提交,到预期的变化出现,可能会经历几个函数的执行,产生一系列的副作用。模拟这一系列的行为,似乎才是集成测试和端到端测试应该做的事情。如果项目中的大部分逻辑都被这种测试覆盖了,似乎与测试金字塔所说的基于单元测试是矛盾的。我认为,当一个现实问题遇到某种教条式规范时,后者应该做出适当的让步。鼓励编写更多单元测试的原因是它们便宜且有针对性。但是在前端项目中,很多正式的单元并没有独立的业务意义。以React项目为例,很多功能都被携带到一个单独的文件中,只是因为它们可以在形式上被提取出来,从而降低了主要功能的复杂度。如果给它写单元测试,就得手动触发它的参数变化,或者检测它的参数函数是否被调用过。对于我们编写的Reacthooks尤其如此。很多时候,自定义的hooks是出于逻辑原因被抽取出来,聚合相关的逻辑和数据来减轻UI组件的负担,但是这些hooks往往不具备易于解释的业务意义,也不会在其他地方使用。所以这种“单元”只是看起来是一个单元,它们实际上只是一个实现环节。这里完整的UI操作流程更像是一个有价值的单元,虽然它们在形式上可能会超出单个功能的范围。但我不想做得太过分。在很多情况下,一个util函数、一个钩子和一个小的公共组件都有独立的价值。因此,它们也应被视为真实单位。资格”有自己专有的测试。testing-library下有一个单独的库叫react-hooks-testing-library,可以让你直接以hooks的形式测试它们,而不用经过UI行为层面。在它的GitHub页面上,明确提出了使用和不使用的场景:当你的hook与组件没有强相关,有独立意义时,可以使用;当你的钩子只被一个组件使用,并且它的定义是strong相关时,不推荐。【插一段:虽然有react-hooks-testing-library这样的工具,但是像SWR这样优秀的三方库在使用testing-library测试自己的hookAPI时,还是选择在UI层面做。方法是将自己的钩子放在一个临时的div标签中进行渲染,将数据的变化映射到html文本的变化,最后对文本内容进行断言。其实对于独立性强的功能,我个人认为放在UI中进行测试并没有多大区别,但是SWR的例子体现了对“模拟真实使用场景进行测试”原则的尊重。】将以上规则应用到Angular项目中也是类似的。对于管道、指令、reducer、effects、service等不独立、通用的,都可以认为是实现过程的一部分,可以从UI行为层面来编写测试。总之,在构思前端测试时,与其拘泥于“单元测试”的字面意思,不如结合实际场景,重新思考什么是真正有价值的“单元”,因地制宜地编写。换句话说,与其关心我们写的测试是不是“单元测试”,不如追求更核心的东西——我们的测试是否以适当的方式验证了逻辑。另外,当我们的“单元”太大的时候,有些逻辑可能覆盖不了。像sonar这样的工具不仅会检查你的线路覆盖率,还会检查你的条件语句是否被测试和执行。当一组测试行为流程包括多个函数,并且每个函数都有多个if...else语句时,如果要在UI操作和mock数据中覆盖所有情况,成本会变得非常高。为此,我们不得不承认,无论测试如何组织,覆盖所有条件分支都是不现实且价值不大的。对于“ExecuteXXXifconditionAismetification”这样的语句,当条件不为A时,没有业务规定。如果为了刻意覆盖所有条件,在不为A的情况下强制返回undefined功能的话,没有太大的价值。对于这种情况,可以使用UIbehavior来测试主要条件。如果你真的觉得有重要的逻辑没有覆盖到,不妨回头想想是不是漏掉了一些输入条件,比如特定的用户输入或者特殊的APImock返回值。但是,当条件分支太多,难以用业务场景表达和模拟时,我们可能需要重新思考代码的实现逻辑是否合理。当然,即使你做了以上,有时你会发现条件组合太多无法覆盖,而且从行为流写测试太复杂了。这时候就得做一定的妥协,把那些不是独立测试的部分分开写。如果这类测试不好写,可以参考刚才SWR官方测试中使用的技巧,将需要测试的功能或对象放在一个临时的UI组件下,以最低的成本做UI行为测试。最后总结一下上面提到的原则:(1)从真实用户的行为过程中进行测试,往往比测试功能本身更能给你带来信心。(2)对于不独立、不通用的功能或对象,将其视为实现的一部分,一般无需为其编写单独的测试。不要拘泥于“单元测试”的字面理解,不要拘泥于形式上的规律。(3)不要把测试覆盖率当成一个太重要的指标,它的目的是帮助提高代码的稳定性。有些代码没有被覆盖也没关系,有些代码值得覆盖很多次。毕竟,我们写测试不是为了写测试。【本文为专栏作者“ThoughtWorks”原创稿件,微信公众号:Thinkworker,转载请联系原作者】点此查看该作者更多好文