演化架构与紧急设计: 测试驱动设计,第 2 部分

让测试进一步驱动和改进设计

测试只是测试驱动开发(TDD)的意外结果之一;如果正确地应用,TDD 能够改进代码的整体设计。演化架构与紧急设计 系列的这一期文章将介绍一个进一步扩展的示例,演示如何根据测试中显现的关注点进行设计。

Neal Ford, 软件架构师/Meme Wrangler, ThoughtWorks Inc.

Neal FordNeal Ford 是一家全球性 IT 咨询公司 ThoughtWorks 的软件架构师和 Meme Wrangler。他的工作还包括设计和开发应用程序、教材、杂志文章、课件和视频/DVD 演示,而且他是各种技术书籍的作者或编辑,包括最近的新书 The Productive Programmer。他主要的工作重心是设计和构建大型企业应用程序。他还是全球开发人员会议上的国际知名演说家。请访问他的 Web 站点



2009 年 5 月 08 日

本文是分两部分的文章的第二部分,讨论如何使用 TDD 在编写代码之前编写测试,并通过这个过程形成更好的设计。在 第 1 部分 中,我采用后测试开发方法(在编写代码之后编写测试)编写了完全数查找程序的一个版本。然后,使用 TDD(在编写代码之前编写测试,这样就可以用测试驱动代码的设计)编写了另一个版本。在第 1 部分的末尾,我发现我在用来保存完全数列表的数据结构类型方面犯了一个根本性错误:我最初根据直觉选用了 ArrayList,但是后来发现 Set 更合适。我将以这个问题为起点,讨论如何改进测试的质量和检查最终代码的质量。

测试质量

使用更好的 Set 抽象的测试见清单 1:

清单 1. 使用更好的 Set 抽象的单元测试
_@Test public void add_factors() {
    Set<Integer> expected =
            new HashSet<Integer>(Arrays.asList(1, 2, 3, 6));
    Classifier4 c = new Classifier4(6);
    c.addFactor(2);
    c.addFactor(3);
    assertThat(c.getFactors(), is(expected));
}

这段代码测试我的问题领域中最关键的部分之一:获取数字的因子。我希望彻底地测试这个步骤,因为它是问题中最复杂的部分,所以也是最容易出现错误的。但是,它包含一个笨重的构造:new HashSet(Arrays.asList(1, 2, 3, 6));。即使有了现代 IDE 支持,这行代码编写起来也很别扭:输入 new,输入 Has,执行代码探察;输入 <Int,再次执行代码探察,真是太麻烦了。我要让它容易些。

关于本系列

系列 旨在从全新的视角来介绍经常讨论但是又难以理解的软件架构和设计概念。通过具体示例,Neal Ford 将帮助您在演化架构紧急设计 的敏捷实践方面打下坚实的基础。通过将重要的架构和设计决定推迟到最后责任时刻,可以防止不必要的复杂度降低软件项目的质量。

潮湿的测试

Andy Hunt 和 Dave Thomas 所著的 The Pragmatic Programmer(见 参考资料)提出了许多良好的编程实践,其中之一是 DRY(Don't Repeat Yourself,不要重复自己)原则。这条原则主张从代码中消除所有重复,因为重复常常会导致错误。但是,DRY 不适用于单元测试。单元测试常常需要测试有细微差异的代码行为,因此涉及到相似和重复的情况。例如,为了在不同的测试中测试各种情况,常常需要复制粘贴代码,以得出 清单 1 中预期的结果 (new HashSet(Arrays.asList(1, 2, 3, 6)))。

对于 TDD,我的经验规则是测试应该是潮湿的,但是不要湿透。也就是说,测试中可以有一些重复(而且这是不可避免的),但是不应该创建笨拙的重复结构。因此,我要重构测试,提供一个 private 辅助方法,用它处理这个常用的创建语句,见清单 2:

清单 2. 保持测试适当潮湿的辅助方法
private Set<Integer> expectationSetWith(Integer... numbers) {
    return new HashSet<Integer>(Arrays.asList(numbers));
}

清单 2 中的代码能够让对因子的所有测试更加简洁,清单 1 中的测试可以改写为清单 3 这样:

清单 3. 更潮湿的数字因子测试
@Test public void factors_for_6() {
    Set<Integer> expected = expectationSetWith(1, 2, 3, 6);
    Classifier4 c = new Classifier4(6);
    c.calculateFactors();
    assertThat(c.getFactors(), is(expected));
}

在编写测试时,也应该遵守良好的设计原则。测试也是代码,良好的原则也适用于测试(尽管原则有所差异)。

边界条件

在为一些新功能编写第一个测试时,TDD 鼓励开发人员编写失败的测试。这可以防止测试意外地通过所有情况,也就是说,测试实际上没有测试任何东西(同义反复 测试)。测试还可以检查您认为正确,但是没有经过充分测试的行为。这些测试不一定需要首先采用失败测试的形式(但是,如果在认为测试应该通过时测试却失败了,这是很有价值的,因为这意味着找到了一个潜在的 bug)。考虑测试会引导您考虑哪些东西是可测试的。

常常被忽视的一种测试用例是边界条件:当遇到不正常的输入时,代码会做什么?围绕 getFactors() 方法编写一些测试,可以帮助我们考虑合理和不合理的输入可能导致什么情况。

因此,我要针对感兴趣的边界条件编写几个测试,见清单 4:

清单 4. 因子的边界条件
@Test public void factors_for_100() {
    Classifier5 c = new Classifier5(100);
    c.calculateFactors();
    assertThat(c.getFactors(),
            is(expectationSetWith(1, 100, 2, 50, 4, 25, 5, 20, 10)));
}

@Test(expected = InvalidNumberException.class)
public void cannot_classify_negative_numbers() {
    new Classifier5(-20);
}

@Test public void factors_for_max_int() {
    Classifier5 c = new Classifier5(Integer.MAX_VALUE);
    c.calculateFactors();
    assertThat(c.getFactors(), is(expectationSetWith(1, 2147483647)));
}

数字 100 看起来很有意思,因为它有许多因子。通过测试多个不同的数字,我认识到负数对于这个问题领域是没有意义的,所以编写了一个排除负数的测试(在我纠正它之前,这个测试确实会失败)。考虑到负数还让我想到了 MAX_INT:如果系统的用户需要 long 数字,我的解决方案应该怎么处理呢?我原来假设数字是整数,但是需要确保这是有效的假设。

请在身边找一张图片或画。假设这张图片包含 2 百万像素。如果把图片压缩到只有 2,000 像素,会发生什么?它看起来还一样吗?(如果它是 Rothko 的画,就有可能看起来一样,但是这种情况很少见)。通过删除信息实现的压缩叫作有损 压缩算法。如果要把压缩版本恢复为 2 百万像素,就需要填补缺失的像素。有时候能够猜测出正确的像素,但是不总是可以。

传统的 “预先做大量的设计(big design up front)” 需求收集过程就像是 “有损压缩”,它不能完全准确地反映应用程序所需的功能。业务分析师不可能预见到可能出现的所有问题,所以要由开发人员补充细节。开发人员在这方面表现很差,这导致在需求的定义者和实现者之间争吵不断。

敏捷的过程把 “解压” 过程尽可能延后,让开发人员身边总是有人可以回答 “程序实际上应该做什么” 这个问题,从而缓解需求收集过程中的损失。没有细节,就不可能进行设计,所以无论采用什么设计方法,必须以可行的方法补充在需求收集和定义过程中损失的细节。

测试边界条件会迫使开发人员明确考虑自己的假设。在编写解决方案时,很容易做出无效的假设。实际上,这正是传统的需求收集过程的缺点之一 — 无法收集足够的细节,所以无法消除不可避免的实现问题。需求收集是一种 有损压缩

因为在定义 “软件必须做什么” 的过程中忽略了太多细节,所以必须通过某些机制帮助重现那些必须问的问题,从而充分地理解需求。凭空猜测业务用户实际上希望做什么是很危险的,很可能会猜错。使用测试研究边界条件有助于找到要问的问题,这对于理解需求非常重要。找到要问的问题会提供许多信息,有助于实现良好的设计。

肯定测试和否定测试

在开始研究完全数问题时,我把它分解为几个子任务。在编写测试时,我发现了另一个重要的子任务。下面是完整的列表:

  1. 我需要所求数字的因子。
  2. 我需要确定某个数字是不是因子。
  3. 我需要决定如何把因子添加到因子列表中。
  4. 我需要把因子加起来。
  5. 我需要确定某个数字是不是完全数。

还没有完成的两个任务是把因子加起来和判断完全数。这两个任务很简单;最后两个测试见清单 5:

清单 5. 完全数的最后两个测试
@Test public void sum() {
    Classifier5 c = new Classifier5(20);
    c.calculateFactors();
    int expected = 1 + 2 + 4 + 5 + 10 + 20;
    assertThat(c.sumOfFactors(), is(expected));
}

@Test public void perfection() {
    int[] perfectNumbers = 
        new int[] {6, 28, 496, 8128, 33550336};
    for (int number : perfectNumbers)
        assertTrue(classifierFor(number).isPerfect());
}

在 Wikipedia 上查找到前几个完全数之后,我可以编写一个测试,它检查实际上是否可以找到完全数。但是,这还没有完。肯定测试只是工作的一半儿。还需要编写另一个测试,确保不会意外地把非完全数分类为完全数。因此,我编写了清单 6 所示的否定测试:

清单 6. 确保完全数分类正确的否定测试
@Test public void test_a_bunch_of_numbers() {
    Set<Integer> expected = new HashSet<Integer>(
            Arrays.asList(PERFECT_NUMS));
    for (int i = 2; i < 33550340; i++) {
        if (expected.contains(i))
            assertTrue(classifierFor(i).isPerfect());
        else
            assertFalse(classifierFor(i).isPerfect());
    }
}

这段代码报告我的完全数算法工作正常,但是它非常慢。通过查看 calculateFactors() 方法(清单 7),我可以猜出原因。

清单 7. 最初的 getFactors() 方法
public void calculateFactors() {
    for (int i = 2; i < _number; i++)
        if (isFactor(i))
            addFactor(i);
}

清单 7 中出现的问题与 第 1 部分 中后测试版本中的问题相同:寻找因子的代码会一直循环到数字本身。可以通过成对地寻找因子来改进此代码,这样就只需要循环到数字的平方根,重构的版本见清单 8:

清单 8. calculateFactors() 方法的性能更好的重构版本
public void calculateFactors() {
    for (int i = 2; i < sqrt(_number) + 1; i++)
        if (isFactor(i))
            addFactor(i);
}

public void addFactor(int factor) {
    _factors.add(factor);
    _factors.add(_number / factor);
}

这与在后测试版本中做过的重构相似(见 第 1 部分),但是这一次要修改两个不同的方法。这里的修改更简单,因为我已经把 addFactors() 功能放在一个单独的方法中了,而且这个版本使用 Set 抽象,这样就不需要通过测试确保没有出现在后测试版本中曾经出现的重复。

优化的指导原则应该总是先确保它正确,然后加快它的运行速度。全面的单元测试集能够轻松地检查行为是否正确,让开发人员能够专心地优化代码,而不必担心是否会破坏代码的正常行为。

最后,完成了完全数查找程序的测试驱动版本;完整的类见清单 9:

清单 9. 数字分类程序的完整 TDD 版本
public class Classifier6 {
    private Set<Integer> _factors;
    private int _number;

    public Classifier6(int number) {
        if (number < 1)
            throw new InvalidNumberException(
            "Can't classify negative numbers");
        _number = number;
        _factors = new HashSet<Integer>();
        _factors.add(1);
        _factors.add(_number);
    }

    private boolean isFactor(int factor) {
        return _number % factor == 0;
    }

    public Set<Integer> getFactors() {
        return _factors;
    }

    private void calculateFactors() {
        for (int i = 2; i < sqrt(_number) + 1; i++)
            if (isFactor(i))
                addFactor(i);
    }

    private void addFactor(int factor) {
        _factors.add(factor);
        _factors.add(_number / factor);
    }

    private int sumOfFactors() {
        int sum = 0;
        for (int i : _factors)
            sum += i;
        return sum;
    }

    public boolean isPerfect() {
        calculateFactors();
        return sumOfFactors() - _number == _number;
    }
}

可组合的方法

第 1 部分 中提到的测试驱动开发的好处之一是可组合性,也就是采用 Kent Beck 提出的组合方法模式(见 参考资料)。组合方法可以用许多内聚的方法构建软件。TDD 能够促进这种做法,因为为了进行测试,必须把软件分解为小的功能块。组合方法生成可重用的构建块,有助于产生更好的设计。

在 TDD 驱动的解决方案中,方法的数量和名称反映了这种思想。下面是 TDD 完全数分类程序的最终版本中的方法:

  • isFactor()
  • getFactors()
  • calculateFactors()
  • addFactor()
  • sumOfFactors()
  • isPerfect()

下面通过一个示例说明组合方法的好处。假设您已经编写了完全数查找程序的 TDD 版本,而您公司中的另一个开发组编写了完全数查找程序的后测试版本(第 1 部分 中有一个示例)。现在,您的用户慌慌张张地跑来说,“我们还必须判断盈数和亏数!” 盈数 的因子的总和大于数字本身,而亏数 的因子的总和小于数字本身。

在后测试版本中,所有逻辑都放在一个方法中,他们必须重写整个解决方案,把盈数、亏数和完全数都涉及的代码分离出来。但是,对于 TDD 版本,只需要编写两个新方法,见清单 10:

清单 10. 支持盈数和亏数
public boolean isAbundant() {
    calculateFactors();
    return sumOfFactors() - _number > _number;
}

public boolean isDeficient() {
    calculateFactors();
    return sumOfFactors() - _number < _number;
}

这两个方法所需的惟一任务是把 calculateFactors() 方法重构为类的构造函数。(这对于 isPerfect() 方法没有害处,但是现在它在所有三个方法中重复出现,因此应该重构)。

把代码编写成小的构建块会提高代码的可重用性,因此这是您应该遵守的主要设计原则之一。使用测试有助于编写可组合的方法,能够改进设计。


度量代码质量

第 1 部分 开头,我指出代码的 TDD 版本比后测试版本更好。我已经给出了许多证据,但是能够进行客观的证明吗?当然,对于代码质量,没有纯粹客观的度量方法,但是有几个指标能够比较客观地反映代码质量;其中之一是圈复杂度(见 参考资料),这是由 Thomas McCabe 发明的度量代码复杂度的方法。公式非常简单:边数减去节点数,再加 2,这里的边代表执行路径,节点代表代码行数。请考虑清单 11 中的代码:

清单 11. 用于判断圈复杂度的简单 Java 方法
public void doit() {
    if (c1) {
        f1();
    } else {        
        f2();
    }
    if (c2) {
        f3();
    } else {
        f4();
    }
}

如果把 清单 11 所示的方法画成流程图(见图 1),就很容易算出边数和节点数并计算出圈复杂度。这个方法的圈复杂度是 3 (8 - 7 + 2)。

图 1. doit() 方法的节点和边
圈复杂度

为了度量完全数代码的两个版本,我将使用开放源码的 Java 圈复杂度工具 JavaNCSS(“NCSS” 代表 “non-commenting source statements”,这意味着这个工具也度量非注释源代码语句)。下载信息见 参考资料

对后测试代码运行 JavaNCSS 会产生图 2 所示的结果:

图 2. 后测试完全数查找程序的圈复杂度
后测试代码的 JavaNCSS 结果

这个版本中只有一个方法,JavaNCSS 报告类的方法平均有 13 行代码,圈复杂度为 5.00。TDD 版本的结果见图 3:

图 3. 完全数查找程序的 TDD 版本的圈复杂度
JavaNCSS 结果

显然,代码的 TDD 版本包含更多方法,每个方法平均有 3.56 行代码,平均圈复杂度只有 1.56。根据这个指标,TDD 版本比后测试代码简单三倍。即使对于这个小问题,这也是很显著的差异。


结束语

演化架构与紧急设计 系列的最近两篇文章中,我深入讨论了在编写代码之前 编写测试的好处。TDD 能够产生更简单的方法,更好的抽象,可重用性更好的构建块。

测试可以引导开发人员沿着更好的设计路径前进,纠正可能出现的偏差。设计人员的主观臆断可能对设计产生严重损害。应该尽可能避免猜想,避免意外地做出错误的决策,但是这很困难。TDD 提供一种有效的习惯性方法,能够帮助开发人员跳出错误的猜想,克服各种困难顺利地设计出解决方案。

在下一篇文章中,我要暂时把测试放在一边,谈谈从 Smalltalk 领域借用的两个重要模式:组合方法和单一抽象层 原则。

参考资料

学习

获得产品和技术

  • JavaNCSS:下载 JavaNCSS 源代码度量套件。

讨论

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology
ArticleID=388112
ArticleTitle=演化架构与紧急设计: 测试驱动设计,第 2 部分
publish-date=05082009