TDD 入门

Posted by 孙继峰 on December 24, 2019

前言

周末参加了 ThoughtWorks 的线下 TDD 技术交流, 收获还是挺多的, 感觉自己之前一直在闭门造车, 还好是没走太歪. 😅


WHAT

TDD 即 Test Driven Design 测试驱动设计 / Test Driven Development 测试驱动开发. TDD 是极限编程中核心的一项实践.
其中最难理解的就是这个驱动的概念, 至少我个人认为是比较难理解的, 需要实践一段时间才能理解.


WHY

场景一

新需求下来了, 明天上线
为了”快速”上线, 不写测试
写完了, 手工测试一下, 赶紧提测上线
测试人员没测出 Bug, 上线了
结果出 Bug 了

场景二

加新功能了
看着以前的一坨代码, 想重构却又不敢
只好找个角落加个 if else
测试没问题, 提测上线
慢慢就会变成祖传代码

以上两个场景的不足

手工测试

自己认为用 PostMan 测了几次差不多没问题了, 但是没有一个指标去衡量是否测试全面了. 这里就需要 IDE 帮助分析出测试的语句覆盖度, 分支覆盖度, 语句分支都 100% 覆盖了, 才能保证测试全面了, 前提是需要有测试用例.

不要过多依赖测试人员

测试人员只能进行黑盒测试, 测试范围有限.
可能自己检查出这个 Bug 只需要几分钟, 如果直接提交给测试团队的话, 可能需要半天甚至更久, 测试团队才能检查出 Bug, 导致反馈周期变长, 间接导致交付周期变长.

软件变更的成本会随时间与开发阶段增加

能在需求阶段解决的问题就不要拖到开发阶段
能在开发阶段解决的问题就不要拖到测试阶段

重构与测试是相辅相成的

没有测试, 只能提心吊胆的重构
没有重构, 代码会变得越来越混乱

TDD 能解决的痛点

  • 薛定谔的代码
  • 重构质量无法保证
  • 反馈周期长

HOW

在编写实现之前编写测试用例.

一个完整的 TDD 流程
  1. 需求分解, 拆分为多个 case
  2. 找产品/客户确认 case 是否正确, 是否有遗漏
  3. 写测试, 只关注输入输出, 不关注中间过程
  4. 写实现, 用最简单的实现满足当前的测试
  5. 重构
  6. 信心满满的提交代码

TDD Hello World

需求

给出一个 1 到 100 的数字, 如果是 3 的倍数就会得到 “Fizz”, 如果是 5 的倍数就会得到 “Buzz”, 如果即是 3 的倍数也是 5 的倍数就得到 “FizzBuzz”, 如果都不是就会得到原数字.

需求拆解
  • 给出 1 得到 1
  • 给出 2 得到 2
  • 给出 3 得到 Fizz
  • 给出 5 得到 Buzz
  • 给出 15 得到 FizzBuzz
    找产品/客户确认

    没通过则修改, 直到通过产品确认后再进行下一步

    编写空的实现

    只要保证它能够被执行, 不报编译错误即可

      public String handle(int input) {
          return "";
      }
    
    编写第一个 case 的测试用例
      @Test
      public void input1() {
          // GIVEN
          int input = 1;
          // WHEN
          String result = handle(input);
          // THEN
          assertThat(result).isEquals("1");
      }
    
    编写恰好能够通过此测试用例的实现
      public String handle(int input) {
          return "1";
      }
    
    编写第二个 case 的测试用例
      @Test
      public void input2() {
          // GIVEN
          int input = 2;
          // WHEN
          String result = handle(input);
          // THEN
          assertThat(result).isEquals("2");
      }
    
    编写恰好能够通过前两个此测试用例的实现
      public String handle(int input) {
          if (input == 1) {
              return "1";
          } else {
              return "2";
          }
      }
    

    我认为这时候需要重构了, 重构后的代码

      public String handle(int input) {
          return String.valueOf(input);
      }
    

    跑了一下测试类, 没问题能跑通, 这次重构是成功的

编写第三个 case 的测试用例
    @Test
    public void input3() {
        // GIVEN
        int input = 3;
        // WHEN
        String result = handle(input);
        // THEN
        assertThat(result).isEquals("Fizz");
    }
编写恰好能够通过前三个测试用例的实现
    public String handle(int input) {
        if (input == 3) {
            return "Fizz";
        }
        return String.valueOf(input);
    }
编写第四个 case 的测试用例
    @Test
    public void input5() {
        // GIVEN
        int input = 5;
        // WHEN
        String result = handle(input);
        // THEN
        assertThat(result).isEquals("Buzz");
    }
编写恰好能够通过前四个测试用例的实现
    public String handle(int input) {
        if (input == 3) {
            return "Fizz";
        } else if (input == 5) {
            return "Buzz";
        }
        return String.valueOf(input);
    }
编写第五个 case 的测试用例
    @Test
    public void input15() {
        // GIVEN
        int input = 15;
        // WHEN
        String result = handle(input);
        // THEN
        assertThat(result).isEquals("Buzz");
    }
编写恰好能够通过前五个测试用例的实现
    public String handle(int input) {
        if (input == 3) {
            return "Fizz";
        } else if (input == 5) {
            return "Buzz";
        } else if (input == 15) {
            return "FizzBuzz";
        }
        return String.valueOf(input);
    }

这时候我觉得又该重构了.
重构后的代码

    public String handle(int input) {
        if (input % 3 == 0 && input % 5 == 0) {
            return "FizzBuzz";
        } else if (input % 3 == 0) {
            return "Fizz";
        } else if (input % 5 == 0) {
            return "Buzz";
        }
        return String.valueOf(input);
    }

暂且不评论代码本身写的怎么样😅 旨在通过这个例子能够比较好的理解驱动的意义. 由测试类去驱动我们完成代码的编写.