单元测试规范

  • 保持单元测试小巧,快速

理论上,任何代码 Check-in 之前都应该把所有测试套件完整的跑一遍。所以保持测试代码轻快能减少开发迭代周期。

  • 单元测试应该是全自动/非交互式的

测试套件通常是定期执行的,执行过程也必须是完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。

  • 让单元测试很容易跑起来

对开发环境进行配置,最好是敲一条命令或是点击一个按钮就能把单个测试用例和测试套件跑起来。

  • 对测试进行评估

对执行的测试进行覆盖率分析,以便得到精确的代码执行覆盖率,调查哪些代码未被执行。

  • 立即修正失败的测试

每个开发人员都应该保证新 Check-in 的测试用例能够跑成功,并且当有代码 Check-in 现有测试用例也都能跑通过。

  • 把测试维持在单元级别

单元测试即类 (Class) 的测试。一个 "测试类" 应该只对应于一个 "被测类",并且对 "被测类" 行为的测试环境应该是隔离的。必须谨慎的避免使用单元测试框架来测试整个程序的工作流,这样的测试即低效又难维护。工作流测试 (译注: 指跨模块/类的数据流测试) 有它自己的地盘,但它绝不是单元测试,必须单独设置和执行。

  • 由简入繁

再简单的测试也远远胜过完全没有测试。一个简单的 "测试类" 会促使建立 "被测类" 基本的测试骨架,可以对构建环境,单元测试环境,执行环境以及覆盖率分析工具等有效性进行检查,同时也确保 "被测类" 能够整合并被调用。

下面便是单元测试版的 Hello,world! :

void testDefaultConstruction() {
    Foo foo = new Foo();
    assertNotNull(foo);
}
  • 保持测试的独立性

为了保证测试稳定可靠且便于维护,测试用例之间决不能有相互依赖,也不能依赖执行的先后次序。

  • 测试类离被测类越近越好

当需要测试名为 Foo 的类时,确保测试类命名为 FooTest(不是 TestFoo)并且放在同一个包下,以免测试类难以访问与维护。

这其中需要确保正确的配置编译环境以免将测试类混入生产库中。

  • 合理的命名测试用例

确保每个测试方法只测试 "被测类" 的一个明确特性,并且相应的给测试方法命名。典型的命名俗定是 test[what],比如 testSaveAs(),testAddListener(),testDeleteProperty() 等。

  • 只测试公有接口

单元测试可以被定义为 通过类的公有 API 对类进行进行测试。一些测试工具允许测试一个类的私有成员,但这种做法应该避免,它让测试变得繁琐而且更难维护。如果有私有成员确实需要进行直接测试,可以考虑把它重构到工具类的公有方法中。但要注意这么做是为了改善设计,而不是帮助测试。

  • 看成是黑盒

从在第三方使用者的角度,测试类是否满足规定的需求。并设法让它出问题 (译注: 原文 tear it apart,本意 "将它撕碎",我的理解是崩溃,出问题,不能正确工作)。

  • 看成是白盒

毕竟被测试类是程序员自写自测的,应该在最复杂的逻辑部分多花些精力测试。

  • 芝麻函数也要测试

通常建议所有重要的函数都应该被测试到,一些芝麻方法,如简单的 setter 和 getter 都可以忽略。但是仍然有充分的理由支持测试芝麻函数。

芝麻 很难定义。对于不同的人有不同的理解。

从黑盒测试的观点看,是无法知道哪些代码是普通的。

即便是再芝麻的函数,也可能包含错误,通常是“复制粘贴”代码的后果:

private double weight_;
private double x_, y_;

public void setWeight(int weight)
{
  weight = weight_;  // error
}

public double getX()
{
  return x_;
}

public double getY()
{
  return x_;  // error
}

因此建议测试所有方法。毕竟芝麻函数也容易测试。

  • 先关注执行覆盖率

区别对待执行覆盖率和实际测试覆盖率。测试的最初目标应该是确保较高的执行覆盖率。这样能保证代码在某些参数输入时能有效执行。一旦执行覆盖率就绪,就应该开始改进测试覆盖率了。注意,实际的测试覆盖率很难衡量 (而且往往趋近于 0%)。

思考以下公有方法:

void setLength(double length);

调用 setLength(1.0) 你可能会得到 100% 的执行覆盖率。要达到 100% 的实际测试覆盖率,有多少个 double 浮点数这个方法就必须被调用多少次,并且要一一验证行为的正确性。这无疑是不可能的任务。

  • 覆盖边界值

确保参数边界值均被覆盖。对于数字,测试负数,0,正数,最小值,最大值,NaN (非数字),无穷大等。对于字符串,测试空字符串,单字符,非 ASCII 字符串,多字节字符串等。对于集合类型,测试空,1,第一个,最后一个等。对于日期,测试 1月1号,2月29号,12月31号等。被测试的类本身也会暗示一些特定情况下的边界值。基本要点是尽可能彻底的测试这些边界值,因为它们都是主要“疑犯”。

  • 提供一个随机值生成器

当边界值都覆盖了,另一个能进一步改善测试覆盖率的简单方法就是生成随机参数,这样每次执行测试都会有不同的输入。

想要做到这点,需要提供一个用来生成基本类型 (如: 浮点数,整型,字符串,日期等) 随机值的工具类。生成器应该覆盖各种类型的所有取值范围。

如果测试时间比较短,可以考虑再裹上一层循环,覆盖尽可能多的输入组合。下面的例子是验证两次转换 little endian 和 big endian 字节序后是否返回原值。由于测试过程很快,可以让它跑上个一百万次。

void testByteSwapper() {
  for (int i = 0; i < 1000000; i++) {
    double v0 = Random.getDouble();
    double v1 = ByteSwapper.swap(v0);
    double v2 = ByteSwapper.swap(v1);
    assertEquals(v0,v2);
  }
}
  • 每个特性只测一次

在测试模式下,有时会情不自禁的滥用断言。这种做法会导致维护更困难,需要极力避免。仅对测试方法名指示的特性进行明确测试。

因为对于一般性代码而言,保证测试代码尽可能少是一个重要目标。

  • 使用显式断言

应该总是优先使用 assertEquals(a,b) 而不是 assertTrue(a == b),因为前者会给出为何导致测试失败的更有意义的信息。在事先不确定输入值的情况下,这条规则尤为重要,比如之前使用随机参数值组合的例子。

  • 提供反向测试

反向测试是指刻意编写问题代码,来验证鲁棒性和能否正确的处理错误。

假设如下方法的参数如果传进去的是负数,会立马抛出异常:

void setLength(double length) throws IllegalArgumentException

可以用下面的方法来测试这个特例是否被正确处理:

try {
  setLength(-1.0);
  fail();  // If we get here,something went wrong
}
catch (IllegalArgumentException exception) {
  // If we get here,all is fine
}
  • 代码设计时谨记测试

编写和维护单元测试的代价是很高的,减少代码中的公有接口和循环复杂度是降低成本,使高覆盖率测试代码更易于编写和维护的有效方法。

一些建议:

使类成员常量化,在构造函数中进行初始化。减少 setter 方法的数量。

限制过度使用继承和公有虚函数。

通过使用友元类 (C++) 或包作用域 (java) 来减少公有接口。

避免不必要的逻辑分支。

在逻辑分支中编写尽可能少的代码。

在公有和私有接口中尽量多用异常和断言验证参数参数的有效性。

限制使用快捷函数。对于黑箱而言,所有方法都必须一视同仁的进行测试。考虑以下简短的例子:

public void scale(double x0,double y0,double scaleFactor) {
  // scaling logic
}
public void scale(double x0,double y0) {
  scale(x0,y0,1.0);
}

删除后者可以简化测试,但用户代码的工作量也将略微增加。

  • 不要访问预定的外部资源

单元测试代码不应该假定外部的执行环境,以便在任何时候/任何地方都能执行。为了向测试提供必需的资源,这些资源应该由测试本身提供。

比如一个解析某类型文件的类,可以把文件内容嵌入到测试代码里,在测试的时候写入到临时文件,测试结束再删除,而不是从预定的地址直接读取。

  • 权衡测试成本

不写单元测试的代价很高,但是写单元测试的代价同样很高。要在这两者之间做适当的权衡,如果用执行覆盖率来衡量,业界标准通常在 80% 左右。

很典型的,读写外部资源的错误处理和异常处理就很难达到百分百的执行覆盖率。模拟数据库在事务处理到一半时发生故障并不是办不到,但相对于进行大范围的代码审查,代价可能太大了。

  • 合理安排测试优先次序

单元测试是典型的自底向上过程,如果没有足够的资源测试一个系统的所有模块,就应该先把重点放在较底层的模块。

  • 为测试失败做好准备

考虑下面的这个例子:

Handle handle = manager.getHandle();
assertNotNull(handle);

String handleName = handle.getName();
assertEquals(handleName,"handle-01");

如果第一个断言失败,紧接其后的语句会导致代码崩溃,剩下的测试都将不被执行。任何时候都要为测试失败做好准备,避免单个失败的测试项中断整个测试套件的执行。上面的例子可以重写成:

Handle handle = manager.getHandle();
assertNotNull(handle);
if (handle == null) return;

String handleName = handle.getName();
assertEquals(handleName,"handle-01");
  • 写测试用例重现 BUG

每上报一个 BUG,都要写一个测试用例来重现这个 BUG (即无法通过测试),并用它作为成功修正代码的标准。

  • 了解单元测试的局限性

单元测试永远无法证明代码的正确性。

一个跑失败的测试可能表明代码有错误,但一个跑成功的测试什么也证明不了。

单元测试最有效的应用场合是验证以及回归测试:当新功能增加和代码进行重构的同时,会不会影响到旧功能的正确性。

results matching ""

    No results matching ""