单元测试 学习笔记 之四

第四章 高质量的测试代码

4.1 测试代码同样要有高质量

有的开发人员认为, 测试代码不是交付给最终用户的产品代码, 只是内部测试使用的, 因此其质量比产品代码低一点也无可厚非. 我们认为这是一个错误的观点.

首先, 虽然测试代码不会被交付给最终用户, 但它同样是交付代码, 它将被交付给下一代维护程序员, 而这些维护程序员, 很有可能就是我们自己. 因此, 为了让我们自己和其他程序员今后的生活轻松一点, 我们应该在书写测试代码的时候, 就以能达到的最高标准来要求.

另一方面, 测试代码也必须是优质的代码. 低劣的测试代码所带来的麻烦, 远超过没有测试代码所带来的麻烦. 因此, 要么我们不写测试代码, 要么就必须写出"好的"测试代码.

所以, 我们希望树立起这样的观念: 测试代码绝不是二等公民, 必须以产品代码的标准来要求测试代码, 因为测试代码也是要交付的代码.

如果有了这样的观念, 那么下一个问题便是: 我们怎样才能写出高质量的测试代码来? 实际上, 任何促使我们写出高质量的产品代码的方法和原则, 也同样适用于促使我们写出高质量的测试代码. 因此, 只要我们遵循这些原则和方法, 我们就能写了高质量的测试代码. 这些方法和原则包括:

  • 不留破窗原则
  • DRY原则
  • 单一职责原则
  • 最小耦合原则
  • 提高代码可读性

我们将在接下来的几节中分别来看看怎样具体贯彻这些原则和方法.

另一方面,同产品代码(production code)一样,测试代码(test code)应该有自己独立的源代码树,从而方便管理,因此在本章的最后一节中,我们将介绍如何管理测试代码的源代码树。

4.2 不留破窗原则

软件中的"破窗"指的就是bug. 对于测试代码而言, 不留破窗原则显得尤其重要. 这是因为如果在测试代码中出现了bug, 这种bug是很难被检测到的. 当单元测试无法通过时, 程序员往往会先从被测的产品代码中去寻找bug, 直到无法在产品代码中找到bug, 才会想到去测试代码中碰碰运气, 这是很浪费时间也破坏士气的. 因此, 在书写测试代码时, 一定要保证第一次写就把测试代码写正确. 而当发现测试代码中出现了bug时, 马上修正, 不留破窗!

4.3 DRY原则

DRY原则要求在软件中的每一项信息都只有单一、无歧义、权威的表示. 当我们的测试代码中出现了违背DRY原则的情况时, 不要犹豫, 要立刻重构, 去除对同一信息的重复表示. DRY原则在测试代码中还体现为代码复用. 测试代码中的代码复用主要是下面的三种形式.

4.3.1 使用helper method

这是一种常见的形式, 把共同代码抽取到一个helper method中, 让其他测试代码调用该helper method. 实际上, Test Fixture中的SetUp()和TearDown()方法就是这种形式的典型应用.

4.3.2 使用参数化测试

参数化测试常常用于对一个被测方法进行黑箱测试, 即给被测方法各种不同的输入, 然后检测被测方法能否对这些输入产生正确的结果. 我们分别看看C++, C#和Java中怎样来进行参数化测试.

4.3.2.1 Google UTF对参数化测试的支持

gTest框架(1.5版)的参数化测试如下表所示.

gTest对参数化测试的支持

声明Test Fixture

#ifndef CLASS_UNDER_TEST_TEST_H
#define CLASS_UNDER_TEST_TEST_H
// In [ClassUnderTest]Test.h file.
#include <gtest/gtest.h>
// Test Fixture declaration.
class [ClassUnderTest]Test : public testing::TestWithParam<param_type>
{
protected:
    // Optional SetUp() and TearDown().
    virtual void SetUp();
    virtual void TearDown();
};
#endif

定义Test Method

// In [ClassUnderTest]Test.cpp file.
#include <gtest/gtest.h>
#include <hippomocks.h>
#include "[ClassUnderTest]Test.h"
// Optional SetUp() and TearDown().
void [ClassUnderTest]Test::SetUp()
{
    ...
}
void [ClassUnderTest]Test::TearDown()
{
    ...
}
// Test Method definition.
INSTANTIATE_TEST_CASE_P(prefix, [ClassUnderTest]Test, param_value_generator);
TEST_P([ClassUnderTest]Test, [Feature]_[Scenario]_[ExpectedBehavior])
{
    ClassUnderTest obj;
    param_type param = GetParam();
    result_type result = obj.SingleParam_MethodUnderTest(param);
    ASSERT_XX(result);
}
// Other Test Method definition.
TEST_F([ClassUnderTest]Test, [Feature]_[Scenario]_[ExpectedBehavior])
{
    ...
}

gTest所支持的参数值生成器

testing::Range(begin, end[, step])

范围在[begin, end)之间, 步长为step. step缺省为1.

testing::Values(val1, val2, ...)

范围为(val1, val2, ...).

testing::ValuesIn(array)
testing::ValuesIn(container)

范围由数组或STL容器给定.

testing::Bool()

范围为[false, true].

可以看出, 目前gTest(1.5版本)对参数化测试的支持还是有较多局限性的, 体现在:

  • 仅支持对被测函数是单参函数时使用.
  • 一旦指定了参数类型, 对于其他参数类型的被测方法则不能使用了.
  • 无法指定每个输入参数值对应的期望返回值.

希望今后的版本会有所加强.

4.3.2.2 NUnit对参数化测试的支持

NUnit对参数化测试提供了很好的支持, 其用法如下表所示.

NUnit对参数化测试的支持

Test Fixture和Test Method

using NUnit.Framework;
using Rhino.Mocks;
[TestFixture]
public class [ClassUnderTest]Test
{
    [SetUp]
    public void SetUp()
    {
        ...
    }
    [TearDown]
    public void TearDown()
    {
        ...
    }
    [Test]
    public void [Feature]_[Scenario]_[ExpectedResult]()
    {
        ...
    }
    [RowTest]
    [Row(param1_1, param2_1, param3_1, expectedResult_1)]
    [Row(param1_2, param2_2, param3_2, expectedResult_2)]
    [Row(param1_3, param2_3, param3_3, expectedResult_3)]
    [ ... ]
    public void [Feature]_[Scenario]_[ExpectedResult](param1_type param1, param2_type param2, param3_type param3, result_type expectedResult)
    {
        Assert.XXX(param1, param2, param3, expectedResult);
    }
}

4.3.2.3 JUnit对参数化测试的支持

使用JUnit进行参数化测试的用法如下.

JUnit对参数化测试的支持

Test Fixture和Test Method

import org.junit.*;
import org.junit.rules.*;
import org.junit.runner.*;
import org.junit.runners.*;
import static org.junit.Assert;
import static org.easymock.EasyMock.*;
// Test Fixture must be annotated with @RunWith.
@RunWith(Parameterized.class)
public class [ClassUnderTest]Test
{
    private param1_type param1;
    private param2_type param2;
    ...
    private result_type expectedResult;
    // The data-preparation method must be annotated with @Parameters, be public static, have no arguments, and return Collection.
    @Parameters
    public static List<Object[]> prepareData()
    {
        Object [][] data = new Object[][]{
            { param1_1, param2_1, ..., expectedResult_1 },
            { param1_2, param2_2, ..., expectedResult_2 },
            { ... },
        };
        return Arrays.asList(data);
    }
    // Test Fixture must have a constructor.
    public [ClassUnderTest]Test(param1_type param1, param2_type param2, ..., result_type expectedResult)
    {
        this.param1 = param1;
        this.param2 = param2;
        ...
        this.expectedResult = expectedResult;
    }
    @Test
    public void [Feature]_[Scenario]_[ExpectedResult]()
    {
        assertXXX(param1, param2, ..., expectedResult);
    }
}

可以看出, 目前JUnit(4.8版)对参数化测试的支持也不如NUnit, 其局限性体现在:

  • 测试类必须引入构造函数来接收由Test Runner传入的参数.
  • 测试类的参数个数是固定的, 由其构造函数确定.

希望今后的版本会得到加强.

4.3.3 引入类继承体系

引入继承体系也是一种代码复用的常见形式, 把共同代码放入基类中, 而把特有代码放入派生类中, 如下图所示.

clip_image002

4.4 单一职责原则

对于一个类, 我们要求它"has only one reason for change"; 对于一个方法, 我们要求它"does one thing and does it well"; 同样地, 对一个测试方法, 我们要求它"test one thing only". 要做到这一点, 可以从以下几方面入手.

4.4.1 在一个测试方法中仅使用一条断言语句

当一个测试方法中出现了多条断言语句时, 实际上说明该测试方法是在测试多件事情了. 这时我们可以有两种方法解决:

  • 把一个测试方法拆分成多个测试方法. 如果拆分的过程导致违背了DRY原则, 那么可以使用前一节所讲到的技术来进行重构.
  • 如果这多个断言语句实际上是在测试同一件事情的多个方面的话, 可以考虑将这些断言语句合并成一条断言语句.

4.4.2 在一个测试方法中仅使用一个mock对象

当一个测试方法中使用了不止一个mock对象时, 说明该测试方法不是只测试一件事情.

4.5 最小耦合原则

最小耦合原则反映在单元测试代码中时, 即要求保证每个测试方法之间是完全隔离、互不影响的. 这就要求:

  • 测试方法之间不应有时间耦合, 即它们的相对执行顺序是不会影响测试结果的. 一个测试方法的通过与否不应该依赖于其它测试方法是否已被执行.
  • 测试方法之间不能彼此调用.
  • 测试方法之间不能共享状态和信息.

4.6 测试代码的可读性

本节我们来看一下测试代码可读性方面的一些指导方针.

4.6.1 保持测试代码的逻辑简单

应该避免在测试代码中引入逻辑. 具体地讲, 测试代码中不应该出现if, switch-case, for/foreach, while, try-catch这样的语句. 测试代码就应该只是顺序执行式代码.

4.6.2 对测试代码采用命名规范

采用整齐一致的命名规范可以使我们的测试代码更能被其他程序员理解. 下面是我们推荐的命名规范.

测试工程(测试项目)

[ProjectUnderTest]Test

测试类

[ClassUnderTest]Test

测试方法

[Feature]_[Scenario]_[ExpectedResult]

测试用子类

Testing[ClassUnderTest]

Stub合作者

Stub[CollaboratorClass]

Mock合作者

Mock[CollaboratorClass]

4.6.3 以可维护的方式使用SetUp()方法

以下是一些对SetUp()方法的用法指导:

  • 仅把SetUp()用于初始化被"所有"测试方法都使用的对象. 如果一个对象仅用于"某些"而非"全部"测试方法, 那么应该在它被使用的测试方法中单独地去初始化它.
  • SetUp()方法应该是简单的, 不包含复杂的逻辑.
  • 如果使用了继承体系来复用SetUp()方法, 那么建议显式地在派生类中调用基类的SetUp()方法, 这样测试代码更具可读性.

clip_image004

4.6.4 保持测试方法的步骤易区分

一个测试方法通常由以下四步构成: create, expect, trigger, verify. 保证这四个步骤易于区分, 不要在同一条语句中同时完成多个步骤.

4.6.5 可以有多个测试类同时操练同一个被测类

人们常常认为每个被测类只能有一个与之相对应的测试类,对被测类的行为的所有测试都必须放入这个唯一的测试类中。这个观点是不正确的。我们没有必要用成打的测试将一个测试类塞得满满当当的。如果一个测试类里面充斥了过多的测试方法,这个测试类的可读性和可维护性都会急剧下降。这时,我们应该将这个测试类进行拆分,从而提高测试代码的可读性。

4.7 管理测试代码的源代码树

从源文件的物理存储路径上讲,我们希望将产品代码与测试分离开,也就是说,应该为产品代码和测试代码分别建立各自的源代码树。例如,下图就是我们所推荐的文件存放路径。

clip_image006

此外,利用编程语言的特性,我们还可以对产品代码和测试代码进行更好的分离,见下表。

编程语言

技法

C++

将产品代码放入production名字空间中,将单元测试代码放入unit_test名字空间中。

C#

将产品代码放入production名字空间中,将单元测试代码放入unit_test名字空间中。

Java

将产品代码放入production package中,将单元测试代码放入unit_test package中。

posted @ 2011-07-17 20:43  李嘉 (Justin)  阅读(1422)  评论(0编辑  收藏  举报