Skip to content

Latest commit

 

History

History
1046 lines (727 loc) · 52.5 KB

File metadata and controls

1046 lines (727 loc) · 52.5 KB

八、测试驱动开发

水星计划在很短的时间内(半天)反复运行。开发团队对所有变更进行了技术审查,有趣的是,应用了测试优先开发的极限编程实践,在每个微增量之前规划和编写测试。——Craig Larman 和 Victor R. Basili,《迭代和增量开发:简史》。IEEE,2003 年

在“单元测试”一节中(见第 2 章),我们已经了解到一套好的小型快速测试可以确保我们的代码正确运行。目前为止,一切顺利。但是测试驱动开发(TDD)有什么特别之处,为什么它值得在本书中增加一章呢?

尤其是在最近几年,测试驱动开发的学科越来越受欢迎。T DD 已经成为软件工匠工具箱中的一个重要组成部分。这有点令人惊讶,因为测试优先方法的基本思想并不新鲜。上面提到的水星计划是美国第一个载人航天计划,从 1958 年到 1963 年在美国国家航空航天局的指导下进行。尽管大约 50 年前实践的测试优先的方法肯定不是我们今天所知道的那种 TDD,但是我们可以说这个基本思想在专业软件开发的很早时候就出现了。

但这种方法似乎已经被遗忘了几十年。在无数拥有数十亿行代码的项目中,测试在开发过程结束时被推迟。项目时间表中重要测试的这种右移有时会带来灾难性的后果,这是众所周知的:如果项目的时间越来越短,开发团队通常会首先放弃重要的测试。

随着软件开发中敏捷实践的日益流行,以及 21 世纪初一种叫做极限编程(XP)的新方法的出现,测试驱动开发被重新发现。Kent Beck 写了他著名的书《测试驱动开发:举例子》[Beck02],像 TDD 这样的测试优先方法经历了一次复兴,并成为软件工匠工具箱中日益重要的工具。

在这一章中,我不仅会解释虽然术语“测试”包含在测试驱动的开发中,但它主要不是关于质量保证的。TDD 提供了比简单的代码正确性验证更多的好处。相反,我将解释 TDD 与有时被称为普通旧单元测试(POUT)的区别,随后详细讨论 TDD 的工作流程,并通过一个详细的实际例子展示如何在 C++ 中进行。

普通旧单元测试(POUT)的缺点

毫无疑问,正如我们在第 2 章中看到的,一套单元测试基本上比没有测试要好得多。但是在许多项目中,单元测试是与要测试的代码的实现并行编写的,有时甚至是在要开发的模块完成之后。图 8-1 所示的活动图将这一过程可视化。

A429836_1_En_8_Fig1_HTML.jpg

图 8-1。

The typical sequence in development with traditional unit testing

这种广泛使用的方法有时也被称为简单旧单元测试(POUT)。基本上 POUT 的意思是软件会“代码优先”开发,不先测试;例如,使用这种方法,单元测试总是在要测试的代码编写完成后编写。对许多开发人员来说,这个顺序似乎是唯一的逻辑顺序。他们认为,要测试某样东西,显然要测试的东西需要以前已经被建造过。在一些开发组织中,这种方法甚至被错误地命名为“测试驱动开发”,这是完全错误的。

就像我说的,简单的老单元测试比没有单元测试要好。尽管如此,这种方法也有一些缺点:

  • 没有必要在事后编写单元测试。一旦一个特性起作用了(…或者看起来起作用了),就没有动力用单元测试来改进代码了。这一点都不好玩,而且对于许多开发人员来说,转移到下一件事情的诱惑太大了。
  • 结果代码可能很难测试。通常,用单元测试来改进现有的代码并不容易,因为人们并不重视原始代码的可测试性。这允许紧密耦合的代码出现。
  • 用改进的单元测试来达到相当高的测试覆盖率并不容易。在代码之后编写单元测试有可能会漏掉一些问题或错误。

作为游戏改变者的测试驱动开发

测试驱动开发(TDD)彻底改变了传统开发。对于还没有接触过 TDD 的开发人员来说,这种方法代表了一种范式的转变。

作为一种所谓的测试优先方法,与 POUT 相反,TDD 不允许在相关的测试被编写之前编写任何产品代码。换句话说:TDD 意味着我们总是在编写相应的产品代码之前编写新特性或功能的测试。这是严格地一步一步完成的:在每个实现的测试之后,只需编写足够的产品代码,测试就能通过。并且只要对于要开发的模块还有未实现的需求,就要这样做。

乍一看,为尚不存在的东西编写单元测试似乎是矛盾的,也有点荒谬。这怎么行?

别担心,很管用。在下一节我们详细讨论了 TDD 背后的过程之后,所有的疑问都有望消除。

TDD 的工作流程

当执行测试驱动开发时,重复运行图 8-2 中描述的步骤,直到满足待开发单元的所有已知需求。

A429836_1_En_8_Fig2_HTML.jpg

图 8-2。

The detailed workflow of TDD as an activity diagram

首先,值得注意的是,被标上“开始”的初始节点之后的第一个动作是,开发者要思考她想做什么。我们在这个动作的上方看到一个接受“需求”的所谓输入引脚这里指的是哪些要求?

首先,软件系统必须满足一些需求。这既适用于顶层业务涉众对整个系统的需求,也适用于较低抽象层的需求,即组件、类和功能的需求,这些需求都是从业务涉众的需求中派生出来的。使用 TDD 和它的测试优先方法,需求被单元测试牢牢地钉住——事实上,在产品代码被编写之前。在我们的单元开发的测试优先方法的例子中,也就是说,在测试金字塔的最低层(参见第 2 章中的图 2-1 ,当然这里指的是最低层的需求。

接下来,要编写一个测试,由此要设计公共接口(API)。这可能令人惊讶,因为在这个周期的第一次运行中,我们仍然没有编写任何产品代码。那么,如果我们有一张白纸,这里可以设计什么界面呢?

嗯,简单的答案是这样的:那张“空白纸”正是我们现在想要填写的,但来自一个不同于往常的视角。我们现在从待开发单元的未来外部客户的角度出发。我们使用一个小的测试来定义我们想要如何使用被开发的单元。换句话说,这一步应该导致良好可测试的,因此也是良好可用的软件单元。

在我们在测试中编写了适当的行之后,我们当然还必须满足编译器的要求,并提供测试所要求的接口。

然后紧接着下一个惊喜:新编写的单元测试必须(最初)失败。为什么呢?

简单的回答:我们必须确保测试根本不会失败。甚至单元测试本身也可能被错误地实现,例如,无论我们在产品代码中做什么,它总是通过。因此,我们必须确保新编写的测试已准备就绪。

现在,我们正在进入这个小工作流程的高潮:我们编写足够的生产代码——一行也不能多!–新的单元测试(…以及,如果有的话,所有先前存在的测试)通过!并且在这一点上遵守纪律非常重要,不要写超过要求的代码(记住第 3 章的 KISS 原则)。由开发人员决定在每种情况下什么是合适的。有时一行代码,甚至一条语句就足够了;在其他情况下,你需要调用一个库函数。如果是后者,现在是时候考虑如何集成和使用这个库了,尤其是如何用一个测试替身来代替它(参见第 2 章中关于测试替身(模拟对象)的部分)。

如果我们现在运行单元测试,并且我们做的一切都是正确的,那么测试将会通过。

现在,我们已经到了这个过程中的一个重要阶段。如果测试现在通过了,在这一步我们总是有 100%的单元测试覆盖率。永远!不仅仅是技术测试覆盖度量意义上的 100%,比如功能覆盖、分支覆盖或者语句覆盖。不,更重要的是,对于已经实现的需求,我们有 100%的单元测试覆盖率!是的,在这一点上,对于要开发的单元,可能仍然有一些或者许多未实现的需求。这是可以的,因为我们将一次又一次地经历 TDD 循环,直到所有的需求都得到满足。但是对于此时已经满足的需求子集,我们有 100%的单元测试覆盖率。

这个事实给了我们巨大的力量!有了这个无缝的单元测试安全网,我们现在能够进行无畏的重构了。代码味道(例如,重复代码)或设计问题现在可以修复。我们不需要害怕破坏功能,因为定期执行的单元测试会给我们即时的反馈。令人高兴的是:如果在重构阶段有一个或多个测试失败,导致失败的代码变化是非常小的。

在重构完成之后,我们现在可以通过继续 TDD 循环来实现另一个尚未实现的需求。如果没有更多的要求,我们准备好了。

8-2 描绘了 TDD 循环的许多细节。归结为图 8-3 中描述的三个主要步骤,TDD 循环通常被称为“红-绿-重构”

  • RED:我们编写一个失败的单元测试。
  • 绿色:我们编写了足够的产品代码,新的测试和所有以前编写的测试都可以通过。
  • 重构:从产品代码和单元测试中消除了代码重复和其他代码味道。

A429836_1_En_8_Fig3_HTML.jpg

图 8-3。

The core workflow of TDD

术语红色和绿色是指典型的单元测试框架集成,可用于各种 IDE,其中通过的测试显示为绿色,失败的测试显示为红色。

Uncle Bob’s Three Rules of Tdd

在他的伟大著作《干净的编码者》[Martin11]中,罗伯特·c·马丁(又名鲍勃大叔)建议我们遵循 TDD 的三个规则:

  • 在你写完一个失败的单元测试之前,不允许你写任何产品代码。
  • 不允许你编写超过足以失败的单元测试——不编译就是失败。
  • 不允许您编写超过足以通过当前失败的单元测试的生产代码。

Martin 认为严格遵守这三条规则会迫使开发人员在很短的周期内完成工作。因此,开发人员将永远不会在几秒钟或几分钟之内就感觉到代码是正确的,一切正常。

理论已经讲得够多了,现在我将通过一个小例子来解释一个使用 TDD 的软件的完整开发。

TDD 举例:罗马数字代码 Kata

如今被称为代码形的基本思想首先是由迪夫·托马斯描述的,他是著名的著作《务实的程序员》的两位作者之一。Dave 认为开发人员应该在小型的、与工作无关的代码库上反复练习,这样他们就可以像音乐家一样精通自己的专业。他说,开发人员应该不断地学习和提高自己,为此,他们需要练习来一遍又一遍地应用理论,每次都利用反馈来做得更好。

代码形是编程中的一个小练习,正好满足这个目的。术语“形”是从武术中继承来的。在远东的格斗运动中,他们用形反复练习他们的基本动作。目标是使运动过程尽善尽美。

这种实践被转移到软件开发中。为了提高他们的编程技能,开发人员应该在小练习的帮助下练习他们的技能。Kat 成为软件工艺运动的一个重要方面。它们可以解决开发人员应该具备的不同能力,例如,了解 IDE 的键盘快捷键,学习一种新的编程语言,关注某些设计原则,或者实践 TDD。在互联网上,有几种适合不同目的的目录,例如迪夫·托马斯关于 http://codekata.com 的收藏。

对于 TDD 的第一步,我们使用了一个强调算法的代码 kata:众所周知的罗马数字代码 kata。

TDD Kata: Convert Arabic Numbers To Roman Numerals

罗马人用字母书写数字。例如,他们写“V”代表阿拉伯数字 5。

您的任务是使用测试驱动开发(TDD)方法开发一段代码,将 1 到 3,999 之间的阿拉伯数字翻译成它们各自的罗马表示。

罗马系统中的数字是由拉丁字母的组合来表示的。今天使用的罗马数字基于七个字符:

    1 ⇒ I
    5 ⇒ V
   10 ⇒ X
   50 ⇒ L
  100 ⇒ C
  500 ⇒ D
1,000 ⇒ M

数字是通过将字符组合在一起并将数值相加而形成的。例如,阿拉伯数字 12 用“XII”(10+1+1)表示。数字 2017 在罗马字母中是“MMXVII”。

例外是 4、9、40、90、400 和 900。为了避免这种情况,四个相等的字符必须连续出现,例如,数字 4 不是用“IIII”来表示,而是用“IV”来表示。这就是所谓的减法,即从 V (5 - 1 = 4)中减去前面的字符 I 所代表的数字。再比如“CM”,就是 900 (1,000 - 100)。

顺便说一下:罗马人没有 0 的等价物,而且他们不知道负数。

准备

在我们能够编写我们的第一个测试之前,我们需要做一些准备,并且必须设置测试环境。

作为这个 kata 的单元测试框架,我使用 Google Test ( https://github.com/google/googletest ),一个在新 BSD 许可下发布的独立于平台的 C++ 单元测试框架。当然,任何其他 C++ 单元测试框架也可以用于这个表。

强烈建议使用版本控制系统。除了少数例外,我们将在 TDD 周期的每一次传递之后执行一次对版本控制系统的提交。这有一个很大的好处,那就是我们能够往回走,退回可能是错误的决定。

此外,我们必须考虑如何组织源代码文件。我对这个 kata 的建议是最初只从一个文件开始,这个文件将占用所有未来的单元测试:ArabicToRomanNumeralsConverterTestCase.cpp。由于 TDD 指导我们逐步完成一个软件单元的形成过程,所以有可能在以后决定是否需要额外的文件。

对于基本的函数检查,我们编写一个 main 函数来初始化 Google Test 并运行所有的测试,我们编写一个简单的单元测试(名为PreparationsCompleted),它总是故意失败,如下面的代码示例所示。

#include <gtest/gtest.h>

int main(int argc, char** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

TEST(ArabicToRomanNumeralsConverterTestCase, PreparationsCompleted) {
  GTEST_FAIL();
}

Listing 8-1.The initial content of ArabicToRom

anNumeralsConverterTestCase.cpp

在编译和链接之后,我们执行生成的二进制文件来运行测试。我们的小程序在标准输出(stdout)上的输出应该如下所示:

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted
../ ArabicToRomanNumeralsConverterTestCase.cpp:9: Failure
Failed
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (2 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (16 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted

 1 FAILED TEST

Listing 8-2.The output of the test

run

不出所料,测试失败了。stdout 上的输出非常有助于想象哪里出错了。它指定失败测试的名称、文件名、行号以及测试失败的原因。在这种情况下,这是一个由特殊的 Google 测试宏引起的故障。

如果我们现在将宏GTEST_FAIL()与测试中的宏GTEST_SUCCEED()交换,在重新编译之后,测试应该通过:

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted
[       OK ] ArabicToRomanNumeralsConverterTestCase.PreparationsCompleted (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (4 ms total)
[  PASSED  ] 1 test.

Listing 8-3.The output of the suc

cessful test run

这很好,因为现在我们知道一切都准备妥当,我们可以开始我们的 ka ta。

第一次测试

第一步是决定我们想要实现的第一个小需求。然后我们会为它写一个失败的测试。对于我们的例子,我们决定从将一个阿拉伯数字转换成罗马数字开始:我们想将阿拉伯数字 1 转换成“I”

因此,我们采用已经存在的虚拟测试,并将其转换为真实的单元测试,这可以证明这个小需求的实现。因此,我们还必须考虑转换函数的接口应该是什么样子。

TEST(ArabicToRomanNumeralsConverterTestCase, 1_isConvertedTo_I) {
  ASSERT_EQ("I", convertArabicNumberToRomanNumeral(1));
}
Listing 8-4.The first test (irrelevant parts of the source code were omitted)

如您所见,我们已经决定使用一个简单的函数,该函数以一个阿拉伯数字作为参数,以一个字符串作为返回值。

但是代码不能在没有编译器错误的情况下编译,因为函数convertArabicNumberToRomanNumeral()还不存在。让我们记住 Bob 叔叔提出的 TDD 三条规则中的第二条:“不允许编写超过足以失败的单元测试——不编译就是失败。”

这意味着我们现在必须停止编写测试代码,以编写足够的产品代码,使其能够被编译而不出错。因此,我们现在将创建转换函数,我们甚至将该函数直接写入源代码文件,其中也包含测试。当然,我们意识到不能继续这样下去。

#include <gtest/gtest.h>

#include <string>

int main(int argc, char** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  return "";
}

TEST(ArabicToRomanNumeralsConverterTestCase, 1_isConvertedTo_I) {
  ASSERT_EQ("I", convertArabicNumberToRomanNumeral(1));
}

Listing 8-5.The function stub satisfies the compiler

现在代码可以再次编译而不会出错。目前这个函数只返回一个空字符串。

此外,我们现在有了第一个可执行的测试,它必须失败(红色),因为测试期望一个“I”,但是函数返回一个空字符串:

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I
../ArabicToRomanNumeralsConverterTestCase.cpp:14: Failure
Value of: convertArabicNumberToRomanNumeral(1)
  Actual: ""
Expected: "I"
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (6 ms total)
[  PASSED  ] 0 tests.
[  FAILED  ] 1 test, listed below:
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I

 1 FAILED TEST

Listing 8-6.The output of Google Test 

after executing the deliberately failing unit test (RED)

好的,这正是我们所期待的。

Note

根据使用的 Google Test 版本,测试框架的输出可能与这里显示的略有不同。

现在我们需要改变函数convertArabicNumberToRomanNumeral()的实现,这样测试就能通过。规则是这样的:做可能有效的最简单的事情。还有什么比从函数中返回一个“I”更容易的呢?

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  return "I";
}
Listing 8-7.The changed functi

on (irrelevant parts of the source code were omitted)

你可能会说,“等一下!这不是一个将阿拉伯数字转换成罗马数字的算法。那是作弊!”

当然,算法还没有准备好。你必须改变你的想法。TDD 的规则规定,我们应该编写通过当前测试的最简单的代码。这是一个渐进的过程,我们才刚刚开始。

[==========] Running 1 test from 1 test case.
[----------] Global test environment set-up.
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase
[ RUN      ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I
[       OK ] ArabicToRomanNumeralsConverterTestCase.1_isConvertedTo_I (0 ms)
[----------] 1 test from ArabicToRomanNumeralsConverterTestCase (0 ms total)

[----------] Global test environment tear-down
[==========] 1 test from 1 test case ran. (1 ms total)
[  PASSED  ] 1 test.

太棒了!测试通过了(绿色),我们可以进入重构步骤了。实际上还不需要重构某些东西,所以我们可以继续进行下一次 TDD 循环。但是首先我们必须将我们的更改提交给源代码库。

第二个测试

对于我们的第二个单元测试,我们将取 2,它必须被转换成"II."

TEST(ArabicToRomanNumeralsConverterTestCase, 2_isConvertedTo_II) {
  ASSERT_EQ("II", convertArabicNumberToRomanNumeral(2));
}

不出所料,这个测试一定会失败(红色),因为我们的函数convertArabicN umberToRomanNumeral()总是返回一个“I.”。在我们验证了测试失败之后,我们补充了实现,以便测试能够通过。我们再一次做了最简单可行的事情。

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  if (arabicNumber == 2) {
    return "II";
  }
  return "I";
}
Listing 8-8.We add some code to pass the new test

两项测试都通过(绿色)。

我们现在应该重构一些东西吗?也许还没有,但是你可能会暗自怀疑我们很快就需要重构了。目前,我们继续进行第三次测试…

第三次测试和之后的整理

不出所料,我们的第三个测试将测试数字 3 的转换:

TEST(ArabicToRomanNumeralsConverterTestCase, 3_isConvertedTo_III) {
  ASSERT_EQ("III", convertArabicNumberToRomanNumeral(3));
}

当然,这个测试会失败(红色)。通过该测试和所有先前测试(绿色)的代码如下所示:

std::string convertArabicNumberToRomanNumeral(const unsigned int arabicNumber) {
  if (arabicNumber == 3) {
    return "III";
  }
  if (arabicNumber == 2) {
    return "II";
  }
  return "I";
}

在第二次测试中,我们已经对新设计有了不好的直觉,这并不是没有根据的。至少现在我们应该对明显的代码重复完全不满意了。很明显,我们不能继续走这条路。无止境的 if 语句序列不是一个解决方案,因为我们最终会得到一个糟糕的设计。重构的时候到了,我们可以无所畏惧地去做,因为 100%的单元测试覆盖率创造了一种舒适的安全感!

如果我们看一下函数convertArabicNumberToRomanNumeral()中的代码,可以看出一种模式。阿拉伯数字就像罗马数字中 I 字符的计数器。换句话说:只要要转换的数字在达到 0 之前可以减 1,就在罗马数字串上加一个“I”。

这可以用一种优雅的方式来完成,使用 while 循环和字符串连接,就像这样:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}
Listing 8-9.The conversion function after refactoring

看起来不错。我们消除了代码重复,找到了一个紧凑的解决方案。我们还必须从参数arabicNumber中移除const声明,因为我们必须操作函数中的阿拉伯数字。并且仍然通过了三个现有的单元测试。

我们可以进行下一项测试。当然,您也可以继续使用 5,但我决定使用“10-is-X”。我希望十国集团会显示出与 1、2 和 3 类似的模式。当然,阿拉伯数字 5 将在以后处理。

TEST(ArabicToRomanNumeralsConverterTestCase, 10_isConvertedTo_X) {
  ASSERT_EQ("X", convertArabicNumberToRomanNumeral(10));
}
Listing 8-10.The 4th unit test

这个测试失败(红色)不应该让任何人感到惊讶。下面是 Google Test 在 stdout 上写的关于这个新测试的内容:

[ RUN      ] ArabicToRomanNumeralsConverterTestCase.10_isConvertedTo_X
../ArabicToRomanNumeralsConverterTestCase.cpp:31: Failure
Value of: convertArabicNumberToRomanNumeral(10)
  Actual: "IIIIIIIIII"
Expected: "X"
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.10_isConvertedTo_X (0 ms)

测试失败了,因为 10 不是"IIIIIIIIII,"而是"X."。但是,如果我们看到 Google Test 的输出,我们就可以得到一个想法。也许和我们处理阿拉伯数字 1,2 的方法一样。和 3,也可以用于 10,20 和 30?

停止!嗯,这是可以想象的,但是我们不应该在没有单元测试引导我们找到这样一个解决方案的情况下为未来创造一些东西。如果我们将 20 和 30 的生产代码与 10 的代码一起实现,我们将不再进行测试驱动的工作。所以,我们再做一次可能有效的最简单的事情。

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  if (arabicNumber == 10) {
    return "X";
  } else {
    std::string romanNumeral;
    while (arabicNumber >= 1) {
      romanNumeral += "I";
      arabicNumber--;
    }
    return romanNumeral;
  }
}
Listing 8-11.The conversion function can now also convert 10

好的,测试和所有之前的测试都通过了(绿色)。我们可以逐步为阿拉伯数字 20 添加一个测试,然后为 30 添加一个测试。在我们运行完两种情况下的 TDD 循环后,我们的转换函数如下所示:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  if (arabicNumber == 10) {
    return "X";
  } else if (arabicNumber == 20) {
    return "XX";
  } else if (arabicNumber == 30) {
    return "XXX";
  } else {
    std::string romanNumeral;
    while (arabicNumber >= 1) {
      romanNumeral += "I";
      arabicNumber--;
    }
    return romanNumeral;
  }
}
Listing 8-12.The result during the 6th TDD-cycle before refactoring

至少现在迫切需要重构。出现的代码有一些不好的味道,比如一些冗余和高圈复杂度。然而,我们的怀疑也得到了证实,对数字 10、20 和 30 的处理遵循着与处理数字 1、2 和 3 相似的模式。让我们来试试:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= 10) {
    romanNumeral += "X";
    arabicNumber -= 10;
  }
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}
Listing 8-13.After the refactori

ng all if-else-decisions are gone

非常好,所有测试立即通过!看来我们的方向是正确的。

然而,我们必须记住 TDD 循环中重构步骤的目标。在这一部分,你可以读到以下内容:从产品代码和单元测试中消除了代码重复和其他代码味道。

我们应该以批判的眼光看待我们的测试代码。目前看起来是这样的:

TEST(ArabicToRomanNumeralsConverterTestCase, 1_isConvertedTo_I) {
  ASSERT_EQ("I", convertArabicNumberToRomanNumeral(1));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 2_isConvertedTo_II) {
  ASSERT_EQ("II", convertArabicNumberToRomanNumeral(2));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 3_isConvertedTo_III) {
  ASSERT_EQ("III", convertArabicNumberToRomanNumeral(3));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 10_isConvertedTo_X) {
  ASSERT_EQ("X", convertArabicNumberToRomanNumeral(10));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 20_isConvertedTo_XX) {
  ASSERT_EQ("XX", convertArabicNumberToRomanNumeral(20));
}

TEST(ArabicToRomanNumeralsConverterTestCase, 30_isConvertedTo_XXX) {
  ASSERT_EQ("XXX", convertArabicNumberToRomanNumeral(30));
}

Listing 8-14.The emerged unit tests have a 

lot of code duplications

请记住我在第 2 章写的关于测试代码质量的话:测试代码的质量必须和生产代码的质量一样高。换句话说,我们的测试需要重构,因为它们包含许多重复,应该设计得更优雅。此外,我们希望增加它们的可读性和可维护性。但是我们能做什么呢?

看看上面的六个测试。测试中的验证总是相同的,可以理解为:“断言阿拉伯数字被转换为罗马数字”T3】

一个解决方案可以是为此提供一个专用断言(也称为自定义断言或自定义匹配器),可以用与上面句子相同的方式来理解:

assertThat(x).isConvertedToRomanNumeral("string");

带有自定义断言的更复杂的测试

为了实现我们的自定义断言,我们首先编写一个失败的单元测试,但是不同于我们以前编写的单元测试:

TEST(ArabicToRomanNumeralsConverterTestCase, 33_isConvertedTo_XXXIII) {
  assertThat(33).isConvertedToRomanNumeral("XXXII");
}

33 的转换已经起作用的概率非常高。因此,我们通过指定一个故意的错误结果作为期望值(“XXXII”)来强制测试失败(红色)。但是这个新的测试失败也是由于另一个原因:编译器不能编译没有错误的单元测试。名为assertThat的函数还不存在,同样也没有isConvertedToRomanNumeral。永远记住 Robert C. Martin 的 TDD 的第二条规则(见上文):“你不允许编写超过足以失败的单元测试——不编译就是失败。”

所以我们必须首先通过编写自定义断言来满足编译器。这将由两部分组成:

  • 一个免费的assertThat(<parameter>)函数,返回一个自定义断言类的实例。
  • 包含真实断言方法的自定义断言类,验证被测试对象的一个或多个属性。
class RomanNumeralAssert {

public:

  RomanNumeralAssert() = delete;
  explicit RomanNumeralAssert(const unsigned int arabicNumber) :
      arabicNumberToConvert(arabicNumber) { }
  void isConvertedToRomanNumeral(const std::string& expectedRomanNumeral) const {
    ASSERT_EQ(expectedRomanNumeral, convertArabicNumberToRomanNumeral(arabicNumberToConvert));
  }

private:

  const unsigned int arabicNumberToConvert;
};

RomanNumeralAssert assertThat(const unsigned int arabicNumber) {
  RomanNumeralAssert assert { arabicNumber };
  return assert;
}

Listing 8-15.A custom assertion for Roman numerals

Note

除了自由函数assertThat,断言类中还可以使用静态和公共类方法。当您面临名称空间冲突时,这可能是必要的,例如,相同函数名的冲突。当然,在使用类方法时,名称空间的名字必须加在前面:RomanNumeralAssert::assertThat(33).isConvertedToRomanNumeral("XXXIII");

现在可以编译代码而不出错,但是新的测试在执行过程中会像预期的那样失败。

[ RUN      ] ArabicToRomanNumeralsConverterTestCase.33_isConvertedTo_XXXIII
../ArabicToRomanNumeralsConverterTestCase.cpp:30: Failure
Value of: convertArabicNumberToRomanNumeral(arabicNumberToConvert)
  Actual: "XXXIII"
Expected: expectedRomanNumeral
Which is: "XXXII"
[  FAILED  ] ArabicToRomanNumeralsConverterTestCase.33_isConvertedTo_XXXIII (0 ms)
Listing 8-16.An excerpt from the output of Google-Test on stdout

因此,我们需要修改测试,并更正我们期望得到的结果中的罗马数字。

TEST(ArabicToRomanNumeralsConverterTestCase, 33_isConvertedTo_XXXIII) {
  assertThat(33).isConvertedToRomanNumeral("XXXIII");
}
Listing 8-17.Our Custom Asserter allows a more compact spelling of the test code

现在,我们可以将之前的所有测试总结成一个测试。

TEST(ArabicToRomanNumeralsConverterTestCase, 

conversionOfArabicNumbersToRomanNumerals_Works) {
  assertThat(1).isConvertedToRomanNumeral("I");
  assertThat(2).isConvertedToRomanNumeral("II");
  assertThat(3).isConvertedToRomanNumeral("III");
  assertThat(10).isConvertedToRomanNumeral("X");
  assertThat(20).isConvertedToRomanNumeral("XX");
  assertThat(30).isConvertedToRomanNumeral("XXX");
  assertThat(33).isConvertedToRomanNumeral("XXXIII");
}
Listing 8-18.All checks can be elegantly pooled in one test function

现在看一下我们的测试代码:无冗余、干净、易读。我们自作主张的直接性相当优雅。现在添加更多的测试非常容易,因为我们只需为每个新测试编写一行代码。

您可能会抱怨这种重构也有一个小缺点。测试方法的名称现在没有重构之前所有测试方法的名称那么具体(参见第 2 章中的单元测试名称一节)。我们能容忍这些小缺点吗?我想是的。我们在这里做了一个妥协:这个小缺点被测试的可维护性和可扩展性方面的好处所补偿。

现在我们可以继续 TDD 循环,并为以下三个测试连续实现产品代码:

assertThat(100).isConvertedToRomanNumeral("C");
assertThat(200).isConvertedToRomanNumeral("CC");
assertThat(300).isConvertedToRomanNumeral("CCC");

在三次迭代之后,重构步骤之前的代码将如下所示:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  if (arabicNumber == 100) {
    romanNumeral = "C";
  } else if (arabicNumber == 200) {
    romanNumeral = "CC";
  } else if (arabicNumber == 300) {
    romanNumeral = "CCC";
  } else {
    while (arabicNumber >= 10) {
      romanNumeral += "X";
      arabicNumber -= 10;
    }
    while (arabicNumber >= 1) {
      romanNumeral += "I";
      arabicNumber--;
    }
  }
  return romanNumeral;
}
Listing 8-19.Our conversion function in the 9th TDD cycle before refactoring

同样的模式出现在 1,2,3 中。以及 10、20 和 30。我们也可以使用类似的循环来处理数百个:

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= 100) {
    romanNumeral += "C";
    arabicNumber -= 100;
  }
  while (arabicNumber >= 10) {
    romanNumeral += "X";
    arabicNumber -= 10;
  }
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}

Listing 8-20.The emerging pattern, as well as which parts of the code are variable and which are identical, is clearly recognizable

又到了大扫除的时候了

在这一点上,我们应该再次对我们的代码进行批判性的审视。如果我们继续这样,代码将包含许多重复的代码,因为三个while-语句看起来非常相似。然而,我们可以通过抽象所有三个while循环中相同的代码部分来利用这些相似性。

重构时间到了!所有三个while-循环中唯一不同的代码部分是阿拉伯数字及其对应的罗马数字。想法是将这些可变部分从循环的其余部分中分离出来。

第一步,我们引入了一个struct,它将阿拉伯数字映射成罗马数字。此外,我们需要该结构的一个数组(这里我们将使用 C++ 标准库中的std::array)。最初,我们将只添加一个元素到数组中,将字母“C”分配给数字 100。

struct ArabicToRomanMapping {
  unsigned int arabicNumber;
  std::string romanNumeral;
};

const std::size_t numberOfMappings = 1;

using ArabicToRomanMappings = std::array<ArabicToRomanMapping, numberOfMappings>;

const ArabicToRomanMappings arabicToRomanMappings = {
  { 100, "C" }
};

Listing 8-21.Introducing an array that holds mappings between Arabic numbers and their Roman equivalent

做好这些准备后,我们修改转换函数中的第一个 while 循环,以验证基本思想是否可行。

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= arabicToRomanMappings[0].arabicNumber) {
    romanNumeral += arabicToRomanMappings[0].romanNumeral;
    arabicNumber -= arabicToRomanMappings[0].arabicNumber;
  }
  while (arabicNumber >= 10) {
    romanNumeral += "X";
    arabicNumber -= 10;
  }
  while (arabicNumber >= 1) {
    romanNumeral += "I";
    arabicNumber--;
  }
  return romanNumeral;
}
Listing 8-22.Replacing the literals with entries from the new array

所有测试都通过了。因此,我们可以继续用映射“10-is- X,”和“1-is- I”填充数组(不要忘记相应地调整数组大小!).

const std::size_t numberOfMappings { 3 };
// ...

const ArabicToRomanMappings arabicToRomanMappings = { {
  { 100, "C" },
  {  10, "X" },
  {   1, "I" }
} };

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  while (arabicNumber >= arabicToRomanMappings[0].arabicNumber) {
    romanNumeral += arabicToRomanMappings[0].romanNumeral;
    arabicNumber -= arabicToRomanMappings[0].arabicNumber;
  }
  while (arabicNumber >= arabicToRomanMappings[1].arabicNumber) {
    romanNumeral += arabicToRomanMappings[1].romanNumeral;
    arabicNumber -= arabicToRomanMappings[1].arabicNumber;
  }
  while (arabicNumber >= arabicToRomanMappings[2].arabicNumber) {
    romanNumeral += arabicToRomanMappings[2].romanNumeral;
    arabicNumber -= arabicToRomanMappings[2].arabicNumber;
  }
  return romanNumeral;
}

Listing 8-23.Again a pattern emerges: the obvious code redundancy can be eliminated by a loop

同样,所有测试都通过了。太棒了!但是仍然有很多重复的代码,所以我们必须继续我们的重构。好消息是,我们现在可以看到所有三个while-循环的唯一区别只是数组索引。这意味着如果我们遍历数组,我们可以只进行一次while-循环。

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber) {
  std::string romanNumeral;
  for (const auto& mapping : arabicToRomanMappings) {
    while (arabicNumber >= mapping.arabicNumber) {
      romanNumeral += mapping.romanNumeral;
      arabicNumber -= mapping.arabicNumber;
    }
  }
  return romanNumeral;
}
Listing 8-24.Through the range based for-loop, the DRY principle is no more violated

所有测试都通过了。哇,太棒了!看一看这段简洁易读的代码。通过将阿拉伯数字添加到数组中,现在可以支持更多的阿拉伯数字到罗马数字的映射。我们将对 1,000 进行测试,必须将其转换为“M.”以下是我们的下一个测试:

assertThat(1000).isConvertedToRomanNumeral("M");

测试不出所料地失败了。通过将“1000-is- M”的另一个元素添加到数组中,新的测试,当然还有所有先前的测试,应该会通过。

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M" },
    {  100, "C" },
    {   10, "X" },
    {    1, "I" }
} };

这个小变化之后的一次成功的测试运行证实了我们的假设:它起作用了!这很容易。我们现在可以添加更多的测试,例如 2000 和 3000。甚至 3333 应该立即工作:

assertThat(2000).isConvertedToRomanNumeral("MM");
assertThat(3000).isConvertedToRomanNumeral("MMM");
assertThat(3333).isConvertedToRomanNumeral("MMMCCCXXXIII");

很好。我们的代码甚至可以处理这些情况。但是,还有一些罗马数字尚未实现。例如,必须转换为“V.”的 5

assertThat(5).isConvertedToRomanNumeral("V");

不出所料,这个测试失败了。有趣的问题如下:既然测试通过了,我们应该做什么?也许你可以考虑对这种情况进行特殊处理。但是这真的是一个特例吗,或者我们可以像对待以前的和已经实现的转换一样对待这次转换吗?

可能最简单的方法就是在数组的正确索引处添加一个新元素。嗯,也许值得一试…

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M" },
    {  100, "C" },
    {   10, "X" },
    {    5, "V" },
    {    1, "I" }
} };

我们的假设为真:所有测试都通过了!甚至像 6 和 37 这样的阿拉伯数字现在也可以正确地转换成罗马数字。我们通过为这些情况添加断言来验证:

  assertThat(6).isConvertedToRomanNumeral("VI");
//...
  assertThat(37).isConvertedToRomanNumeral("XXXVII");

接近终点线

毫不奇怪,我们可以对“50-is- L”和“500-is- D.”使用基本相同的方法

接下来,我们需要处理所谓的减法记数法的实现,例如,阿拉伯数字 4 必须转换成罗马数字“IV.”。我们如何优雅地实现这些特例呢?

嗯,经过短暂的考虑后,很明显这些情况并没有什么特别的!最后,当然不禁止向数组中添加映射规则,其中字符串包含两个而不是一个字符。例如,我们可以在arabicToRomanMappings数组中添加一个新的“4-is-IV”条目。也许你会说:“那不是黑吗?”不,我不这么认为。它实用而简单,不会让事情变得不必要的复杂。

因此,我们首先添加一个将失败的新测试:

assertThat(4).isConvertedToRomanNumeral("IV");

对于要通过的新测试,我们为 4 添加相应的映射规则(参见数组中的 pe nultimate 条目):

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M"  },
    {  500, "D"  },
    {  100, "C"  },
    {   50, "L"  },
    {   10, "X"  },
    {    5, "V"  },
    {    4, "IV" },
    {    1, "I"  }
} };

在我们执行了所有测试并验证它们通过之后,我们可以确定我们的解决方案也适用于 4!因此,我们可以对“9-is-IX”、“40-is-XL”、“90-is-XC”等重复这种模式。模式总是相同的,所以我没有在这里显示最终的源代码(完整代码的最终结果如下所示),但我认为这并不难理解。

搞定了。

有趣的问题是:我们什么时候知道自己完了?我们必须实现的软件已经完成了?我们可以停止运行 TDD 循环?我们真的必须通过单元测试来测试从 1 到 3999 的所有数字才能知道我们完成了吗?

简单的答案是:如果我们代码的所有需求都已经成功实现,并且我们没有找到一个新的单元测试来产生新的产品代码,那么我们就完成了!

这正是我们的 TDD 形现在的情况。我们仍然可以向测试方法中添加更多的断言;每次都可以通过测试,而不需要改变产品代码。这是 TDD 对我们“说话”的方式:“嘿,伙计,你完了!”

结果如下所示:

#include <gtest/gtest.h>

#include <string>

#include <array>

int main(int argc, char** argv) {
  testing::InitGoogleTest(&argc, argv);
  return RUN_ALL_TESTS();
}

struct ArabicToRomanMapping {
  unsigned int arabicNumber;
  std::string romanNumeral;
};

const std::size_t numberOfMappings { 13 };

using ArabicToRomanMappings = std::array<ArabicToRomanMapping, numberOfMappings>;

const ArabicToRomanMappings arabicToRomanMappings = { {
    { 1000, "M"  },
    {  900, "CM" },
    {  500, "D"  },
    {  400, "CD" },
    {  100, "C"  },
    {   90, "XC" },
    {   50, "L"  },
    {   40, "XL" },
    {   10, "X"  },
    {    9, "IX" },
    {    5, "V"  },
    {    4, "IV" },
    {    1, "I"  }
} };

std::string convertArabicNumberToRomanNumeral(unsigned int arabicNumber)

{
  std::string romanNumeral;
  for (const auto& mapping : arabicToRomanMappings) {
    while (arabicNumber >= mapping.arabicNumber) {
      romanNumeral += mapping.romanNumeral;
      arabicNumber -= mapping.arabicNumber;
    }
  }
  return romanNumeral;
}

// Test code starts here...

class RomanNumeralAssert {

public:

  RomanNumeralAssert() = delete;
  explicit RomanNumeralAssert(const unsigned int arabicNumber) :
      arabicNumberToConvert(arabicNumber) { }
  void isConvertedToRomanNumeral(const std::string& expectedRomanNumeral) const {
    ASSERT_EQ(expectedRomanNumeral, convertArabicNumberToRomanNumeral(arabicNumberToConvert));
  }

private:

  const unsigned int arabicNumberToConvert;
};

RomanNumeralAssert assertThat(const unsigned int arabicNumber) {
  return RomanNumeralAssert { arabicNumber };
}

TEST(ArabicToRomanNumeralsConverterTestCase, conversionOfArabicNumbersToRomanNumerals_Works) {
  assertThat(1).isConvertedToRomanNumeral("I");
  assertThat(2).isConvertedToRomanNumeral("II");
  assertThat(3).isConvertedToRomanNumeral("III");
  assertThat(4).isConvertedToRomanNumeral("IV");
  assertThat(5).isConvertedToRomanNumeral("V");
  assertThat(6).isConvertedToRomanNumeral("VI");
  assertThat(9).isConvertedToRomanNumeral("IX");
  assertThat(10).isConvertedToRomanNumeral("X");
  assertThat(20).isConvertedToRomanNumeral("XX");
  assertThat(30).isConvertedToRomanNumeral("XXX");
  assertThat(33).isConvertedToRomanNumeral("XXXIII");
  assertThat(37).isConvertedToRomanNumeral("XXXVII");
  assertThat(50).isConvertedToRomanNumeral("L");
  assertThat(99).isConvertedToRomanNumeral("XCIX");
  assertThat(100).isConvertedToRomanNumeral("C");
  assertThat(200).isConvertedToRomanNumeral("CC");
  assertThat(300).isConvertedToRomanNumeral("CCC");
  assertThat(499).isConvertedToRomanNumeral("CDXCIX");
  assertThat(500).isConvertedToRomanNumeral("D");
  assertThat(1000).isConvertedToRomanNumeral("M");
  assertThat(2000).isConvertedToRomanNumeral("MM");
  assertThat(2017).isConvertedToRomanNumeral("MMXVII");
  assertThat(3000).isConvertedToRomanNumeral("MMM");
  assertThat(3333).isConvertedToRomanNumeral("MMMCCCXXXIII");
  assertThat(3999).isConvertedToRomanNumeral("MMMCMXCIX");
}

Listing 8-25.This version 

has been checked-in at GitHub (URL see below) with the commit message “Done.”

Info

完整的罗马数字 Kata 的源代码,包括它的版本历史,可以在 GitHub 上找到: https://github.com/clean-cpp/book-samples/

等等!然而,仍然有一个非常重要的步骤要做:我们必须将生产代码与测试代码分开。像我们的工作台一样,我们一直在使用文件ArabicToRomanNumeralsConverterTestCase.cpp,但是现在是时候了,软件工匠必须从老虎钳上取下他完成的作品。换句话说,生产代码现在必须被移动到一个不同的、仍待创建的新文件中;但是当然单元测试应该仍然能够测试代码。

在最后的重构步骤中,可以做出一些设计决策。例如,它是保留一个独立的转换函数,还是应该将转换方法和数组包装到一个新的类中?我显然倾向于后者(将代码嵌入到一个类中),因为它是面向对象的设计,并且借助封装更容易隐藏实现细节。

无论产品代码将如何被提供并被集成到它的使用环境中(这取决于目的),我们的无缝单元测试覆盖使得不太可能因此出错。

TDD 的优势

测试驱动开发主要是用于软件组件的增量设计和开发的工具和技术。这就是为什么缩写 TDD 也经常被称为“测试驱动设计”这是一种方式,当然不是唯一的方式,在你写产品代码之前考虑你的需求或者设计。

TDD 的显著优势如下:

  • 如果做得好,TDD 会迫使你在编写软件时迈出一小步。这种方法确保您总是只需要编写几行产品代码,就可以再次达到一切正常的舒适状态。这也意味着您最多只需要几行代码就可以实现一切正常工作。这是与预先产生和改变大量产品代码的传统方法的主要区别,后者伴随着软件有时不能在几小时或几天内没有错误地编译和执行的缺点。
  • TDD 建立了一个非常快速的反馈回路。开发人员必须始终知道他们是否仍然在一个正确的系统上工作。因此,对他们来说,有一个快速的反馈回路,在一瞬间知道一切都正常工作是很重要的。复杂的系统和集成测试,尤其是如果它们仍然是手工执行的,就不能做到这一点,而且太慢了(记住第 2 章中的测试金字塔)。
  • 首先创建单元测试有助于开发人员真正考虑需要做什么。换句话说,TDD 确保代码不是简单地从大脑被砍进键盘。这很好,因为以这种方式编写的代码通常容易出错,难以阅读,有时甚至是多余的。许多开发人员通常比他们交付优秀工作的真实能力走得更快。从积极的意义上来说,TDD 是一种让开发人员慢下来的方法。不要担心,经理们,你们的开发人员放慢速度是好事,因为当高测试覆盖率显示出它的积极作用时,这将很快得到回报,开发过程中的质量和速度将显著提高。
  • 使用 TDD,无缝规范以可执行代码的形式出现。例如,用办公套件的文本处理程序用自然语言编写的规格说明是不可执行的——它们是“死工件”
  • 开发人员更加自觉和负责地处理依赖关系。如果需要另一个软件组件或者甚至是一个外部系统(例如,一个数据库),那么这种依赖性可以通过一个抽象(接口)来定义,并由一个用于测试的测试副本(也称为模拟对象)来代替。得到的软件模块(例如,类)更小,松散耦合,并且只包含通过测试所必需的代码。
  • 默认情况下,使用 TDD 的新兴产品代码将拥有 100%的单元测试覆盖率。如果 TDD 被正确地执行,那么不应该有一行产品代码不是由先前编写的单元测试激发的。

测试驱动的开发可以成为一个好的和可持续的软件设计的驱动者和推动者。如同许多其他的工具和方法一样,TDD 的实践不能保证一个好的设计。它不是解决设计问题的灵丹妙药。设计决策仍然由开发人员做出,而不是由工具做出。至少,TDD 是一种有用的方法,可以避免被认为是糟糕的设计。许多在日常工作中使用 TDD 的开发人员可以确认,使用这种方法很难产生或容忍糟糕和混乱的代码。

毫无疑问,开发人员已经完成了所有需要的功能:如果所有的单元测试都是绿色的,这意味着单元的所有需求都得到了满足,工作完成了!一个令人愉快的副作用是,它完成的质量很高。

此外,TDD 工作流还驱动着待开发单元的设计,尤其是它的界面。使用 TDD 和测试优先,API 的设计和实现由其测试用例来指导。任何试图为遗留代码编写单元测试的人都知道这有多困难。这些系统通常是“代码优先”构建的许多不方便的依赖和糟糕的 API 设计使这类系统中的测试变得复杂。如果一个软件单元很难被测试,它也很难被重用。换句话说:TDD 给出了一个软件单元可用性的早期反馈,也就是说,这个软件在它计划的执行环境中可以被集成和使用的简单程度。

什么时候我们不应该使用 TDD

最后一个问题是:我们应该使用测试优先的方法开发系统的每一部分代码吗?

我明确的回答是不!

毫无疑问:测试驱动开发是指导软件设计和实现的一个很好的实践。理论上,用这种方式开发软件系统的几乎所有部分都是可能的。作为一种积极的副作用,新兴的代码是 100%默认测试的。

但是项目的某些部分太简单、太小或者不太复杂,以至于不能证明这种方法是正确的。如果你可以快速地编写代码,因为复杂性和风险都很低,那么你当然可以这么做。这种情况的例子是没有功能的纯数据类(顺便说一下,这是一种气味,但出于其他原因;参见第 6 章中关于贫血类的部分),或者只是将两个模块耦合在一起的简单粘合代码。

此外,对于 TDD,原型 ping 可能是一项非常困难的任务。当你进入一个新的领域,或者你应该在一个没有领域经验的非常创新的环境中开发软件,你有时不确定你要走哪条路才能找到解决方案。在需求非常不稳定和模糊的项目中首先编写单元测试可能是一项极具挑战性的任务。有时候,简单快速地写下第一个基本解决方案,并在后续步骤中借助改进的单元测试来确保其质量可能会更好。

另一个 TDD 帮不上忙的大挑战是获得一个好的架构。TDD 不能取代软件系统的粗粒度结构(子系统、组件等)上的必要反映。如果你面临关于框架、库、技术或架构模式的基本决策,TDD 将不会帮助你。

对于其他任何事情,我强烈推荐 TDD。当您必须用 C++ 开发一个软件单元(如一个类)时,这种方法可以节省大量时间,避免麻烦和错误的开始。对于任何比几行代码更复杂的东西,软件工匠可以像其他开发人员不经过测试就能编写代码一样快地测试代码,如果不是更快的话。—桑德罗·曼库索

Tip

如果你想更深入地研究 C++ 的测试驱动开发,我推荐 Jeff Langr 的优秀著作《测试驱动开发的现代 C++ 编程》[Langr13]。Jeff 的书对 TDD 提供了更深入的见解,并为您提供了在 C++ 中进行 TDD 的挑战和回报的实践课程。