TDD备忘

125 阅读4分钟

个人实践的TDD备忘录。

1. 什么是TDD

测试驱动开发,可以粗糙的理解为先写单元测试,再写代码。 TDD的思路很简单,当代码执行的每一步都是可信的(可以按照预期正确运行), 那么最终获得的结果也必定是正确的。那么通过单元测试来保证其中每一步的可信,也就可以保证最终运行的结果。

2. 实现

red-green-refactor cycle

设想一个简单的需求:读取周边wifi热点的状态,输出为json串,如果读不到就输出空json

step 1: 阅读需求,设计暴露的接口

   private List<ScanResult> loadScanResult(Context c) //获取Wi-Fi列表
   private List<WifiMacInfo> scanResultToWifiMacInfo(List<ScanResult> ls); //过滤并生成需求的mac列表。

step 2: 构建单元测试

举一个接口的例子

@Test
public void testScanResultToListSizeShouldBeZero(){
   List<WifiMacInfo> res = FingerprintManager.scanResultToWifiMacInfo(null);
   assertEquals(res.size(), 0);
   
   List<ScanResult> ls = new ArrayList<ScanResult>();
   
   res = FingerprintManager.scanResultToWifiMacInfo(ls);
   assertEquals(res.size(), 0);
}

step3: 实现空白接口,使全部单测失效

    private List<WifiMacInfo> scanResultToWifiMacInfo(List<ScanResult> ls){
        return null;
    }

step4: 修改接口实现,使测试通过

这时候才是真正的编码环节

    private List<WifiMacInfo> scanResultToWifiMacInfo(List<ScanResult> ls){
        return new ArrayList<WIfiMacInfo>(); //假装实现
    }

step5: 重构运行全部单测使其通过

重构通常不需要动单测嗲吗,只要修改逻辑代码然后运行测试代码全部通过即能保证逻辑不变。

3. 使用场景

  1. 业务逻辑相对稳定: 业务逻辑变化越大,可复用的粒度就会越细。若业务逻辑改动到结构框架都有变化,那么TDD中单测代码就只剩下构建当次的的使用价值,而且还要考虑到单测代码本身也可能是有bug的,尤其是在首次使用。

  2. 连续重构,如代码性能优化。TDD预先构建的测试程序,能有效保障业务逻辑前后的一致性。

  3. 逻辑多,runtime(如网络,UI等难以预估的模块)相关性少 不可控的部分均需要mock,会大大降低测试代码的兼容性。

4. 代码的可测性

4.1. 尽量构建为没有副作用(side effects)/状态的纯函数

所谓纯函数即调用时固定的输入产生固定的输出。

当一个方法调用后,除了方法返回值,还对其他地方如类的成员变量有修改,那么这个方法就是有副作用的。 单元测试与oo并不友好的一大原因就是副作用,例如:

class A {
    private int i = 0;
    public void increase(){
      this.i++;
    }
}

如果一个单测代码要验证increase是否准确,那么必须想办法访问一个private成员变量i。当然会有很多办法,例如暴露一个测试期的桥方法getI()或者反射。但是如果变量在不那么容易访问的位置或者本身具备复杂状态时,单测代码写起来就会比较坑了。

class A{
    private int i = 0;
    int calculateK(int k){
      return k++;
    }
    public void increase(){
      i = calculateK(i);
    }
}

改进后的方法,increase只是调用了另外一个方法,本身没有业务逻辑故可以认为是可信的。而calculateK已经不存在副作用了,只需要把各种case灌进去对比返回值,不再需要桥方法和mock。

4.2. 一个方法只做一件事

   class A{
      int countOfAInIndexPage(){
          HttpClient client == new HttpClient();
          String page = client.get("http://www.baidu.com");
          
          int countOfA = 0;
          for(Char c : page.getChar){
             if(c == "A") countOfA++;
          }
          return countOfA;
      }
   }

代码很简单,读取百度首页,统计字符A的数量并返回。 但难以用单测保证质量的原因在于方法其实执行了两个动作:1,从网络读取;2,统计。第一步的问题可能导致第二步的失败,故仅仅通过一个单测无法指出是代码问题还是外部环境问题。

解决方案也很简单,将功能拆分为2个方法测试。

5 Tips

个人一些感受

过度追求覆盖率意义不大,首先对于终端来说,UI和网络等不可控因素太多难以逐个mock。其次,过度追求覆盖率会导致方法拆分过多,例如if(list != null && list.size() > 0) 也应拆成一个独立方法进行测试,如果真拆成如此细碎估计代码没人愿意维护。

TDD并未降低开发效率,只是将原本的调试测试环节前置。开发需求时大部分时间往往是在思考PRD如何转化到现有代码内。单测和业务代码开发的这个过程是完全一致的,额外的付出只是编写了一遍测试代码。但在调试和测试需求时,因为工作已经在测试代码中完成了大部分,故这部分时间可以大幅节省下来。

private方法确实没有太好的方法处理,我的解决方式是提供一个package桥方法给单测使用

mockito虽然好用,但是mock太多的代码几乎就无法维护,还是少mock为妙。