测试驱动开发:第一个示例
前言
写个测试,保证它通过,接着重构设计,这就是 TDD 的全部内容了。
- 啊哈,我太勤快了,这么快就更新第二篇文章了,吐槽下身份证过期了导致旅游计划被搁置 😓,疫情下太久没用过身份证了,没想到还过期了。
- 废话少说,这篇文章篇幅有些多啊,不过都是代码片段实际看起来也还好,完整代码在这里,Src 目录下的对应文件夹,可以先编译试试,体会下。
- 笔记目录,再次厚颜无耻的求下 star 或者 follow 🚩。
Soundex 类
以 TDD 的方式开发 Soundex 类,这种类可以提升应用程序的搜索能力。这个算法是将单词编码为一个字母和三个数字,它将发音相似的单词映射到相同的编码。Wiki 解释:
保留第一个字母。丢掉所有出现的 a、e、i、o、 u、y、h、w。
以数字来代替辅音(第一个字母除外):
- b、f、p、v : 1
- c、g、j、k、q、s、x、z : 2
- d、t : 3
- l : 4
- m、n : 5
- r : 6
如果相邻字母编码相同,用一个数字表示它们即可。同样,如果出现两个编码相同的字母,且它们被 h 或 w 隔开,也这样处理;但如果被元音隔开,就要编码两次。这条规则同样适用于第一个字母。
当得到一个字母和三个数字时,停止处理。如果需要,补零以对齐。
Let’s start
TDD 并非一次将所有测试全部实现,而是每次只关注一个功能点对应的单元测试,当完成后再考虑下一个需要加入系统的功能。
从宏观的角度来看,TDD 的践行步骤是:编写一个最基本单元功能的测试代码 –> 测试失败 –> 实现功能代码最低限度的使得测试代码通过测试 –> 测试成功 –> 考虑下一个基本单元功能并编写对应测试 –> …。
基于这一思路,以 Soundex 举例:最先需要实现的便是步骤一,保留第一个字母:
#include "catch2/catch.hpp"
TEST_CASE("Retain sole letter of one letter world", "[SoundexEncoding]")
{
GIVEN("A soundex var") { Soundex soundex; }
}
- 我们创建了一个 Soundex 一个对象,然后到此为止,因为现在已经编译不通过了,需要先解决这个问题:
- 只在为了使失败测试通过时才编写产品代码。
- 当测试刚好失败时,停止继续编写。编译失败也是失败。
- 只编写刚好能让一个失败测试通过的产品代码。
现在编译在已经通知未定义 Soundex 的错误:
#include "catch2/catch.hpp"
// !TODO : 当前暂时与测试代码同一个文件,当感觉代码放在同一个文件有些麻烦时,再用合适的方式迁出
/**
* @brief 实现 Soundex 类
*
*/
class Soundex
{
};
TEST_CASE("Retain sole letter of one letter world", "[SoundexEncoding]")
{
GIVEN("A soundex var") { Soundex soundex; }
}
现在继续向前添加功能,设计使 Soundex 对外提供一个 encode(string) 公共成员函数,现在代码使无法编译通过:
...
TEST_CASE("Retain sole letter of one letter world", "[SoundexEncoding]")
{
GIVEN("A soundex var")
{
Soundex soundex;
WHEN("Input one char") { auto encoded = soundex.encode("A"); }
}
}
...
修复错误,使 Soundex 支持 encode 方法:
/**
* @brief Soundex 算法类
*
*/
class Soundex
{
public:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& word) const { return ""; }
};
现在检验返回值是否符合 Soundex 算法转换后的结果:
...
WHEN("Input one char")
{
auto encoded = soundex.encode("A");
THEN("Check that the return value is correct") { REQUIRE_THAT(encoded, Catch::Equals("A")); }
}
...
现在得到了与前边编译失败不同的结果,断言失败。当然这是必然的,因为 encode 方法并没有什么实现。
/**
* @brief Soundex 算法类
*
*/
class Soundex
{
public:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return "A"; }
};
现在断言能通过了,不过功能并没有完善,不过这里展示了一个渐进式 TDD 开发的过程。
去掉不干净的代码
即使代码很短也可能存在问题,TDD 为开发提供了更好的时机去修复,即每次完成单元测试代码以及功能代码编写后都可以进行快速的增量审阅,避免小问题越积越多。
命名空间的修复
单元测试代码中,Equals 使用 Catch 命名空间比较影响阅读连贯性,使用命名空间将断言读起来像一个句子:
#include "catch2/catch.hpp"
using namespace Catch;
TEST_CASE("Retain sole letter of one letter world", "[SoundexEncoding]")
{
GIVEN("A soundex var")
{
Soundex soundex;
WHEN("Input one char")
{
auto encoded = soundex.encode("A");
THEN("Check that the return value is correct") { REQUIRE_THAT(encoded, Equals("A")); }
}
}
}
消除重复代码
重复代码对于维护成本和风险都会提升。审阅代码是需要着重优化。
在此例中没有太明显的重复,但是 "A"
这个硬编码的字符串出现了很多回,可以将 encode 方法中返回的硬编码 “A” 优化掉:
/**
* @brief Soundex 算法类
*
*/
class Soundex
{
public:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return Word; }
};
任何时候,一个完整的测试集合声明了系统中期望的行为。这里蕴含着一个潜台词:如果一个行为没有对应的测试来描述,那这个行为要么不存在,要么不是期望的(或者测试本身没有尽到描述行为的职责)。
当历经 TDD 的各个周期时,我们会使用重构来审阅设计,同时修复出现的所有问题。重构的主要关注点是提升表达能力,去除重复代码。就代码的可维护性来说,这两个点最有裨益。
增量性
对于前文的硬编码可能心存疑虑,但是我们都很清楚硬编码最多存在一小会,随着对目标描述而编写更多的测试用例,逐渐就会替换掉硬编码。
继续完善 Soundex 功能,对于现在的代码并不符 Soundex 规范,以只传递单个字符为前提,并不符合第四条即如果没有三个数字,需要补零。接下来为这个功能编写新的测试。
TEST_CASE("Pads with zeros to ensure three digits", "[SoundexEncoding]")
{
GIVEN("A soundex var")
{
Soundex soundex;
WHEN("Input one char")
{
auto encoded = soundex.encode("I");
THEN("Check that the return value pads with zeros") { REQUIRE_THAT(encoded, Equals("I000")); }
}
}
}
失败了:
/home/caolei/WorkSpace/TDD_Learning/test/Catch2/TestSoundex.cpp:45: FAILED:
REQUIRE_THAT( encoded, Equals("I000") )
with expansion:
"I" equals: "I000"
===============================================================================
test cases: 2 | 1 passed | 1 failed
assertions: 2 | 1 passed | 1 failed
让测试通过:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return Word + "000"; }
这时会发现第一条用例失败了,这时因为第一条用例与 Soundex 的规则不符合,修改其断言语句已适配规则:
TEST_CASE("Retain sole letter of one letter world", "[SoundexEncoding]")
{
GIVEN("A soundex var")
{
Soundex soundex;
WHEN("Input one char")
{
auto encoded = soundex.encode("A");
THEN("Check that the return value is correct") { REQUIRE_THAT(encoded, Equals("A000")); }
}
}
}
现在有两条相似的测试仅仅数据有些差异,但是没有关系,每个测试描述一种行为。我们不仅要确保系统按预期工作,还要让每个人知道所有既定的系统行为。
完整这个功能周期后,考虑重构代码。可以发现 encode 的实现有些让人迷惑,尤其是不清楚条款的人看到这种魔数组合叫人心生厌恶 😢,因此需要对其封装下,提取独立的方法配以意图明确的名字。同时跑一下测试用例确保重构的修改对于已有功能无影响。
/**
* @brief Soundex 算法类
*
*/
class Soundex
{
public:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return zeroPad(Word); }
private:
/**
* @brief 按照 Soundex 的规则要求进行补零
*
* @param Word
* @return std::string
*/
std::string zeroPad(const std::string& Word) const { return Word + "000"; }
};
fixture 与设置
在重构的时候,不仅要审阅产品代码,还要审阅测试。现在测试代码中存在一些重复的地方,每个测试用例的 GIVEN 都需要创建一个 Soundex 对象。有两种方式修改:
- 将两个测试用例合并入一个,公用一个 GIVEN。优点是比较方便修改,缺点如果用例比较多,测试代码会很长。
TEST_CASE("Retain sole letter of one letter world", "[SoundexEncoding]")
{
GIVEN("A soundex var")
{
Soundex soundex;
WHEN("Input one char")
{
auto encoded = soundex.encode("A");
THEN("Check that the return value is correct") { REQUIRE_THAT(encoded, Equals("A000")); }
}
// 第二个 WHEN 执行时会重新从 GIVEN 运行,不受前一个 WHEN 影响
WHEN("Input one another char")
{
auto encoded = soundex.encode("I");
THEN("Check that the return value pads with zeros") { REQUIRE_THAT(encoded, Equals("I000")); }
}
}
}
- 第二种便是使用 class fixture 方法,GIVEN 与 WHEN 存在重复,虽然本文采用的单元测试编写风格是 BDD-ScenarioGivenWhenThen 但是对于短小的测试短如果测试名称可以描述清楚其目的,那么就没有必要太啰嗦:
class FixtureSoundex
{
public:
Soundex MSoundex;
};
TEST_CASE_METHOD(FixtureSoundex, "Retain sole letter of one letter world", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("A"), Equals("A000"));
}
TEST_CASE_METHOD(FixtureSoundex, "Pads with zeros to ensure three digits", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("I"), Equals("I000"));
}
Catch2 会在运行每个测试时创建 fixture 实例。现在就可以删除测试内的局部变量 soundex。现在 Soundex 代码有些长了,是时候要把 TODO 事项解决一下。
//Include/Soundex.h
#pragma once
#include <string>
// !TODO: 暂时将实现也都放入同一个文件中比较方便修改,后续合适的时机抽出
/**
* @brief Soundex 算法类
*
*/
class Soundex
{
public:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return zeroPad(Word); }
private:
/**
* @brief 按照 Soundex 条款4 要求进行补零
*
* @param Word
* @return std::string
*/
std::string zeroPad(const std::string& Word) const { return Word + "000"; }
};
测试代码:
//test/Catch2/TestSoundex.cpp
#include "Soundex.h"
#include "catch2/catch.hpp"
#include "spdlog/spdlog.h"
using namespace Catch;
class FixtureSoundex
{
public:
Soundex MSoundex;
};
TEST_CASE_METHOD(FixtureSoundex, "Retain sole letter of one letter world", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("A"), Equals("A000"));
}
TEST_CASE_METHOD(FixtureSoundex, "Pads with zeros to ensure three digits", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("I"), Equals("I000"));
}
思索与测试驱动开发
简单地说,TDD 的周期就是写一个测试,先确保测试失败,然后编码让测试通过,接着审阅代码和打磨设计(包括测试的设计),最后确保所有测试依然通过。
接下来将要处理规则 2:即在第一个字母后,用数字替换辅音。替换规则表中字母 b 对应数字 1,以此编写用例:
TEST_CASE_METHOD(FixtureSoundex, "Replace consonants with appropriate numbers", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("Ab"), Equals("A100"));
}
不出所料的失败,接下来便是让测试通过,然后重构功能代码。在寻求解决方案时,我们并不需要找到一个通用的方法,但是也不要使用已有的处理逻辑,例如:
std::string encode(const std::string& word) const {
if (word == "Ab") return "A100";
return zeroPad(word);
}
编写了这个特例,但是看起来它就像对 Ab 进行特殊处理成 A1 后的补零输出,但是我们已有 zeroPad 了,虽说特例并非错误,我们也可以用其他的测试用例来修改这个特例,这里仅仅是列举出当我想要功能向前开发时的标准,因为一直添加特例也是可行的不是么 😀。
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const
{
auto encoded = Word.substr(0, 1);
if (Word.length() > 1) { encoded += "1"; }
return zeroPad(encoded);
}
Ops,补零出现了错误:
/home/caolei/WorkSpace/TDD_Learning/test/Catch2/TestSoundex.cpp:43: FAILED:
REQUIRE_THAT( encoded, Equals("A100") )
with expansion:
"A1000" equals: "A100"
===============================================================================
test cases: 3 | 2 passed | 1 failed
assertions: 3 | 2 passed | 1 failed
修改 zeroPad 方法以通过测试:
/**
* @brief 安装 Soundex 条款4 要求进行补零
*
* @param Word
* @return std::string
*/
std::string zeroPad(const std::string& Word) const
{
auto zerosNeeded = 4 - Word.length();
return Word + std::string(zerosNeeded, '0');
}
现在看起来不错,用例也已经通过了。但是对于 encode 方法的实现还是有些不满意,其中充斥了一些编码细节和魔数,对于不熟悉功能的其他人阅读起来是灾难的,不多废话重构它 🚀:
#pragma once
#include <string>
// !TODO: 暂时将实现也都放入同一个文件中比较方便修改,后续合适的实际抽出
/**
* @brief Soundex 算法类
*
*/
class Soundex
{
public:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return zeroPad(head(Word) + encodedDigits(Word)); }
private:
/**
* @brief 获取单词的第一个字母
*
* @param Word
* @return std::string
*/
std::string head(const std::string& Word) const { return Word.substr(0, 1); }
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
if (Word.length() > 1) return "1";
return "";
}
/**
* @brief 按照 Soundex 的规则要求进行补零
*
* @param Word
* @return std::string
*/
std::string zeroPad(const std::string& Word) const
{
auto zerosNeeded = 4 - Word.length();
return Word + std::string(zerosNeeded, '0');
}
};
以声明性的方式组织代码,使其非常易于理解。设计中非常重要的一方面是从实现(怎么做)中分离接口(做什么),这提供了迈向更高层次设计方案的跳板。
有时也会担心一些实现的细节并不是那么好:第一,是不是应该用 stringstream,而不是直接将字符串连接起来?第二,为什么不尽可能地用单独的 char?例如,为什么用 return words.substr(0, 1); 而非 return word.front();?第三,用 return std::string(); 不是比 return “”; 更好吗?
这些替代的代码方案可能更好。但这些都是过早优化(premature optimization)。这个时候,一个好的设计(接口一致且代码可读性高)更重要。一旦以牢靠的设计实现了正确的行为后,再考虑是否优化性能。
先不要考虑对于性能进行优化,还是要优先考虑好的代码设计,例如消除代码中的魔数
,取而代之一个合理名字的常量。
static const size_t MaxCodeLength{4};
......
/**
* @brief 安装 Soundex 的规则要求进行补零
*
* @param Word
* @return std::string
*/
std::string zeroPad(const std::string& Word) const
{
auto zerosNeeded = MaxCodeLength - Word.length();
return Word + std::string(zerosNeeded, '0');
}
对于 encodedDigits() 中的硬编码 1
,需要代码将字母 b 替换成 1,而写后续还要支持对其他字符的数字转换,可以通过一个合理的函数名字替代,最终的代码如下:
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
if (Word.length() > 1) return encodedDigit();
return "";
}
/**
* @brief 获取一个字符对应的数字
*
* @return std::string
*/
std::string encodedDigit() const { return "1"; }
测试驱动
现在要继续使用 TDD 来促使开发进度继续向前,首要考虑一点以此时驱动开发更多的辅音变换逻辑,使解决方案更加具有通用性,是应该继续在 Replace consonants with appropriate numbers
增加更多例子的断言判断,还是新增一个测试用例呢?
TDD 的经验法则是一个测试一个断言(参考 7.3 节,获取更多信息)。我们提倡专注测试行为,而非测试功能函数。大部分时候要遵从这一个规则。
因此对于第二个辅音编码测试并不是另一个行为,我们将它加到同一个测试用例里,同时希望当一个断言失败后其他用例可以继续执行,这里使用 CHECK 来进行检查,同时可以使用 SECTION 方式编写测试代码:
TEST_CASE_METHOD(FixtureSoundex, "Replace consonants with appropriate numbers", "[SoundexEncoding]")
{
SECTION("Test to replace the consonants in the entered words with appropriate numbers")
{
CHECK_THAT(MSoundex.encode("Ab"), Equals("A100"));
CHECK_THAT(MSoundex.encode("Ac"), Equals("A200"));
}
}
修改代码保证测试用例通过:
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
if (Word.length() > 1) return encodedDigit(Word[1]);
return "";
}
/**
* @brief 获取一个字符对应的数字
*
* @param Letter
* @return std::string
*/
std::string encodedDigit(char Letter) const
{
if (Letter == 'c') return "2";
return "1";
}
现在再添加一个测试用例体现更多的数据输入情况:
...
CHECK_THAT(MSoundex.encode("Ab"), Equals("A100"));
CHECK_THAT(MSoundex.encode("Ac"), Equals("A200"));
CHECK_THAT(MSoundex.encode("Ad"), Equals("A300"));
...
按照之前一样的步骤添加这次的功能吗?不 💣,这时就会发现功能实现代码开始有重复的逻辑产生,是时候将其重构以更加通用的实现,这里使用一个 hash 集合代替简单重复的 if 分支判断:
/**
* @brief 获取一个字符对应的数字
*
* @param Letter
* @return std::string
*/
std::string encodedDigit(char Letter) const
{
const std::unordered_map<char, std::string> encodings { { 'b', "1" }, { 'c', "2" }, { 'd', "3" } };
return encodings.find(Letter)->second;
}
应该继续将其余所有的辅音转换,都以测试驱动的方式开发么,是否应该覆盖所有可能出问题的地方 ❓
请注意我们是在进行测试驱动开发
,而非测试。TDD 着力于代码设计。测试主要用于表述你要构建的行为。TDD 过程中编写的测试大都是这个流程的附属产物。有了这些测试,在接下来改动代码时,你会更有信心。而且 TDD 的一个重要方面就是够用即可,在开发新功能即行为时,编写测试覆盖,而逻辑代码不在改变时,就不用编写测试代码了。
既然如此,那么先完成转换表:
const std::unordered_map<char, std::string> encodings {
{ 'b', "1" }, { 'f', "1" }, { 'p', "1" }, { 'v', "1" },
{ 'c', "2" }, { 'g', "2" }, { 'j', "2" }, { 'k', "2" }, { 'q', "2" },
{ 's', "2" }, { 'x', "2" }, { 'z', "2" },
{ 'd', "3" }, { 't', "3" },
{ 'l', "4" },
{ 'm', "5" }, { 'n', "5" },
{ 'r', "6" } };
关于对应的测试程序 Replace consonants with appropriate numbers
需要思考其中的三个断言是否能增加对于此功能的特性理解,或者对特殊的行为有描述。如果没有那么就可以算作重复的测试将其删除了,最终使用断言并选一个不同的辅音来进行测试:
TEST_CASE_METHOD(FixtureSoundex, "Replace consonants with appropriate numbers", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("Ax"), Equals("A200"));
}
如果出现别的情况呢
现在实现的 encodedDigit 是假定能够在 encodings 映射中找到传入的字母,之前为了以最低限度通过测试所做的假设,现在需要考虑其他的情况,即有没有可能传入的字母没有在映射中找到?发生时如何解决。
Soundex 如何解决不能识别的字母?可以通过客户或者 Wiki 百科了解到,即忽略。输入 A# 得到 A000。现在为这个行为编写一些列外的情形测试。
TEST_CASE_METHOD(FixtureSoundex, "Ignore nonrecognition characters", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("A#"), Equals("A000"));
}
修改代码以通过测试:
/**
* @brief 获取一个字符对应的数字
*
* @param Letter
* @return std::string
*/
std::string encodedDigit(char Letter) const
{
// clang-format off
const std::unordered_map<char, std::string> encodings {
{ 'b', "1" }, { 'f', "1" }, { 'p', "1" }, { 'v', "1" },
{ 'c', "2" }, { 'g', "2" }, { 'j', "2" }, { 'k', "2" }, { 'q', "2" },
{ 's', "2" }, { 'x', "2" }, { 'z', "2" },
{ 'd', "3" }, { 't', "3" },
{ 'l', "4" },
{ 'm', "5" }, { 'n', "5" },
{ 'r', "6" } };
// clang-format on
auto it = encodings.find(Letter);
return it == encodings.end() ? "" : it->second;
}
当通过 TDD 完成一个周期的功能时,往往在重构环节就可以开始考虑完善这个功能,如同此例。现在我们可以认为针对单一字符输入为前提可以正确的进行转换了。
一次只做一件事
当完成单一字符的转换,现在需要通过测试驱动开发出用以转换一个词末尾剩下的字母了。
TEST_CASE_METHOD(FixtureSoundex, "Replaces multiple consonants with digits", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("Acdl"), Equals("A234"));
}
简单的办法便是除第一个字母,遍历剩下的字母并转换。但是当前的代码结构并不容易支持,可以先重构下代码。
但是请注意,一次只做一件事。测试驱动开发时,要保持每一步都不同。在写测试时,不要跑去重构。同样,在尝试让测试通过时也不要去重构。并行总是容易出错,不是么 🐛。
为了便于重构,先将我们刚才的测试注释掉,[.]
标识会使 Catch2 跳过这个测试。
TEST_CASE_METHOD(FixtureSoundex, "Replaces multiple consonants with digits", "[.][SoundexEncoding]")
重构下当前解决方案。不要将整个词传入 encodedDigits(),而是将词尾(除了第一个字母外的其余字母)传入 encodedDigits(),这样遍历更加简洁。
...
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return zeroPad(head(Word) + encodedDigits(tail(Word))); }
...
/**
* @brief 获取单词除了第一个字母外的其余字母
*
* @param Word
* @return std::string
*/
std::string tail(const std::string& Word) const { return Word.substr(1); }
...
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
if (Word.empty()) return "";
return encodedDigit(Word.front());
}
运行下测试确保改动不会破话其他功能。回到 TDD 周期开始,启用被暂时禁掉的测试 "Replaces multiple consonants with digits"
,使其失败。
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
std::string encoding;
for (auto letter : Word)
encoding += encodedDigit(letter);
return encoding;
}
通过 for 循环遍历每个字母,这样不在需要对 Word 进行判空了,删除之。
限制长度
规则 4 声明 Soundex 编码结果必须是 4 个字符,为此行为增加新的测试。
TEST_CASE_METHOD(FixtureSoundex, "Limits length to four characters", "[SoundexEncoding]")
{
REQUIRE(MSoundex.encode("Dcdlb").length() == 4);
}
定位问题长处超出,修复问题:
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
std::string encoding;
for (auto letter : Word)
{
if (encoding.length() == MaxCodeLength - 1) break;
encoding += encodedDigit(letter);
}
return encoding;
}
重构下代码,更好的表达含义:
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
std::string encoding;
for (auto letter : Word)
{
if (isComplete(encoding)) break;
encoding += encodedDigit(letter);
}
return encoding;
}
/**
* @brief 完成字符串的 Soundex 规则编码
*
* @param Encoding
* @return true
* @return false
*/
bool isComplete(const std::string& Encoding) const { return Encoding.length() == MaxCodeLength - 1; }
丢掉元音
规则 1 说要丢掉所有的元音以及 w、h 和 y。现在为此功能添加测试用例。
TEST_CASE_METHOD(FixtureSoundex, "Ignores Vowel like letters", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("Baeiouhycdl"), Equals("B234"));
}
运行下测试发现是通过的,主要是由于 encodedDigit() 对于转换表中找不到的字母会返回空字符串。
这里需要注意下:如果没有改动类定义,测试就通过了,那背后肯定另有故事(参见 3.5),如果接下来的测试也都继续通过,那么应该考虑回滚掉代码改动。测试提前通过的原因,可能是你的步伐有点大,但这样你可能不会感受到 TDD 带来的好处。
让测试自我澄清
接下来处理两个相邻字母有相同数字编码的情况。按照规则 3,将用一个数字标识这些字母,这条规则也适用于第一个字母。先用测试描述第一个情况:
TEST_CASE_METHOD(FixtureSoundex, "Combines duplicate encodings", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("Abfcgdt"), Equals("A123"));
}
b 和 f 都编码为 1,c 和 g 编码为 2,d 和 t 编码为 3。最终 Abfcgdt –> A123。可以添加一些前置条件(precondition)断言,以帮助阅读代码的人建立这种关联。
TEST_CASE_METHOD(FixtureSoundex, "Combines duplicate encodings", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encodedDigit("b"), Equals(MSoundex.encodedDigit("f")));
REQUIRE_THAT(MSoundex.encodedDigit("c"), Equals(MSoundex.encodedDigit("g")));
REQUIRE_THAT(MSoundex.encodedDigit("d"), Equals(MSoundex.encodedDigit("t")));
REQUIRE_THAT(MSoundex.encode("Abfcgdt"), Equals("A123"));
}
这里就需要将 encodedDigit 转化成 public 接口,仁者见仁、智者见智吧,个人还是认为虽然这样更加方便了后续读取,但是代码的封装性同样重要,客户不需要的接口完全没有必要暴露出来,写个注释信息不也很好么。
/**
* @brief 获取 Encoding 最后一个字符
*
* @param Encoding
* @return std::string
*/
std::string lastDigit(const std::string& Encoding) const
{
if (Encoding.empty()) return "";
return std::string(1, Encoding.back()); // string 初始化一个字符的方法
}
跳出条条框框来测试
考虑重复的第二种情况,第二个字母与第一个字母重复。可以发现目前所有的测试都是以一个大写字母开始,其余都是小写,但这个算法应该是大小写无关的,这里可以先把重复的测试暂停,先处理下大小写的测试。
为了能够快速、简单地比较,Soundex 算法将类似的词编码至相同的代码。字母的大小写并不影响发音。但为了简化比较 Soundex 编码,我们将自始至终使用一样的写法。
TEST_CASE_METHOD(FixtureSoundex, "Upper cases first letter", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("abcd"), StartsWith("A"));
}
修改 encode 将首字母大写:
/**
* @brief 按照 Soundex 算法转换输入的字符串内容
*
* @param word
* @return std::string
*/
std::string encode(const std::string& Word) const { return zeroPad(upperFront(head(Word)) + encodedDigits(tail(Word))); }
.....
/**
* @brief 返回传入的字符串首字母大写
*
* @param String
* @return std::string
*/
std::string upperFront(const std::string& String) const
{
return std::string(1, std::toupper(static_cast<unsigned char>(String.front())));
}
还可以修改 “Ignores Vowel like letters” 用例来确认功能:
TEST_CASE_METHOD(FixtureSoundex, "Ignores Vowel like letters", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("BaAeEiIoOuUhHyYcdl"), Equals("B234"));
}
- encodedDigits() 中的代码有点隐晦和难以理解。我们必须深入考虑来发现以下几点:
- 许多字母没有对应的编码;
- encodedDigit() 对于上述字母会返回空字符串;
- 将一个空字符串和 encodedDigits() 中的变量 encodings 连接起来没有任何意义。
重构一下,以便代码更加明了。将删除空字符串使用一个特殊的字符传替代,显式判断无用的字符不在进行空字符串拼接。
const std::string NotADigit("*");
.....
std::string encodedDigit(char Letter) const
{
.....
auto it = encodings.find(Letter);
return it == encodings.end() ? NotADigit : it->second;
}
.....
std::string encodedDigits(const std::string& Word) const
{
std::string encoding;
for (auto letter : Word)
{
if (isComplete(encoding)) break;
auto digit = encodedDigit(letter);
if (digit != NotADigit && digit != lastDigit(encoding)) { encoding += digit; }
}
return encoding;
}
.....
std::string lastDigit(const std::string& Encoding) const
{
if (Encoding.empty()) return NotADigit;
return std::string(1, Encoding.back()); // string 初始化一个字符的方法
}
接下来处理一个辅音大小写的测试:
TEST_CASE_METHOD(FixtureSoundex, "Ignores case when encoding consonants", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("BCDL"), Equals(MSoundex.encode("Bcdl")));
}
它声明了对”BCDL”和”Bcdl”的编码结果是一样的。也就是说,我们并不关心实际的编码是什么,只要大写的输入和小写的输入得到的结果一样就行。
/**
* @brief 获取一个字符对应的数字
*
* @param Letter
* @return std::string
*/
std::string encodedDigit(char Letter) const
{
......
auto it = encodings.find(lower(Letter));
return it == encodings.end() ? NotADigit : it->second;
}
private:
.......
/**
* @brief 将输入的 char 字符转换为小写
*
* @param C
* @return char
*/
char lower(char C) const { return std::tolower(static_cast<unsigned char>(C)); }
言归正传
现在开始处理第二个字符与第一个字符相同的情况。这个就是促使我们先将算法改写为大小写无关的,现在编写这个行为测试。
TEST_CASE_METHOD(FixtureSoundex, "Combines duplicate codes when 2nd letter duplicates 1st", "[SoundexEncoding]")
{
REQUIRE_THAT(MSoundex.encode("Bbcd"), Equals("B230"));
}
实现方法也很简单,就是将需要编码的词从之前的之将词尾传入 encodedDigits() 改为将整个词都传入其中,使其可以先分析首字母转换完成后的数字,然后比对第二个字母是否需要丢弃:
......
std::string encode(const std::string& Word) const { return zeroPad(upperFront(head(Word)) + tail(encodedDigits(Word))); }
.....
std::string encodedDigits(const std::string& Word) const
{
std::string encoding;
encoding += encodedDigit(Word.front());
for (auto letter : Word)
{
if (isComplete(encoding)) break;
auto digit = encodedDigit(letter);
if (digit != NotADigit && digit != lastDigit(encoding)) { encoding += digit; }
}
return encoding;
}
.....
重构至单一责任的函数
是时候考虑重构了,考虑单一责任原则,函数 encodedDigits() 变得很复杂,它通过两个步骤完成算法:
- 它首先将首字母的编码追加至变量 encoding 中;
- 然后遍历剩下的字母,追加结果至 encoding。
可以将 encodedDigits() 中的两个步骤提取成两个单独的函数,每个函数各自包含一个抽象概念的实现细节。如此,encodedDigits() 中的代码只是声明了解决方案的策略。
/**
* @brief 获取首字母后其他字符转化的对应数字
*
* @param Word
* @return std::string
*/
std::string encodedDigits(const std::string& Word) const
{
std::string encoding;
encodeHead(encoding, Word);
encodeTail(encoding, Word);
return encoding;
}
/**
* @brief 对词首字母进行编码翻译
*
* @param Encoding
* @param Word
*/
void encodeHead(std::string& Encoding, const std::string& Word) const { Encoding += encodedDigit(Word.front()); }
/**
* @brief 对词尾字符进行编码翻译
*
* @param Encoding
* @param Word
*/
void encodeTail(std::string& Encoding, const std::string& Word) const
{
for (auto letter : tail(Word))
{
if (isComplete(Encoding)) break;
auto digit = encodedDigit(letter);
if (digit != NotADigit && digit != lastDigit(Encoding)) Encoding += digit;
}
}
将 encodeTail() 中的 for 循环体提取出来。
/**
* @brief 对词尾字符进行编码翻译
*
* @param Encoding
* @param Word
*/
void encodeTail(std::string& Encoding, const std::string& Word) const
{
for (auto letter : tail(Word))
{
if (!isComplete(Encoding)) { encodeLetter(Encoding, letter); }
}
}
/**
* @brief 翻译单一字符 letter 并拼接到 Encoding
*
* @param Encoding
* @param Letter
*/
void encodeLetter(std::string& Encoding, char Letter) const
{
auto digit = encodedDigit(Letter);
if (digit != NotADigit && digit != lastDigit(Encoding)) { Encoding += digit; }
}
还有许多优化,详情可以看看笔记配套的程序代码。
收尾工作
那元音怎么办呢?规则 3 说被一个元音(不是 h 或 w )分开的相同编码,应该编码两次。
TEST_CASE_METHOD(FixtureSoundex, "Does not combine duplicate encodings separate by vowels")
{
REQUIRE_THAT(MSoundex.encode("Jbob"), Equals("J110"));
}
修改了 encodeLetter() 中的条件表达式,在不是重复编码或最后一个字母是元音的情况下,追加一个数字。这个声明也敦促了一些其他的相应改动。
/**
* @brief 对词尾字符进行编码翻译
*
* @param Encoding
* @param Word
*/
void encodeTail(std::string& Encoding, const std::string& Word) const
{
for (auto i = 1u; i < Word.length(); i++)
{
if (!isComplete(Encoding)) { encodeLetter(Encoding, Word[i], Word[i - 1]); }
}
}
/**
* @brief 翻译单一字符 letter 并拼接到 Encoding
*
* @param Encoding
* @param Letter
*/
void encodeLetter(std::string& Encoding, char Letter, char LastLetter) const
{
auto digit = encodedDigit(Letter);
if (digit != NotADigit && (isDuplicateLetter(Encoding, digit) || isVowel(LastLetter))) { Encoding += digit; }
}
/**
* @brief 判断是否是元音字符
*
* @param Letter
* @return true
* @return false
*/
bool isVowel(char Letter) const { return std::string("aeiouy").find(lower(Letter)) != std::string::npos; }
漏了什么测试码
我们很少拥有所有的规范。很少有人会如此幸运。即便是 Soundex 的规则,看似完整,实际却不能蕴含所有情形。在编程过程中,一些测试或代码实现经常会激发我们进行其他方面的思考。一般而言,要么把这些思考结果记在脑子里,要么写到一个列表或记事本中。下面就是对 Soundex 进行思考而得到的列表。
- 若给定的词中含有分隔符,如句点(例如,Mr.Smith),该怎么办?应该忽略它们(就像现在做的这样),抛出一个异常(假定客户应该把词合理地分好),还是做些其他操作?说到异常,怎样以测试驱动的方法在代码中加入异常处理?在 4.4.5 节中,你将学到怎样设计期望抛出异常的测试。
- 空字符串该怎样编码?(或者说,可以假定不会接收到一个空字符串输入吗?)
- 该怎样处理非英语字母中的辅音(如 ñ)?Soundex 算法依然适用吗?isVowel()函数需要支持带变音符的元音吗?
如何解决这些问题应当适时的做出自己的决定,最好的方法往往时问问客户。
解决方案
一个解决方案应具备的一些重要特征。
- 它实现了客户的需求。如果没有,那么不管怎样,它都不是好的解决方案。在 TDD 中,你编写的测试能够帮助你了解你的解决方案是不是客户要的。性能可能是众多客户需求中的一项。你的一部分职责就是理解他们的性能需求,如果没必要的话,就不要花费时间去做性能优化。
- 它可以工作。如果一个解决方案有大量的缺陷,那么构建得再优雅,也不是好的解决方案。TDD 可以帮助确定我们交付的软件能以期望的方式工作。TDD 不是银弹。你交付的软件依然会有缺陷,所以照样需要许多其他方式的测试。但是,TDD 会让你发布的代码包含非常少的缺陷。
- 它易于理解。对于编写得不好的代码,每个人都需要花费大量的时间去理解。TDD 让你可以安全地重新组织代码以提高可读性。
- 它易于修改。通常,容易修改的代码意味着高质量的设计。TDD 使你可以持续地修改,以保持设计的高质量。
END
至此,便完成了一个实际的 TDD 过程。
总结
- TDD 的践行步骤:
- 编译一个最基本单元功能的测试代码。
- 测试失败。
- 实现功能代码最低限度使得测试代码通过。
- 测试成功。
- goto ⬆ 第一步。
- TDD 的各个周期都会使用重构来审阅设计。
践行步骤解析
编写最基本的单元功能测试代码:请谨记我们践行的是 TDD,而非单纯的测试工作,应该测试的是代码行为,当向测试代码添加重复的断言语句时应该自问下这个断言是否可以提升对测试含义以及功能的理解。
测试失败:每次以最小的步伐向前推进,引起问题后就去解决掉它。
实现功能代码最低限度的使得测试代码通过:
最低限度
即刚刚好满足测试用例所需要的功能,即使是通过特例来完成的。就像 Soundex 中出现过多次特例一样,经常使用特例的方法来快速通过测试代码。- 但是请注意特例也不是万能的,就像文档中我所讲的 不要使用已有的处理逻辑,避免使用大量的特例浪费时间,当第二次添加的测试用例,就不可以再使用上一次一样的特例方法了,应该寻求更加通用的实现方法。这一步不仅仅是重构,也是作为我们代码开发前进的动力,是对行为的一种完善。
测试成功:必然保证之前失败的用例要正确的通过,且没有影响已有的用例。
审视代码进行重构:重构不仅仅是某个单一步骤执行,而是当历经 TDD 的各个周期时,我们会使用重构来审阅设计,同时修复出现的所有问题。重构的主要关注点是提升表达能力,去除重复代码。就代码的可维护性来说,这两个点最有裨益。
一次只进行一件事:当发现一个行为的实现可能需要先重构下代码才能更方便的支持,那么就要先将这个行为失败的测试描述先注释掉。当完成重构后再打开并完成。一次只做一件事。测试驱动开发时,要保持每一步都不同。再写测试时,不要跑去重构。同样,在尝试让测试通过时也不要去重构。
如果出现并未主动实现对应行为代码,新增加的测试用例确成功了这时就要警惕:如果接下来的测试也都继续通过,那么应该考虑回滚掉代码改动。测试提前通过的原因,可能是你的步伐有点大,但这样你可能不会感受到 TDD 带来的好处。
- 一个解决方案应具备的一些重要特征。
- 它实现了客户的需求。如果没有,那么不管怎样,它都不是好的解决方案。在 TDD 中,你编写的测试能够帮助你了解你的解决方案是不是客户要的。性能可能是众多客户需求中的一项。你的一部分职责就是理解他们的性能需求,如果没必要的话,就不要花费时间去做性能优化。
- 它可以工作。如果一个解决方案有大量的缺陷,那么构建得再优雅,也不是好的解决方案。TDD 可以帮助确定我们交付的软件能以期望的方式工作。TDD 不是银弹。你交付的软件依然会有缺陷,所以照样需要许多其他方式的测试。但是,TDD 会让你发布的代码包含非常少的缺陷。
- 它易于理解。对于编写得不好的代码,每个人都需要花费大量的时间去理解。TDD 让你可以安全地重新组织代码以提高可读性。
- 它易于修改。通常,容易修改的代码意味着高质量的设计。TDD 使你可以持续地修改,以保持设计的高质量。
TDD Q&A
有时也会担心一些实现的细节并不是那么好,是否应该再重构是考虑优化?
- 再未完全实现功能设计之前,这些都属于过早优化。这个时候一个好的设计更重要,一旦以牢靠的设计实现了正确的行为后,并且有完整的单元测试来给你提供勇气后再进行优化吧!
没有被单元测试代码所描述的行为算正确还是错误?
- 任何时候,一个完整的测试集合声明了系统中期望的行为。这里蕴含着一个潜台词:如果一个行为没有对应的测试来描述,那这个行为要么不存在,要么不是期望的(或者测试本身没有尽到描述行为的职责)。
对于有大量的数据细节测试,例如 Soundex 中的辅音表,是否需要将所有的表信息都进行单元测试覆盖?
- 请注意我们实在进行
测试驱动开发
,而非测试。TDD 着力于代码设计。测试主要用于表述你要构建的行为。TDD 过程中编写的测试大都是这个流程的附属产物。有了这些测试,在接下来改动代码时,你会更有信心。而且 TDD 的一个重要方面就是够用即可,在开发新功能即行为时,编写测试覆盖,而逻辑代码不在改变时,就不用编写测试代码了。 - 也就是说如果你读取这个表数据的行为单一,那么它就不需要全部都覆盖到。
- 请注意我们实在进行