10k

设计模式之美-课程笔记18-规范与重构2

为了保证重构不出错,有哪些非常能落地的技术手段?

单元测试

  • 什么是单元测试
  • 为甚写
  • 如何写
  • 如何在团队中推行单元测试

What

  1. 工程师自己写的用以保证代码的正确性的测试。相比于集成测试(端到端),他的粒度更小(类、函数)。
public class Text {
  private String content;

  public Text(String content) {
    this.content = content;
  }

  /**
   * 将字符串转化成数字,忽略字符串中的首尾空格;
   * 如果字符串中包含除首尾空格之外的非数字字符,则返回null。
   */
  public Integer toNumber() {
    if (content == null || content.isEmpty()) {
      return null;
    }
    //...省略代码实现...
    return null;
  }
}

考验的是工程师的思维缜密程度,能否设计出各种正常以及异常情况的测试用例来保证代码在任何预期或者非预期情况下的正常运行。

对于上述代码,我们可以设计这样的测试用例:

  • 只包含数字“123”, toNumber输出对应整数123;
  • 字符串是空或者null,返回null
  • 字符串包含首尾空格:“123 ”, ” 123 “, 返回对应整数123。
  • 如果字符串包含多个首尾空格:“ 123 ”,toNumber() 返回对应的整数:123;
  • 如果字符串包含非数字字符:“123a4”,“123 4”,toNumber() 返回 null;

将测试用例翻译为代码。

public class Assert {
  public static void assertEquals(Integer expectedValue, Integer actualValue) {
    if (actualValue != expectedValue) {
      String message = String.format(
              "Test failed, expected: %d, actual: %d.", expectedValue, actualValue);
      System.out.println(message);
    } else {
      System.out.println("Test succeeded.");
    }
  }

  public static boolean assertNull(Integer actualValue) {
    boolean isNull = actualValue == null;
    if (isNull) {
      System.out.println("Test succeeded.");
    } else {
      System.out.println("Test failed, the value is not null:" + actualValue);
    }
    return isNull;
  }
}

public class TestCaseRunner {
  public static void main(String[] args) {
    System.out.println("Run testToNumber()");
    new TextTest().testToNumber();

    System.out.println("Run testToNumber_nullorEmpty()");
    new TextTest().testToNumber_nullorEmpty();

    System.out.println("Run testToNumber_containsLeadingAndTrailingSpaces()");
    new TextTest().testToNumber_containsLeadingAndTrailingSpaces();

    System.out.println("Run testToNumber_containsMultiLeadingAndTrailingSpaces()");
    new TextTest().testToNumber_containsMultiLeadingAndTrailingSpaces();

    System.out.println("Run testToNumber_containsInvalidCharaters()");
    new TextTest().testToNumber_containsInvalidCharaters();
  }
}

public class TextTest {
  public void testToNumber() {
    Text text = new Text("123");
    Assert.assertEquals(123, text.toNumber());
  }

  public void testToNumber_nullorEmpty() {
    Text text1 = new Text(null);
    Assert.assertNull(text1.toNumber());

    Text text2 = new Text("");
    Assert.assertNull(text2.toNumber());
  }

  public void testToNumber_containsLeadingAndTrailingSpaces() {
    Text text1 = new Text(" 123");
    Assert.assertEquals(123, text1.toNumber());

    Text text2 = new Text("123 ");
    Assert.assertEquals(123, text2.toNumber());

    Text text3 = new Text(" 123 ");
    Assert.assertEquals(123, text3.toNumber());
  }

  public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
    Text text1 = new Text("  123");
    Assert.assertEquals(123, text1.toNumber());

    Text text2 = new Text("123  ");
    Assert.assertEquals(123, text2.toNumber());

    Text text3 = new Text("  123  ");
    Assert.assertEquals(123, text3.toNumber());
  }

  public void testToNumber_containsInvalidCharaters() {
    Text text1 = new Text("123a4");
    Assert.assertNull(text1.toNumber());

    Text text2 = new Text("123 4");
    Assert.assertNull(text2.toNumber());
  }
}

why

  1. 有效的发现代码中的bug
  2. 发现代码设计上的问题: 如果一段代码很难写单元测试,或者需要测试框架中很高级的特性,那意味着代码设计可能不是很合理,比如没有使用依赖注入,大量使用静态函数,全局变量,代码高度耦合。
  3. 单元测试是对集成测试的有力补充。异常、边界case可以用mock很好的模拟。
  4. 写UT的过程就是重构的过程。写ut就相当于一次自我code review,我们也许可以发现一些设计上的问题(比如代码设计的不可测试)以及代码编写方面的问题(比如一些边界条件处理不当),然后针对性重构。
  5. 阅读单侧能帮助你快速熟悉代码:单元测试其实就是用户用例,反映了代码的功能和如何使用。
  6. 单元测试是TDD可落地执行的改进方案。单元测试正好是对 TDD 的一种改进方案,先写代码,紧接着写单元测试,最后根据单元测试反馈出来问题,再回过头去重构代码。这个开发流程更加容易被接受,更加容易落地执行,而且又兼顾了 TDD 的优点。

How

利用现成的测试框架例如Junit:

import org.junit.Assert;
import org.junit.Test;

public class TextTest {
  @Test
  public void testToNumber() {
    Text text = new Text("123");
    Assert.assertEquals(new Integer(123), text.toNumber());
  }

  @Test
  public void testToNumber_nullorEmpty() {
    Text text1 = new Text(null);
    Assert.assertNull(text1.toNumber());

    Text text2 = new Text("");
    Assert.assertNull(text2.toNumber());
  }

  @Test
  public void testToNumber_containsLeadingAndTrailingSpaces() {
    Text text1 = new Text(" 123");
    Assert.assertEquals(new Integer(123), text1.toNumber());

    Text text2 = new Text("123 ");
    Assert.assertEquals(new Integer(123), text2.toNumber());

    Text text3 = new Text(" 123 ");
    Assert.assertEquals(new Integer(123), text3.toNumber());
  }

  @Test
  public void testToNumber_containsMultiLeadingAndTrailingSpaces() {
    Text text1 = new Text("  123");
    Assert.assertEquals(new Integer(123), text1.toNumber());

    Text text2 = new Text("123  ");
    Assert.assertEquals(new Integer(123), text2.toNumber());

    Text text3 = new Text("  123  ");
    Assert.assertEquals(new Integer(123), text3.toNumber());
  }

  @Test
  public void testToNumber_containsInvalidCharaters() {
    Text text1 = new Text("123a4");
    Assert.assertNull(text1.toNumber());

    Text text2 = new Text("123 4");
    Assert.assertNull(text2.toNumber());
  }
}

不能只看覆盖率,要去考虑异常边界

public double cal(double a, double b) {
  if (b != 0) {
    return a / b;
  }
}

cal(10.0, 2.0)就可以覆盖所有代码,但是测不到除数为0 的异常情况。

单元测试不要依赖被测试函数的具体实现逻辑,它只关心被测函数实现了什么功能。我们切不可为了追求覆盖率,逐行阅读代码,然后针对实现逻辑编写单元测试

Thoughts? Leave a comment