如何停止编写糟糕的测试用例

2024-11-03   出处: Medium  作/译者:Alec Barba/暖阳

我已经看到很多文章都在说单元测试是垃圾,认为它们只会拖慢开发速度,开发人员要花 90% 的时间来debug测试用例而不是修复bug,等等。

我从没想过这会成为一个热门话题,但我坚信测试对于快速、安全、大规模地构建优秀代码至关重要。我曾以为这是共识,但显然不是。

通常,测试被诟病是因为人们不理解如何编写测试用例。我们花了那么多时间思考、阅读、迭代设计模式、面向对象编程、组合优于继承、不同的架构等等。但是,当我们看着我们的测试套件时,却发现它们实际上是我们编写过的最差的代码,无论是在性能、可读性、简洁性方面,还是在编写生产代码时我们试图避免的所有方面。

难怪人们会抱怨写得不好的代码性能很差。唯一的问题是,根本原因并不在于测试这个概念,而在于测试用例写得不好。

如果你也在抱怨,我很抱歉地告诉你,问题不在测试,而在你自己。所以,让我们试着解决它!

免责声明:本文面向 TS/JS 开发人员,但其中的思想可以轻松应用于任何其他语言。如果你想得到比本文更好的指导,我强烈建议你看看文末的参考文献。我把这篇文章分为三个部分,因为其中任何一个部分的知识差距都可能导致你不愿继续读下去,即使其他两个部分都很扎实。

第一部分是基础知识和理论,第二部分是实际示例,第三部分是一些在Jest中快速调试的技巧。

基础与理论

唯一有用的测试是当你的应用程序无法运行时将失败的测试。

是的,这很基础,但在指南的开头提到很值得。如果你的测试无法断言某种不良行为会被避免,那么编写这样的测试就没有意义。

测试就像一门科学。它们不能证明你的软件有效,只能证明它无效。

同样,测试只能确保没有发生bug。编写一个能确保完美行为的测试是非常困难的,大多数情况下是不可能的。你可以编写使用一组特定输入的一些正向case,但通常情况下,你不会也不应该测试每一个可能的输入。

这就意味着,你不需要过度复杂测试,也不需要做大量的断言,只要遵循Pareto定律(也称二八法则),就能找到平衡点。

编写代码和获得反馈之间的距离越小,开发就变得越可预测。

这就是测试如此重要的根本原因。如果我需要等到测试人员/质量保证人员进行彻底的(手工)验证,那么很难预测我什么时候能完成任务。

另一方面,如果我在流程的早期就编写了一组测试,确保我正在构建的代码遵循某些行为规范,那么我就可以轻松地知道何时能实际完成编码。

什么是行为

行为是指系统在特定状态下对一系列输入做出响应的任何保证。

项目的生命周期越长,测试就越重要。

如果你只是在为黑客马拉松编写快速代码,那很好。成本与回报并不成比例。但如果你的测试将在数年甚至数十年内运行,那就意味着代码将以某种方式运行数年。

项目周期越长,你所写的每一个测试用例的价值就越大。

确定性

当输入相同时,代码总是产生相同的输出,这就是确定性。

在我们可能的范围内,测试必须是确定的。正如 Fowler 曾经说过的:非确定性测试毫无用处

封闭测试

简单地说,每个测试都应该是独立且自包含的。我的意思是,测试不依赖于非确定性组件,如网络延迟或测试环境之外的交互。如果你依赖的服务器位于大洋的另一端,那么即使其他代码都完美无缺,连接中的故障也会破坏你的测试。

虽然大型测试无法避免,但单元测试应保证封闭测试。

测试是一种反馈机制

它们不是技术要求。它们在一定程度上保证了你的代码运行正确。在开发工作流程中,测试运行得越频繁,效果越好。

测试即文档

如果你以清晰的方式编写测试,那么它们在检查时就会一目了然。这意味着,无论谁读了你的测试,都会对你的公共 API 有足够的了解。这可能是你的团队、公司中的其他 POD,也可能是正在查看你的公共 API 的外部开发人员。如果有清晰的测试,每个读者都会受益匪浅。

其他必要的定义

  1. Spy:观察行为,但不以任何方式与之交互
  2. Stub:观察行为,并以某种方式改变数据或实现
  3. Mock: 带有期望的存根
  4. Fake:它是一个实现第三方接口的类,但有自己的实现

使用mock​来指代其他术语可能会引起混淆。这就是为什么一些公司和开发者更喜欢test doubles​这个术语。

动手实践

严格断言的好处

当应用程序代码出现问题时,编写更严格的断言会使测试更难通过,从而更容易捕获错误。换句话说

   expect(typeof result).toBe("number"); // 松耦合,有大量的意外结果是该测试无法捕获的
   expect(result).ToBe(2); // 现在只接受一个值,更严格,更利于捕获bug

如果 result 是两个数字的和,输入是 a= 1,b=1,你认为哪一个测试能更好地检测函数是否正常运行?

尽可能避免编写否定断言

让我们用一个小例子来说明这一点:

    const result = await service.getCreatedUser(id);
    expect(result).not.ToBeEmpty();
    // 与
    expect(result).toEqual({id:1, user: 'test_user'});

第一个 expect 只涵盖了 getCreatedUser​ 方法返回某些内容的事实,而第二个断言则验证了它返回的正是你所期望的用户。

另外,我们的大脑更难处理否定语句 。因此,避免使用 not​ 有助于提高清晰度。

尽可能编写更清晰的断言

比方说,在当前的票据中, 你正在进行一个包含日期的测试。是使用布尔比较好,还是实际的日期比较好?

请看来自 jest-extended文档的代码片段:

    test('passes when input is before date', () => {
      expect(new Date('01/01/2018')).toBeBefore(new Date('01/01/2017'));
      expect('01/01/2019').not.toBeBefore(new Date('01/01/2018'));
    });

这将产生以下断言错误:

​​

这个断言比expected true but got false​更酷、更清晰。

循环断言是大忌

重复/硬编码字符串比重复使用同一函数来验证预期与实际要好得多。让我们看一下以下代码片段:

    export class Service {
       public getFoo(id){
          return helper(id);
       }
    }
    // test file
    describe('mytest', ()=>{
       it('Should generate the appropriate result', ()=> {
          const expected = helper(1);

          const result = service.getFoo(1);

          expect(result).toEqual(expected);
       })
    });

这个测试什么都没做。如果引入了bug,测试也不会失败,因为helper被用来产生预期结果和实际结果。

解决此问题的一种方法是编写自己的预期结果

    export class Service {
       public getFoo(id){
          return helper(id);
       }
    }
    // test file
    describe('mytest', ()=>{
       it('Should generate baz for odd numbers', ()=> {
          const expected = "baz";

          const result = service.getFoo(1);

          expect(result).toEqual(expected);
       })
    });

如果mock依赖关系过于复杂,那就不要mock。

要么做集成测试,要么就不要编写测试。榨干果汁不值得。如果你需要设置 4-5 个spies、mocks、修改全局窗口,并且每次添加单个测试时都祈求上帝的怜悯,那么可维护性成本将超过你的单元测试。

如果你需要助手,那么Builders会非常棒,因为它们会在每个测试中明确说明Given/When。

让我们来看一个例子:

    describe('myAdminService', ()=> {
       it('Should reject the request if user is not an admin', ()=> {
         const user = UserBuilder.newUser()
              .addName('Alec')
              .addPermissions(['employee']);

           expect(service.withdrawAllTheMoney(user)).toThrow();
       }
    })

在这里,builder可以帮助你避免使用大量的模板代码,并轻松完成设置。很容易看出用户拥有“员工”权限。

调试技巧

这些是您可以在 Jest 中使用的一些简单技巧,可以让你只关注到那些未正常工作的测试。虽然这些技巧并不新颖,但我认为它们是测试中的 console.log

跳过不相关的测试

如果你有两个测试,但不确定其中一个是否会导致另一个失败,你可以在 jest 中简单地 ​skip​它们,而不是注释它

    describe('Test Suite', ()=>{
       it.skip('Should assert case 1', ()=>{
          // 此测试不会运行
       })

       it('Should assert case 2', ()=>{
          // 这个测试将运行
       });
    });

你可以将它们与 it​ 和 test​ 函数以及 describe​ 块一起使用。

调试时只运行重要的测试

如果你只想运行一个测试,并且暂时只关注该测试,你只需这样做即可:

    describe('Test Suite', ()=>{
       it('Should assert case 1', ()=>{
          // 此测试不会运行
       })

       it.only('Should assert case 2', ()=>{
          // 这个测试将运行
       });
    });

同上,适用于​test​和describe​块。

使用Concurrent测试你的测试是否真正相互隔离

使用 concurrent​ 功能可以很好地了解你的 mock 是否相互干扰,或者你的测试是否很不稳定。

默认情况下,每个测试套件都会并行运行,但套件中的每个​describe​块都会按顺序运行。让我们来看看它是如何工作的:

    describe('Test Suite', ()=>{
       it.concurrent('Should assert case 1', ()=>{
          // this will run at the same time as case 2
       })

       it.concurrent('Should assert case 2', ()=>{
          // this one will run at the same time as case 1
       });
    });

如果它们按顺序通过,但同时运行时却失败了,则可能是使用test doubles做了一些不稳定的设置。

- silent=false 会让你走得更远

很多组织默认关闭此标记。运行一个抛出 128391237 条日志的 Jenkins 流水线完全是在浪费资源,而且也不能说明什么问题。不过,你应该知道,如果你看不到任何 console.logs 文件,原因很可能是该标记被设置为true​。

你应该只测试自己编写的软件

这里有什么诀窍?如果你明确测试的是第三方,你可以选择:

  1. 删除测试。这不是你的责任。
  2. 如果你是个好公民,而且第三方是开源的,那就在他们的 repo 中写一份 PR。

这样你就不必维护它了,而且还能为社区做贡献。

参考文献

如果你想要一个很好的方法论来将测试集成到的开发工作流程中:

如果你想知道如何在 javascript 中进行出色的测试,本文的主要资料来源是:

如果你想了解测试的重要性以及如何处理大型测试,请查看第11-14章:

  • Software Engineering at Google, Titus Winters, Tom Manshreck, Hyrum Wright et al. 点击此处获取

消除测试中的非确定性的重要性:

如果你有其他建议,欢迎在评论中提出。祝你编码愉快!


声明:本文为本站编辑转载,文章版权归原作者所有。文章内容为作者个人观点,本站只提供转载参考(依行业惯例严格标明出处和作译者),目的在于传递更多专业信息,普惠测试相关从业者,开源分享,推动行业交流和进步。 如涉及作品内容、版权和其它问题,请原作者及时与本站联系(QQ:1017718740),我们将第一时间进行处理。本站拥有对此声明的最终解释权!欢迎大家通过新浪微博(@测试窝)或微信公众号(测试窝)关注我们,与我们的编辑和其他窝友交流。
151° /1510 人阅读/0 条评论 发表评论

登录 后发表评论