美文网首页
构筑测试体系

构筑测试体系

作者: JBryan | 来源:发表于2023-12-23 21:08 被阅读0次

    重构是很有价值的工具,但只有重构还不行。要正确地进行重构,前提是得有一套稳固的测试集合,以帮我发现难以避免的疏漏。编写优良的测试程序,可以极大提高我的编程速度,也许这会违反许多程序员的直觉。

    1 自测试代码的价值

    如果你认真观察自己如何分配时间,就会发现,编写代码的时间仅占有所有时间很少的一部分,有些时间花在设计上,有些时间用来决定下一步干什么,但是花费在调试的时间是最多的。修复bug通常是比较快的,但找出bug所在却是一场噩梦。
    我需要做的就是把我所期望的输出放到测试代码中,然后做一个对比就行了。由于我频繁地运行测试,每次测试都在不久之前,因此我知道bug地源头就是我刚刚写下的代码。因为代码量很少,我对它也记忆尤新,所以就能轻松找出bug。
    注意到这一点后,我对测试的积极性提高了。我不再等待每次迭代结尾时再增加测试,而是只要写好一个功能点,就立即添加她们。
    说服别人也这么做并不容易,编写测试程序,意味着要写很多额外的代码。除非你确实体会到这种方法是如何提升编程速度的,否则自测试似乎就没什么意义。
    事实上,撰写测试代码的最好时机是在开始动手编码之前。编写测试代码其实就是在问自己:为了添加这个功能,我需要实现些什么,编写测试代码还能帮我把注意力集中于接口而非实现。
    先编写一个测试,编写代码使测试通过,然后进行重构保证代码整洁。这个“测试、编码、重构”的循环应该在每个小时内都完成很多次。这种良好的节奏感可使编程工作更加高效、有条不紊的方式开展。
    有时我需要重构一些没有测试的代码,在重构之前,我得先改造这些代码,使其能够自测试才行。

    2 如何编写单元测试

    写单元测试就是针对代码设计各种测试用例,以覆盖各种输入、异常、边界情况,并将其翻译成代码。我们可以利用一些测试框架来简化单元测试的编写。除此之外,对于单元测试,我们需要建立以下正确的认知:

    • 编写单元测试尽管繁琐,但并不是太耗时;
    • 我们可以稍微放低对单元测试代码质量的要求;
    • 覆盖率作为衡量单元测试质量的唯一标准是不合理的,实际上,更重要的是要看测试用例是否覆盖了所有可能的情况,特别是一些 corner case;
    • 单元测试不要依赖被测代码的具体实现逻辑,只需要关注代码实现的功能即可;
    • 单元测试框架无法测试,多半是因为代码的可测试性不好。

    3 单元测试为何难以落地

    很多人往往会觉得写单元测试比较繁琐,并且没有太多挑战,而不愿意去做。有很多团队和项目在刚开始推行单元测试的时候,还比较认真,执行得比较好。但当开发任务紧了之后,就开始放低对单元测试的要求,一旦出现破窗效应,慢慢的,大家就都不写了,这种情况很常见。
    由于历史遗留问题,原来的代码都没有写单元测试,代码已经堆砌了十几万行了,不可能再一个一个去补单元测试。这种情况下,我们首先要保证新写的代码都要有单元测试,其次,每次在改动到某个类时,如果没有单元测试就顺便补上,不过这要求工程师们有足够强的主人翁意识(ownership),毕竟光靠 leader 督促,很多事情是很难执行到位的。
    写好代码直接提交,然后丢给黑盒测试狠命去测,测出问题就反馈给开发团队再修改,测不出的问题就留在线上出了问题再修复。在这样的开发模式下,团队往往觉得没有必要写单元测试,但如果我们把单元测试写好、做好 Code Review,重视起代码质量,其实可以很大程度上减少黑盒测试的投入。

    4 案例实战

    实战内容来自《极客时间》专栏《设计模式之美》,有兴趣的可以看下原文。
    Transaction 是经过我抽象简化之后的一个电商系统的交易类,用来记录每笔订单交易的情况。Transaction 类中的 execute() 函数负责执行转账操作,将钱从买家的钱包转到卖家的钱包中。真正的转账操作是通过调用 WalletRpcService RPC 服务来完成的。除此之外,代码中还涉及一个分布式锁 DistributedLock 单例类,用来避免 Transaction 并发执行,导致用户的钱被重复转出。

    public class Transaction {
      private String id;
      private Long buyerId;
      private Long sellerId;
      private Long productId;
      private String orderId;
      private Long createTimestamp;
      private Double amount;
      private STATUS status;
      private String walletTransactionId;
      
      // ...get() methods...
      
      public Transaction(String preAssignedId, Long buyerId, Long sellerId, Long productId, String orderId) {
        if (preAssignedId != null && !preAssignedId.isEmpty()) {
          this.id = preAssignedId;
        } else {
          this.id = IdGenerator.generateTransactionId();
        }
        if (!this.id.startWith("t_")) {
          this.id = "t_" + preAssignedId;
        }
        this.buyerId = buyerId;
        this.sellerId = sellerId;
        this.productId = productId;
        this.orderId = orderId;
        this.status = STATUS.TO_BE_EXECUTD;
        this.createTimestamp = System.currentTimestamp();
      }
      
      public boolean execute() throws InvalidTransactionException {
        if ((buyerId == null || (sellerId == null || amount < 0.0) {
          throw new InvalidTransactionException(...);
        }
        if (status == STATUS.EXECUTED) return true;
        boolean isLocked = false;
        try {
          isLocked = RedisDistributedLock.getSingletonIntance().lockTransction(id);
          if (!isLocked) {
            return false; // 锁定未成功,返回false,job兜底执行
          }
          if (status == STATUS.EXECUTED) return true; // double check
          long executionInvokedTimestamp = System.currentTimestamp();
          if (executionInvokedTimestamp - createdTimestap > 14days) {
            this.status = STATUS.EXPIRED;
            return false;
          }
          WalletRpcService walletRpcService = new WalletRpcService();
          String walletTransactionId = walletRpcService.moveMoney(id, buyerId, sellerId, amount);
          if (walletTransactionId != null) {
            this.walletTransactionId = walletTransactionId;
            this.status = STATUS.EXECUTED;
            return true;
          } else {
            this.status = STATUS.FAILED;
            return false;
          }
        } finally {
          if (isLocked) {
           RedisDistributedLock.getSingletonIntance().unlockTransction(id);
          }
        }
      }
    }
    

    在 Transaction 类中,主要逻辑集中在 execute() 函数中,所以它是我们测试的重点对象。为了尽可能全面覆盖各种正常和异常情况,针对这个函数,我设计了下面 6 个测试用例。

    1. 正常情况下,交易执行成功,回填用于对账(交易与钱包的交易流水)用的 walletTransactionId,交易状态设置为 EXECUTED,函数返回 true。
    2. buyerId、sellerId 为 null、amount 小于 0,返回 InvalidTransactionException。
    3. 交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。
    4. 交易已经执行了(status==EXECUTED),不再重复执行转钱逻辑,返回 true。
    5. 钱包(WalletRpcService)转钱失败,交易状态设置为 FAILED,函数返回 false。
    6. 交易正在执行着,不会被重复执行,函数直接返回 false。
      对于上面的测试用例,第 2 个实现起来非常简单,我就不做介绍了。我们重点来看其中的 1 和 3。
      现在,我们就来看测试用例 1 的代码实现。
    public void testExecute() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      boolean executedResult = transaction.execute();
      assertTrue(executedResult);
    }
    

    execute() 函数的执行依赖两个外部的服务,一个是 RedisDistributedLock,一个 WalletRpcService。这就导致上面的单元测试代码存在下面几个问题。

    • 如果要让这个单元测试能够运行,我们需要搭建 Redis 服务和 Wallet RPC 服务。搭建和维护的成本比较高。
    • 我们还需要保证将伪造的 transaction 数据发送给 Wallet RPC 服务之后,能够正确返回我们期望的结果,然而 Wallet RPC 服务有可能是第三方(另一个团队开发维护的)的服务,并不是我们可控的。换句话说,并不是我们想让它返回什么数据就返回什么。
    • Transaction 的执行跟 Redis、RPC 服务通信,需要走网络,耗时可能会比较长,对单元测试本身的执行性能也会有影响。
    • 网络的中断、超时、Redis、RPC 服务的不可用,都会影响单元测试的执行。
      如果代码中依赖了外部系统或者不可控组件,比如,需要依赖数据库、网络通信、文件系统等,那我们就需要将被测代码与外部系统解依赖,而这种解依赖的方法就叫作“mock”。所谓的 mock 就是用一个“假”的服务替换真正的服务。mock 的服务完全在我们的控制之下,模拟输出我们想要的数据。
      我们通过继承 WalletRpcService 类,并且重写其中的 moveMoney() 函数的方式来实现 mock。具体的代码实现如下所示。通过 mock 的方式,我们可以让 moveMoney() 返回任意我们想要的数据,完全在我们的控制范围内,并且不需要真正进行网络通信。
    public class MockWalletRpcServiceOne extends WalletRpcService {
      public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
        return "123bac";
      } 
    }
    
    public class MockWalletRpcServiceTwo extends WalletRpcService {
      public String moveMoney(Long id, Long fromUserId, Long toUserId, Double amount) {
        return null;
      } 
    }
    

    因为 WalletRpcService 是在 execute() 函数中通过 new 的方式创建的,我们无法动态地对其进行替换。也就是说,Transaction 类中的 execute() 方法的可测试性很差,需要通过重构来让其变得更容易测试。该如何重构这段代码呢?
    我们可以应用依赖注入,将 WalletRpcService 对象的创建反转给上层逻辑,在外部创建好之后,再注入到 Transaction 类中。重构之后的 Transaction 类的代码如下所示:

    public class Transaction {
      //...
      // 添加一个成员变量及其set方法
      private WalletRpcService walletRpcService;
      
      public void setWalletRpcService(WalletRpcService walletRpcService) {
        this.walletRpcService = walletRpcService;
      }
      // ...
      public boolean execute() {
        // ...
        // 删除下面这一行代码
        // WalletRpcService walletRpcService = new WalletRpcService();
        // ...
      }
    }
    

    现在,我们就可以在单元测试中,非常容易地将 WalletRpcService 替换成 MockWalletRpcServiceOne 或 WalletRpcServiceTwo 了。重构之后的代码对应的单元测试如下所示:

    public void testExecute() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      // 使用mock对象来替代真正的RPC服务
      transaction.setWalletRpcService(new MockWalletRpcServiceOne()):
      boolean executedResult = transaction.execute();
      assertTrue(executedResult);
      assertEquals(STATUS.EXECUTED, transaction.getStatus());
    }
    

    WalletRpcService 的 mock 和替换问题解决了,我们再来看 RedisDistributedLock。RedisDistributedLock 是一个单例类。单例相当于一个全局变量,我们无法 mock(无法继承和重写方法),也无法通过依赖注入的方式来替换。
    如果 RedisDistributedLock 是我们自己维护的,可以自由修改、重构,那我们可以将其改为非单例的模式,或者定义一个接口,比如 IDistributedLock,让 RedisDistributedLock 实现这个接口。这样我们就可以像前面 WalletRpcService 的替换方式那样,替换 RedisDistributedLock 为 MockRedisDistributedLock 了。但如果 RedisDistributedLock 不是我们维护的,我们无权去修改这部分代码,这个时候该怎么办呢?
    我们可以对 transaction 上锁这部分逻辑重新封装一下。具体代码实现如下所示:

    public class TransactionLock {
      public boolean lock(String id) {
        return RedisDistributedLock.getSingletonIntance().lockTransction(id);
      }
      
      public void unlock() {
        RedisDistributedLock.getSingletonIntance().unlockTransction(id);
      }
    }
    
    public class Transaction {
      //...
      private TransactionLock lock;
      
      public void setTransactionLock(TransactionLock lock) {
        this.lock = lock;
      }
     
      public boolean execute() {
        //...
        try {
          isLocked = lock.lock();
          //...
        } finally {
          if (isLocked) {
            lock.unlock();
          }
        }
        //...
      }
    }
    

    针对重构过的代码,我们的单元测试代码修改为下面这个样子。这样,我们就能在单元测试代码中隔离真正的 RedisDistributedLock 分布式锁这部分逻辑了。

    public void testExecute() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      
      TransactionLock mockLock = new TransactionLock() {
        public boolean lock(String id) {
          return true;
        }
      
        public void unlock() {}
      };
      
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      transaction.setWalletRpcService(new MockWalletRpcServiceOne());
      transaction.setTransactionLock(mockLock);
      boolean executedResult = transaction.execute();
      assertTrue(executedResult);
      assertEquals(STATUS.EXECUTED, transaction.getStatus());
    }
    

    至此,测试用例 1 就算写好了。我们通过依赖注入和 mock,让单元测试代码不依赖任何不可控的外部服务。你可以照着这个思路,自己写一下测试用例 4、5、6。
    现在,我们再来看测试用例 3:交易已过期(createTimestamp 超过 14 天),交易状态设置为 EXPIRED,返回 false。针对这个单元测试用例,我们还是先把代码写出来,然后再来分析。

    public void testExecute_with_TransactionIsExpired() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId);
      transaction.setCreatedTimestamp(System.currentTimestamp() - 14days);
      boolean actualResult = transaction.execute();
      assertFalse(actualResult);
      assertEquals(STATUS.EXPIRED, transaction.getStatus());
    }
    

    那如果没有针对 createTimestamp 的 set 方法,那测试用例 3 又该如何实现呢?实际上,这是一类比较常见的问题,就是代码中包含跟“时间”有关的“未决行为”逻辑。我们一般的处理方式是将这种未决行为逻辑重新封装。针对 Transaction 类,我们只需要将交易是否过期的逻辑,封装到 isExpired() 函数中即可,具体的代码实现如下所示:

    public class Transaction {
    
      protected boolean isExpired() {
        long executionInvokedTimestamp = System.currentTimestamp();
        return executionInvokedTimestamp - createdTimestamp > 14days;
      }
      
      public boolean execute() throws InvalidTransactionException {
        //...
          if (isExpired()) {
            this.status = STATUS.EXPIRED;
            return false;
          }
        //...
      }
    }
    

    针对重构之后的代码,测试用例 3 的代码实现如下所示:

    public void testExecute_with_TransactionIsExpired() {
      Long buyerId = 123L;
      Long sellerId = 234L;
      Long productId = 345L;
      Long orderId = 456L;
      Transction transaction = new Transaction(null, buyerId, sellerId, productId, orderId) {
        protected boolean isExpired() {
          return true;
        }
      };
      boolean actualResult = transaction.execute();
      assertFalse(actualResult);
      assertEquals(STATUS.EXPIRED, transaction.getStatus());
    }
    

    实际上,可测试性差的代码,本身代码设计得也不够好,很多地方都没有遵守我们之前讲到的设计原则和思想,比如“基于接口而非实现编程”思想、依赖反转原则等。重构之后的代码,不仅可测试性更好,而且从代码设计的角度来说,也遵从了经典的设计原则和思想。这也印证了我们之前说过的,代码的可测试性可以从侧面上反应代码设计是否合理。除此之外,在平时的开发中,我们也要多思考一下,这样编写代码,是否容易编写单元测试,这也有利于我们设计出好的代码。
    常见的测试不友好的代码有下面这 5 种:

    • 代码中包含未决行为逻辑。所谓的未决行为逻辑就是,代码的输出是随机或者说不确定的,比如,跟时间、随机数有关的代码。
    • 滥用可变全局变量。全局变量是一种面向过程的编程风格,有种种弊端。实际上,滥用全局变量也让编写单元测试变得困难。
    • 滥用静态方法。静态方法跟全局变量一样,也是一种面向过程的编程思维。在代码中调用静态方法,有时候会导致代码不易测试。主要原因是静态方法也很难 mock。
    • 使用复杂的继承关系。如果父类需要 mock 某个依赖对象才能进行单元测试,那所有的子类、子类的子类……在编写单元测试的时候,都要 mock 这个依赖对象。
    • 高度耦合的代码。如果一个类职责很重,需要依赖十几个外部对象才能完成工作,代码高度耦合,那我们在编写单元测试的时候,可能需要 mock 这十几个依赖的对象。

    相关文章

      网友评论

          本文标题:构筑测试体系

          本文链接:https://www.haomeiwen.com/subject/oiccndtx.html