测试驱动开发(TDD)实践与技巧

Posted 芥末的无奈

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了测试驱动开发(TDD)实践与技巧相关的知识,希望对你有一定的参考价值。

引言

测试驱动开发,英文全称 Test-Driven Development,简称 TDD,是一种不同于传统软件开发流程的开发方法。

《程序员的职业素养》第五章,我第一次看到有关 TDD 内容,当时Bob大叔向我展示了一种不可思议的编程开发方法,这种方法颠覆了我的认知。Bob 大叔列举了 TDD 很多好处,例如确定性、降低代码缺陷、方便代码重构、测试单元文档化,更优秀的代码设计等等。并强力安利读者们尝试 TDD,因为 TDD 是专业人士的选择。

在 Bob 大叔的洗脑下,我决定去尝试学习如何使用 TDD。从写第一行 TDD 代码到现在,已经大概一年了,这期间我一直坚持用 TDD 的方式来写代码。还没毕业之前,需要写的代码比较少,鲜有机会锻炼 TDD 能力。进入公司工作后,就有大把的机会用 TDD 进行开发,对 TDD 的理解逐渐有一个比较全面的认知。此次要分享的目的就是向你展示实际使用 TDD 的系统方法,你将会学到:

  • TDD的基本工作方式
  • TDD的潜在好处
  • TDD怎杨帮助解决设计缺陷
  • TDD的难点和成本
  • TDD怎么减少甚至免除调试工作
  • 怎样长时间维持TDD
  • 如何编写高质量的测试

另外,还会分享我这段时间使用 TDD 的一些感受,以及 TDD 对我开发思维的影响

Google Mock

在开始 TDD 之前,需要说明一些关于测试框架的内容,这一点不是必须的,你完全可以用assert来完成测试的工作,但是测试框架提供了更好编码体验,能够提高开发效率,你只需要花一点点的时间学习它,就能获得巨大的收获,何乐而不为呢。

本文所有的例子都是 C++ 代码,所用的测试框架为 Google Mock,它是一种模拟(Mock)和匹配器的框架,其中包含了单元测试工具 Google Test。

详细的安装以及使用,请参考 快速上手Google C++测试框架。这里我们简单重述一遍如何使用 Google Mock,以便你可以不被打断的继续看下去。

测试用例结构

一个测试用例,在 Google Mock 下,大致是这样的结构:

TEST(TestSuiteName, TestName) 
{
  ... test body ...
}

TestSuiteName是测试套件名称,TestName测试用例名称。'测试套件’这个翻译很怪,让人不知所云,其实它是许多测试用例的集合,可以认为是一种逻辑上分组。

从左到右阅读测试套件和测试用例的名称,可以连成一句话,描述了我们想要验证的行为,例如下面的代码中:Set insert ignores duplicate values

TEST(SetInsert, IgnoresDuplicateValues)
{
  std::set<int> a_set;
  int val = 0;

  a_set.insert(val);
  a_set.insert(val);

  ASSERT_THAT(a_set.size(), Eq(1));
}

断言

使用自动断言是自动化测试的关键,Google Mock 中提供了两种断言:

  • ASSERT_* 失败是会终止当前函数,ASSERT_* 后面的代码将不会运行
  • EXPECT_* 失败时不会终止,EXPECT_* 后面的代码将会继续运行

Google Mock 提供了两种形式的断言:经典式 和 Hamcrest 断言

经典式断言

下表列出了 经典式断言 的两个主要断言。其他框架也会使用类似的名称

形式描述实例
ASSERT_TRUE(表达式)表达式返回假(或者0),测试失败ASSERT_TRUE(4<7)
ASSERT_EQ(期待值,实际值)期待值和实际值不等时,测试失败ASSERT_EQ(4, 20/5)

更多关于经典断言的内容请参考:

Hamcrest 断言

Hamcrest 断言是为了提高测试的表达能力,创建复杂断言的灵活性,以及测试错误所提供的信息

Hamcrest 断言使用匹配器比较实际结果。匹配器可以组成复杂但易懂的比较表达式。你也可以自己定义匹配器。

几个简单的示例胜过千言万语

TEST(StringTest, StringEq)
{
    string actual = string("al") + "pha";
    ASSERT_THAT(actual, Eq("alpha"));
}

断言可以从左到右读作:断定实际值等于"alpha"。对比与 ASSERT_EQ(actual, "alpha"),Hamcrest 断言用区区几个额外的字符就提高了阅读性。

匹配器的价值在于它能极大地提升测试的表达能力,许多匹配器能够减少所需的代码量,同时也能提升测试的抽象层次。

ASSERT_THAT(actual, StartsWith("alx"));

Hamcrest断言在提升失败信息的可读性方面意义更大。

Value of: actual
Expected: starts with "alx"
  Actual: "alpha"

Hamcrest 断言真的很 Coooool,如果你想进一步了解它,可以参考 快速上手Google C++测试框架 中关于匹配器的部分。

关于 Google Mock 的部分,我们就此打住,了解了以上的内容,你已经可以无障碍继续阅读了。
太棒了!如果你坚持看到了这里,那么你已经穿好鞋,准备好向 TDD 出发了。相信我,这趟旅途一定会让你受益匪浅。

测试驱动开发:第一个示例

开场白

写个测试,保证它通过,接着重构设计。这就是TDD的全部内容。但是这个三个简单的步骤背后却另有乾坤。就像踢足球一样,规则简单,但是想要踢得好,就需要进行训练以及学习相应的技巧。为了让你更快的理解TDD,这里我举个例子,让我们一起用TDD的形式开发一个小工具。这个实例提供了很多教学点,展示了TDD如何增量地设计一个程序。

场景:字节转换器
我们需要一个类,它能将一组字节转换成一组音频采样点(float),这样可以方便音频算法处理

再提供两个强假设:

  • 字节都是以小端格式排列的
  • 音频数据是 16 bit的,也就是说两个字节构成一个采样点(float)

开始吧

OK,让我们开始,上面的场景告诉我们,有一组字节(byte),我们需要将其转换成采样点…。等等!我们应该从最简单的开始,如果这个字节数组只有两个字节呢?为此我们写个测试:

#include <gmock/gmock.h>

TEST(AByteConverter, ConvertsTwoBytesToOneFloat)
{
  ByteConverter converter;
}
  • 第一行代码包含了gmock的头文件
  • 一个简单的测试声明需要使用 TEST 宏。TEST宏包括两个参数:测试套件名称和测试用例名称。从左右往右阅读测试套件和测试用例名称,可以连成一句话,描述了我们想要验证的行为:A Byte Converter converts two bytes to one float
  • 我们创建了一个 ByteConverter 对象,然后…,停止!在写更多测试代码之前,我们已经加入了一些不能通过编译的代码:我们还没有定义 ByteConverter 类,先停下来解决这个问题。这个方法和TDD的三条规则保持一致:
    • 只在为了使失败的测试用例通过时才编写产品代码
    • 当测试刚好失败时,停止继续编写。编译失败也是失败。
    • 只编写刚好让一个失败测试用例通过的代码

我们现在编译失败了(原则二),因此我们停止编写测试,开始编写产品代码(原则一)。这种增量地获得反馈是很好的办法,因为有时候只需要几行代码,就能产生一大堆的编译错误。若能够及时的看到代码产生的错误,那么就可以更容易的解决它们。

编译器提示我们需要 ByteConverter 类。我们可以添加一对.h/.cpp文件,但是先别自找麻烦。相反,不要急于使用独立文件,先简单地在测试文件中包含所有东西。在最后提交代码的时候,或者苦于所有东西都放在同一个文件的时候,再以适当的方式把产品代码独立出去。这种方法可以减少一直在文件间来回切换的开销,是一种短期内不引入复杂开销而节省时间的方式。

#include <gmock/gmock.h>

class ByteConverter
{
};

TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
  ByteConverter converter;
}

如果你无法忍受将测试代码和产品代码暂时放在一起的做法,那就马上把东西分散到不同的文件吧,依然可以继续我们的示例。但是我建议你先试试这种方法,因为它可能是一种更有效的工作方式。

我们遵循了原则三:只编写刚好让测试通过的代码。很显然,我们并没有完成测试,ConvertTwoBytesToFloat没有测试任何行为,所以它验证不了什么,但是我们却对负反馈(编译失败)采取了应对措施,加入了足够的代码消除它。

构建并运行上面的测试就能得到正反馈了,如下:

[==========] Running 1 test from 1 test suite.
[----------] Global test environment set-up.
[----------] 1 test from AByteConverter
[ RUN      ] AByteConverter.ConvertTwoBytesToOneFloat
[       OK ] AByteConverter.ConvertTwoBytesToOneFloat (0 ms)
[----------] 1 test from AByteConverter (0 ms total)

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

测试通过,欢乐时刻!虽然这个测试除了创建一个空类之外,没有干任何事情。但是,我们已经有了一些基本要素。并且,我们已经验证目前代码是正确的。如果你include的头文件写错了,或者class定义的分号忘记加了,那么你将第一时间发现这些错误。所以要尽早并频繁地测试,通常测试失败的原因只有一个。

继续吧!往测试中加入几行代码,来表示我们预期的客户端与ByteConverter对象交互方式

#include <gmock/gmock.h>
#include <vector>

class ByteConverter
{
};

TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
  ByteConverter converter;
  std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

  float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);
}

如上述代码所示,我们暴露了一个convertTwoBytesToFloat公共函数。尝试编译会失败,因为convertTwoBytesToFloat不存在。这个负反馈迫使我们去编写足够的代码,让测试通过编译。

class ByteConverter
{
public:
  float convertTwoBytesToFloat(uint8_t byte_1st, uint8_t byte_2nd)
  {
    return 0.0f;
  }
};

现在代码可以编译了,所有测试也通过了。是时候验证一些东西了:给定两个字节,convertTwoBytesToFloat 能够正确一个float返回吗?我们加入一个验证结果的断言:

TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
  ByteConverter converter;
  std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

  float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);

  ASSERT_THAT(resutl, ::testing::FloatEq(1.0));
}

断言可以用来验证结果是否符合预期。上面代码中断言中声明了convertTwoBytesToFloat返回的值等于1.0。现在编译通过了,但是断言却失败了。

乍一看,Google Mock给出的信息可能不太容易读懂。从最后一行开始看,它提示我们有一个测试没有通过,并且在 [RUN] 和 [FAILED] 之间信息帮助我们了解测试为什么失败。在上面的例子中,可以看到下面信息:

Value of: result
Expected: is approximately 1
  Actual: 0 (of type float)

断言信息非常明确:Google Mock期待 result 的值应该约等于1,但实际上却是0.

断言是意料之中的,因为为了通过编译,我们特意硬编码了一个0。得到这个负反馈是好事,也是TDD周期中可以发生的事情。首先,我们保证新加的测试不能通过,这表示这个功能没有被显示(有时候是通过的,但这通常不是个好事)。起初测试是失败,在加入适当的代码后测试通过了,这也说明测试时可靠的。

失败的断言提醒我们编写仅通过断言的代码即可。如下:

class ByteConverter
{
public:
  float convertTwoBytesToFloat(uint8_t byte_1st, uint8_t byte_2nd)
  {
    int16_t result = byte_2nd | byte_1st;
    return (float)(result);
  }
};

现在编译并运行。最后两行显示测试通过了:

去掉不干净的代码

啥?我们才写了两行产品代码和四行测试代码就有问题?当然,区区几行代码也容易引入缺陷。让我们先审阅一下刚写的代码,找一找缺陷吧。有一点是确定的,测试中的断言不是非常便于阅读。

ASSERT_THAT(result, ::testing::FloatEq(1.0));

我们希望断言阅读起来想个句子,但是::testing::却妨碍了阅读。我们引入using来帮忙:

#include <gmock/gmock.h>
#include <vector>
using namespace testing;

TEST(AByteConverter, ConvertTwoBytesToOneFloat)
{
  ByteConverter converter;
  std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

  float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);

  ASSERT_THAT(result, FloatEq(1.0));
}

现在断言看起来好多了,我们称这个小小的改动为重构,在不改变现有行为的前提下改进了设计。

另外,byte_2nd | byte_1st 看起来不知所云,还有(float)(result)强制转换不是良好的编程风格,我们进一步重构:

class ByteConverter
{
public:
  float convertTwoBytesToFloat(uint8_t byte_1st, uint8_t byte_2nd)
  {
    return static_cast<float>(convertTwoBytesToInt16(byte_1st, byte_2nd));
  }

private:
  int16_t convertTwoBytesToInt16(uint8_t byte_1st, uint8_t byte_2nd)
  {
      return byte_2nd | byte_1st;
  }
};

我们使用static_cast替换了强制转换,并封装convertTwoBytesToInt16用来提高了代码的可阅读性。

现在看起来好多了,重新编译并且运行,保证测试时通过。

增量性

我们已经完成了将两个比特转换成浮点数值的函数,如果了解过音频的同学可能注意到了,我们编写的代码不符合音频处理的使用习惯,因为音频处理中,采样点的范围应该是[-1, 1]。就目前来说,我们的代码中没有做归一化,所以它并不符合规范。所以数值的归一化是我们接下去要做的事情。

为了测试新的行为,我们需要写一个新的单元测试:

TEST(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
    ByteConverter converter;
    std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

    float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);
}

同样的,convertTwoBytesToNormalizedFloat 没有定义,所以编译不过。因此编写刚好让编译通过的代码:

float convertTwoBytesToNormalizedFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
    return 0.0f;
}

然后编写断言:

TEST(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
    ByteConverter converter;
    std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

    float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);

    ASSERT_THAT(normalized_result, FloatEq(1.0/32768));
}

断言失败,这是预期之中的。接下去,写刚好让测试通过的代码:

float convertTwoBytesToNormalizedFloat(uint8_t byte_1st, uint8_t byte_2nd)
{
    return convertTwoBytesToFloat(byte_1st, byte_2nd)/32768.0f;
}

新的测试通过了!但是代码中去出现了一些坏味:32768这个数字不知所云,并且重复了两次,一次在测试之中,一次在产品代码中。该进行重构了:

class ByteConverter
{
public:
    constexpr static float MAX_16BIT_VAL = 32768.0f;
    
    float convertTwoBytesToNormalizedFloat(uint8_t byte_1st, uint8_t byte_2nd)
    {
        return convertTwoBytesToFloat(byte_1st, byte_2nd)/MAX_16BIT_VAL;
    }
    ...
};

TEST(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
    ByteConverter converter;
    std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

    float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);

    ASSERT_THAT(normalized_result, FloatEq(1.0/ByteConverter::MAX_16BIT_VAL));
}

我们将32768提取为静态常量,消除了硬编码的重复,并且提供了代码的可阅读性。

fixture 设置

在重构的时候,不仅要审阅产品代码,还要审阅测试代码。如上所述,我们的测试都要创建 ByteConverter 实例,并且使用相同的代码。我们不乐意看到这种貌似无关紧要的重复代码。这些重复会积累的很快,并且通常会演化为更为复杂的重复代码。这也会让测试变得主次不分,对于阅读代码的来说,这会分散注意力,从而忽视了真正需要关注的内容。

相关的测试拥有一些共同的代码是常见的,Google Mock 允许我们定义一个 fixture 类,我们可以在这个类里为相关的测试声明函数和数据。

class AByteConverter : public Test
{
public:
    ByteConverter converter;
    std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0

};

TEST_F(AByteConverter, ConvertTwoBytesToFloat)
{
    float result = converter.convertTwoBytesToFloat(two_bytes[0], two_bytes[1]);

    ASSERT_THAT(result, FloatEq(1.0));
}

TEST_F(AByteConverter, ConvertTwoBytesToNormalizedFloat)
{
    float normalized_result = converter.convertTwoBytesToNormalizedFloat(two_bytes[0], two_bytes[1]);

    ASSERT_THAT(normalized_result, FloatEq(1.0/ByteConverter::MAX_16BIT_VAL));
}

上述代码中,我们创建了一个名为 AByteConverter 的 fixture(必须继承::testing::Test)。在 fixture 内部我们声明了公共变量 convertertwo_bytes,以便测试可以访问。

Google Mock 运行每个单元测试时,会创建fixture实例。也就是说,在运行 ConvertTwoBytesToFloat 之前,它会创建一个 AByteConverter 实例。在运行ConvertTwoBytesToNormalizedFloat之前,创建另一个 AByteConverter 实例。

为了使用fixture,我们将宏TEST改成了TEST_FF表示fixture。如果忘记加F,所有测试都会失败。

去除掉重复的测试代码至少了两个好处:

  • 提高了测试的抽象度。现在每个测试只有两行代码,这有助我们集中精力关注与测试相关的东西
  • 可以减低未来的维护测试的开销。试想一下,如果ByteConverter的构造函数发生变化,那么只需要改动一个地方即可。

思索与测试驱动开发

简单的说,TDD的周期就是写一个测试,先确保测试失败,然后编码让其通过,接着审阅代码和打磨设计(包括测试的设计),最后保证所有测试仍然通过。在一天中,你不断的重复这个周期,保持周期尽量小,以便得到更多的反馈。

让我们继续下一个测试,我们传入一组字节,得到一组浮点值。沿着这个思路想下去,我们首先会考虑,返回的个数是多少?Ok,为了验证这个行为,让我们来写一个测试:

TEST_F(AByteConverter, ConvertsBytesToFloatsWithSizeOfHalfSizeOfBytes)
{
    int num_bytes = 5;
    std::vector<uint8_t> five_bytes(num_bytes);

    auto floats = converter.convertBytesToFloat(five_bytes);
}

我们编写了一个新的函数convertBytesToFloat,它返回一组浮点数。同样的,这里提示编译失败,写一个刚好让编译通过的代码:

std::vector<float> convertBytesToFloat(const std::vector<uint8_t>& bytes)
{
    return std::vector<float>();
}

编译通过,接着写测试代码:

TEST_F(AByteConverter, ConvertsBytesToFloatsWithSizeOfHalfSizeOfBytes)
{
    int num_bytes = 5;
    std::vector<uint8_t> five_bytes(num_bytes);

    auto floats = converter.convertBytesToFloat(five_bytes);

    ASSERT_THAT(floats.size(), Eq(num_bytes/2));
}

测试失败。然后接着写产品代码:

std::vector<float> convertBytesToFloat(const std::vector<uint8_t>& bytes)
{
    auto num_floats = (bytes.size() / 2);

    std::vector<float> floats(num_floats);

    return floats;
}

在编译运行,测试通过。值得庆幸的是,上面的代码没有需要重构的地方,至少现在没有。所以我们赶紧进入下一个测试。

很自然的,下一个测试中我们应该验证其转换的浮点值是否正确:

TEST_F(AByteConverter, ConvertsBytesToFloats)
{
    std::vector<uint8_t> five_bytes{
        0x01, 0x00, // 1
        0x02, 0x00, // 2
        0x00
    };

    auto floats = converter.convertBytesToFloats(five_bytes);

    ASSERT_THAT(floats[0], Eq(1));
    ASSERT_THAT(floats[0], Eq(2));
}

很明显,测试失败了:

非常好,这是符合预期的失败,我们接着写让测试刚好通过产品代码:

std::vector<float> convertBytesToFloats(const std::vector<uint8_t> &bytes)
{
    auto num_floats = (bytes.size() / 2);

    std::vector<float> floats(num_floats);
    for(int i = 0; i < num_floats; ++i)
    {
        floats[i] = convertTwoBytesToFloat(bytes[i*2], bytes[i*2 + 1]);
    }

    return floats;
}

接着编译运行,所有测试都通过了。

重构时间到!现在的代码中有好几处可以进行改进:

  • convertBytesToFloats函数中,2多次出现,并且含义不明确
  • 新的测试代码中,用了两个ASSERT_THAT来验证结果。但是如果返回的数组包含多个值呢?岂不是要写一大串的ASSERT_THAT才能验证结果?其实我们只需要一个ASSSERT_THAT就可以对比容器的内容了
  • five_bytes 变量在两个单元测试中都出现了,可以通过fixture来消除重复

先从最简单的重构开始,消除重复的five_bytes


class AByteConverter : public ::testing::Test
{
public:
    ByteConverter converter;
    std::vector<uint8_t> two_bytes{0x01, 0x00}; // 1.0
    std::vector<uint8_t> five_bytes
        {
            0x01, 0x00, // 1
            0x02, 0x00, // 2
            0x00
        };
};

TEST_F(AByteConverter, ConvertsBytesToFloatsWithSizeOfHalfSizeOfBytes)
{
    auto floats = converter.convertBytesToFloats(five_bytes);

    ASSERT_THAT(floats.size(), Eq(five_bytes.size()/2));
}

TEST_F(AByteConverter, ConvertsBytesToFloats)
{
    auto floats = converter.convertBytesToFloats(five_bytes);

    ASSERT_THAT(floats[0], Eq(1));
    ASSERT_THAT(floats[1], Eq(2));
}

记得每次重构完成后都要重新运行单元测试,确保重构没有改变代码行为。

然后消除多余的ASSERT_THAT,只需要添加一个匹配器

class AByteConverter : public ::testing::Test
{
public:
    ByteConverter converterTDD测试驱动开发的实践心得

提高代码质量的12个技巧

❀涨姿势❀提高代码质量的12个技巧

TDD和BDD

BDD介绍

转载浅谈TDDBDD与ATDD软件开发