TDD是常见的敏捷开发实践之一。 TDD是一种编写软件的样式,它使用测试来帮助您理解需求阶段的最后一步。 在编写代码之前先编写测试,以巩固对代码需要做什么的理解。
大多数开发人员认为,从TDD派生的主要好处是最终获得的整套单元测试。 但是,如果操作正确,TDD可以更好地更改代码的总体设计,因为它会将决定推迟到最后一个负责任的时刻。 因为您没有预先做出设计决策,所以它使您可以选择更好的设计方案或重构更好的设计。 本文通过一个示例演示了使设计从围绕单元测试的决策中脱颖而出的强大功能。
TDD工作流程
测试驱动开发一词中的重要词是驱动 ,表示测试驱动开发过程。 图1显示了TDD工作流程:
图1. TDD工作流程
图1中的工作流程是:
- 编写失败的测试。
- 编写代码以使其通过。
- 重复步骤1和2。
- 在此过程中,应积极进行重构。
- 当您再也无法想到测试时,您必须完成。
TDD与之后的测试
测试驱动的开发坚持认为测试首先出现。 只有编写了测试(并且失败)后,才可以编写测试代码。 许多开发人员使用一种称为后测试开发(TAD)的测试变体,您可以在其中编写代码,然后编写单元测试。 在这种情况下,您仍然可以获得测试,但没有获得TDD的紧急设计方面。 没有什么可以阻止您编写一些完全丑陋的代码,然后再试着测试它的方式。 通过首先编写代码,您可以嵌入关于代码将如何工作的先入之见,然后对其进行测试。 TDD要求您做相反的事情:首先编写测试,并允许它告知您如何编写使测试通过的代码。 为了说明这一重要区别,我将扩展一个例子。
完美数字
为了展示TDD的设计优势,我需要解决一个问题。 肯特·贝克(Kent Beck)在他的书《 测试驱动开发》 (请参见参考资料 )中以货币为例,它很好地说明了TDD,但有点简化。 真正的挑战是找到一个示例,它不会那么复杂,以至于您在问题领域迷失了方向,但足够复杂以显示实际价值。
为此,我选择了完美的数字 。 对于那些没有进行数学琐事的人,这个概念可以追溯到Euclid之前(Euclid是谁的早期证明之一,得出了完美的数字)。 理想数是一个因数加起来的数字。 例如,6是一个完美数,因为6的因数(不包括6本身)是1、2和3,并且1 + 2 + 3 =6。一个关于完美数的更多算法定义是一个数,其中这些因素(不包括数字本身)等于数字 。 在我的示例中,计算为1 + 2 + 3 +6-6 = 6。
那就是要解决的问题领域:创建一个完美数查找器。 我将以两种不同的方式实现此解决方案。 首先,我将关闭要进行TDD的大脑部分,然后编写解决方案,然后为其编写测试。 然后,我将开发该解决方案的TDD版本,以便可以比较和对比这两种方法。
在此示例中,我使用Java语言(版本5或更高版本,因为我在测试中使用注释),JUnit 4.x(最新版本)和Google代码中的Hamcrest匹配器实现了一个完美数查找器。 相关主题 )。 Hamcrest匹配器在标准JUnit匹配器之上提供了人性化的界面语法糖。 例如,您可以编写assertEquals(actual, is(expected))
代替assertEquals(expected, actual)
assertEquals(actual, is(expected))
,其读起来更像是真实的句子。 Hamcrest匹配器是JUnit 4.x附带的(它们只是静态导入)。 如果您仍在使用JUnit 3.x,则可以下载兼容版本。
之后测试
清单1显示了PerfectNumberFinder
的第一个版本:
清单1.经过测试的PerfectNumberFinder
public class PerfectNumberFinder1 {
public static boolean isPerfect(int number) {
// get factors
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(number);
for (int i = 2; i < number; i++)
if (number % i == 0)
factors.add(i);
// sum factors
int sum = 0;
for (int n : factors)
sum += n;
// decide if it's perfect
return sum - number == number;
}
}
这不是特别壮观的代码,但可以完成工作。 我首先将所有因素的列表创建为动态列表( ArrayList
)。 我将1和目标编号添加到列表中。 (我坚持上面给出的公式,所有因子列表都包括1和数字本身。)然后,我对可能的因子进行迭代,直到数字本身,依次检查每个因子是否为因子。 如果是这样,我将其添加到列表中。 接下来,我总结所有因素,最后写出上面显示的公式的Java版本以确定完善性。
现在,我需要一个测试后的单元测试来确定它是否有效。 我至少需要进行两项测试:一项用于查看完美数字是否正确报告,另一项用于检查我是否没有误报。 单元测试如清单2所示:
清单2. PerfectNumberFinder
单元测试
public class PerfectNumberFinderTest {
private static Integer[] PERFECT_NUMS = {6, 28, 496, 8128, 33550336};
@Test public void test_perfection() {
for (int i : PERFECT_NUMS)
assertTrue(PerfectNumberFinder1.isPerfect(i));
}
@Test public void test_non_perfection() {
List<Integer>expected = new ArrayList<Integer>(
Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 100000; i++) {
if (expected.contains(i))
assertTrue(PerfectNumberFinder1.isPerfect(i));
else
assertFalse(PerfectNumberFinder1.isPerfect(i));
}
}
@Test public void test_perfection_for_2nd_version() {
for (int i : PERFECT_NUMS)
assertTrue(PerfectNumberFinder2.isPerfect(i));
}
@Test public void test_non_perfection_for_2nd_version() {
List<Integer> expected = new ArrayList<Integer>(Arrays.asList(PERFECT_NUMS));
for (int i = 2; i < 100000; i++) {
if (expected.contains(i))
assertTrue(PerfectNumberFinder2.isPerfect(i));
else
assertFalse(PerfectNumberFinder2.isPerfect(i));
}
assertTrue(PerfectNumberFinder2.isPerfect(PERFECT_NUMS[4]));
}
}
这段代码正确报告了完美的数字,但是对于负数测试,它运行非常缓慢,因为我要检查很多数字。 单元测试中可能会出现性能问题,这使我回到代码中来查看是否可以进行一些改进。 目前,我一直在循环使用数字本身来获取因素。 但是我需要走那么远吗? 如果我可以成对地收获这些因素,那不是。 所有因素成对出现(例如,如果目标数为28,当我找到2个因素时,我也可以抓住14)。 如果我可以成对收集因子,则只需要增加数字的平方根即可。 为此,我改进了算法并将代码重构为清单3:
清单3.算法的改进版本
public class PerfectNumberFinder2 {
public static boolean isPerfect(int number) {
// get factors
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(number);
for (int i = 2; i <= sqrt(number); i++)
if (number % i == 0) {
factors.add(i);
factors.add(number / i);
}
// sum factors
int sum = 0;
for (int n : factors)
sum += n;
// decide if it's perfect
return sum - number == number;
}
}
该代码在可观的时间内运行,但是一些测试断言失败。 事实证明,当成对收集数字时,当您到达整数平方根时会意外地两次捕获数字。 例如,对于数字16,平方根为4,它无意间两次被添加到列表中。 通过为这种情况创建一个保护条件,可以很容易地解决这个问题,如清单4所示:
清单4.固定的改进算法
for (int i = 2; i <= sqrt(number); i++)
if (number % i == 0) {
factors.add(i);
if (number / i != i)
factors.add(number / i);
}
现在,我有一个测试后的完美数字查找器版本。 它可以工作,但是有些设计问题也会使他们头昏脑沉。 首先,我使用注释来描述代码的各个部分。 这始终是代码的味道:要求将其重构为自己的方法时需要大喊大叫。 我刚刚添加的新内容可能需要评论,以说明小警卫条件的作用,但现在我将不再赘述。 最大的问题在于它的长度。 我在Java项目上的经验法则说,任何方法都不得超过10行代码。 如果一个方法超出了这个数目,那么几乎可以肯定的是,它不应该做更多的事情。 这种方法显然违反了这种启发式方法,因此我将再次尝试使用TDD。
通过TDD进行紧急设计
对TDD进行编码的原则是:“我可以为其编写测试的最简单的方法是什么?” 在这种情况下,“数字是否完美?”? 不,这个答案太广泛了。 我必须分解问题,并思考“完美数字”的含义。 我可以轻松地提出发现理想数字所需的几个步骤:
- 我需要有关数量的因素。
- 我需要确定数字是否是一个因素。
- 我需要总结这些因素。
对于最简单的事物,此列表中的哪个项目似乎最简单? 我认为这是一个数字是否是另一个数字的因数的确定,所以这是我的第一个测试,如清单5所示:
清单5.测试“数字是一个因素吗?”
public class Classifier1Test {
@Test public void is_1_a_factor_of_10() {
assertTrue(Classifier1.isFactor(1, 10));
}
}
这个简单的测试对于愚蠢的人来说是微不足道的,这就是我想要的。 要编译此测试,您必须具有一个名为Classifier1
的类,该类具有isFactor()
方法。 因此,在获得红条之前,我必须创建班级的骨架结构。 编写疯狂的琐碎的单元测试可以使您在需要开始以任何重要方式考虑问题领域之前就已经掌握了结构。 我一次只想考虑一件事,这使我可以研究骨骼结构,而不必担心要解决的问题的细微差别。 一旦可以编译并显示一个红色条,就可以编写代码了,如清单6所示:
清单6.因子方法的第一次通过
public class Classifier1 {
public static boolean isFactor(int factor, int number) {
return number % factor == 0;
}
}
好的,这很好而且很简单,就可以了。 现在,我可以继续执行下一个最简单的任务:获取数字因素列表。 该测试如清单7所示:
清单7.下一个测试:数字的因素
@Test public void factors_for() {
int[] expected = new int[] {1};
assertThat(Classifier1.factorsFor(1), is(expected));
}
清单7是我可以召集的最简单的测试,因此现在我可以编写使该测试通过的最简单的代码(并在以后进行重构以使其更加复杂)。 清单8显示了下一个方法:
清单8.简单的factorsFor()
方法
public static int[] factorsFor(int number) {
return new int[] {number};
}
尽管此方法有效,但它使我不再陷入困境。 使isFactor()
方法静态化似乎是一个好主意,因为它仅根据其输入返回某些内容。 但是,现在我也已经将factorsFor()
方法设为静态,这意味着我必须将一个名为number
的参数传递给这两种方法。 这段代码变得非常程序化,这是太多静态性的副作用。 为了解决这个问题,我将重构现有的两种方法,这很容易,因为到目前为止我的代码很少。 重构的Classifier
类出现在清单9中:
清单9.改进的Classifier
类
public class Classifier2 {
private int _number;
public Classifier2(int number) {
_number = number;
}
public boolean isFactor(int factor) {
return _number % factor == 0;
}
}
我已经将数字设为Classifier2
类中的成员变量,这使我避免将其作为参数传递给一堆静态方法。
分解列表中的下一件事情说,我需要找到一个数字的因素。 因此,我的下一个测试应该检查一下(如清单10所示):
清单10.下一个测试:数字的因素
@Test public void factors_for_6() {
int[] expected = new int[] {1, 2, 3, 6};
Classifier2 c = new Classifier2(6);
assertThat(c.getFactors(), is(expected));
}
现在,我将实现该方法,该方法返回给定参数的因子数组,如清单11所示:
清单11. getFactors()
方法的getFactors()
public int[] getFactors() {
List<Integer> factors = new ArrayList<Integer>();
factors.add(1);
factors.add(_number);
for (int i = 2; i < _number; i++) {
if (isFactor(i))
factors.add(i);
}
int[] intListOfFactors = new int[factors.size()];
int i = 0;
for (Integer f : factors)
intListOfFactors[i++] = f.intValue();
return intListOfFactors;
}
这段代码允许测试通过,但是经过反思,这太糟糕了! 当您调查使用测试实现代码的方法时,有时会发生这种情况。 这段代码有什么可怕的? 首先,它非常长且复杂,并且遭受“不止一件事情”的困扰。 我的本能使我返回了一个int[]
,但它使底部的代码增加了很多复杂性,并且什么也没买。 开始考虑太多事情以使将来的方法更方便一些(可能称为此方法),这是一个滑坡。 您需要一个令人信服的理由在这个关头添加一些复杂的东西,而我还没有这个理由。 查看这段代码表明,也许factors
也应该作为类的内部状态存在,这使我无法使用此方法的功能。
测试表面的有益特性之一是真正的内聚方法。 肯特·贝克(Kent Beck)在颇有影响的一本名为Smalltalk最佳实践模式的书中对此进行了撰写(请参阅参考资料 )。 肯特在那本书中定义了一种称为复合方法的模式。 组合方法模式定义了三个关键语句:
- 将您的程序划分为执行一项可识别任务的方法。
- 将所有操作保持在同一抽象级别的方法中。
- 这自然会导致程序具有许多小的方法,每个方法只有几行。
组合方法是TDD提升的有益设计特征之一,在清单11的getFactors()
方法中,我显然违反了这种模式。 我可以按照以下步骤修复它:
- 促进
factors
进入内部状态。 - 将
factors
的初始化代码移动到构造函数。 - 摆脱镀金到
int[]
代码的转换,如果有好处,请稍后再处理。 - 为
addFactors()
添加另一个测试。
第四步相当微妙但很重要。 编写此有缺陷的代码版本表明,我的分解第一阶段还没有完成。 埋在此long方法中间的addFactors()
代码行是可测试的行为。 太琐碎了,以至于我第一次看问题时都没有注意到,但是现在我看到了。 这是经常发生的情况。 一个测试可以使您将问题分解为越来越小的块,每个块都可以测试。
我将暂时搁置较大的getFactors()
问题,并解决新的最小问题。 因此,我的下一个测试是addFactors()
,如清单12所示:
清单12.测试addFactors()
@Test public void add_factors() {
Classifier3 c = new Classifier3(6);
c.addFactor(2);
c.addFactor(3);
assertThat(c.getFactors(), is(Arrays.asList(1, 2, 3, 6)));
}
清单13中所示的受测试代码本身就是简单性:
清单13.添加因子的简单代码
public void addFactor(int factor) {
_factors.add(factor);
}
我运行单元测试,充满信心地看到一个绿色的条,但是它失败了! 这样简单的测试怎么会失败? 根本原因如图2所示:
图2.测试失败的根本原因
我的期望列表的值为1, 2, 3, 6
,而实际的回报为1, 6, 2, 3
。 啊,那是因为我更改了代码以在构造函数中加1和数字。 解决此问题的一种方法是始终以1和数字始终排在第一位为前提写我的期望。 但这是正确的解决方案吗? 否。问题更为根本。 因子是数字列表吗? 不,它们是一组数字。 我的第一个(不正确的)假设导致我使用整数列表作为因子,但这是一个糟糕的抽象。 通过现在重构我的代码以使用集而不是列表,我不仅解决了此问题,而且使整体解决方案更好,因为我现在使用的是更准确的抽象。
如果您在没有任何代码使判断变得模糊之前编写测试,那么这恰恰是测试可能暴露出的一种错误思维。 现在,由于有了这个简单的测试,我的代码的总体设计更好了,因为我发现了一个更合适的抽象。
结论
到目前为止,我已经讨论了关于完美数问题的紧急设计。 特别要注意的是,解决方案的第一个版本(测试后版本)对数据类型做出了相同的错误假设。 “之后测试”测试代码的粗粒度功能,而不是单个部分。 TDD测试构成粗粒度功能的构造块,从而在此过程中公开更多信息。
在下一部分中,我将继续讨论完美数字问题,并举例说明如果您不进行测试的话可能会出现的设计类型的更多示例。 完成TDD版本后,我将比较两个代码库之间的一些指标。 我还将解决关于TDD的其他一些棘手的设计问题,例如是否以及何时测试私有方法。
翻译自: https://www.ibm.com/developerworks/java/library/j-eaed2/index.html