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() ... }