Go语言精进之路:从新手到高手的编程思想、方法和技巧(2)
上QQ阅读APP看书,第一时间看更新

44.2 stub:对返回结果有一定预设控制能力的替身

stub也是一种替身概念,和fake替身相比,stub替身增强了对替身返回结果的间接控制能力,这种控制可以通过测试前对调用结果预设置来实现。不过,stub替身通常仅针对计划之内的结果进行设置,对计划之外的请求也无能为力。

使用Go标准库net/http/httptest实现的用于测试的Web服务就可以作为一些被测对象所依赖外部服务的stub替身。下面就来看一个这样的例子。

该例子的被测代码为一个获取城市天气的客户端,它通过一个外部的天气服务来获得城市天气数据:

// chapter8/sources/stubtest1/weather_cli.go
type Weather struct {
    City    string `json:"city"`
    Date    string `json:"date"`
    TemP    string `json:"temP"`
    Weather string `json:"weather"`
}

func GetWeatherInfo(addr string, city string) (*Weather, error) {
    url := fmt.Sprintf("%s/weather?city=%s", addr, city)
    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("http status code is %d", resp.StatusCode)
    }

    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }

    var w Weather
    err = json.Unmarshal(body, &w)
    if err != nil {
        return nil, err
    }

    return &w, nil
}

下面是针对GetWeatherInfo函数的测试代码:

// chapter8/sources/stubtest1/weather_cli_test.go
var weatherResp = []Weather{
    {
        City:    "nanning",
        TemP:    "26~33",
        Weather: "rain",
        Date:    "05-04",
    },
    {
        City:    "guiyang",
        TemP:    "25~29",
        Weather: "sunny",
        Date:    "05-04",
    },
    {
        City:    "tianjin",
        TemP:    "20~31",
        Weather: "windy",
        Date:    "05-04",
    },
}

func TestGetWeatherInfoOK(t *testing.T) {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter,
        r *http.Request) {
        var data []byte

        if r.URL.EscapedPath() != "/weather" {
            w.WriteHeader(http.StatusForbidden)
        }

        r.ParseForm()
        city := r.Form.Get("city")
        if city == "guiyang" {
            data, _ = json.Marshal(&weatherResp[1])
        }
        if city == "tianjin" {
            data, _ = json.Marshal(&weatherResp[2])
        }
        if city == "nanning" {
            data, _ = json.Marshal(&weatherResp[0])
        }

        w.Write(data)
    }))
    defer ts.Close()
    addr := ts.URL
    city := "guiyang"
    w, err := GetWeatherInfo(addr, city)
    if err != nil {
        t.Fatalf("want nil, got %v", err)
    }
    if w.City != city {
        t.Errorf("want %s, got %s", city, w.City)
    }
    if w.Weather != "sunny" {
        t.Errorf("want %s, got %s", "sunny", w.City)
    }
}

在上面的测试代码中,我们使用httptest建立了一个天气服务器替身,被测函数GetWeatherInfo被传入这个构造的替身天气服务器的服务地址,其对外部服务的依赖需求被满足。同时,我们看到该替身具备一定的对服务返回应答结果的控制能力,这种控制通过测试前对返回结果的预设置实现(上面例子中设置了三个城市的天气信息结果)。这种能力可以实现对测试结果判断的控制。

接下来,回到mailclient的例子。之前的示例只聚焦于对Send的测试,而忽略了对Compose的测试。如果要验证邮件内容编排得是否正确,就需要对ComposeAndSend方法的返回结果进行验证。但这里存在一个问题,那就是ComposeAndSend依赖的签名获取方法sign.Get中返回的时间签名是当前时间,这对于测试代码来说就是一个不确定的值,这也直接导致ComposeAndSend的第一个返回值的内容是不确定的。这样一来,我们就无法对Compose部分进行测试。要想让其具备可测性,我们需要对被测代码进行局部重构:可以抽象出一个Signer接口(这样就需要修改创建mailClient的New函数),当然也可以像下面这样提取一个包级函数类型变量(考虑到演示的方便性,这里使用了此种方法,但不代表它比抽象出接口的方法更优):

// chapter8/sources/stubtest2/mailclient.go
var getSign = sign.Get // 提取一个包级函数类型变量
func (c *mailClient) ComposeAndSend(subject, sender string, destinations []string, body string) (string, error) {
    signTxt := getSign(sender)
    newBody := body + "\n" + signTxt

    for _, dest := range destinations {
        err := c.mlr.SendMail(subject, sender, dest, newBody)
        if err != nil {
            return "", err
        }
    }
    return newBody, nil
}

我们看到新版mailclient.go提取了一个名为getSign的函数类型变量,其默认值为sign包的Get函数。同时,为了演示,我们顺便更新了ComposeAndSend的参数列表以及mailer的接口定义,并增加了一个sender参数:

// chapter8/sources/stubtest2/mailer/mailer.go
type Mailer interface {
    SendMail(subject, sender string, destination string, body string) error
}

由于getSign的存在,我们就可以在测试代码中为签名获取函数(sign.Get)建立stub替身了。

// chapter8/sources/stubtest2/mailclient_test.go
var senderSigns = map[string]string{
    "tonybai@example.com":  "I'm a go programmer",
    "jimxu@example.com":    "I'm a java programmer",
    "stevenli@example.com": "I'm a object-c programmer",
}
func TestComposeAndSendWithSign(t *testing.T) {
    old := getSign
    sender := "tonybai@example.com"
    timestamp := "Mon, 04 May 2020 11:46:12 CST"

    getSign = func(sender string) string {
        selfSignTxt := senderSigns[sender]
        return selfSignTxt + "\n" + timestamp
    }

    defer func() {
        getSign = old //测试完毕后,恢复原值
    }()

    m := &fakeOkMailer{}
    mc := New(m)
    body, err := mc.ComposeAndSend("hello, stub test", sender,
        []string{"xxx@example.com"}, "the test body")
    if err != nil {
        t.Errorf("want nil, got %v", err)
    }

    if !strings.Contains(body, timestamp) {
        t.Errorf("the sign of the mail does not contain [%s]", timestamp)
    }

    if !strings.Contains(body, senderSigns[sender]) {
        t.Errorf("the sign of the mail does not contain [%s]", senderSigns [sender])
    }

    sender = "jimxu@example.com"
    body, err = mc.ComposeAndSend("hello, stub test", sender,
           []string{"xxx@example.com"}, "the test body")
    if err != nil {
        t.Errorf("want nil, got %v", err)
    }

    if !strings.Contains(body, senderSigns[sender]) {
        t.Errorf("the sign of the mail does not contain [%s]", senderSigns [sender])
    }
}

在新版mailclient_test.go中,我们使用自定义的匿名函数替换了getSign原先的值(通过defer在测试执行后恢复原值)。在新定义的匿名函数中,我们根据传入的sender选择对应的个人签名,并将其与预定义的时间戳组合在一起返回给ComposeAndSend方法。

在这个例子中,我们预置了三个Sender的个人签名,即以这三位sender对ComposeAndSend发起请求,返回的结果都在stub替身的控制范围之内。

在GitHub上有一个名为gostub(https://github.com/prashantv/gostub)的第三方包可以用于简化stub替身的管理和编写。以上面的例子为例,如果改写为使用gostub的测试,代码如下:

// chapter8/sources/stubtest3/mailclient_test.go
func TestComposeAndSendWithSign(t *testing.T) {
    sender := "tonybai@example.com"
    timestamp := "Mon, 04 May 2020 11:46:12 CST"

    stubs := gostub.Stub(&getSign, func(sender string) string {
        selfSignTxt := senderSigns[sender]
        return selfSignTxt + "\n" + timestamp
    })
    defer stubs.Reset()
    ...
}