每周技巧 #122:测试夹具、清晰性和数据流

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #122: Test Fixtures, Clarity, and Dataflow

原文最初作为 totw/122 发布于 2016 年 8 月 30 日。

作者:Titus Winters

更新于 2017 年 10 月 20 日。

快捷链接:abseil.io/tips/122

要晦涩,也要晦涩得清楚。 —— E.B. White

测试代码和生产代码有什么不同?其中一点是:测试本身没有测试。如果你写出一团分散在多个文件里、带着数百行 SetUp 的意大利面式测试代码,别人怎么能确定这个测试真的在测试它应该测试的东西?太多时候,代码评审者只能假设这些准备工作是合理的,最多对每个单独测试用例的逻辑做抽查。遇到这种情况,某些东西变化时测试很可能会失败,但很少能清楚看出变化的是否正是应该被测试捕捉到的东西。

反过来,如果你让每个测试尽量简单、直接,那么别人就更容易通过阅读确认它是正确的,理解其中逻辑,并以更高质量评审测试逻辑。下面看几种达成这一点的简单办法。

夹具中的数据流

考虑下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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_ 比较……那里之前又存了什么?”

把它和用更简单风格写出的等价代码比较一下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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 例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 验证之前没有被修改。你很可能还得继续来回滚动。

换成下面这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 通常会更好,测试也几乎总是更清楚。

每周技巧 #120:返回值不可触碰

上一节

每周技巧 #123:`absl::optional` 和 `std::unique_ptr`

下一节