



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