测试驱动设计

    技术2024-04-10  75

    这是一个由两部分组成的文章的第二部分,该文章探讨了在编写代码之前,如何使用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数会发生什么情况? 我最初的假设将数字限制为整数,但是我需要确保这是一个有效的假设。

    环顾四周,找到图片或艺术品。 让我们任意说图片包含200万像素。 如果将图像压缩到仅2,000像素会发生什么? 看起来还是一样吗? (也许这是Rothko的绘画,但这很少见。)通过删除信息进行压缩是一种有损的压缩算法。 如果您使用压缩版本并尝试将其还原为200万像素,则需要进行一些整理。 有时您可能能够正确猜测,但并非在每种情况下都可以。

    传统的“预先大设计”需求会议是应用程序需要执行的有损压缩。 业务分析师无法预料会出现的每个问题,因此开发人员只能创建信息来填充细节。 众所周知,开发人员在这方面很糟糕,这导致定义需求的人与实施需求的人之间产生了很多痛苦。

    敏捷过程试图通过尽可能延迟延迟解压缩算法来减轻这种损耗,并始终让周围的人可以回答有关其实际功能的问题。 没有细节的设计是不可能的,因此,无论采用哪种方法,您都必须想出一种可行的方法来填充由收集和定义过程不可避免地删除的细节。

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

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

    正面和负面测试

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

    我需要有关数量的因素。 我需要确定数字是否是一个因素。 我需要确定如何将因素添加到因素列表中。 我需要总结这些因素。 我需要确定一个数字是否完美。

    剩下的两个任务是对因素求和并进行完善测试。 这两项任务不会令人惊讶。 清单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报告该类的方法平均使用13行代码,圈复杂度为5.00。 将此与TDD版本进行比较,如图3所示:

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

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

    摘要

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

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

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


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

    相关资源:集成测试中测试驱动程序的设计
    Processed: 0.011, SQL: 9