(五)测试驱动开发技巧--测试替身


测试替身

在前面的章节中了解了 TDD 的基础内容,但是在真实的生产环境中对象必须协同工作,有时依赖合作对象使得 TDD 变得举步维艰,本章将会介绍如何使用测试替身来解决。

  • 测试替身解答了一个困惑我好久的问题,就是代码耦合的情况下我该如何测试这个函数会按照要求调用其他关联组件。测试替身即可以让你编写代码来感知到调用以及调用次数,还会倒逼你去尝试注入依赖组件从而进行解藕,相信看完这一章会有所感受。
  • 本章会有一些代码标注了 git hash 值,标识我是那次修改对应的代码,方便对比。这不得夸下当时我可太有耐心了 😄。

依赖问题

  • Story:位置描述服务
    • 作为一个地图应用开发人员,我需要这样的服务,即它能基于给定的位置(经纬度)返回一行信息来描述离它最近的地方。

构建位置描述服务中一个重要的工作就是去调用一个外部 API,这个 API 能接受一个位置信息,并返回位置数据。书中举例 REST 服务,给定一个 GET URL,它会以 JSON 格式返回位置数据。测试驱动开发位置描述服务会遇到一个难题。至少出于以下几点原因,对 REST 调用的依赖会成为一个问题。

  1. 通过一个 HTTP 来调用 REST 服务非常缓慢,这也导致测试的运行速度变慢。
  2. REST 服务可能不是一直处于可用状态。
  3. REST 服务返回的结果得不到保证。

为什么这些依赖会使得测试变得困难呢?

  1. 依赖一个慢速的协作对象会让测试慢得难以忍受。
  2. 依赖一个不稳定的服务(要么不可用,要么每次返回不同的结果)会导致测试间断性地失败。

而且如果当前没有发起 HTTP 调用的代码,可能是被人还没有设计实现完成,同时你也没有时间自己去实现一个 HTTP 类怎么办?如果自己就是负责 HTTP 类实现的人怎么办?或许可以先了解下位置描述服务整体设计与使用方法后在考虑 HTTP 工具类的具体实现细节。

测试替身原理

在上述提到的问题,可以利用测试替身来避免被这类问题阻塞。测试替身起到代替的作用:它代替了实际产品代码中的类。

如上文 HTTP 类带来了困难,可以为其创建测试替身!当客户提交一个 GET 请求至 HTTP 对象时,测试替身能够返回预先准备的响应。测试替身应该返回什么是由测试自己决定的。

  • 假设需要构建一个服务,有以下几个功能类可以复用(当前还未实现):
    • CurlHttp,它使用 cURL 发起 HTTP 请求。这个类派生自纯虚基类 Http,这个基类定义两个函数:get() 和 initialize()。客户端代码在调用 get() 前必须先调用 initialize()。
    • Address,一个包含几个字段的结构。
    • AddressExtractor,它借助 JsonCpp 库从一个 JSON 字符串中提取地址 (本文将 Catch2 实现版本从书中的 JsonCpp 切换成 nlohmann/json.hpp),并填写 Address 结构。

可能的代码流程:

CurlHttp http;
http.initialize();
auto jsonResponse = http.get(createGetRequestUrl(latitude, longitude));

AddressExtractor extractor;
auto address = extractor.addressFrom(jsonResponse);

return summaryDescription(address);

手动打造的测试替身

配套工程参考代码版本:Git SHA (92c0b746c3b862199597c6a9eb2da93abca8a5aa);

如果想要使用替身,首先必须使其取代 CurlHttp 类的行为。C++提供了许多不同的方法,其中多态的使用频率最高。我们先来看一下 CurlHttp 类所实现的基类 Http 接口

virtual ~Http() { }
virtual void initialize() = 0;
virtual std::string get(const std::string& Url) const = 0;

利用多态只需要在派生类中覆写虚函数,并在这个覆写中提供特别的行为来支持测试,然后将基类指针传递给地名描述服务。现在来进行一些测试

class APlaceDescriptionService : public Test
{
public:
    static const string ValidLatitude;
    static const string ValidLongitude;
};

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    PlaceDescriptionService service { &httpStub };
    auto description = service.summaryDescription(ValidLatitude, ValidLongitude);
    ASSERT_THAT(description, Eq("Drury Ln, Fountain, CO, US"));
}

这里我们所需的替身 HttpStub 还没有实现,在测试代码文件中先定义一个。

class HttpStub : public Http
{
    void initialize() override { }
    std::string get(const std::string& Url) const override { return "???"; }
};

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
....
}

返回问号字符串没有什么作用,可以尝试会返回一个搜索服务真实的 Json 响应,内容可以尝试向 Nominatim 提交 Get 请求获取,这里保持书中的示例:

class HttpStub : public Http
{
    void initialize() override { }
    std::string get(const std::string& Url) const override
    {
        return R"({ "address": {
         "road":"Drury Ln",
         "city":"Fountain",
         "state":"CO",
         "country":"US" }})";
    }
};

接下来将 HttpStub 实例传递给了 PlaceDescriptionService 的构造函数。和原先的预想相比,我们正在改变设计。服务对象本身不创建私有的 Http 实例,相反,使用该服务对象的客户端需要自己创建一个 Http 实例,并把它传给服务对象服务对象通过一个基类指针持有这个 Http 实例。

PlaceDescriptionService::PlaceDescriptionService(Http* IHttp)
        : MHttp(IHttp)
{
}

利用多态实现了灵活的测试替身,功能类 PlaceDescriptionService 不清楚它持有的 Http 实例时一个真实的还是一个测试用的实例。

参考代码版本:Git SHA (05f37b1b63764fdd54fe4d9555b3b9d276136d6d);

继续向下 summaryDescription 接口调用的参数是需要传入两个坐标,这里为 ValidLatitude、ValidLongitude 进行初始化:

class APlaceDescriptionService : public Test
{
public:
    static const string ValidLatitude;
    static const string ValidLongitude;
};

const string APlaceDescriptionService::ValidLatitude("38.005");
const string APlaceDescriptionService::ValidLongitude("-104.44");

这时编译成功可以运行一下哎,不出意外失败了这时由于还没有具体实现 summaryDescription 接口的功能。

至此,可以编写 summaryDescription() 了。但是首先还需要一个 AddressExtractor。它能解析 JSON 响应,并填写 Address 结构体。详细的过程略过,可以查阅源码

.....
TEST_F(AnAddressExtractor, ReturnsPopulatedAddressForValidJsonResult)
{
    const auto* json = R"({
         "place_id":"15331615",
         "address":{
            "road":"War Eagle Court",
            "city":"Colorado Springs",
            "state":"Colorado",
            "country":"United States of America",
         }
      })";

    auto address = Extractor.addressFrom(json);

    ASSERT_THAT(address.Road, Eq("War Eagle Court"));
    ASSERT_THAT(address.City, Eq("Colorado Springs"));
    ASSERT_THAT(address.State, Eq("Colorado"));
    ASSERT_THAT(address.Country, Eq("United States of America"));
}
.....

最后我们实现 summaryDescription():

string PlaceDescriptionService::summaryDescription(const string& Latitude, const string& Longitude) const
{
    const auto* getRequestUrl = "";
    auto jsonResponse = MHttp->get(getRequestUrl);
    AddressExtractor extractor;
    auto address = extractor.addressFrom(jsonResponse);
    return address.Road + ", " + address.City + ", " + address.State + ", " + address.Country;
}

下边参考代码版本:Git SHA (432dedc2c1589fe7a8bfb3487879fec56997e6fa);

当测试调用 summaryDescription() 时,对 get() 的调用作用到 HttpStub 实例上,返回我们预先硬编码的 Json 字符串。但是请求的 URL 应该是什么,当正在接受测试的代码和一个协同对象交互时,需要保证给它传递一个正确的值。返回硬编码值的测试替身叫作存根(stub)。类似地,我们也可以称 get() 为存根方法。

实际上,当传给 get() 一个空的字符串时就可以进行增量的开发了,接下来编写能够正确给 getRequestUrl 赋值的代码。利用三角法,并为第二个位置添加一个断言

class HttpStub : public Http
{
    void initialize() override { }
    std::string get(const std::string& Url) const override
    {
        verify(Url);
        return R"({ "address": {
         "road":"Drury Ln",
         "city":"Fountain",
         "state":"CO",
         "country":"US" }})";
    }

    void verify(const string& Url) const
    {
        auto expectedArgs("lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                          "lon=" + APlaceDescriptionService::ValidLongitude);
        ASSERT_THAT(Url, EndsWith(expectedArgs));
    }
};

在调用 get() 时,存根实现可以确保参数符合预期,接下来修改代码通过测试:

string PlaceDescriptionService::summaryDescription(const string& Latitude, const string& Longitude) const
{
    const string& getRequestUrl = "lat=" + Latitude + "&lon=" + Longitude;
    auto jsonResponse = MHttp->get(getRequestUrl);
    AddressExtractor extractor;
    auto address = extractor.addressFrom(jsonResponse);
    return address.Road + ", " + address.City + ", " + address.State + ", " + address.Country;
}

由于当前 URL 没有指定服务器路径,修改 verify() 函数,让它传给 get() 一个完整的 URL:

void verify(const string& Url) const
{
    string urlStart("http://open.mapquestapi.com/nominatim/v1/reverse?format=json&");
    auto expectedArgs(urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                        "lon=" + APlaceDescriptionService::ValidLongitude);
    ASSERT_THAT(Url, Eq(expectedArgs));
}

最终实现 summaryDescription() 方法后:

string PlaceDescriptionService::summaryDescription(const string& Latitude, const string& Longitude) const
{
    auto request = createGetRequestUrl(Latitude, Longitude);
    auto response = get(request);
    return summaryDescription(response);
}
string PlaceDescriptionService::summaryDescription(const string& Response) const
{
    AddressExtractor extractor;
    auto address = extractor.addressFrom(Response);
    return address.summaryDescription();
}
string PlaceDescriptionService::get(const string& RequestUrl) const
{
    return MHttp->get(RequestUrl);
}

string PlaceDescriptionService::createGetRequestUrl(const string& Latitude, const string& Longitude) const
{
    string server { "http://open.mapquestapi.com/" };
    string document { "nominatim/v1/reverse" };
    return server + document + "?" + keyValue("format", "json") + "&" + keyValue("lat", Latitude) + "&" +
           keyValue("lon", Longitude);
}

string PlaceDescriptionService::keyValue(const std::string& Key, const std::string& Value) const
{
    return Key + "=" + Value;
}
  • 现在代码中还欠缺的地方:
    1. 重复代码:测试中的文本和产品代码中的文本完全一样。第七章会讲解如何去除这种重复。
    2. PlaceDescriptionService 中的一些结构是可以复用的。可以考虑如何设计更加方便支持第二个服务。

在使用测试替身时提升测试的抽象程度

参考代码版本:Git SHA (71f934b009b744e4fd3d66d1a8d21792ad88471a);

在使用测试替身时,由于测试中模糊的信息增加了许多理解的难度,例如 ReturnsDescriptionForValidLocation 测试中隐藏了许多相关信息,为什么最终断言的比对目标是 Drury Ln, Fountain, CO, US 这些都会给阅读测试的人查看 HttpStub 中与之相关的实现细节才能理解。

因此我们需要重构测试,使它可以自包含。可以修改 HttpStub 的实现,让测试负责设定 get() 方法的返回值。

class HttpStub : public Http
{
public:
    string ReturnResponse;
    void initialize() override { }
    std::string get(const std::string& Url) const override
    {
        verify(Url);
        return ReturnResponse;
    }

    void verify(const string& Url) const
    {
        .....
    }
};

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    httpStub.ReturnResponse = R"({"address": {
                                    "road":"Drury Ln",
                                    "city":"Fountain",
                                    "state":"CO",
                                    "country":"US" }})";
    PlaceDescriptionService service { &httpStub };
    auto description = service.summaryDescription(ValidLatitude, ValidLongitude);
    ASSERT_THAT(description, Eq("Drury Ln, Fountain, CO, US"));
}

这样阅读的人可以将摘要描述和 HttpStub 返回的 JSON 对象对应起来,类似的,也可以将 URL 验证以到测试中。

class HttpStub : public Http
{
public:
    string ReturnResponse;
    string ExpectedURL;
    void initialize() override { }
    std::string get(const std::string& Url) const override
    {
        verify(Url);
        return ReturnResponse;
    }

    void verify(const string& Url) const { ASSERT_THAT(Url, Eq(ExpectedURL)); }
};

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    httpStub.ReturnResponse = //....

    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    httpStub.ExpectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                           "lon=" + APlaceDescriptionService::ValidLongitude;

    PlaceDescriptionService service { &httpStub };
    //.....
}

现在,在测试代码中可以清晰的表达出意图,同时 HttpStub 消减至一个小类,返回需要的存根同时验证期望的信息。,一个 HttpStub 对象验证了这样的事实:会有一个期望的 URL 传给 HttpStub。

使用模拟对象工具

本结学习如何使用 Google Mock 来实现对多个相似的模拟对象辅助生成 Mock 对象的方法。

定义一个派生类

参考代码版本:Git SHA (74290eccd1b383da76082716ac070f7b06609a28);

现在已重新开发 summaryDescription() 举例,我们需要模拟 HTTP 的方法:get() 和 initialize()。

为了使用 Google Mock 自身的模拟对象,我们首先需要创建一个派生类用来声明所模拟的方法。Google Mock 允许我们简洁地定义名为 HttpStub 的派生类。

class HttpStub : public Http
{
public:
    MOCK_METHOD0(initialize, void()); // 使用宏来声明模拟的方法
    MOCK_CONST_METHOD1(get, string(const string&)); // 1 标识一个参数,第一个参数成员函数名称,第二个宏参数给出方法其他信息(返回值和参数声明)
};

GoogleTest 1.10.x Release 版本支持了一个新宏定义,舍去了繁琐的方法,使用如下:

class HttpStub : public Http
{
public:
    MOCK_METHOD(void, initialize, (), (override));
    MOCK_METHOD(string, get, (const string&), (const override));
};
CmakeLists.txt
//当前使用的 googletest 版本为 1.10.x MOCK_METHOD 宏在编译器下会有错误信息,依据 gmock issues 无用报错 关闭相关警告
add_definitions(-Wno-gnu-zero-variadic-macro-arguments)

Google Mock 把一个 mock 声明转为派生类中的一个成员函数。Google Mock 还在幕后实现了这个函数。

设立期望

参考代码版本:Git SHA (8e49b0b7091856f6308584b40d1d8bdc2114c621);

首先屏蔽掉 summaryDescription() 的具体实现重新开发一次。

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;

    EXPECT_CALL(httpStub, get(expectedURL));
    PlaceDescriptionService service { &httpStub };
    service.summaryDescription(ValidLatitude, ValidLongitude);
}

通过 EXPECT_CALL 宏设立期望,这个宏配置 Google Mock 去验证给定的 expectedURL 参数去调用 httpStub 对象的 get() 逻辑是否吻合。其断言生效在模拟对象跳出作用域后开始验证,断言步骤被隐式的执行了。

如果需要,也可以强制 Google Mock 在模拟对象跳出作用域前做验证: Mock::VerifyAndClearExpectations(&httpStub);

首先空实现 summaryDescription(),让测试可以编译运行:

string PlaceDescriptionService::summaryDescription(const string& Latitude, const string& Longitude) const
{
    return "";
}

执行后,测试出现失败信息,直到测试结束为止,httpStub 对象的 get() 也没有被调用。:

[----------] 1 test from APlaceDescriptionService
[ RUN      ] APlaceDescriptionService.ReturnsDescriptionForValidLocation
/home/caolei/WorkSpace/TDD_Learning/test/Gmock/PlaceDescriptionServiceTestByGMock/PlaceDescriptionServiceTest.cpp:36: Failure
Actual function call count doesn't match EXPECT_CALL(httpStub, get(expectedURL))...
         Expected: to be called once
           Actual: never called - unsatisfied and active
[  FAILED  ] APlaceDescriptionService.ReturnsDescriptionForValidLocation (0 ms)
[----------] 1 test from APlaceDescriptionService (0 ms total)

修改代码使之通过测试:

string PlaceDescriptionService::summaryDescription(const string& Latitude, const string& Longitude) const
{
    string server { "http://open.mapquestapi.com/" };
    string document { "nominatim/v1/reverse" };
    // clang-format off
    string url = server + document + "?" +
                    keyValue("format", "json") + "&" +
                    keyValue("lat", Latitude) + "&" +
                    keyValue("lon", Longitude);
    // clang-format on
    auto response = MHttp->get(url);
    AddressExtractor extractor;
    auto address = extractor.addressFrom(response);
    return address.summaryDescription();
}

松模拟和严模拟

参考代码版本:Git SHA (46639a97cb73d0cd34071e70e1cc0bd05560fc94);

summaryDescription() 的视线中并没有遵顼 CurlHttp 接口规则,即在使用 get() 前没有进行 initialize 初始化。可以在测试中新增一个期望来确保初始化被调用:

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;

    EXPECT_CALL(httpStub, initialize());
    EXPECT_CALL(httpStub, get(expectedURL));
    PlaceDescriptionService service { &httpStub };
    service.summaryDescription(ValidLatitude, ValidLongitude);
}

接下来保持 summaryDescription() 中策略不变,单独抽离 get() 接口成为单独的方法:

string PlaceDescriptionService::get(const string& Url) const
{
    MHttp->initialize();
    return MHttp->get(Url);
}

模拟对象中的顺序

参考代码版本:Git SHA (e1a51df6b0e5af65d328017b1e9c9ebb1b46b0ea);

initialize() 和 get() 的调用是有先后顺序的,默认情况下 GMOCK 不会验证满足调用期望的顺序,如果想要验证,可以定义 InSequence 实例:

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    InSequence forceExpectationOrder;

    HttpStub httpStub;
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;

    EXPECT_CALL(httpStub, initialize());
    EXPECT_CALL(httpStub, get(expectedURL));
    PlaceDescriptionService service { &httpStub };
    service.summaryDescription(ValidLatitude, ValidLongitude);
}

还可以更加精细控制,具体使用可以参见 GoogleMock DOC

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;

    Expectation Expectations = EXPECT_CALL(httpStub, initialize());
    EXPECT_CALL(httpStub, get(expectedURL)).After(Expectations);

    PlaceDescriptionService service { &httpStub };
    service.summaryDescription(ValidLatitude, ValidLongitude);
}

巧妙的模拟工具特性

参考代码版本:Git SHA (8478946c5e86720daeec8eba54ef1dd5438899a1);

EXPECT_CALL 宏支持许多修饰符。它的语法如下(?和代表每个修饰符的基数:?表示可以选用修饰符一次;表示可以多次使用修饰符。):

EXPECT_CALL(mock-object, method (matchers)?)
     .With(multi-argument-matcher)  ?
     .Times(cardinality)            ?
     .InSequence(sequences)         *
     .After(expectations)           *
     .WillOnce(action)              *
     .WillRepeatedly(action)        ?
     .RetiresOnSaturation();        ?

GMOCK 工具可以支持几乎所有的模拟方式,如下例模拟输入参数的函数并指定一个返回值:

class DifficultCollaborator
{
public:
    virtual bool calculate(int* Result) { throw 1; };
};

....

TEST(ATarget, ReturnsAnAmountWhenCalculatePasses)
{
    DifficultCollaboratorMock difficult;
    Target calc;
    //SetArgPointee<0>(3) 表示 第 0 个参数值为 3。
    EXPECT_CALL(difficult, calculate(_)).WillOnce(DoAll(SetArgPointee<0>(3), Return(true)));
    auto result = calc.execute(&difficult);
    ASSERT_THAT(result, Eq(3));
}

GMOCK 还提供了许多功能支持,但是大部分情况只需要基本的机制就足够了,如果在 TDD 过程中经常遇到要用怪异的模拟工具特性,请检查下设计是否做了过多的事情。

当你尝试着为非测试驱动的、结构不良的系统编写测试并遇到问题时,或许需要使用模拟工具提供的更加强大的特性。我们会在第 8 章中讨论这类问题。

排除模拟失败

  1. 产品代码中是否有合理调用?
  2. 是否正确定义了模拟方法?
  3. 有没有将要模拟的成员函数声明为虚函数?
  4. MOCK_METHOD() 声明正不正确?
  5. 排除参数匹配的担忧,为所有参数和返回值使用通配符 testing::_ ,如果测试通过,那么可以确定有一个参数不能被正确匹配。

一个还是两个测试

当使用手工创建的模拟对象时,我们最终只用了一个测试来验证最终的目标,即为一个位置生成概要信息描述。但是,在第二个示例中,我们却用了两个测试。

拥有两个测试还会提供额外的好处。第一,一个模拟验证是一个断言。我们已经用一个断言来验证概要信息字符串了。将测试分为两个,与一个断言一个测试保持一致(参考 7.3 节)。第二,独立的测试更具可读性。由于在 Google Mock 中设立期望会导致很难界定设置断言的位置(这也和 4.2.4 节一致),因此我们为简化基于模拟的测试的努力是值得的。

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    HttpStub httpStub;
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;

    Expectation Expectations = EXPECT_CALL(httpStub, initialize());
    //  get(expectedURL) 这一步同时验证了应该传给 get 的参数与 expectedURL 一致
    EXPECT_CALL(httpStub, get(expectedURL)).After(Expectations);

    PlaceDescriptionService service { &httpStub };
    service.summaryDescription(ValidLatitude, ValidLongitude);
}

TEST_F(APlaceDescriptionService, FormatsRetrievedAddressIntoSummaryDescription)
{
    NiceMock<HttpStub> httpStub;
    EXPECT_CALL(httpStub, get(_))
        .WillOnce(Return(
            R"({ "address": {
              "road":"Drury Ln",
              "city":"Fountain",
              "state":"CO",
              "country":"US" }})"));
    PlaceDescriptionService service(&httpStub);

    auto description = service.summaryDescription(ValidLatitude, ValidLongitude);

    ASSERT_THAT(description, Eq("Drury Ln, Fountain, CO, US"));
}

让测试替身各就各位

在引入一个测试替身时需要做两件事。第一,编写测试替身。第二,在目标测试中使用测试替身的一个实例。这样的做法又称作依赖注入(Dependency Injection,DI)。

以前文的 PlaceDescriptionService 为例,它是通过构造函数将测试替身注入其中,有些情况可能使用 setter 成员函数来注入测试替身更合适,这种方法又称作构造注入或 setter 注入。

覆写工厂方法和覆写 Getter

参考代码版本:Git SHA (fe19774356b4cb14d14e4041029c535b3619ebf3);

想要使用覆写工厂方法,首先要修改产品代码使用工厂模式获取写作类的实例:

首先修改 PlaceDescriptionService 中的成员 Http* MHttp,不在以成员方式保存而是 httpService() 获取:

//PlaceDescriptionService.h
class PlaceDescriptionService
{
public:
    virtual ~PlaceDescriptionService() {};
    std::string summaryDescription(const std::string& Latitude, const std::string& Longitude) const;

private:
    .....

protected:
    virtual std::shared_ptr<Http> httpService() const;
};

//PlaceDescriptionService.cpp
string PlaceDescriptionService::get(const string& Url) const
{
    auto http = httpService();
    http->initialize();
    return http->get(Url);
}

shared_ptr<Http> PlaceDescriptionService::httpService() const
{
    return make_shared<CurlHttp>();
}

通过 httpService 获取可以看作使通过工厂模式获取到功能类,在测试中,我们就可以通过继承 PlaceDescriptionService 来覆写 httpService 传入我们自定义的 Http 功能类:

class PlaceDescriptionServiceStubHttpService : public PlaceDescriptionService
{
public:
    // 这里创建了一个构造用于传入自定义的 Http 功能类
    PlaceDescriptionServiceStubHttpService(shared_ptr<HttpStub> ParmHttpStub)
            : HttpStub { ParmHttpStub }
    {
    }
    shared_ptr<Http> httpService() const override { return HttpStub; }
    shared_ptr<Http> HttpStub;
};

具体测试代码于之间差不多,唯一的差异就是使用智能指针进行了封装:

TEST_F(APlaceDescriptionService, ReturnsDescriptionForValidLocation)
{
    shared_ptr<HttpStub> httpStub { new HttpStub };
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;

    Expectation Expectations = EXPECT_CALL(*httpStub, initialize());
    //  get(expectedURL) 这一步同时验证了应该传给 get 的参数与 expectedURL 一致
    EXPECT_CALL(*httpStub, get(expectedURL)).After(Expectations);

    PlaceDescriptionServiceStubHttpService service { httpStub };
    service.summaryDescription(ValidLatitude, ValidLongitude);
}

覆写工厂方法展示了使用测试替身所带来的测试覆盖率漏洞。由于我们的测试覆写了产品代码中 httpService() 的实现,因此测试并没有使用实际产品代码中的这个函数。正如前面所说,要确保在集成测试中使用实际的服务!同时,不要在工厂方法中加入实际逻辑的代码,否则,未经测试的代码就会越来越多。工厂方法应当只返回协作类型的一个实例。

关于 Getter 其实与覆盖工厂差不多,区别如下:

class PlaceDescriptionService
{
public:
    PlaceDescriptionService();
    virtual ~PlaceDescriptionService() {}
    std::string summaryDescription(
    const std::string& latitude, const std::string& longitude) const;

private:
    // ...
    std::shared_ptr<Http> http_;

protected:
    virtual std::shared_ptr<Http> httpService() const;
};

PlaceDescriptionService::PlaceDescriptionService()
    : http_{make_shared<CurlHttp>()} {}
// ...
shared_ptr<Http> PlaceDescriptionService::httpService() const {
    return http_;
}

使用工厂

参考代码版本:Git SHA (398ef02bf56e9484335f5f1aed0f07caf01424b1);

工厂类是用来负责创建和返回实例的。如果你有一个 HttpFactory,那么就可以在测试中告诉它返回一个 HttpStub 实例而非 Http 实例。注意不要仅仅为了支持测试而引入工厂模式。

下例使工厂实现:

#include "CurlHttp.h"
#include "HttpFactory.h"

#include <memory>

using namespace std;

HttpFactory::HttpFactory()
{
    reset();
}

shared_ptr<Http> HttpFactory::get()
{
    return Instance;
}

void HttpFactory::reset()
{
    Instance = make_shared<CurlHttp>();
}

void HttpFactory::setInstance(shared_ptr<Http> NewInstance)
{
    Instance = NewInstance;
}

修改测试代码,在测试执行前,调用 setInstance 将工厂中产品替换掉:

class APlaceDescriptionService : public Test
{
public:
    static const string ValidLatitude;
    static const string ValidLongitude;

    shared_ptr<HttpStub> httpStub;
    shared_ptr<HttpFactory> factory;
    shared_ptr<PlaceDescriptionService> service;

    virtual void SetUp() override
    {
        factory = make_shared<HttpFactory>();
        service = make_shared<PlaceDescriptionService>(factory);
    }

    void TearDown() override
    {
        factory.reset();
        httpStub.reset();
    }
};

class APlaceDescriptionServiceWithHttpMock : public APlaceDescriptionService
{
public:
    void SetUp() override
    {
        APlaceDescriptionService::SetUp();
        httpStub = make_shared<HttpStub>();
        factory->setInstance(httpStub);
    }
};

TEST_F(APlaceDescriptionServiceWithHttpMock, MakesHttpRequestToObtainAddress)
{
    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };
    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;
    EXPECT_CALL(*httpStub, initialize());
    EXPECT_CALL(*httpStub, get(expectedURL));
    service->summaryDescription(ValidLatitude, ValidLongitude);
}

修改 summaryDescription() 代码,通过工厂获取 Http 实例:

//构造传入工厂实例
PlaceDescriptionService::PlaceDescriptionService(shared_ptr<HttpFactory> HttpFactory)
        : httpFactory_ { HttpFactory }
{
}

//冲获取接口改为从工厂获得实例
string PlaceDescriptionService::get(const string& Url) const
{
    // auto http = httpService();
    auto http = httpFactory_->get();
    http->initialize();
    return http->get(Url);
}

通过模板参数

参考代码版本:Git SHA (35932b820ad5de4a9bb584f1eeed6d4f6bfa86f4);

可以通过模板参数进行注入,它不需要客户程序传递一个协作类的实例。把 PlaceDescriptionService 声明为一个模板,它有一个类型名称,即 HTTP。在这个模板中加入一个成员变量,http_,其类型为 HTTP。因为我们想让客户使用类名 PlaceDescriptionService,所以我们将模板类改名为 PlaceDescriptionServiceTemplate。在定义模板之后,我们使用 typedef 来定义 PlaceDescriptionService,它将产品类 Http 作为 PlaceDescriptionServiceTemplate 的模板参数。

template<typename HTTP>
class PlaceDescriptionServiceTemplate {
public:
    // ...
    // 测试中的mock需要引用
    HTTP& http() {
        return http_;
    }
private:
    // ...
    std::string get(const std::string& url) {
        http_.initialize();
        return http_.get(url);
    }
    // ...
    HTTP http_;
};
class Http;
typedef PlaceDescriptionServiceTemplate<Http> PlaceDescriptionService;

在测试时也是通过声明一个服务模拟类,即 HttpStub,为模板 PlaceDescriptionServiceTemplate 的参数。

class APlaceDescriptionService : public Test
{
public:
    static const string ValidLatitude;
    static const string ValidLongitude;
};

class APlaceDescriptionServiceWithHttpMock : public APlaceDescriptionService
{
public:
    PlaceDescriptionServiceTemplate<HttpStub> service;
};

TEST_F(APlaceDescriptionServiceWithHttpMock, MakesHttpRequestToObtainAddress)
{

    string urlStart { "http://open.mapquestapi.com/nominatim/v1/reverse?format=json&" };

    auto expectedURL = urlStart + "lat=" + APlaceDescriptionService::ValidLatitude + "&" +
                       "lon=" + APlaceDescriptionService::ValidLongitude;
    EXPECT_CALL(service.http(), initialize());
    EXPECT_CALL(service.http(), get(expectedURL));

    service.summaryDescription(ValidLatitude, ValidLongitude);
}

注入工具

用于注入协作对象作为依赖对象的工具又称为依赖注入工具。首先,你需要掌握这里描述的手工注入技巧。其次,再去调研一下注入工具,来检查它们能否带来一些改善。依赖注入工具通常在完全支持反射机制的语言中更加有效。

设计会变化

在使用测试替身时会发现,正在改变你的设计方法。这样可能会令你不安。但是不要担心,这是正常的反应。

内聚与耦合

在面对一些慢速或者不稳定的协同对象,最好的方法是将它们隔离成单独的类。例如发送 HTTP 的请求,虽然它并不是非常复杂,放到一个小而独立的类中有些不值得,但是选择这样的方式将带来宠用的机会和更灵活的设计弹性(可以利用多态的方式来替换)。

另一种方法是创建更加过程化、弱内聚的代码,在测试后行的时候,一个 PlaceDescriptionService 的典型解决方案:

string PlaceDescriptionService::summaryDescription(
      const string& latitude, const string& longitude) const {
   // retrieve JSON response via API
   response_ = "";
   auto url = createGetRequestUrl(latitude, longitude);
   curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
   curl_easy_perform(curl);
   curl_easy_cleanup(curl);

   // parse json response
   Value location;
   Reader reader;
   reader.parse(response_, location);
   auto jsonAddress = location.get("address", Value::null);

   // populate address from json
   Address address;
   address.road = jsonAddress.get("road", "").asString();
   address.city = jsonAddress.get("hamlet", "").asString();
   address.state = jsonAddress.get("state", "").asString();
   address.country = jsonAddress.get("country", "").asString();

   return address.road + ", " + address.city + ", " +
          address.state + ", " + address.country;
}

这是典型的后测试代码。虽然我们可以将其拆成多个更小的函数,就像稍早前的做法一样,但是开发人员不会这么做。测试后行的开发者不习惯定期做重构,通常,他们不需要使用快速的测试来让重构变得更快、更安全。

  • 从设计的角度,这二十多行代码违背了单一责任原则——需要修改 summaryDescription() 的原因有多个:
    1. 首先,这个函数与 cURL 紧密耦合;
    2. 其次,这二十多行代码的函数算作冗长函数,想要完全理解它会花费很多时间。
    3. 再者,这种冗长的函数会导致不必要的代码重复。许多可重用的代码深埋于冗长的函数中,那么重用将不会发生。

即使这样也可以对其进行测试,例如使用 link substitution 写一个快速的单元测试(参见 8.9 节),或者写一个能发起一个即时的 REST 服务调用的集成测试。

而当践行 TDD 时,自然而然的会寻求高内聚、低耦合的设计。你会开始意识到灵活设计的好处,也会很快发现好的设计是怎样与测试和谐共存的,而且这些测试具有体量小,易于编写、阅读和维护的特点。

转嫁私有依赖

如果不需要方便于测试,PlaceDescriptionService 中的 HTTP 调用可以通过成员保存一个私有的实例,使得客户端调用不需要再 setter 或者构造注入。反之,需要客户端创建 HTTP 对象,并将其传给 PlaceDescriptionService 实例,就将其对 HTTP 的依赖转嫁给了客户端。

Q: setter 或构造函数注入是不是违反了信息隐藏?
A: 从客户端程序的角度来说,确实是违反了。但是有几种方法有效避免:

  1. 使用其他依赖注入方法,使得某人即使利用了这些暴露出的信息,也无法造成不好的影响。
  2. 提供某人的实例,先配置一个默认的实例,如果测试提供了 HttpStub 实例,那么它将被替换。而真正的客户端时不需要做什么改动的。

Q: 如果心怀恶意的开发者提供一个具有破坏性的 Http 实例呢?
A: 如果产品的客户是团队之外的人,那么可以选择其他注入形式。如果担心团队内部的开发者有意利用注入点做些不好的事,那你将面临更大的问题。

Q: 我逐渐能够测试驱动开发了,但我担心仅仅为了测试的目的而改变我的设计方法。我的团队中的其他人可能也会这么觉得。
A: 确切地了解软件能如期工作是改变设计方式的重要原因。你可以这样和同事讲:“我更关心代码是否如期工作。做出这么一个小的让步意味着我们能够更容易地测试代码,也会有更多的测试能够帮助我们更容易地打磨设计,我们也会对代码更有信心。所以你们能重新思考下我们的标准吗?”

使用测试替身的策略

使用测试替身和其他工具一样,最大的挑战不是学会怎么用它们,而是知道什么时候使用。

探索设计

现在假设 AddressExtractor 不存在,在测试驱动开发 summaryDescription() 时,肯定会意识到需要一些逻辑去处理 JSON 格式的响应,并返回一个格式化的字符串,通常开发者可以在 PlaceDescriptionService 中全部自己实现。

但是还可以寻求潜在的重用性、更大的灵活性和易于理解的代码设计,为了遵守单一原则,或许可以将需要的逻辑拆分成两块:解析 JSON 格式的响应和格式化输出。

TDD 会促使你在任何时候都要做出清醒的设计选择。例如你可以先写一个描述 summaryDescription() 怎样于外部协作者交互的测试。这个协作者的工作是得到一个 JSON 格式的响应,并返回相应的地址数据结构。目前而言,我们可以忽略实现这个协作者的细节,先集中使用 mock 来测试驱动开发 summaryDescription() ,就像前文中 HTTP 对象交互一样。

当以这种方式开发完成时,可以通过引入 mock 来替代缺失的协作者功能。在某一时刻,你或其他人将会实现这个协作者。这时你可以作出以下选择:移除 mock,以便待测试的代码使用产品级的协作者;保留 mock。

也许你已经作好了选择。如果协作者引入麻烦的依赖,那么就需要保留 mock。否则,移除 mock 会降低测试的复杂度。但是,你也许选择保留它,特别是需要用它来描述与协作者的交互式设计中的重要方面。
或许最好的指导方针需要考虑维护和理解测试所需的精力。如果没有 mock,事情可能会简单些,但不总是这样的。mock 可能需要大量的代码来初始化一些协作者,这也会增加维护测试的成本。

明智地使用测试替身

如果你要彻头彻尾地测试驱动开发一个带有快速测试的系统,这其中的大部分系统都需要使用测试替身。在使用测试替身时,可以参考下面的建议:

  • 重新思考设计:你是为了简化依赖对象的创建而使用 mock 的吗?如果是的话,那么重新修改依赖结构。你是不是在多个地方为同一个东西使用了 mock?如果是的话,那么重新设计来消除这样的重复。
  • 意识到单元测试覆盖率上的让步。一个测试替身代表了系统测试覆盖率的漏洞。因为测试替身提供的逻辑正是单元测试所不能覆盖的,所以一定要确保其他测试覆盖到这部分逻辑。
  • 重构测试。不能让对第三方工具的依赖成为问题。使用随意的方法会导致 mock 的大量增加,也会导致大量的重复和复杂难懂的测试。要像重构产品代码那样去重构测试!把期望声明封装进一个公共的辅助函数能够提高抽象、降低依赖度、减少重复代码。
  • 质疑以过度复杂的方式使用测试替身。如果你身陷 mock,那可能是因为你尝试了过度测试或你的设计有缺陷。这时使用多级的 mock 通常能解决问题。如果遇到了问题,那么就要将测试分解为多个小的测试来简化问题。同样也要检测一下所测代码是否可以拆解。
  • 表达力胜于功能。选择 mock 工具是因为它能帮助你创建高度抽象的测试,这些测试可以文档化系统行为和设计,而不仅仅是因为它有很酷的功能,可以做精巧和深奥的事情。除非必要,否则不要使用这些精巧深奥的功能。

其他关于测试替身的主题

称呼

本章中使用的术语有测试替身、mock 和 Stub。还有以下的定义称呼:

  • 测试替身:为测试而模拟产品代码的代码。
  • Stub:一个返回硬编码值的测试替身。
  • Spy:一个保存接受信息以便日后验证的测试替身。
  • mock:一个基于期望自我验证的测试替身。
  • Fake:提供产品类轻量级实现的测试替身。

测试替身该放在哪

一开始在相同的测试文件中定义测试替身,以便开发者看到。当多个 fixture 使用同一个测试替身时,再将声明移植到单独的头文件中。当不再需要查看测试替身时,应该把它们从视野中移除。

虚函数表和性能

引入测试替身是为了测试驱动开发一个有复杂依赖关系的类。许多创建测试替身的技术都需要创建派生类来覆写虚成员函数。如果之前的产品类没有虚函数,那么现在会有,并且会有一个虚函数表。虚函数表带来额外的间接性是有开销的。

然而,如果你必须大规模地调用模拟的产品函数,那么就需要先得到一些性能数据。如果性能降到不可接受的程度,就要考虑不同的模拟方式(或许基于模板的方案),重新设计(可能的话,通过优化其他地方来补偿性能损失),或者引入集成测试来弥补单元测试的不足(参见 10.2 节)。

模拟具体的类

在前面的示例中,我们通过实现一个纯虚的 Http 接口来创建一个 mock。很多系统主要由具体的类构成,因此没有多少此类接口。从设计的角度讲,使用接口是将系统中的一部分和另一部分隔离的方式。

依赖倒置原则(Dependency Inversion Principle,DIP)提倡让客户端依赖抽象的接口而非具体的实现,从而达到消除依赖的目的。以纯虚类的方式引入这种抽象,能够加快编译并隔离复杂性。更重要的是,它们能让测试变得简单。

在某些情况下也可以创建一个派生自一个具体类的 mock。问题是产生的类混合了产品代码和模拟的行为,这又被称为部分模拟。它可以体现出两方面问题:

  1. 首先,部分模拟通常能告诉你所模拟的类过大,如果你只需要其中的一部分而不是全部,那么就可以按照这些边界将类拆分成两个。
  2. 其次,使用部分模拟很可能使你陷入麻烦,以至于很快陷入模拟地狱。在一些情况下,你也可能遇到诡秘的问题:“啊!我原以为代码此时会与模拟的方法交互,但是看起来它与真正的方法交互了。”

结论便是:如果你使用诸如部分模拟此类难以驾驭的工具,那么你的设计正散发着坏味。举例来说,一个干净的设计会采用具体类继承一个接口的方式。这样,测试也不再需要部分模拟了,为这个接口创建一个测试替身即可。

总结

  • 真实的生产环境中对象必须协同工作,有时依赖合作对象使得 TDD 变得举步维艰,可以使用 MOCK 技术来解决这些问题。由于 Catch2 没有提供 MOCK 功能支持,本章节主要的代码演示均使用 GMOCK。但是 Catch2 也是可以利用 GMOCK 相关宏来完善测试需要,具体使用

  • 依赖问题:当构建产品代码是无法避免与第三方进行交互,这时就会有许多不确定性(接口不稳定、速度慢、甚至没有开发完成等等),这时就可以利用 MOCK 替身技术来避免阻塞。

  • 使用模拟对象工具:

    class HttpStub : public Http
    {
    public:
        MOCK_METHOD(void, initialize, (), (override));
        MOCK_METHOD(string, get, (const string&), (const override));
    };
    //CmakeLists.txt
    //当前使用的 googletest 版本为 1.10.x MOCK_METHOD 宏在编译器下会有错误信息,依据 gmock issues 无用报错 关闭相关警告
    add_definitions(-Wno-gnu-zero-variadic-macro-arguments)
    
  • 设立期望:

    • 通过 EXPECT_CALL 宏设立期望,这个宏配置 Google Mock 去验证以给定的参数去调用目标对象逻辑是否吻合。

    • EXPECT_CALL 宏支持许多修饰符。它的语法如下(?和代表每个修饰符的基数:?表示可以选用修饰符一次;表示可以多次使用修饰符。):

      EXPECT_CALL(mock-object, method (matchers)?)
          .With(multi-argument-matcher)  ?
          .Times(cardinality)            ?
          .InSequence(sequences)         *
          .After(expectations)           *
          .WillOnce(action)              *
          .WillRepeatedly(action)        ?
          .RetiresOnSaturation();        ?
      
  • 排除模拟失败的方法:

    1. 产品代码中是否有合理调用?
    2. 是否正确定义了模拟方法?
    3. 有没有将要模拟的成员函数声明为虚函数?
    4. MOCK_METHOD() 声明正不正确?
    5. 排除参数匹配的担忧,为所有参数和返回值使用通配符 testing::_ ,如果测试通过,那么可以确定有一个参数不能被正确匹配。
  • 使用测试替身:在引入一个测试替身时需要做两件事。第一,编写测试替身。第二,在目标测试中使用测试替身的一个实例。这样的做法又称作依赖注入(Dependency Injection,DI)。

  • 内聚与耦合:在典型的后测试代码情况,开发者可能不会经常进行重构,通常他们也无法使用快速的测试让重构变得更快更安全。这样往往产生一些冗余且耦合的代码。即使这样也可以对其进行测试,能够利用到的技术:

  • 转嫁私有依赖:注入模式往往需要对客户端调用产生依赖关系,即 setter 或者构造注入。

  • 使用 mock 的策略:在开发前期,可以通过引入 mock 来替代缺失的协作者功能。在某一时刻,你或其他人将会实现这个协作者。这时你可以作出以下选择:移除 mock,以便待测试的代码使用产品级的协作者;保留 mock。

    • 如果协作者引入麻烦的依赖,那么就需要保留 mock。否则,移除 mock 会降低测试的复杂度。但是,你也许选择保留它,特别是需要用它来描述与协作者的交互式设计中的重要方面。
  • 明智地使用测试替身:当需要从头 TDD 开发一个带有快速测试的系统,大部分系统功能都需要使用测试替身,在使用时可以参考明章节#智地使用测试替身。

Q/A

Q: setter 或构造函数注入是不是违反了信息隐藏?
A: 从客户端程序的角度来说,确实是违反了。但是有几种方法有效避免:

  1. 使用其他依赖注入方法,使得某人即使利用了这些暴露出的信息,也无法造成不好的影响。
  2. 提供某人的实例,先配置一个默认的实例,如果测试提供了 HttpStub 实例,那么它将被替换。而真正的客户端时不需要做什么改动的。

Q: 如果心怀恶意的开发者提供一个具有破坏性的 Http 实例呢?
A: 如果产品的客户是团队之外的人,那么可以选择其他注入形式。如果担心团队内部的开发者有意利用注入点做些不好的事,那你将面临更大的问题。

Q: 我逐渐能够测试驱动开发了,但我担心仅仅为了测试的目的而改变我的设计方法。我的团队中的其他人可能也会这么觉得。
A: 确切地了解软件能如期工作是改变设计方式的重要原因。你可以这样和同事讲:“我更关心代码是否如期工作。做出这么一个小的让步意味着我们能够更容易地测试代码,也会有更多的测试能够帮助我们更容易地打磨设计,我们也会对代码更有信心。所以你们能重新思考下我们的标准吗?”


文章作者: Layton
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Layton !
  目录