HTTP 测试——七牛 httptest 测试工具包使用小结

HTTP 测试——七牛 httptest 测试工具包使用小结

编程那点事 随便写写 Go 3812 字 / 8 分钟

新年快乐

有一段时间没更博客了呀~ 一月初的时候是在忙着准备期末考试,今年很幸运一门都没有挂科。最没底的概率论被老师给捞过了,要知道这课我就只去了两次。计组很意外的期末考试拿了 84 的分数,不枉我每节课都坐第一排认真听讲哈哈。加上这学期省赛一等奖的绩点加成,下学期绩点可以有 4.1,奖学金应该是稳了的。 同时在期末考完毛概后,想稍稍放松一下,居然给 BiliSRC 交了两个高危。直接 8k 入账,像做梦一样。总之这阵子还是挺欧的。

这几天的话都在忙协会 HGAME 的一些事情。出题、答疑、写 WP、踢人等等等。一直没啥时间学新东西,本来设想的是寒假好好看看《编译原理》和《操作系统导论》的,但书到现在还是没翻几页啊…… 同时,这个寒假我还打算重构协会线下赛 AWD 的平台。如果可以的话,我打算将其作为一个长期维护的开源项目。目前是基本写完了后端 + 选手前端,因此今天想聊的就是我在写平台后端的过程中所遇到的 HTTP 测试的问题。

测试 测试 Test Test

AWD 平台是一个说简单也不算简单的项目。其相较于线上的 Jeopardy 夺旗赛,在题目、以及分数计算方面有很大的不同。AWD 的每个队伍都拥有相同题目下的不同靶机。分数非实时计算,而是按轮结算,并且存在几个队伍平分分数的情况;同时平分的分数又要加到队伍对应的靶机分数上…… 如此多的需求,若没有给后端编写测试样例进行测试,到后期 debug 的成本会很高。你在动调的时候很容易被这些繁杂的逻辑给绕进去。说到测试,可能大家比较熟知就是单元测试,即对软件中最小可测试单元进行测试,像一个类、一个函数。设置好入参以及期望的返回值即可,颇有 Online Judge 的味道。 Go 原生其实是有一套挺完善的测试模块的,即test包,这里不深入赘述。之前在看b站源码的时候,发现他们用的是goconveyhttps://github.com/smartystreets/goconvey 这个项目进行单元测试。这个项目的特点在于,他提供了一个很炫酷的 Web 界面来显示测试情况。

但是比较遗憾的是,我发现我的 AWD 平台后端,好像无法进行单元测试!原因是我的整个项目中并没有什么能够测试函数,几乎都是在从router进来,然后执行一个函数后结束。例如:

// 管理员登录
r.POST("/manager/login", func(c *gin.Context) {
	c.JSON(s.ManagerLogin(c))
})

唔…… 这就比较尴尬了呀。这样看来我只能编写脚本直接 HTTP 测试接口的功能是否正常。但是为每一个接口都编写测试脚本,设置传参,检查返回未免太麻烦了。在网上找了一阵子后,我发现了七牛的 httptest 开源 HTTP 测试工具包,正好符合我的需求。 早在 Gopher China 2015 上,七牛创始人许式伟就分享了《七牛如何做HTTP服务测试》,介绍的就是七牛自己的 httptest。这个工具直到近几年才开源好像。现在已经是七牛内部 HTTP 测试的首选。

httptest 的主要的亮点在于她自己的测试脚本 DSL 语言,她的语法简单,你可以用她快速写出一个 HTTP 测试:

# /manager/login 管理员登录
# 成功
post $(host)/login
json '{"Name": "e99", "Password": "123456"}'
ret 200
json '{"data": $(token),"error": 0,"msg": "success"}'

这是我 AWD 平台中管理员登录接口的测试。可以看到,短短几行就设置了请求方法、请求体,并且对期望的返回状态码,返回体进行了设置。甚至还可以对返回的参数进行绑定保存。之后只要在命令行里执行 httptest 程序测试即可。这一点放在后文中详细讨论。

@Li4n0 之后也给我安利了 JetBrains 全家桶里自带的 httpClient test。httpClient test 也可以执行相应.http文件,测试 HTTP 请求。但我感觉这个东西更像是 PostMan,主要用来测试接口是否正常;对于返回内容的检测,功能不是很强大。并且 httpClient test 需要编写相对繁杂的测试文件。

开始魔改!

但作为一个只有 500+ stars,40+ forks 的项目,httptest 确实还不太健壮。她还是有一些不足的地方,因此我在 httptest 上自己修复魔改了一些功能。可能写得不是很优雅,不过足够我使用了。

Content-Type 匹配的 bug

这个问题我已经给 httptest 提了 issue,并给出了我认为的解决方案。

Content-Type 形如 application/json; charset=utf-8 时,无法进行 JSON 解析

但可惜直到现在(5 天后),我仍未收到回复,可能是因为在新年假期吧。

2020 年 3 月 8 日凌晨更新:Pull Request 通过,代码合并进主分支。喜提七牛 Contributor。(๑•̀ㅂ•́)و✧

上文中我提到了,httptest 可以将 HTTP 的返回体与我们预设的进行匹配。对于string字符串来说,就是常规的比较是否相等。而对于 JSON 类型的返回数据,httptest 是先对其使用json.Unmarshal解析到一个interface上:

p.Err = json.Unmarshal(p.RawBody, &p.BodyObj)

然后使用反射来对interface的键值进行比较。 但是我这里却在比较时产生了报错:

response.go:184: match response body failed: unmatched value

这里说的是响应体匹配失败。我在翻阅了 httptest 的源码之后发现了她的问题所在: https://github.com/qiniu/httptest/blob/master/response.go#L92

switch p.BodyType {
    case "application/json":
        p.Err = json.Unmarshal(p.RawBody, &p.BodyObj)
        fmt.Println(p.BodyObj)
        if p.Err != nil {
            p.ctx.Fatal("unmarshal response body failed:", p.Err)
        }
    case "application/bson":
        p.Err = bson.Unmarshal(p.RawBody, &p.BodyObj)
        if p.Err != nil {
            p.ctx.Fatal("unmarshal response body failed:", p.Err)
        }
        b, _ := json.Marshal(p.BodyObj)
        p.BodyObj = nil
        p.Err = json.Unmarshal(b, &p.BodyObj)
    default:
        p.BodyObj = string(p.RawBody)
}

httptest 根据返回头中的Content-Type来判断返回数据类型的。但她是通过switch-case进行匹配,即Content-Type必须为application/json才会被当做 JSON 进行处理。而 Gin 的响应头为application/json; charset=utf-8,这就使得其进入default分支将返回体当做字符串处理了。因而出现了匹配失败的错误。 我的修复方式是用strings.Contains()来匹配字符串中是否存在application/json,修改后的代码如下:

if strings.Contains(p.BodyType, "application/json") {
	p.Err = json.Unmarshal(p.RawBody, &p.BodyObj)
	if p.Err != nil {
		p.ctx.Fatal("unmarshal response body failed:", p.Err)
	}
} else if strings.Contains(p.BodyType, "application/bson") {
	p.Err = bson.Unmarshal(p.RawBody, &p.BodyObj)
	if p.Err != nil {
		p.ctx.Fatal("unmarshal response body failed:", p.Err)
	}
	b, _ := json.Marshal(p.BodyObj)
	p.BodyObj = nil
	p.Err = json.Unmarshal(b, &p.BodyObj)
} else {
	p.BodyObj = string(p.RawBody)
}

这下就没问题啦~

提问:该如何为一个 Go Module 贡献代码??

在解决上述问题的过程中,我需要修改 httptest 这个包。同时要在我的项目中使用经过我修改后的包。 我刚开始的思路是在 Goland 里单独设置当前项目的$GOPATH,将依赖包拉取到这个单独的$GOPATH中进行修改。但这样做是十分不正确的。首先 GoLand 会报错说$GOPATH中的文件不属于本项目不允许我修改;其次是强行修改后的文件不方便 push 到 GitHub 上。

又是一顿上网找了很多资料后,我找到了解决的办法:What is the proper golang workflow for developing locally and pushing to github?

replace example.com/original/import/path => /your/forked/import/path

**我可以在go.mod中替换 Go Module 的路径为其它的目录。**这样就可以在本地修改调试 Go Module,满意后再 push 到 GitHub 上。 同时,我才发现 GitHub 上的 Go Module 版本其实就是 GitHub Releases 版本。Push 上去后随手发个 Release,等上一会儿后就可以用go get拉到最新的包了。

这大概就是我摸索出来的 Go Module 的开发流程,不知道是不是正规的流程。如果有问题,欢迎大家指出。

最后,我还想要个随机字符串~

对于需要插入、修改数据的接口,往往需要生成随机的测试内容。我最初的想法是设置一个环境变量为随机的字符串,然后在 httptest 里使用$(env.xxx)来获取。 但转而一想,如果我能魔改一下 httptest 的 DSL 解释器,使其在解析变量时将我指定的变量换成随机字符串不就好了? 因此,我又花了些时间动调了一下 httptest 的源码。没有学过编译原理的我阅读起来有些吃力。 大概整理了一下她的执行流程图,可能不是很准确:

可以看到 httptest 是一行行边解释边执行的 其中对于$(xxx)变量的解析,是在parseStructArgs -> parseArg -> SubstText -> Subst -> decodeVar

if pos+2 < len(exprvar) {
	start := exprvar[pos+1]
	switch start {
		case '(', '{':
		end := ")"
		if start == '{' {
			end = "}"
		}
		exprleft := exprvar[pos+2:]
		pos2 := strings.Index(exprleft, end)
		if pos2 >= 0 {
			key2 := exprleft[:pos2]
			val2, err2 := GetAsString(data, key2, ft, failIfNotExists)
			if err2 != nil {
				return nil, 0, errors.Info(err2, "expr.Exec - GetAsString failed", key2).Detail(err2)
			}
			return append(b, val2...), len(exprvar) - len(exprleft[pos2+1:]), nil
		}
	}
}

这一段看起来其实挺有意思的,首先是将变量开头的$去除,然后匹配{}(),字符串截取提取出来括号中间变量的名称,然后进入GetAsString函数获取该变量的值。

func GetAsString(data interface{}, key string, ft int, failIfNotExists bool) (val string, err error) {
	v, ok := dyn.Get(data, key)
	if !ok {
		if failIfNotExists {
			return "", errors.New("dyn.Get key `" + key + "` not found")
		}
		return AsString(nil, ft)
	}
	return AsString(v, ft)
}

看到这里我心态就炸了,传入的key就是我们的变量名,它的值是进入dyn.Get()函数通过反射从data中获取的。 然而dyn这一套属于另一个七牛的包github.com/qiniu/dyn,这应该是他们为了这个动态语言写的包。如果想控制变量的解析的话,那我还得魔改这个包。这就未免有些麻烦了。 最后我想出一个很愚蠢的办法——当 DSL 代码被送入exec.ExecCases,我先将代码中所有的随机字符串占位符替换成随机字符串。

randomStr := randstr.String(16)
b = []byte(strings.Replace(string(b), "[RANDOM_STR]", "\""+randomStr+"\"", -1))
err = exec.ExecCases(t, string(b))

虽然比较暴力比较蠢,不过又不是不能用?!

总得来说这一波源码跟下来,虽然我没做出啥实质的成果,但我看到了一个简单的语言解释器是怎样写的。代码一层层动调跟下去后,才发现底层的原理居然是如此的简单甚至是……有趣?

同时自己还写了个执行工具

七牛自己的 httptest 工具是他们自己开源的 qiniutest。这东西就是简单的给 httptest 包了层命令行,读取了命令行传入的文件内容,丢给 httptest 处理。它一次只能处理一个qtf文件,且还必须要手动指定。我在这上面做了简单的修改,使其直接获取当前目录下所以的qtf文件并全部执行测试。 工具开源地址:wuhan005/httptest_executor go install安装后,现在我只需要在目录下执行httptest_executor就可以执行所有测试文件~

总结一下吧

最近过年,效率其实挺低下的,就这么些个代码我陆陆续续地看了好几天。然后博客又咕了好几天,现在总算抽时间写完了…… 接下来几天要好好看书继续写平台前端了。啊…… 还有 5 天才能看到 hanser 直播,我好难受啊…… 有一说一,每次边听 hanser 直播边写代码,心情总是超级的好,心里总是会被幸福感充满。hanser 最棒了!!