最初以 totw/122 发布于 2016-08-30
作者:Titus Winters (titus@google.com)
更新日期:2017-10-20
快速链接:abseil.io/tips/122
清晰晦涩。- E.B. 怀特
测试代码与生产代码有何不同?首先,测试是未经测试的:当你编写的乱七八糟的意大利面条代码分布在多个文件中,并且有数百行的 SetUp 时,怎么可能有人确信测试真的在测试它需要测试的内容呢?很多时候,代码审查员不得不假设设置是合理的,最多也就是抽查每个测试用例的逻辑。在这种情况下,如果有什么变化,您的测试很可能会失败,但这种变化是否正确却很少有人清楚。
另一方面,如果每个测试都尽可能简单明了,就更容易通过检查发现其正确性,理解其逻辑,并审查更高质量的测试逻辑。让我们看看实现这一点的几种简单方法。
固定装置中的数据流
请看下面的示例:
class FrobberTest : public ::testing::Test {
protected:
void ConfigureExampleA() {
example_ = "Example A";
frobber_.Init(example_);
expected_ = "Result A";
}
void ConfigureExampleB() {
example_ = "Example B";
frobber_.Init(example_);
expected_ = "Result B";
}
Frobber frobber_;
string example_;
string expected_;
};
TEST_F(FrobberTest, CalculatesA) {
ConfigureExampleA();
string result = frobber_.Calculate();
EXPECT_EQ(result, expected_);
}
TEST_F(FrobberTest, CalculatesB) {
ConfigureExampleB();
string result = frobber_.Calculate();
EXPECT_EQ(result, expected_);
}
在这个相当简单的示例中,我们的测试包含 30 行代码。我们很容易想象出比这简单 10 倍的例子:肯定比任何一个屏幕都要多。读者或代码审查员要验证代码是否正确,就必须像下面这样四处扫描:
- "好的,这是一个 FrobberTest,定义在哪里......哦,这个文件。太好了"。
- "ConfigureExampleA......这是一个 FrobberTest 方法。它对一些成员变量进行操作。它们是什么类型?它们是如何初始化的?好的,Frobber 和两个字符串。有 SetUp 吗?好的,默认构造。"
- "回到测试: 好的,我们计算了一个结果,并将其与 expected_ 进行了比较......我们又在那里存储了什么?
与以更简单的方式编写的等效代码进行比较:
TEST(FrobberTest, CalculatesA) {
Frobber frobber;
frobber.Init("Example A");
EXPECT_EQ(frobber.Calculate(), "Result A");
}
TEST(FrobberTest, CalculatesB) {
Frobber frobber;
frobber.Init("Example B");
EXPECT_EQ(frobber.Calculate(), "Result B");
}
有了这种风格,即使在有数百个测试的世界里,我们也能仅通过局部信息就准确地知道发生了什么。
首选自由函数
在前面的示例中,所有变量的初始化都是简洁明了的。在实际测试中,情况并非总是如此。不过,关于数据流和避免固定装置的想法可能同样适用。请看下面这个 protobuf 例子:
class BobberTest : public ::testing::Test {
protected:
void SetUp() override {
bobber1_ = PARSE_TEXT_PROTO(R"(
id: 17
artist: "Beyonce"
when: "2012-10-10 12:39:54 -04:00"
price_usd: 200)");
bobber2_ = PARSE_TEXT_PROTO(R"(
id: 21
artist: "The Shouting Matches"
when: "2016-08-24 20:30:21 -04:00"
price_usd: 60)");
}
BobberProto bobber1_;
BobberProto bobber2_;
};
TEST_F(BobberTest, UsesProtos) {
Bobber bobber({bobber1_, bobber2_});
SomeCall();
EXPECT_THAT(bobber.MostRecent(), EqualsProto(bobber2_));
}
集中式重构再次导致了大量的间接性:声明和初始化是分开的,而且可能与实际使用相去甚远。此外,由于中间有 SomeCall(),而且我们使用了一个固定装置和固定装置成员变量,如果不检查 SomeCall() 的细节,就无法确定 bobber1_ 和 bobber2_ 在初始化和 EXPECT_THAT 验证之间是否被修改过。这可能需要更多的滚动操作。
请考虑
BobberProto RecentCheapConcert() {
return PARSE_TEXT_PROTO(R"(
id: 21
artist: "The Shouting Matches"
when: "2016-08-24 20:30:21 -04:00"
price_usd: 60)");
}
BobberProto PastExpensiveConcert() {
return PARSE_TEXT_PROTO(R"(
id: 17
artist: "Beyonce"
when: "2012-10-10 12:39:54 -04:00"
price_usd: 200)");
}
TEST(BobberTest, UsesProtos) {
Bobber bobber({PastExpensiveConcert(), RecentCheapConcert()});
SomeCall();
EXPECT_THAT(bobber.MostRecent(), EqualsProto(RecentCheapConcert()));
}
将初始化移到自由函数中,可以清楚地看到没有隐藏的数据流。帮助程序的名称选得好,意味着你甚至不用向上滚动就能查看测试的正确性,从而了解帮助程序的详细信息。
五个简单步骤
你可以通过以下步骤通常提升测试的清晰度:
- 在合理的情况下避免使用夹具。有时候这并不适用。
- 如果使用夹具,尽量避免夹具成员变量。对这些成员变量的操作过于容易类似于全局变量:数据流难以追踪,因为夹具中的任何代码路径都可能修改这些成员。
- 如果有需要复杂初始化的变量,使每个测试难以阅读,考虑使用辅助函数(而不是夹具的一部分)来文档化初始化并直接返回对象。
- 如果必须使用包含成员变量的夹具,尽量避免直接操作这些成员的方法:尽可能将其作为参数传递,以使数据流清晰。
- 尝试在编写头文件之前编写测试:如果从易于测试的用法开始,你的 API 通常会更好,你的测试几乎总是更清晰。