Skip to content

Latest commit

 

History

History
883 lines (603 loc) · 38.2 KB

File metadata and controls

883 lines (603 loc) · 38.2 KB

九、面向函数式编程的测试驱动开发

测试驱动开发 ( TDD )是一种非常有用的软件设计方法。方法如下——我们首先编写一个失败的测试,然后实现最小的代码使测试通过,最后重构。我们以快速连续的小周期来做这件事。

我们将研究纯函数如何简化测试,并提供一个将 TDD 应用于函数的例子。纯函数允许我们编写简单的测试,因为它们总是为相同的输入参数返回相同的值;因此,它们相当于大数据表。因此,我们可以编写测试来模拟输入和预期输出的数据表。

本章将涵盖以下主题:

  • 如何使用数据驱动测试来利用纯函数
  • 了解 TDD 周期的基础知识
  • 如何用 TDD 设计一个纯函数

技术要求

你将需要一个支持 C++ 17 的编译器。我用的是 GCC 7.3.0

代码可以在的 GitHub 上找到。com/ PacktPublishing/动手-函数-用- Cpp 编程Chapter09文件夹中的。它包括并使用doctest,这是一个单头开源单元测试库。你可以在它的 GitHub 资源库上找到它。com/ onqtam/ doctest

功能编程的 TDD

20 世纪 50 年代的编程与我们今天所知道的非常不同。我们现在所知道的程序员的工作被分成三个角色。程序员会编写要实现的算法。然后,一个专门的打字员会用一台特殊的机器把它打成穿孔卡片。然后程序员不得不手动验证穿孔卡是否正确——尽管有数百张。一旦高兴穿孔卡是正确的,程序员就会把它们带给主机操作员。因为现存的唯一的计算机都很大而且非常昂贵,所以花在计算机上的时间必须得到保护。主机操作员负责计算机,确保最重要的任务优先,因此一个新程序可能要等几天才能运行。一旦运行,程序将打印一个完整的堆栈跟踪。如果有错误,程序员必须看一张写满奇怪符号的很长的纸,找出可能是什么错误。这个过程缓慢,容易出错,而且不可预测。

然而,一些工程师想出了一个主意。如果他们没有从一个失败的程序中得到复杂的读数,而是得到一个错误的明确指示呢?他们决定开始编写额外的代码来检查生产代码,并产生一个通过或失败的输出。他们会运行单元测试,而不是运行程序,或者除了运行程序之外。

随着终端的发明,以及后来的个人电脑和强大的调试器的出现,一旦程序员的反馈循环变短,单元测试的实践就被遗忘了。然而,它从未完全消失,它突然以不同的形式回来了。

在 20 世纪 90 年代,单元测试出人意料地再次出现。一群程序员,包括肯特·贝克、沃德·坎宁安和罗恩·杰弗里斯,尝试将开发实践发挥到极致。他们努力的结果叫做极限编程 ( XP )。其中一个实践是单元测试,结果非常有趣。

单元测试的常见做法是在编写代码后编写一些测试,作为测试周期的一部分。这些测试通常是由测试人员编写的——他们与实现这些特性的程序员是不同的群体。

然而,最初的 XPers 以不同的方式尝试了单元测试。如果我们将测试和代码一起编写呢?更有趣的是,如果我们在实现之前编写测试会怎么样?这导致了两种不同的技术——测试优先编程 ( TFP ),包括先写几个测试,然后写一些代码让测试通过,还有 TDD,我们会更详细地讨论。

当我第一次听说这些技术时,我既困惑又着迷。你怎么能为不存在的东西编写测试呢?会有什么好处?幸运的是,在 J.B. Rainsberger 的支持下,我很快意识到了 TFP/TDD 的威力。我们的客户和利益相关者希望尽快在软件中实现工作特性。然而,他们往往无法解释自己想要什么样的功能。从测试开始意味着你完全理解要实现什么,并引导有用和有趣的对话来阐明需求。一旦需求明确,我们就可以专注于实现。此外,在 TDD 中,我们会尽快清理代码,这样我们就不会在久而久之制造混乱。这确实是一种令人惊讶的强大技术!

但是让我们从头开始。我们如何编写单元测试?更重要的是,对于我们的目的来说,为纯函数编写单元测试更容易吗?

纯函数的单元测试

让我们首先看看单元测试是什么样子的。我已经在这本书里使用它们有一段时间了,我相信你已经理解了代码。但是现在是时候看一个特别的例子了:

TEST_CASE("Greater Than"){
    int first = 3;
    int second = 2;

    bool result = greater<int>()(first, second);

    CHECK(result);
}

我们首先用特定的值初始化两个变量(单元测试的排列部分)。然后我们称之为生产代码(单元测试的法案部分)。最后,我们检查结果是否是我们期望的(单元测试的断言部分)。我们正在使用的名为doctest的库为允许我们编写单元测试的宏提供了实现。虽然 C++ 有更多的单元测试库,包括 GTest 和Boost::unit_test在内的著名例子,但是它们提供给程序员的工具非常相似。

当谈论单元测试时,更重要的是找出使它们有用的特征。之前的测试规模小,重点突出,速度快,失败的原因只有一个。所有这些特性使测试变得有用,因为它易于编写、易于维护、清晰明了,并且如果引入了错误,还能提供有用且快速的反馈。

就技术而言,前面的测试是基于示例的,因为它使用了一个非常具体的示例来检查代码的特定行为。我们将在第 11 章基于属性的测试中看到一种不同的单元测试方法,称为基于属性的测试。由于这是一个基于示例的测试,一个有趣的问题出现了:如果我们要测试greaterThan函数,还有哪些示例会很有趣?

好吧,我们想看看函数的所有可能的行为。那么,它可能的输出是什么?这里有一个列表:

  • 如果第一个值大于第二个值,则为 True
  • 如果第一个值小于第二个值,则为 False

然而,这还不够。让我们添加边缘案例:

  • 如果第一个值等于第二个值,则为 False

我们不要忘记可能的错误。传入值的域是什么?传递负值可以吗?浮点值?复数?这是与该职能部门的利益相关者进行的一次有趣的对话。

现在让我们假设最简单的情况——函数将只接受有效的整数。这意味着我们还需要两个单元测试来检查第一个参数小于第二个参数以及两个参数相等的情况:

TEST_CASE("Not Greater Than when first is less than second"){
    int first = 2;
    int second = 3;

    bool result = greater<int>()(first, second);

    CHECK_FALSE(result);
}

TEST_CASE("Not Greater Than when first equals second"){
    int first = 2;

    bool result = greater<int>()(first, first);

    CHECK_FALSE(result);
}

第 7 章用功能操作消除重复中,我们讨论了代码相似性以及如何消除它。这里,我们有一个测试之间相似的例子。去除它的一种方法是编写所谓的数据驱动测试 ( DDT )。在滴滴涕中,我们写下输入和预期输出的清单,并在每一行数据上重复测试。不同的测试框架提供了不同的方法来编写这些测试;就目前而言,doctest对滴滴涕的支持有限,但我们仍然可以把它们写成如下:

TEST_CASE("Greater than") {
    struct Data {
        int first;
        int second;
        bool expected;
 } data;

    SUBCASE("2 is greater than 1") { data.first = 2; data.second = 1; 
        data.expected = true; }
    SUBCASE("2 is not greater than 2") { data.first = 2; data.second = 
         2; data.expected = false; }
    SUBCASE("2 is not greater than 3") { data.first = 2; data.second = 
         3; data.expected = false; }

    CAPTURE(data);

    CHECK_EQ(greaterThan(data.first, data.second), data.expected);
}

如果我们忽略管道代码(定义struct Data和调用CAPTURE宏),这显示了一种非常方便的编写测试的方式——尤其是对于纯函数。根据定义,如果纯函数在接收相同的输入时返回相同的输出,那么用输入/输出列表来测试它们是很自然的。

滴滴涕的另一个便利之处是,我们只需在列表中添加一行新内容,就可以轻松添加新的测试。这尤其有助于我们在使用纯函数进行 TDD 时。

TDD 周期

TDD 是一个开发周期,通常呈现如下:

  • 红色:写测试失败。
  • 绿色:通过对生产代码进行尽可能小的更改,使测试通过。
  • 重构:重新组织代码以包含新引入的行为。

然而,TDD 从业者(比如我自己)会热衷于提及 TDD 周期从另一个步骤开始——思考。更准确地说,在编写第一个测试之前,让我们了解我们试图实现什么,并在现有代码中找到一个好的地方来添加行为。

这个循环看似简单。然而,初学者经常纠结于第一个测试应该是什么,之后的测试应该是什么,以及编写过于复杂的代码。重构本身就是一门艺术,需要代码气味、设计原则和设计模式的知识。总的来说,最大的错误是想太多你想要获得的代码结构,并编写导致这种情况的测试。

相反,TDD 需要心态的改变。我们从行为开始,一小步一小步地完善适合行为的代码结构。一个好的练习者的步数会小于 15 分钟。但这并不是 TDD 的唯一惊喜。

TDD 最大的惊喜是,它可以让你探索同一问题的各种解决方案,从而教会你软件设计。你愿意探索的解决方案越多,你设计代码的能力就越强。当带着适当的好奇心练习时,TDD 是一种持续的学习体验。

希望我让你对 TDD 产生了好奇。关于这个话题还有很多需要研究的,但是,对于我们的目标来说,尝试一个例子就足够了。既然我们谈论的是函数式编程,我们将使用 TDD 来设计一个纯函数。

示例–使用 TDD 设计纯函数

我们再次需要一个问题来展示 TDD 的实际应用。因为我喜欢用游戏练习开发实践,所以我浏览了编码道场武士刀(http://codingdojo.org/kata/PokerHands/)的列表,并为这个练习选择了扑克手问题。

扑克牌的问题

这个问题的描述如下——给定两手或更多手扑克,我们需要比较它们,并返回排名较高的一手以及它获胜的原因。

每手牌有五张牌,这些牌是从一副普通的 52 张牌中挑选出来的。这副牌由四套花色组成——梅花、钻石、红心和黑桃。每一套以2开始,以一张王牌结束,表示如下——23456789TJQKA ( T表示 10)。

扑克牌中的牌会形成队形。一手牌的价值由这些阵型决定,按以下降序排列:

  • 同花顺:同花色连续取值的五张牌。例如2♠3♠4♠5♠6♠。起始值越高,同花顺越有价值。
  • 四张一类:四张同值的牌。最高的一张是四张王牌— A♣A♠A♦A♥
  • 满堂红:三张同值的牌,另外两张同值(但不同)的牌。最高的一个如下——A♣A♠A♦K♥K♠
  • 同花:同花色五张牌。例如— 2♠3♠5♠6♠9♠
  • :连续值五张牌。例如— 2♣3♠4♥5♣6♦
  • 三张一类:三张同值的卡。例如— 2♣2♠2♥
  • 两对:见对。例如— 2♣2♠3♥3♣
  • 配对:两张相同价值的卡。例如— 2♣2♠
  • 高牌:当没有其他阵型出现时,比较每手牌的最高牌,最高者获胜。如果最高的牌具有相同的值,则比较次高的牌,以此类推。

要求

我们的目标是实现一个程序,比较两个或更多的扑克手,并返回赢家和原因。例如,让我们使用以下输入:

  • 玩家 1 : *2♥ 4♦ 7♣ 9♠ K♦*
  • 玩家 2 : *2♠ 4♥ 8♣ 9♠ A♥*

对于这个输入,我们应该得到以下输出:

  • 玩家 2 以他们的高牌获胜——一张王牌

第一步——思考

让我们更详细地看看这个问题。更准确地说,我们试图将问题分解成更小的部分,而不需要过多考虑实现。我发现查看输入和输出的可能例子是有用的,并且从一个简化的问题开始,它允许我实现一些尽可能快的工作,同时保留问题的本质。

很明显,我们有大量的组合需要测试。那么,对于限制我们测试用例的问题,什么是有用的简化呢?

一个显而易见的方法是从一只较短的手开始。我们可以从手中的一张牌开始,而不是五张牌。这将我们的规则限制在高牌。下一步是两张牌,介绍对>高牌,和高牌对>低牌对等等。

另一种方法是从五张牌开始,但要限制规则。从一张高牌开始,然后实施一对,再实施两对,以此类推;或者,反过来,从同花顺一直往下到对子和高牌。

关于 TDD 有趣的事情是,这些道路中的任何一条都将导致以相同方式工作的结果,尽管通常使用不同的代码结构。TDD 的功能之一是通过改变测试的顺序来帮助您访问同一问题的多个设计。

不用说,这个问题我以前也做过,但一直都是从一张卡在手开始的。让我们找点乐子,尝试不同的方式,好吗?我选择跟五张牌,从同花开始。为了简单起见,我现在只支持两个玩家,因为我喜欢给他们命名,所以我将使用爱丽丝和鲍勃。

例子

对于这种情况,有哪些有趣的例子?让我们首先考虑可能的输出:

  • 爱丽丝以同花顺获胜。
  • 鲍勃以同花顺获胜。
  • 爱丽丝和鲍勃有同样好的同花顺。
  • 尚未决定(如尚未实施)。

现在,让我们为这些输出写一些输入示例:

Case 1: Alice wins

Inputs:
 Alice: 2♠, 3♠, 4♠, 5♠, 6♠
 Bob: 2♣, 4♦, 7♥, 9♠, A♥

Output:
 Alice wins with straight flush

Case 2: Bob wins

Inputs:
    Alice: 2♠, 3♠, 4♠, 5♠, 9♠
    Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
    Bob wins with straight flush

Case 3: Alice wins with a higher straight flush

Inputs:
    Alice: 3♠, 4♠, 5♠, 6♠, 7♠
    Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
    Alice wins with straight flush

Case 4: Draw

Inputs:
    Alice: 3♠, 4♠, 5♠, 6♠, 7♠
    Bob: 3♣, 4♣, 5♣, 6♣, 7♣

Output:
    Draw (equal straight flushes)

Case 5: Undecided

Inputs:
    Alice: 3♠, 3♣, 5♠, 6♠, 7♠
    Bob: 3♣, 4♣, 6♣, 6♥, 7♣

Output:
    Not implemented yet.

有了这些例子,我们就可以开始写我们的第一个测试了!

第一次测试

根据我们之前的分析,我们的第一个测试如下:

Case 1: Alice wins

Inputs:
 Alice: 2♠, 3♠, 4♠, 5♠, 6♠
 Bob: 2♣, 4♦, 7♥, 9♠, A♥

Output:
 Alice wins with straight flush

我们来写吧!我们预计这次测试会失败,所以我们现在可以做任何我们想做的事情。我们需要用前面的卡片初始化两只手。现在,我们将使用vector<string>来表示每只手。然后,我们将调用一个函数(它还不存在),我们想象它将在某个时候实现两只手之间的比较。最后,我们对照之前定义的预期输出消息检查结果:

TEST_CASE("Alice wins with straight flush"){
    vector<string> aliceHand{"2♠", "3♠", "4♠", "5♠", "6♠"};
    vector<string> bobHand{"2♣", "4♦", "7♥", "9♠", "A♥"};

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Alice wins with straight flush", result);
}

目前,这个测试没有编译,因为我们甚至还没有创建comparePokerHands函数。是时候向前看了。

通过第一次测试

我们先写函数。函数需要返回一些东西,所以我们现在只返回空字符串:

auto comparePokerHands = [](const auto& aliceHand, const auto& bobHand){
    return "";
};

让测试通过的最简单的实现是什么?这就是 TDD 变得更奇怪的地方。通过测试的最简单实现是将预期结果作为硬编码值返回:

auto comparePokerHands = [](const auto& aliceHand, const auto& bobHand){
    return "Alice wins with straight flush";
};

此时,我的编译器会抱怨,因为我打开了所有警告,并将所有警告报告为错误。编译器注意到我们没有使用这两个参数并发出抱怨。这是一个有效的投诉,但我计划很快开始使用这些论点。C++ 语言为我们提供了一个简单的解决方案——只需删除或注释掉参数名称,如以下代码所示:

auto comparePokerHands = [](const auto& /*aliceHand*/, const auto&  
    /*bobHand*/){
        return "Alice wins with straight flush";
};

我们进行测试,第一次测试通过了!酷,有东西起作用了!

重构

有什么需要重构的吗?我们有两个带注释的参数名,我通常会删除它们,因为带注释的代码很混乱。但是,我已经决定暂时保留它们,因为我知道我们很快就会用到它们。

我们还有一个副本——相同的Alice wins with straight flush字符串出现在测试和实现中。将其作为常量或公共变量提取值得吗?如果这是我们实现的结束,那么当然。但我知道,字符串实际上是由多种东西构建的——获胜玩家的名字,以及基于哪手牌获胜的规则。我想保持这个原样一段时间。

因此,没有什么需要重构的。所以,我们继续吧!

再想想

目前的执行情况令人失望。仅仅返回硬编码值并不能解决很多问题。还是真的?

这是学习 TDD 时需要的心态转变。我知道是因为我经历过。我非常习惯于看最终结果,将这个解决方案与我试图实现的目标进行比较,感觉并不令人满意。然而,有一种不同的方式来看待它——我们有一些可行的东西,我们有最简单的可能实现。还有很长的路要走,但是我们已经可以向我们的利益相关者展示一些东西了。此外,正如我们将看到的,我们将始终建立在坚实的基础上,因为我们编写的代码已经过全面测试。这两件事是令人难以置信的解放;我只能希望你在试用 TDD 的时候也会有同样的感受。

但是,我们下一步怎么办?我们有几个选择。

首先,我们可以写另一个测试,爱丽丝以同花顺获胜。然而,这不会改变实现中的任何事情,测试将立即通过。虽然这似乎违背了 TDD 周期,但为我们的想法增加更多的测试并没有错。绝对是一个有效的选择。

其次,我们可以进入下一个测试,在这个测试中,鲍勃以同花顺获胜。这肯定会改变一些事情。

这两个选项都很好,你可以选择其中任何一个。但是既然我们想在实践中看到滴滴涕,让我们先写更多的测试。

更多测试

很容易把我们的测试变成滴滴涕,增加更多的病例。我们将改变爱丽丝手的值,同时保持鲍勃的手完好无损。结果如下:

TEST_CASE("Alice wins with straight flush"){
    vector<string> aliceHand;
    const vector<string> bobHand {"2♣", "4♦", "7♥", "9♠", "A♥"};

    SUBCASE("2 based straight flush"){
        aliceHand = {"2♠", "3♠", "4♠", "5♠", "6♠"};
    };
    SUBCASE("3 based straight flush"){
        aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
    };
    SUBCASE("4 based straight flush"){
        aliceHand = {"4♠", "5♠", "6♠", "7♠", "8♠"};
    };
    SUBCASE("10 based straight flush"){
        aliceHand = {"T♠", "J♠", "Q♠", "K♠", "A♠"};
    };

    CAPTURE(aliceHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Alice wins with straight flush", result);
}

所有这些测试再次通过。是时候进行下一次测试了。

次好的

我们描述的第二个测试是当鲍勃同花顺获胜时:

Case: Bob wins

Inputs:
 Alice: 2♠, 3♠, 4♠, 5♠, 9♠
 Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
 Bob wins with straight flush

我们来写吧!这一次,让我们从一开始就使用数据驱动格式:

TEST_CASE("Bob wins with straight flush"){
    const vector<string> aliceHand{"2♠", "3♠", "4♠", "5♠", "9♠"};
    vector<string> bobHand;

    SUBCASE("2 based straight flush"){
        bobHand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

当我们运行这个测试时,它失败了,原因很简单——我们有一个硬编码的实现,说爱丽丝赢了。现在怎么办?

通过测试

再一次,我们需要找到最简单的方法让这个测试通过。即使我们不喜欢这个实现,下一步也是清理混乱。那么,最简单的实现是什么?

我们显然需要在实现中引入一个条件语句。问题是,我们应该检查什么?

再说一次,我们有几个选择。一种选择是再次假装,使用一些简单的东西,比如将鲍勃的手与我们期望获胜的确切手进行比较:

auto comparePokerHands = [](const vector<string>& /*aliceHand*/, const vector<string>& bobHand){
    const vector<string> winningBobHand {"2♣", "3♣", "4♣", "5♣", "6♣"};
    if(bobHand == winningBobHand){
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

为了让它编译,我们还必须让vector<string>指针的类型到处出现。一旦做出这些改变,测试就通过了。

我们的第二个选择是开始对同花顺进行实际检查。然而,这本身是一个小问题,要正确地做到这一点需要更多的测试。

现在我将选择第一个选项,重构,然后开始更深入地研究检查的实现。

重构

有什么需要重构的吗?我们仍然有重复的字符串。此外,我们在包含鲍勃手的向量中添加了一个副本。但我们预计两者都将很快消失。

然而,另一件事却困扰着我——vector<string>无处不在。让我们通过为其命名vector<string>类型来消除这种重复— Hand:

using Hand = vector<string>;

auto comparePokerHands = [](const Hand& /*aliceHand*/, const Hand& bobHand){
    Hand winningBobHand {"2♣", "3♣", "4♣", "5♣", "6♣"};
    if(bobHand == winningBobHand){
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

TEST_CASE("Bob wins with straight flush"){
    Hand aliceHand{"2♠", "3♠", "4♠", "5♠", "9♠"};
    Hand bobHand;

    SUBCASE("2 based straight flush"){
        bobHand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

是时候再想想了。我们有两个用硬编码值实现的案例。对于爱丽丝同花顺获胜来说,这不是一个大问题,但是如果我们用不同的一组牌为鲍勃添加另一个测试用例,这就是一个问题。我们可以继续进行一些测试,但不可避免的是,我们需要实际检查同花顺。我认为现在是最好的时机。

那么,什么是同花顺?这是一套五张牌,有相同的花色和连续的数值。我们需要一个函数,可以取一套五张牌,如果是同花顺就返回true,如果不是就返回false。让我们写下几个例子:

  • 输入:2♣ 3♣ 4♣ 5♣ 6♣ = >输出:true
  • 输入:2♠ 3♠ 4♠ 5♠ 6♠ = >输出:true
  • 输入:T♠ J♠ Q♠ K♠ A♠ = >输出:true
  • 输入:2♣ 3♣ 4♣ 5♣ 7♣ = >输出:false
  • 输入:2♣ 3♣ 4♣ 5♣ 6♠ = >输出:false
  • 输入:2♣ 3♣ 4♣ 5♣ = >输出:false(只有四张牌,正好需要五张)
  • 输入:[empty vector] = >输出:false(无牌,正好需要五张)
  • 输入:2♣ 3♣ 4♣ 5♣ 6♣ 7♣ = >输出:false(六张牌,正好需要五张)

你会注意到我们也考虑了边缘案例和奇怪的情况。我们有足够的信息继续,所以让我们写下一个测试。

下一个测试–简单的同花

我更喜欢从正面的案例开始,因为它们更倾向于推进实施。让我们看看最简单的一个:

  • 输入:2♣ 3♣ 4♣ 5♣ 6♣ = >输出:true

测试如下所示:

TEST_CASE("Hand is straight flush"){
    Hand hand;

    SUBCASE("2 based straight flush"){
        hand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    CAPTURE(hand);

    CHECK(isStraightFlush(hand));
}

同样,测试没有编译,因为我们没有实现isStraightFlush函数。但是测试是对的,它失败了,所以是时候继续前进了。

通过测试

同样,第一步是编写函数体并返回预期的硬编码值:

auto isStraightFlush = [](const Hand&){
    return true;
};

我们做了测试,他们通过了,所以我们现在结束了!

走向

你可以看到事情的发展。我们可以为正确的同花顺添加更多的输入,但是它们不会改变实现。迫使我们推进实现的第一个测试是我们第一个不是同花同花的一组牌的例子。

对于本章的目标,我将快进。但是,我强烈建议你自己走完所有的小步骤,把你的结果和我的进行比较。学习 TDD 的唯一方法就是自己练习,反思自己的方法。

实现 isStraightFlush

让我们再来看看我们试图实现的目标——同花顺,这是通过拥有五张完全相同花色和连续值的牌来定义的。我们只需要用代码表达这三个条件:

auto isStraightFlush = [](const Hand& hand){
    return has5Cards(hand) && 
        isSameSuit(allSuits(hand)) && 
        areValuesConsecutive(allValuesInOrder(hand));
};

许多不同的 lambdas 有助于实现。首先,为了检查地层的长度,我们使用has5Cards:

auto has5Cards = [](const Hand& hand){
    return hand.size() == 5;
};

然后,为了检查它是否有相同的套装,我们使用allSuits从手上提取套装,isSuitEqual比较两个套装,isSameSuit检查一只手的所有套装是否相同:

using Card = string;
auto suitOf = [](const Card& card){
    return card.substr(1);
};

auto allSuits = [](Hand hand){
    return transformAll<vector<string>>(hand, suitOf);
};

auto isSameSuit = [](const vector<string>& allSuits){
    return std::equal(allSuits.begin() + 1, allSuits.end(),  
        allSuits.begin());
};

最后,为了验证数值是否连续,我们使用valueOf从牌中提取数值,allValuesInOrder从一手牌中获取所有数值并对其进行排序,toRange从初始值开始创建一系列连续的数值,areValuesConsecutive检查一手牌中的数值是否连续:

auto valueOf = [](const Card& card){
    return charsToCardValues.at(card.front());
};

auto allValuesInOrder = [](const Hand& hand){
    auto theValues = transformAll<vector<int>>(hand, valueOf);
    sort(theValues.begin(), theValues.end());
    return theValues;
};

auto toRange = [](const auto& collection, const int startValue){
    vector<int> range(collection.size());
    iota(begin(range), end(range), startValue);
    return range;
};

auto areValuesConsecutive = [](const vector<int>& allValuesInOrder){
    vector<int> consecutiveValues = toRange(allValuesInOrder, 
        allValuesInOrder.front());

    return consecutiveValues == allValuesInOrder;
};

拼图的最后一块是从charint的地图,帮助我们将所有的卡值,包括TJQKA转换成数字:

const std::map<char, int> charsToCardValues = {
    {'1', 1},
    {'2', 2},
    {'3', 3},
    {'4', 4},
    {'5', 5},
    {'6', 6},
    {'7', 7},
    {'8', 8},
    {'9', 9},
    {'T', 10},
    {'J', 11},
    {'Q', 12},
    {'K', 13},
    {'A', 14},
};

让我们也看看我们的测试(显然都通过了)。首先,有效的同花顺;我们将从23410开始检查直冲,以及它们如何沿着数据间隔变化:

TEST_CASE("Hand is straight flush"){
    Hand hand;

    SUBCASE("2 based straight flush"){
        hand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    SUBCASE("3 based straight flush"){
        hand = {"3♣", "4♣", "5♣", "6♣", "7♣"};
    };

    SUBCASE("4 based straight flush"){
        hand = {"4♣", "5♣", "6♣", "7♣", "8♣"};
    };

    SUBCASE("4 based straight flush on hearts"){
        hand = {"4♥", "5♥", "6♥", "7♥", "8♥"};
    };

    SUBCASE("10 based straight flush on hearts"){
        hand = {"T♥", "J♥", "Q♥", "K♥", "A♥"};
    };

    CAPTURE(hand);

    CHECK(isStraightFlush(hand));
}

最后,测试一组不是有效同花顺的牌。对于输入,我们将使用几乎同花的手牌,除了来自另一套花色、没有足够的牌或牌太多:

TEST_CASE("Hand is not straight flush"){
    Hand hand;

    SUBCASE("Would be straight flush except for one card from another 
        suit"){
            hand = {"2♣", "3♣", "4♣", "5♣", "6♠"};
    };

    SUBCASE("Would be straight flush except not enough cards"){
        hand = {"2♣", "3♣", "4♣", "5♣"};
    };

    SUBCASE("Would be straight flush except too many cards"){
        hand = {"2♣", "3♣", "4♣", "5♣", "6♠", "7♠"};
    };

    SUBCASE("Empty hand"){
        hand = {};
    };

    CAPTURE(hand);

    CHECK(!isStraightFlush(hand));
}

现在是时候回到我们的主要问题了——比较扑克牌。

将止回阀直接插回比较器中

尽管到目前为止我们已经实现了一切,但是我们对comparePokerHands的实现仍然是硬编码的。让我们记住它的当前状态:

auto comparePokerHands = [](const Hand& /*aliceHand*/, const Hand& bobHand){
    const Hand winningBobHand {"2♣", "3♣", "4♣", "5♣", "6♣"};
    if(bobHand == winningBobHand){
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

但是,我们现在有了一种检查同花顺的方法!所以,让我们插入我们的实现:

auto comparePokerHands = [](Hand /*aliceHand*/, Hand bobHand){
    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }
    return "Alice wins with straight flush";
};

我们所有的测试都通过了,所以我们快完成了。是时候在我们的Bob wins with straight flush案例中增加一些测试,以确保我们没有遗漏任何东西。我们将为爱丽丝保留相同的手牌,几乎是同花,并将鲍勃的手牌从基于2310的同花中改变:

TEST_CASE("Bob wins with straight flush"){
    Hand aliceHand{"2♠", "3♠", "4♠", "5♠", "9♠"};
    Hand bobHand;

    SUBCASE("2 based straight flush"){
        bobHand = {"2♣", "3♣", "4♣", "5♣", "6♣"};
    };

    SUBCASE("3 based straight flush"){
        bobHand = {"3♣", "4♣", "5♣", "6♣", "7♣"};
    };

    SUBCASE("10 based straight flush"){
        bobHand = {"T♣", "J♣", "Q♣", "K♣", "A♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

之前的测试都通过了。所以,我们已经完成了两个案例——当爱丽丝或鲍勃同花顺,而他们的竞争对手没有同花顺。是时候进入下一个案子了。

比较两次连续冲洗

正如我们在本节开头所讨论的,还有另一种情况,爱丽丝和鲍勃都有同花顺,但爱丽丝赢了一个更高的同花顺:

Case: Alice wins with a higher straight flush

Inputs:
 Alice: 3♠, 4♠, 5♠, 6♠, 7♠
 Bob: 2♣, 3♣, 4♣, 5♣, 6♣

Output:
 Alice wins with straight flush

让我们编写测试并运行它:

TEST_CASE("Alice and Bob have straight flushes but Alice wins with higher straight flush"){
    Hand aliceHand;
    Hand bobHand{"2♣", "3♣", "4♣", "5♣", "6♣"};

    SUBCASE("3 based straight flush"){
        aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
    };

    CAPTURE(aliceHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Alice wins with straight flush", result);
}

测试失败,因为我们的comparePokerHands函数返回鲍勃赢了,而不是爱丽丝。让我们用最简单的实现来解决这个问题:

auto comparePokerHands = [](const Hand& aliceHand, const Hand& bobHand){
    if(isStraightFlush(bobHand) && isStraightFlush(aliceHand)){
         return "Alice wins with straight flush";
    }

    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }

    return "Alice wins with straight flush";
};

我们的实现决定了如果爱丽丝和鲍勃都同花顺,爱丽丝总是赢。这显然不是我们想要的,但测试通过了。那么,我们可以写什么测试来推动实现呢?

原来我们之前的分析漏掉了一个案例。我们观察了当爱丽丝和鲍勃都同花顺并且爱丽丝获胜时会发生什么;但是当鲍勃有更高的同花顺时呢?让我们写下一个例子:

Case: Bob wins with a higher straight flush

Inputs:
 Alice: 3♠, 4♠, 5♠, 6♠, 7♠
 Bob: 4♣, 5♣, 6♣, 7♣, 8♣

Output:
 Bob wins with straight flush

是时候写另一个失败的测试了。

比较两次直冲(续)

到目前为止,这个测试很容易写出来:

TEST_CASE("Alice and Bob have straight flushes but Bob wins with higher 
    straight flush"){
        Hand aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
        Hand bobHand;

        SUBCASE("3 based straight flush"){
            bobHand = {"4♣", "5♣", "6♣", "7♣", "8♣"};
    };

    CAPTURE(bobHand);

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Bob wins with straight flush", result);
}

测试又失败了,因为我们的实现假设爱丽丝和鲍勃同花顺时爱丽丝总是赢。可能是时候检查一下哪一个是最高的同花顺了。

为此,我们需要再次写下几个案例,并完成我们的 TDD 周期。我将再次快进到实施阶段。我们最终得到了下面的助手函数,它比较了两个直接的同花顺。如果第一手牌有较高的同花,则返回1,如果两者相等,则返回0,如果第二手牌有较高的同花,则返回-1:

auto compareStraightFlushes = [](const Hand& first, const Hand& second){
    int firstHandValue = allValuesInOrder(first).front();
    int secondHandValue = allValuesInOrder(second).front();
    if(firstHandValue > secondHandValue) return 1;
    if(secondHandValue > firstHandValue) return -1;
    return 0;
};

通过改变我们的实现,我们可以通过测试:

auto comparePokerHands = [](const Hand& aliceHand, const Hand& bobHand){
    if(isStraightFlush(bobHand) && isStraightFlush(aliceHand)){
        int whichIsHigher = compareStraightFlushes(aliceHand, bobHand);
        if(whichIsHigher == 1) return "Alice wins with straight flush";
        if(whichIsHigher == -1) return "Bob wins with straight flush";
    }

    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }

    return "Alice wins with straight flush";
};

这给我们留下了最后一个案例——平局。考验再次变得十分明显:

TEST_CASE("Draw due to equal straight flushes"){
    Hand aliceHand;
    Hand bobHand;

    SUBCASE("3 based straight flush"){
        aliceHand = {"3♠", "4♠", "5♠", "6♠", "7♠"};
    };

    CAPTURE(aliceHand);
    bobHand = aliceHand;

    auto result = comparePokerHands(aliceHand, bobHand);

    CHECK_EQ("Draw", result);
}

实现上的变化相当简单:

auto comparePokerHands = [](Hand aliceHand, Hand bobHand){
    if(isStraightFlush(bobHand) && isStraightFlush(aliceHand)){
        int whichIsHigher = compareStraightFlushes(aliceHand, bobHand);
        if(whichIsHigher == 1) return "Alice wins with straight flush";
        if(whichIsHigher == -1) return "Bob wins with straight flush";
        return "Draw";
    }

    if(isStraightFlush(bobHand)) {
        return "Bob wins with straight flush";
    }

    return "Alice wins with straight flush";
};

这不是最漂亮的函数,但它通过了我们所有的同花顺比较测试。我们肯定可以将其重构为更小的函数,但是我将在这里停止,因为我们已经达到了我们的目标——我们已经使用 TDD 和 DDT 设计了不仅一个,而且多个纯函数。

摘要

在本章中,您已经学习了如何编写单元测试,如何编写数据驱动测试,以及如何使用数据驱动测试结合 TDD 来设计纯函数。

TDD 是有效软件开发的核心实践之一。虽然它有时看起来奇怪和违反直觉,但它有一个强大的优势——每隔几分钟,你就有一些可以演示的工作。通过测试不仅是一个演示点,也是一个保存点。如果在尝试重构或实现下面的测试时发生了任何错误,您总是可以回到最后一个保存点。我发现这种做法在 C++ 中更有价值,因为在 c++ 中,很多事情都可能出错。事实上,从第 3 章深入 Lambdas 开始,我就用 TDD 方法编写了所有的代码。这非常有帮助,因为我知道我的代码正在工作——如果没有这种方法,写一本技术书籍是很难做到的。我强烈建议你多看看 TDD,自己去实践;这是你变得精通的唯一方法。

带有函数式编程的 TDD 是完美的搭配。当它与命令式面向对象代码一起使用时,我们经常需要考虑突变,这使得事情变得更加困难。有了纯函数和数据驱动的测试,添加更多测试的实践变得尽可能简单,并允许我们专注于实现。在功能操作的支持下,在许多情况下,通过测试变得更加容易。我个人觉得这种组合非常值得;我希望你会发现它同样有用。

现在是时候向前迈进,重新审视软件设计的另一部分——设计模式了。它们会随着函数式编程而改变吗?(剧透警报——它们实际上变得简单多了。)这是我们下一章要讨论的。