测试驱动设计_测试驱动的设计,第2部分

本文探讨了使用测试驱动开发(TDD)如何在编写代码前优化设计,通过具体案例展示了TDD如何促进更好的抽象和模块化,提高代码质量和可维护性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

这是一个由两部分组成的文章的第二部分,该文章探讨了在编写代码之前,如何使用TDD可以使编写测试的过程中出现更好的设计。 在第1部分中 ,我使用后测试开发(在编写代码后编写测试)编写了一个理想数查找器版本。 然后,我使用TDD编写了一个版本(在代码之前编写测试,从而使测试能够驱动代码的设计)。 在第1部分的末尾,我发现我在思考用于保留完整数字列表的数据结构类型方面存在一个基本缺陷:本能以ArrayList开头,但我发现抽象更适合到Set 。 到那时,我将继续讨论,将讨论范围扩展到可以提高测试质量和检查完成代码质量的方式。

测试质量

清单1中显示了使用更好抽象的Set的测试:

清单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 ,让代码洞察力接管ad nauseam 。 我将使其变得更容易。

潮湿测试

编写良好代码的口头禅之一来自安迪·亨特(Andy Hunt)和戴夫·托马斯(Dave Thomas)的《实用程序员》 (请参阅参考资料 )-DRY(不要重复自己)原理。 它建议您将所有重复都排除在代码之外,因为它经常会导致问题。 但是,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.进行数字检验的Moister测试
@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鼓励开发人员在编写一些新功能的第一个测试时编写失败的测试。 这样可以防止在所有情况下意外通过测试,从而使测试实际上不测试任何内容( 重言式测试)。 测试还可以验证您认为自己正确的行为,但还没有经过足够的测试以使您有信心。 这些测试不一定要先失败(尽管您认为测试应该通过的失败是纯金的,因为您已经发现了潜在的错误)。 考虑测试会导致您考虑什么是可测试的。

一些经常被忽略的测试用例是边界条件 :面对不寻常的输入,您的代码将做什么? 围绕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数会发生什么情况? 我最初的假设将数字限制为整数,但是我需要确保这是一个有效的假设。

测试边界条件会迫使您质疑您的假设。 在编写解决方案时,很容易做出无效的假设。 实际上,这是传统需求收集的弱点之一-它永远无法收集足够的细节来消除不可避免出现的实施问题。 需求收集是一种有损的压缩形式

由于在定义软件必须执行的工作的过程中忽略了太多,因此您需要适当的机制来帮助您重新创建必须完全理解的问题。 对业务人员真正想要的东西进行猜测是危险的,因为您大多会误会他们。 使用测试调查边界条件可以帮助您找到要提出的问题,这是大多数理解的难题。 找到正确的问题要提出的问题对实现良好的设计至关重要。

正面和负面测试

在开始研究问题时,我将其分解为几个子任务。 在编写测试时,我发现了另一个重要的分解任务。 这是整个列表:

  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());
    }
}

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

清单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版本在客观上要优于测试后版本。 我已经展示了很多轶事证据,但是该证据又如何呢? 当然,不存在纯粹客观的代码质量度量,但是几个度量可以显示其某些维度。 其中之一是圈复杂度 (见相关信息 ),由托马斯·麦凯布创建测量的代码的复杂性。 公式很简单:边数减去节点数再加上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”代表“非注释源语句”,该工具也可以测量)。 请参阅相关主题下载信息。

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

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

此版本中仅存在一种方法,并且JavaNCSS报告该类的方法平均使用13行代码,圈复杂度为5.00。 将此与TDD版本进行比较,如图3所示:

图3. TDD版本的完美数查找器的圈复杂度
JavaNCSS结果

TDD版本的代码显然包括更多方法,每种方法平均使用3.56行代码,平均循环复杂度仅为1.56。 通过这种度量,TDD版本比测试后代码简单三倍以上。 即使对于这个小问题,这也是一个巨大的差异。

摘要

Evolutionary体系结构和紧急设计系列的最后两部分中,在编写代码之前 ,我对测试的好处进行了深入的探讨。 您最终会获得更简单的方法和更好的抽象性,这些方法可作为构建基块更可重用。 您可以免费获得测试!

如果您无法进行测试,那么测试可以引导您走上更好的设计之路。 设计师及其先入为主的观念是对良好设计的最隐患之一。 断开意外做出错误决定的大脑部分的连接非常困难。 TDD提供了一种惯常的方法,使解决方案从问题中冒出来,而不是以错误观念的形式下雨。

在下一部分中,我将进行一段时间的测试,并讨论从Smalltalk领域借来的两个重要模式:组合方法和单一级别的抽象原理。


翻译自: https://www.ibm.com/developerworks/java/library/j-eaed3/index.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值