diff --git a/README.md b/README.md index 5e7683e..a9e108e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,288 @@ -七牛HTTP测试工具 +qiniupkg.com/httptest.v1 ============= +[![Build Status](https://travis-ci.org/qiniu/httptest.v1.svg?branch=develop)](https://travis-ci.org/qiniu/httptest.v1) [![GoDoc](https://godoc.org/qiniupkg.com/httptest.v1?status.svg)](https://godoc.org/qiniupkg.com/httptest.v1) + [![Qiniu Logo](http://open.qiniudn.com/logo.png)](http://www.qiniu.com/) -# 背景资料 +# 下载 + +``` +go get -u qiniupkg.com/httptest.v1 +``` + +# 概述 + +一些背景资料: * 演讲稿:[七牛如何做HTTP服务测试?](http://open.qiniudn.com/qiniutest.pdf) * 文字整理稿:[七牛如何做HTTP服务测试?](http://blog.qiniu.com/archives/2541) + +这是一套 HTTP 服务测试脚本框架及实用程序。我们定义了一个测试脚本的 DSL 语言。大体看起来是这样的: + +```bash +#为了让一套代码同时可以测试 Stage 环境和 Product 环境,我们推荐将 Host、AK/SK 作为环境变量传入 +#同时也避免了 AK/SK 这样敏感内容进入代码库 +match $(env) `envdecode QiniuTestEnv` +auth qboxtest `qbox $(env.AK) $(env.SK)` +host rs.qiniu.com $(env.RSHost) + +post http://rs.qiniu.com/delete/`base64 Bucket:Key` +auth qboxtest +#发起请求,并开始检查结果 +ret 200 + +post http://rs.qiniu.com/batch +auth qboxtest +match $(ekey1) |base64 Bucket:Key| +match $(ekey2) |base64 Bucket2:Key2| +json '{ + "op": ["/delete/$(ekey1)", "/delete/$(ekey2)"] +}' +#发起请求,并开始检查结果 +ret 200 + +post http://rs.qiniu.com/batch +auth qboxtest +form op=/delete/`base64 Bucket:Key`&op=/delete/`base64 Bucket:NotExistKey` +#发起请求,并开始检查结果 +ret 298 +json '[{"code": $(code1)}, {"code": 612}]' +match $(code1) 200 +``` + +## 命令详解 + +* 参见:[qiniutest](https://github.com/qiniu/qiniutest) + + +# 文法 + +整体是以命令行文法为基础。一条指令由命令及命令参数构成。命令及命令参数之间以空白字符(空格或TAB)分隔。如果某个参数中包含空格或其他特殊字符,则可以: + +* 用 \ 转义。比如 '\ ' 表示 ' '(空格),'\t' 表示 TAB 字符,等等。 +* 用 '...' 或 "..." 包含。两者都允许出现 $(var) 或 ${var} 形式表示的变量(这一点和 linux shell 有很大不同,详细见后文 “智能变量” 一节)。他们的区别在于:'...' 中不支持用 \ 转义,也不支持子命令(见后文 “子命令” 一节),出现任何内容都当作普通字符对待。所以 `'\t|abc|'` 用 "..." 来表达必须用 `"\\t\|abc\|"`。 + +# 类型系统 + +在 linux shell 的命令行中,所有的输入输出都是字符串,基本上没有类型系统可言。这一点我们和 linux shell 有很大的不同。我们的脚本有完备的类型系统。 + +我们支持并仅支持 json 文法所支持的所有类型。基础类型包括:number (在 Go 语言中是 float64)、bool、string。复合类型包括 array(在 Go 语言里面是 slice,不是数组)和 dictionary/object (在 Go 语言中是 map/interface{})。特别需要注意的是,我们的类型系统里面没有 int 类型。null 不是空 array,也不是空 dictionary,而是空 object。 + +由于我们采用了命令行文法,所以表达类型和常规文法有一定的差异。比如 200、'200'、"200" 都表示同一个东西:number 类型的 200。表达字符串 "200" 必须用 '"200"' 或者 "\"200\""。 + +上面样例中的 + +``` +'[{"code": $(code1)}, {"code": 612}]' +``` + +是一个 array,如果我们用紧凑文法写,避免任何的空白字符,可以写成这样: + +``` +[{"code":$(code1)},{"code":612}] +``` + +当然我们建议表达复合类型的时候,尽量还是用 '...' 来写,以保证可阅读性。 + +另外,考虑到 json 只有如下这些语法单元: + +* array/dictionary/object/string: `[...]`, `{...}`, `null`, `"..."` +* bool: `true`, `false` +* number: `0..9`, `-` +* var: `$(...)` // 我们扩展的语法 + +我们可以增加一条规则: + +* 所有 a..z 或 A..Z 开头的非 `true`, `false`, `null` 文本,被认为是合法的 json string。 + +也就是说,以下这段文本: + +``` +http://rs.qiniu.com/batch +``` + +等价于: + +``` +'"http://rs.qiniu.com/batch"' +``` + +另外,对于那些明确接受 string 参数的指令,也可以省略 '"..."' 这样的外衣。 + + +# 智能变量 + +和 linux shell 类似,我们也支持 $(var) 或 ${var} 格式的变量。但是,$(var) 并不像 linux shell 那样,在命令行词法分析阶段就被处理掉了,它是本 DSL 代表变元的语法成分,和 "..." 是常字符串的语法成分类似。另外,由于我们存在类型系统,所以 $(var) 表达的不是一段文本,而是一个可能是任意类型的 object。这带来这样一些差异: + +* 支持 dictionary/object、array 的 member 成员获取操作。比如对 dictionary 可以做 $(a.b.c) 形式的 member 访问。对于 array,理论上应该支持 $(a[1]) 这种形式,不过目前我们用的是 $(a.1)。表达 `a[2].b[3].c` 可以用 $(a.2.b.3.c) 表示。 +* 变量智能 marshal。在不同的场景下,变量的 marshal 结果会有差异。所以变量 marshal 需要上下文,而不是简单的字符串替换。比如 http://rs.qiniu.com/delete/$(ekey) 和 {"delete":$(ekey)} 这两个地方,$(ekey) 的 marshal 结果有很大的差别。除了出现在 json 里面的 $(ekey) 需要用 "..." 括起来,而 url 中不需要这样的显著差别外,对于特殊字符的 escape 转义方法也完全不同(但是这个细节经常容易被忽略)。 + +# 匹配(match) + +这几乎是这套 DSL 中最核心的概念。作为一门语言,有变量,自然会有赋值的概念。在这里的确有实现赋值的能力,但它不叫赋值,而是叫匹配。先看例子: + +```bash +match $(a.b) 1 +match $(a.c) '"hello"' +``` + +这个例子的结果是,得到了一个变量 a,其值为 {"b": 1, "c": "hello"}。 + +到现在为止,你看到的 match 像赋值的一面。但是你不能对已经绑定了特定值的变量再次赋不同的值: + +```bash +match $(a.b) 1 +match $(a.b) 1 #可以匹配,因为$(a.b)的值的确为1 +match $(a.b) 2 #失败,1和2不相等 +``` + +match 语句可以很复杂,如: + +```bash +match '{"c": {"d": $(d)}}' '{"c": {"d": "hello", "e": "world"}, "f": 1}' +``` + +一般地,match 命令的文法为: + +```bash +match +``` + +其中 `` 中不能出现未绑定的变量。`` 中则允许存在未绑定的变量。`` 和 `` 不必完全一致,但是 `` 中出现的,在 `` 中也必须出现,也就是要求是子集关系(`` 是 `` 的子集)。`` 中某个变量如果还未绑定,则按照对应的 `` 的值进行绑定;如果变量已经绑定,则两边的值必须是匹配的。 + +支撑我们整个 DSL 的基石,正是匹配文法。这里你可以把所有支持的命令都看成是 bool 表达式,如果返回 true 则成功,返回 false 则失败。我们看下一开始你看到的例子的片段: + +```bash +ret 298 +json '[{"code": $(code1)}, {"code": 612}]' +``` + +它表达的含义是,要求返回包的 StatusCode = 298,然后返回的 Response Body 必须能够匹配 `'[{"code": $(code1)}, {"code": 612}]'`,Content-Type 则必须为 `application/json`。它等价于: + +```bash +ret #不带参数的 ret 仅仅发起请求,并将返回包存储在 $(resp) 变量中,不做任何匹配 +match 298 $(resp.code) +match '["application/json"]' $(resp.header.Content-Type) +match '[{"code": $(code1)}, {"code": 612}]' $(resp.body) +``` + +# 子命令 + +如同 linux shell 一样,我们可以在一条命令中,嵌入另一个命令,并把该命令的执行结果作为本命令输入的一部分。这种嵌入其他命令之中的命令,我们称为子命令。样例如下: + +```bash +host rs.qiniu.com `env QiniuRSHost` + +match $(ekey1) |base64 Bucket:Key| +match $(ekey2) |base64 Bucket2:Key2| +``` + +和 linux shell 相比,我们多了一个子命令语法:`|...|`。这没有别的意图,纯粹是为了 Go 语言的友好性(linux 风格的子命令在 Go 里面表达需要特别费劲)。 + +我们样例中的两个子命令 `env` 和 `base64` 都是返回 string 类型。但作为我们 DSL 的一部分,子命令同样可以返回我们类型系统中的任意类型。所以,原则上我们的子命令如同变量一样,有着上下文相关的 marshal 需求,比如: + +```bash +post http://rs.qiniu.com/delete/`base64 Bucket:Key` +match $(foo) {"ekey":`base64 Bucket:Key`} +``` + +为了达到这样的效果,我们可以想象一种子命令的实现手法: + +```bash +match $(__auto_var_1) `base64 Bucket:Key` +post http://rs.qiniu.com/delete/$(__auto_var_1) + +match $(__auto_var_2) `base64 Bucket:Key` +match $(foo) {"ekey":$(__auto_var_2)} +``` + +也就是为每个子命令背地里生成一个自动变量,这样就可以让上下文相关的 marshal 能力,统一到由 “智能变量” 来支持。 + +# HTTP API 测试 + +请求包: + +```bash +req #可以简写为 post 或 get 或 delete +auth +header +header +body #可以简写为 form 或 json +``` + +返回包测试: + +```bash +ret #参数可不指定。不带参数的 ret 仅仅发起请求,并将返回包存储在 $(resp) 变量中 +header +header +body #可以简写为 json +``` + +# 多案例支持 + +一般测试案例框架都有选择性执行某个案例、多个案例共享 setUp、tearDown 这样的启动和终止代码。我们 DSL 也支持,如下: + +```bash +#代码片段1 +... + +case testCase1 +#代码片段2 +... + +case testCase2 +#代码片段3 +... + +tearDown +#代码片段4 +... +``` + +这段代码里面,“代码片段1” 将被认为是 setUp 代码,“代码片段4” 是 tearDown 代码,所有 testCase 开始前都会执行一遍“代码片段1”,退出前执行一遍“代码片段4”。每个 case 不用写 end 语句,遇到下一个 case 或者遇到 tearDown 就代表该 case 结束。 + + +# 运算能力 + +目前,这套 DSL 的运算能力是比较有限的。基本上只能做字符串拼接(concat)。如下: + +```bash +match $(c) '"Hello $(a), $(b)!"' +``` + +如果我们希望做复杂运算,我设想未来有可能通过支持 `calc` 这样的子命令。例如: + +```bash +match $(g) `calc max($(a), $(b), $(c)) + sin($(d)) + $(e)` +``` + +实现一个 `calc` 并不复杂,在 C++ 中用 [TPL](https://github.com/xushiwei/tpl) 只是几十分钟的事情(但在 Go 语言里面怎么做还没有特别去研究)。 + +考虑尽可能利用现有资源的话,我们可以考虑内嵌 lua 来实现 calc 支持。比如: + +```bach +match $(g) `calc math.max($(a), $(b), $(c)) + math.sin($(d)) + $(e)` +``` + +参考: + +* https://github.com/aarzilli/golua +* https://github.com/stevedonovan/luar (基于 golua 的进一步包装) + +有了 `calc` 事情就更有意思了,我们还可以直接用 `calc` 命令做断言,比如: + +```bash +calc $(a) < $(b) +``` + +在 `calc` 外面不套任何指令,由于 `calc` 返回 `false` 或 `true`,而基于前面返回 `true` 表示成功,返回 `false` 表示失败的原则,这个指令直接就是断言。当然为了友好,我们可以搞个别的名字: + +```bash +assert $(a) < $(b) +``` + +# 流程控制 + +等等,难道我们真要做一个图灵完备的语言?上面的运算能力的讨论已经有点脱离需求了(先实际使用中检验吧),我们就此打住吧。 diff --git a/assert.go b/assert.go new file mode 100644 index 0000000..b6238a3 --- /dev/null +++ b/assert.go @@ -0,0 +1,129 @@ +package httptest + +import ( + "reflect" + + "github.com/qiniu/x/jsonutil" +) + +// --------------------------------------------------------------------------- + +func castFloat(v interface{}) (float64, bool) { + + t := reflect.ValueOf(v) + + kind := t.Kind() + if kind < reflect.Int || kind > reflect.Float64 { + return 0, false + } + + if kind <= reflect.Int64 { + return float64(t.Int()), true + } + if kind <= reflect.Uintptr { + return float64(t.Uint()), true + } + return t.Float(), true +} + +func Equal(v1, v2 interface{}) bool { + + f1, ok1 := castFloat(v1) + f2, ok2 := castFloat(v2) + if ok1 != ok2 { + return false + } + if ok1 && f1 == f2 { + return true + } + return reflect.DeepEqual(v1, v2) +} + +func EqualSet(obj1, obj2 interface{}) bool { + + var v interface{} + if text, ok := obj1.(string); ok { + err := jsonutil.Unmarshal(text, &v) + if err != nil { + return false + } + obj1 = v + } + + v1 := reflect.ValueOf(obj1) + if v1.Kind() != reflect.Slice { + return false + } + + if text, ok := obj2.(string); ok { + err := jsonutil.Unmarshal(text, &v) + if err != nil { + return false + } + obj2 = v + } + + v2 := reflect.ValueOf(obj2) + if v2.Kind() != reflect.Slice { + return false + } + + if v1.Len() != v2.Len() { + return false + } + for i := 0; i < v1.Len(); i++ { + item1 := v1.Index(i) + if !hasElem(item1.Interface(), v2) { + return false + } + } + return true +} + +func hasElem(item1 interface{}, v2 reflect.Value) bool { + + for j := 0; j < v2.Len(); j++ { + item2 := v2.Index(j) + if Equal(item1, item2.Interface()) { + return true + } + } + return false +} + +// --------------------------------------------------------------------------- + +type Var struct { + Data interface{} + Ok bool +} + +func (p Var) Equal(v interface{}) bool { + + return p.Ok && Equal(p.Data, v) +} + +func (p Var) EqualObject(obj string) bool { + + if !p.Ok { + return false + } + + var v interface{} + err := jsonutil.Unmarshal(obj, &v) + if err != nil { + return false + } + + return Equal(p.Data, v) +} + +func (p Var) EqualSet(obj interface{}) bool { + + if !p.Ok { + return false + } + return EqualSet(p.Data, obj) +} + +// --------------------------------------------------------------------------- diff --git a/assert_test.go b/assert_test.go new file mode 100644 index 0000000..d52f645 --- /dev/null +++ b/assert_test.go @@ -0,0 +1,25 @@ +package httptest + +import ( + "testing" +) + +// --------------------------------------------------------------------------- + +func TestVar(t *testing.T) { + + if !(Var{1, true}.EqualObject("1")) { + t.Fatal("EqualObject failed") + } + + if !(Var{[]float64{1, 2}, true}.EqualSet("[2, 1]")) { + t.Fatal("EqualSet failed") + } + + if (Var{[]float64{1, 2, 3}, true}.EqualSet("[2, 1]")) { + t.Fatal("EqualSet failed") + } +} + +// --------------------------------------------------------------------------- + diff --git a/context.go b/context.go new file mode 100644 index 0000000..acc2952 --- /dev/null +++ b/context.go @@ -0,0 +1,222 @@ +package httptest + +import ( + "fmt" + "io" + "net/http" + "strings" +) + +// --------------------------------------------------------------------------- + +type TransportComposer interface { + Compose(base http.RoundTripper) http.RoundTripper +} + +type Executor interface { + Exec(ctx *Context, code string) +} + +// --------------------------------------------------------------------------- + +func mimeType(ct string) string { + + if ct == "form" { + return "application/x-www-form-urlencoded" + } + if ct == "binary" { + return "application/octet-stream" + } + if strings.Index(ct, "/") < 0 { + return "application/" + ct + } + return ct +} + +// --------------------------------------------------------------------------- + +type Request struct { + method string + url string + auth TransportComposer + ctx *Context + header http.Header + bodyType string + body string +} + +func NewRequest(ctx *Context, method, url string) *Request { + + ctx.DeleteVar("resp") + + p := &Request{ + ctx: ctx, + method: method, + url: url, + header: make(http.Header), + } + ctx.Log(" ====>", method, url) + return p +} + +func (p *Request) WithAuth(v interface{}) *Request { + + if v == nil { + p.auth = nil + return p + } + if name, ok := v.(string); ok { + auth, ok := p.ctx.auths[name] + if !ok { + p.ctx.Fatal("WithAuth failed: auth not found -", name) + } + p.auth = auth + return p + } + if auth, ok := v.(TransportComposer); ok { + p.auth = auth + return p + } + p.ctx.Fatal("WithAuth failed: invalid auth -", v) + return p +} + +func (p *Request) WithHeader(key string, values ...string) *Request { + + p.header[key] = values + return p +} + +func (p *Request) WithBody(bodyType, body string) *Request { + + p.bodyType = mimeType(bodyType) + p.body = body + return p +} + +func (p *Request) WithBodyf(bodyType, format string, v ...interface{}) *Request { + + p.bodyType = mimeType(bodyType) + p.body = fmt.Sprintf(format, v...) + return p +} + +func mergeHeader(to, from http.Header) { + + for k, v := range from { + to[k] = v + } +} + +func (p *Request) send() (resp *http.Response, err error) { + + var body io.Reader + if len(p.body) > 0 { + body = strings.NewReader(p.body) + } + req, err := p.ctx.newRequest(p.method, p.url, body) + if err != nil { + p.ctx.Fatal("http.NewRequest failed:", p.method, p.url, p.body, err) + return + } + + mergeHeader(req.Header, p.ctx.DefaultHeader) + + if body != nil { + if p.bodyType != "" { + req.Header.Set("Content-Type", p.bodyType) + } + req.ContentLength = int64(len(p.body)) + } + + mergeHeader(req.Header, p.header) + + t := p.ctx.transport + if p.auth != nil { + t = p.auth.Compose(t) + } + + c := &http.Client{Transport: t} + return c.Do(req) +} + +func (p *Request) Ret(code int) (resp *Response) { + + resp1, err := p.send() + resp = newResponse(p, resp1, err) + p.ctx.MatchVar("resp", map[string]interface{}{ + "body": resp.BodyObj, + "header": resp.Header, + "code": float64(resp.StatusCode), + }) + return resp.matchCode(code) +} + +// --------------------------------------------------------------------------- + +type TestingT interface { + Fatal(args ...interface{}) + Log(args ...interface{}) +} + +type NilTestingT struct {} + +func (p NilTestingT) Fatal(args ...interface{}) {} +func (p NilTestingT) Log(args ...interface{}) {} + +// --------------------------------------------------------------------------- + +type Context struct { + TestingT + varsMgr + hostsMgr + transport http.RoundTripper + auths map[string]TransportComposer + DefaultHeader http.Header + MatchResponseError func(message string, req *Request, resp *Response) +} + +func New(t TestingT) *Context { + + auths := make(map[string]TransportComposer) + p := &Context{ + TestingT: t, + auths: auths, + transport: http.DefaultTransport, + DefaultHeader: make(http.Header), + MatchResponseError: matchRespError, + } + p.initHostsMgr() + p.initVarsMgr() + return p +} + +func (p *Context) SetTransport(transport http.RoundTripper) { + + p.transport = transport +} + +func (p *Context) SetAuth(name string, auth TransportComposer) { + + p.auths[name] = auth +} + +func (p *Context) Exec(executor Executor, code string) *Context { + + executor.Exec(p, code) + return p +} + +func (p *Context) Request(method, url string) *Request { + + return NewRequest(p, method, url) +} + +func (p *Context) Requestf(method, format string, v ...interface{}) *Request { + + url := fmt.Sprintf(format, v...) + return NewRequest(p, method, url) +} + +// --------------------------------------------------------------------------- + diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..e2f1a80 --- /dev/null +++ b/context_test.go @@ -0,0 +1,130 @@ +package httptest + +import ( + "io" + "log" + "net/http" + "reflect" + "strconv" + "testing" + + "github.com/qiniu/http/httputil" + "github.com/qiniu/x/mockhttp" +) + +// --------------------------------------------------------------------------- + +type mockTestingT struct { + NilTestingT + messages []string + ok bool +} + +func (p *mockTestingT) Fatal(v ...interface{}) { + + log.Println(v...) + if len(v) > 0 { + if msg, ok := v[0].(string); ok { + p.messages = append(p.messages, msg) + } + } + p.ok = false +} + +// --------------------------------------------------------------------------- + +func init() { + + http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { + httputil.ReplyWith(w, 200, "application/text", []byte(req.URL.Path)) + }) + + http.HandleFunc("/form", func(w http.ResponseWriter, req *http.Request) { + req.ParseForm() + httputil.Reply(w, 200, req.Form) + }) + + http.HandleFunc("/json", func(w http.ResponseWriter, req *http.Request) { + h := w.Header() + if ct, ok := req.Header["Content-Type"]; ok { + h["Content-Type"] = ct + } + h.Set("Content-Length", strconv.FormatInt(req.ContentLength, 10)) + w.WriteHeader(200) + io.Copy(w, req.Body) + }) + + mockhttp.ListenAndServe("example.com", nil) +} + +func Test_ContextDemo(t *testing.T) { + + ctx := New(t) + ctx.SetTransport(mockhttp.DefaultTransport) + + ctx.Request("GET", "http://example.com/json"). + WithBody("json", `{"a": 1, "b": ["b1", "b2"]}`). + Ret(200). + WithBody("json", `{"a": 1}`) + + ctx.Request("GET", "http://example.com/form?a=1&b=b1&b=b2"). + Ret(200). + WithBody("json", `{"a": ["1"], "b": ["b1", "b2"]}`) +} + +// --------------------------------------------------------------------------- + +type caseContext struct { + method, url string + auth interface{} + reqHeader http.Header + reqBody string + reqBodyType string + code int + respHeader http.Header + respBody string + respBodyType string + messages []string + ok bool +} + +func TestContext(t1 *testing.T) { + + cases := []caseContext{ + { + method: "POST", + url: "http://example.com/hello", + auth: nil, + reqHeader: http.Header{}, + reqBody: "", + reqBodyType: "", + code: 200, + respHeader: http.Header{}, + respBody: "/hello", + respBodyType: "application/text", + messages: nil, + ok: true, + }, + } + + for _, c := range cases { + t := &mockTestingT{ok: true} + ctx := New(t) + ctx.SetTransport(mockhttp.DefaultTransport) + req := ctx.Request(c.method, c.url).WithAuth(c.auth) + for k, v := range c.reqHeader { + req.WithHeader(k, v...) + } + req.WithBody(c.reqBodyType, c.reqBody) + resp := req.Ret(c.code) + for k, v := range c.respHeader { + resp.WithHeader(k, v...) + } + resp.WithBody(c.respBodyType, c.respBody) + if !reflect.DeepEqual(t.messages, c.messages) || t.ok != c.ok { + t1.Fatal("TestContext failed:", c, *t) + } + } +} + +// --------------------------------------------------------------------------- diff --git a/exec/auto_var.go b/exec/auto_var.go new file mode 100644 index 0000000..5a84956 --- /dev/null +++ b/exec/auto_var.go @@ -0,0 +1,44 @@ +package exec + +import ( + "strconv" + + "github.com/qiniu/httptest" +) + +// --------------------------------------------------------------------------- + +type autoVarMgr struct { + varNo int64 +} + +func (p *autoVarMgr) enterFrame() (baseFrame int64) { + + return p.varNo +} + +func (p *autoVarMgr) substObject(ctx *httptest.Context, v interface{}) string { + + p.varNo++ + varName := getAutoVarName(p.varNo) + err := ctx.MatchVar(varName, v) + if err != nil { + ctx.Fatal("create auto variable failed:", err) + } + return "$(" + varName + ")" +} + +func (p *autoVarMgr) leaveFrame(ctx *httptest.Context, baseFrame int64) { + + for varNo := baseFrame + 1; varNo <= p.varNo; varNo++ { + varName := getAutoVarName(varNo) + ctx.DeleteVar(varName) + } +} + +func getAutoVarName(varNo int64) string { + + return "--auto-var-" + strconv.FormatInt(varNo, 10) +} + +// --------------------------------------------------------------------------- diff --git a/exec/cmds.go b/exec/cmds.go new file mode 100644 index 0000000..c0e6bb1 --- /dev/null +++ b/exec/cmds.go @@ -0,0 +1,274 @@ +package exec + +import ( + "fmt" + "strings" + + "github.com/qiniu/httptest" +) + +// --------------------------------------------------------------------------- + +type hostArgs struct { + Host string `arg:"host - eg. api.qiniu.com"` + Portal string `arg:"portal - eg. :"` +} + +func (p *Context) Cmd_host(ctx *httptest.Context, args *hostArgs) { + + ctx.SetHost(args.Host, args.Portal) +} + +// --------------------------------------------------------------------------- + +type authArgs struct { + AuthInfo interface{} `arg:"auth-information"` + AuthInterface interface{} `arg:"auth-interface,opt"` +} + +func (p *Context) Cmd_auth(ctx *httptest.Context, args *authArgs) { + + if args.AuthInterface == nil { + if req, ok := p.current.(*httptest.Request); ok { + req.WithAuth(args.AuthInfo) + } else { + ctx.Fatal("incorrect context to call `auth `") + } + } else { + if name, ok := args.AuthInfo.(string); ok { + if auth, ok := args.AuthInterface.(httptest.TransportComposer); ok { + ctx.SetAuth(name, auth) + return + } + } + ctx.Fatal("usage: auth ") + } +} + +// --------------------------------------------------------------------------- + +type printlnArgs struct { + Values []interface{} `arg:value` +} + +func (p *Context) Cmd_println(ctx *httptest.Context, args *printlnArgs) { + + p.Cmd_echo(ctx, args) +} + +func (p *Context) Cmd_echo(ctx *httptest.Context, args *printlnArgs) { + + fprintln := func(v ...interface{}) (int, error) { + ctx.Log(v...) + return fmt.Println(v...) + } + httptest.PrettyPrintln(fprintln, args.Values...) +} + +// --------------------------------------------------------------------------- + +type req1Args struct { + Url string `arg:url` +} + +type reqArgs struct { + Method string `arg:method` + Url string `arg:url` +} + +func (p *Context) Cmd_req(ctx *httptest.Context, args *reqArgs) { + + p.current = ctx.Request(strings.ToUpper(args.Method), args.Url) +} + +func (p *Context) Cmd_post(ctx *httptest.Context, args *req1Args) { + + p.current = ctx.Request("POST", args.Url) +} + +func (p *Context) Cmd_get(ctx *httptest.Context, args *req1Args) { + + p.current = ctx.Request("GET", args.Url) +} + +func (p *Context) Cmd_delete(ctx *httptest.Context, args *req1Args) { + + p.current = ctx.Request("DELETE", args.Url) +} + +func (p *Context) Cmd_put(ctx *httptest.Context, args *req1Args) { + + p.current = ctx.Request("PUT", args.Url) +} + +// --------------------------------------------------------------------------- + +type headerArgs struct { + Key string `arg:"key"` + Values []string `arg:"value,keep"` +} + +func (p *Context) Cmd_header(ctx *httptest.Context, args *headerArgs) { + + if req, ok := p.current.(*httptest.Request); ok { + req.WithHeaderv(args.Key, args.Values...) + } else if resp, ok := p.current.(*httptest.Response); ok { + resp.WithHeaderv(args.Key, args.Values...) + } else { + ctx.Fatal("incorrect context to call `header ...`") + } +} + +// --------------------------------------------------------------------------- + +type body1Args struct { + Body string `arg:"body,keep"` // keep: 保留 $(var) 不要自动展开 +} + +type bodyArgs struct { + BodyType string `arg:"body-type - eg. json, form, application/json, application/text, etc"` + Body string `arg:"body,keep"` // keep: 保留 $(var) 不要自动展开 + Keep bool `flag:"pure-text - keep $(var) as pure text"` +} + +func (p *Context) Cmd_body(ctx *httptest.Context, args *bodyArgs) { + + p.withBodyv(ctx, args.BodyType, args.Body, args.Keep) +} + +func (p *Context) Cmd_json(ctx *httptest.Context, args *body1Args) { + + p.withBodyv(ctx, "json", args.Body, false) +} + +func (p *Context) Cmd_bson(ctx *httptest.Context, args *body1Args) { + + p.withBodyv(ctx, "bson", args.Body, false) +} + +func (p *Context) Cmd_form(ctx *httptest.Context, args *body1Args) { + + if req, ok := p.current.(*httptest.Request); ok { + p.current = req.WithBodyv("form", args.Body) + } else { + ctx.Fatal("incorrect context to call `form `") + } +} + +func (p *Context) Cmd_binary(ctx *httptest.Context, args *body1Args) { + + if req, ok := p.current.(*httptest.Request); ok { + p.current = req.WithBodyv("binary", args.Body) + } else { + ctx.Fatal("incorrect context to call `binary `") + } +} + +func (p *Context) withBodyv(ctx *httptest.Context, bodyType, body string, keep bool) { + + if req, ok := p.current.(*httptest.Request); ok { + if keep { + req.WithBody(bodyType, body) + } else { + req.WithBodyv(bodyType, body) + } + } else if resp, ok := p.current.(*httptest.Response); ok { + if keep { + resp.WithBody(bodyType, body) + } else { + resp.WithBodyv(bodyType, body) + } + } else { + ctx.Fatal("incorrect context to call:", p.rawCmd) + } +} + +// --------------------------------------------------------------------------- + +type retArgs struct { + Code int `arg:"code,opt"` // opt: 可选参数 +} + +func (p *Context) Cmd_ret(ctx *httptest.Context, args *retArgs) { + + if req, ok := p.current.(*httptest.Request); ok { + p.current = req.Ret(args.Code) + } else { + ctx.Fatal("incorrect context to call `ret `") + } +} + +// --------------------------------------------------------------------------- + +type clearArgs struct { + VarNames []string `arg:var-name` +} + +func (p *Context) Cmd_clear(ctx *httptest.Context, args *clearArgs) { + + for _, varName := range args.VarNames { + ctx.DeleteVar(varName) + } +} + +// --------------------------------------------------------------------------- + +type matchArgs struct { + Expected interface{} `arg:"expected-object,keep"` // keep: 不要做 Subst + Source interface{} `arg:"source-object"` +} + +func (p *Context) Cmd_match(ctx *httptest.Context, args *matchArgs) { + + err := ctx.Match(args.Expected, args.Source) + if err != nil { + expected := substObject(ctx, args.Expected, httptest.Fmttype_Text) + ctx.Fatal("match failed:", err, "-", expected, args.Source) + } +} + +func substObject(ctx *httptest.Context, obj interface{}, ft int) interface{} { + + if obj2, err := ctx.Subst(obj, ft); err == nil { + return obj2 + } + return obj +} + +// --------------------------------------------------------------------------- + +type letArgs struct { + Expected interface{} `arg:"var,keep"` // keep: 不要做 Subst + Source interface{} `arg:"value"` +} + +func (p *Context) Cmd_let(ctx *httptest.Context, args *letArgs) { + + err := ctx.Let(args.Expected, args.Source) + if err != nil { + ctx.Fatal("let failed:", err, "-", args.Expected, "value:", args.Source) + } +} + +// --------------------------------------------------------------------------- + +type equalArgs struct { + Object1 interface{} `arg:"object1"` + Object2 interface{} `arg:"object2"` +} + +func (p *Context) Cmd_equal(ctx *httptest.Context, args *equalArgs) { + + if !httptest.Equal(args.Object1, args.Object2) { + ctx.Fatal("equal test failed:", p.rawCmd, "- objects:", args.Object1, args.Object2) + } +} + +func (p *Context) Cmd_equalSet(ctx *httptest.Context, args *equalArgs) { + + if !httptest.EqualSet(args.Object1, args.Object2) { + ctx.Fatal("equalSet test failed:", p.rawCmd, "- objects:", args.Object1, args.Object2) + } +} + +// --------------------------------------------------------------------------- diff --git a/exec/executor.go b/exec/executor.go new file mode 100644 index 0000000..6038188 --- /dev/null +++ b/exec/executor.go @@ -0,0 +1,111 @@ +package exec + +import ( + "reflect" + "strings" + + "github.com/qiniu/dyn/flag" + "github.com/qiniu/httptest" + "github.com/qiniu/x/cmdline" +) + +// --------------------------------------------------------------------------- + +type IContext interface { + GetRawCmd() string +} + +type IExternalContext interface { + FindCmd(ctx IContext, cmd string) reflect.Value +} + +var ( + External IExternalContext + ExternalSub IExternalContext +) + +// --------------------------------------------------------------------------- + +type Context struct { + rawCmd string + current interface{} + autoVarMgr +} + +func New() *Context { + + return &Context{} +} + +func (p *Context) Exec(ctx *httptest.Context, code string) { + + sctx := &subContext{ + ctx: ctx, + parent: p, + } + sctx.parser = cmdline.NewParser() + sctx.parser.ExecSub = sctx.execSubCmd + +retry: + code, err := p.parseAndExec(ctx, sctx, code) + if err == nil { + goto retry + } +} + +func (p *Context) GetRawCmd() string { + + return p.rawCmd +} + +func (p *Context) findCmd(cmd string) (method reflect.Value) { + + v := reflect.ValueOf(p) + method = v.MethodByName("Cmd_" + cmd) + if method.IsValid() { + return + } + + if External == nil { + return + } + return External.FindCmd(p, cmd) +} + +func (p *Context) parseAndExec( + ctx *httptest.Context, sctx *subContext, code string) (codeNext string, err error) { + + baseFrame := p.enterFrame() + defer p.leaveFrame(ctx, baseFrame) + + cmd, codeNext, err := sctx.parser.ParseCode(code) + if err != nil && err != cmdline.EOF { + ctx.Fatal(err) + return + } + if len(cmd) > 0 { + // + // p.Cmd_xxx(ctx *httptest.Context, cmd []string) + method := p.findCmd(cmd[0]) + if !method.IsValid() { + ctx.Fatal("command not found:", cmd[0]) + return + } + cmdLen := len(code) - len(codeNext) + p.rawCmd = strings.Trim(code[:cmdLen], " \t\r\n") + ctx.Log("====>", p.rawCmd) + _, err = runCmd(ctx, method, cmd) + if err != nil { + ctx.Fatal(cmd, "-", err) + return + } + } + return +} + +func runCmd(ctx *httptest.Context, method reflect.Value, cmd []string) (out []reflect.Value, err error) { + + return flag.ExecMethod(ctx.Context, method, reflect.ValueOf(ctx), cmd) +} + +// --------------------------------------------------------------------------- diff --git a/exec/multicase.go b/exec/multicase.go new file mode 100644 index 0000000..ff068c4 --- /dev/null +++ b/exec/multicase.go @@ -0,0 +1,174 @@ +package exec + +import ( + "errors" + + "github.com/qiniu/httptest" + "github.com/qiniu/x/cmdline" + + . "github.com/qiniu/x/ctype" +) + +var ( + ErrUnexpected = errors.New("unexpected") + ErrSyntaxError_Case = errors.New("syntax error: please use `case `") + ErrSyntaxError_TearDown = errors.New("syntax error: please use `tearDown`") +) + +// --------------------------------------------------------------------------- + +/* +一般测试案例框架都有选择性执行某个案例、多个案例共享 setUp、tearDown 这样的启动和终止代码。我们也可以考虑支持。设想如下: + + #代码片段1 + ... + + case testCase1 + #代码片段2 + ... + + case testCase2 + #代码片段3 + ... + + tearDown + #代码片段4 + ... + +这段代码里面,“代码片段1” 将被认为是 setUp 代码,“代码片段4” 是 tearDown 代码,所有 testCase 开始前都会执行一遍“代码片段1”,退出前执行一遍“代码片段4”。每个 case 不用写 end 语句,遇到下一个 case 或者遇到 tearDown 就代表该 case 结束。 +*/ +type Case struct { + Name string + Code string +} + +type Cases struct { + SetUp string + TearDown string + Items []Case +} + +func (p *Cases) Exec(ctx *httptest.Context, code string) { + + ectx := New() + ectx.Exec(ctx, p.SetUp) + if code != "" { + ectx.Exec(ctx, code) + } + ectx.Exec(ctx, p.TearDown) +} + +// --------------------------------------------------------------------------- + +func ExecCases(t httptest.TestingT, code string) (err error) { + + cases, err := ParseCases(code) + if err != nil { + return + } + + if len(cases.Items) == 0 { + ctx := httptest.New(t) + cases.Exec(ctx, "") + return nil + } + + for _, c := range cases.Items { + ctx := httptest.New(t) + ctx.Log("==========", c.Name, "===========") + cases.Exec(ctx, c.Code) + } + return +} + +// --------------------------------------------------------------------------- + +const ( + endOfLine = EOL | SEMICOLON // [\r\n;] + symbols = CSYMBOL_NEXT_CHAR + blanks = SPACE_BAR | TAB + blankAndEOLs = SPACE_BAR | TAB | endOfLine +) + +func ParseCases(code string) (cases Cases, err error) { + + seg, code, n := parseSeg(code) + cases.SetUp = seg + + for code != "" { + switch code[:n] { + case "case": + caseName, code2, err2 := parseCase(code[n:]) + if err2 != nil { + err = err2 + return + } + seg, code, n = parseSeg(code2) + cases.Items = append(cases.Items, Case{caseName, seg}) + case "tearDown": + code2, err2 := parseTearDown(code[n:]) + if err2 != nil { + err = err2 + return + } + seg, code, n = parseSeg(code2) + cases.TearDown = seg + default: + err = ErrUnexpected + return + } + } + return +} + +func parseSeg(code string) (seg string, codeNext string, n int) { + + code = cmdline.Skip(code, blankAndEOLs) + codeNext = code + for { + n = cmdline.Find(codeNext, blankAndEOLs) + switch codeNext[:n] { + case "case", "tearDown", "": + seg = code[:len(code)-len(codeNext)] + return + default: + k := cmdline.Find(codeNext, endOfLine) + codeNext = cmdline.Skip(codeNext[k:], blankAndEOLs) + } + } +} + +func parseCase(code string) (caseName string, codeNext string, err error) { + + code = cmdline.Skip(code, blanks) + symbolNext := cmdline.Skip(code, symbols) + n := len(code) - len(symbolNext) + if n > 0 { + code2 := cmdline.Skip(symbolNext, blanks) + if isEOL(code2) { + return code[:n], code2, nil + } + } + err = ErrSyntaxError_Case + return +} + +func parseTearDown(code string) (codeNext string, err error) { + + code = cmdline.Skip(code, blanks) + if isEOL(code) { + return code, nil + } + err = ErrSyntaxError_TearDown + return +} + +func isEOL(str string) bool { + + for _, c := range str { + return Is(endOfLine, c) + } + return true +} + +// --------------------------------------------------------------------------- diff --git a/exec/multicase_test.go b/exec/multicase_test.go new file mode 100644 index 0000000..3ceaaf5 --- /dev/null +++ b/exec/multicase_test.go @@ -0,0 +1,72 @@ +package exec + +import ( + "reflect" + "testing" +) + +// --------------------------------------------------------------------------- + +type caseParseCases struct { + code string + cases Cases + err error +} + +func TestParseCases(t *testing.T) { + + tests := []caseParseCases{ + { + code: ` + #代码片段1 + ... + + case testCase1 + #代码片段2 + ... + + case testCase2 + #代码片段3 + ... + + tearDown + #代码片段4 + ... + `, + cases: Cases{ + SetUp: `#代码片段1 + ... + + `, + TearDown: `#代码片段4 + ... + `, + Items: []Case{ + { + Name: "testCase1", + Code: `#代码片段2 + ... + + `, + }, + { + Name: "testCase2", + Code: `#代码片段3 + ... + + `, + }, + }, + }, + }, + } + for _, c := range tests { + cases, err := ParseCases(c.code) + if !reflect.DeepEqual(cases, c.cases) || err != c.err { + t.Fatal("ParseCases failed:", cases, err) + } + } +} + +// --------------------------------------------------------------------------- + diff --git a/exec/subcmds.go b/exec/subcmds.go new file mode 100644 index 0000000..139d2d0 --- /dev/null +++ b/exec/subcmds.go @@ -0,0 +1,78 @@ +package exec + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/qiniu/dyn/cmdarg" + "github.com/qiniu/httptest" +) + +// --------------------------------------------------------------------------- + +type base64Args struct { + StdEncoding bool `flag:"std - use standard base64 encoding. default is urlsafe base64 encoding."` + Fdecode bool `flag:"d - to decode data. default is to encode data."` + Data string `arg:"data"` +} + +func (p *subContext) Eval_base64(ctx *httptest.Context, args *base64Args) (string, error) { + + encoding := base64.URLEncoding + if args.StdEncoding { + encoding = base64.StdEncoding + } + if args.Fdecode { + b, err := encoding.DecodeString(args.Data) + if err != nil { + return "", err + } + return string(b), nil + } else { + return encoding.EncodeToString([]byte(args.Data)), nil + } +} + +// --------------------------------------------------------------------------- + +type envArgs struct { + VarName string `arg:"var-name"` +} + +func (p *subContext) Eval_env(ctx *httptest.Context, args *envArgs) (string, error) { + + v := os.Getenv(args.VarName) + if v != "" { + return v, nil + } + return "", fmt.Errorf("env `%s` not found", args.VarName) +} + +// --------------------------------------------------------------------------- + +type decodeArgs struct { + Text string `arg:"text"` +} + +func (p *subContext) Eval_decode(ctx *httptest.Context, args *decodeArgs) (interface{}, error) { + + return cmdarg.Unmarshal(args.Text) +} + +// --------------------------------------------------------------------------- + +type envdecodeArgs struct { + VarName string `arg:"var-name"` +} + +func (p *subContext) Eval_envdecode(ctx *httptest.Context, args *envdecodeArgs) (interface{}, error) { + + v := os.Getenv(args.VarName) + if v != "" { + return cmdarg.Unmarshal(v) + } + return nil, fmt.Errorf("env `%s` not found", args.VarName) +} + +// --------------------------------------------------------------------------- diff --git a/exec/subcontext.go b/exec/subcontext.go new file mode 100644 index 0000000..997ce70 --- /dev/null +++ b/exec/subcontext.go @@ -0,0 +1,72 @@ +package exec + +import ( + "errors" + "reflect" + + "github.com/qiniu/httptest" + "github.com/qiniu/x/cmdline" +) + +var ( + ErrSubCmdNotFound = errors.New("sub command not found") +) + +// --------------------------------------------------------------------------- + +type subContext struct { + parent *Context + parser *cmdline.Parser + ctx *httptest.Context + rawCmd string +} + +func (p *subContext) GetRawCmd() string { + + return p.rawCmd +} + +func (p *subContext) findCmd(cmd string) (method reflect.Value) { + + v := reflect.ValueOf(p) + method = v.MethodByName("Eval_" + cmd) + if method.IsValid() { + return + } + + if ExternalSub == nil { + return + } + return ExternalSub.FindCmd(p, cmd) +} + +func (p *subContext) execSubCmd(code string) (val string, err error) { + + cmd, err := p.parser.ParseCmd(code) + if err != nil { + return + } + + // + // p.Eval_xxx(ctx *httptest.Context, cmd []string) (interface{}, error) + method := p.findCmd(cmd[0]) + if !method.IsValid() { + return "", ErrSubCmdNotFound + } + + p.rawCmd = code + out, err := runCmd(p.ctx, method, cmd) + if err != nil { + return + } + if len(out) != 2 { + return "", ErrSubCmdNotFound + } + if !out[1].IsNil() { + return "", out[1].Interface().(error) + } + + return p.parent.substObject(p.ctx, out[0].Interface()), nil +} + +// --------------------------------------------------------------------------- diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8051d29 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/qiniu/httptest + +go 1.12 + +require ( + github.com/qiniu/dyn v0.0.0-20190906045710-c4ab5e0a4412 + github.com/qiniu/x v8.0.0+incompatible + gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e139f09 --- /dev/null +++ b/go.sum @@ -0,0 +1,15 @@ +github.com/qiniu/dyn v0.0.0-20190906045710-c4ab5e0a4412 h1:g/TRXnoT0y1zElIkBvrbwudjyn8LgbDNgJWCQ6eOJA0= +github.com/qiniu/dyn v0.0.0-20190906045710-c4ab5e0a4412/go.mod h1:zjv6PkMIiScGjPmV1wZHy8P3nMniAUY8o95L0xfpxHc= +github.com/qiniu/dyn v1.0.1 h1:b0R3kpzy678Dy2PR+vkmATwiRcVGiiOV3id1vAHB+b8= +github.com/qiniu/http v0.0.0-20190905152611-38a69a9cb396 h1:/b+vwwjEwXKPAaj4RysfOXoqLC6faQS8oDekWj2PyHw= +github.com/qiniu/http v0.0.0-20190905152611-38a69a9cb396/go.mod h1:af4XXoWKEutQvBJV+29GduxmjTxeJBJDJFlhygtr0LE= +github.com/qiniu/x v8.0.0+incompatible h1:5KRlM4br8QjRrvQq8iZpu8V86tyP2ociSqbSVvAGz7E= +github.com/qiniu/x v8.0.0+incompatible/go.mod h1:KpRKWYG/GaidPQVpoQ2Cvuvtts3gYnoo2PftgdmAiU4= +gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22 h1:VpOs+IwYnYBaFnrNAeB8UUWtL3vEUnzSCL1nVjPhqrw= +gopkg.in/mgo.v2 v2.0.0-20190816093944-a6b53ec6cb22/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA= +qiniupkg.com/dyn v1.0.1 h1:VdNWFW0PdiBlohC/ZH2EPuu4WoRmiNxM7msD3DV/o0c= +qiniupkg.com/dyn v1.0.1/go.mod h1:6p+RCv+0tqvnjLZxAbhe4+lBteqqfBytYynNqI5p7sA= +qiniupkg.com/http v0.0.0-20190905152611-38a69a9cb396 h1:h5ln4WX2CeXFU7gBEQM1LWd902yo2R4yy+gbZiErizs= +qiniupkg.com/httptest.v1 v0.0.0-20150719151625-f01bcf9afe88 h1:5hGHFPmhIXRzOfHUqboTSopVBQoie7n4qtMoD4cZGv4= +qiniupkg.com/httptest.v1 v0.0.0-20150721161941-2b2a6b8937b1 h1:nn+E6qWLkILnBMatPjDCoLJnmB+JXgCbF5bclSYW6WI= +qiniupkg.com/x v8.0.0+incompatible h1:SbuenQ7F/TwR1CQ1YwU9PlIltg55UNtnVw1mtEwsPPU= diff --git a/host.go b/host.go new file mode 100644 index 0000000..acdf550 --- /dev/null +++ b/host.go @@ -0,0 +1,62 @@ +package httptest + +import ( + "io" + "net/http" + "strings" +) + +// --------------------------------------------------------------------------- + +type hostsMgr struct { + hostToPortals map[string]string +} + +func (p *hostsMgr) initHostsMgr() { + + p.hostToPortals = make(map[string]string) +} + +func (p *hostsMgr) hostOf(url string) (host string, url2 string, ok bool) { + + var istart int + + // http://host/xxx or https://host/xxx + if strings.HasPrefix(url[4:], "://") { + istart = 7 + } else if strings.HasPrefix(url[4:], "s://") { + istart = 8 + } else { + return + } + + n := strings.IndexByte(url[istart:], '/') + if n < 1 { + return + } + + host = url[istart:istart+n] + portal, ok := p.hostToPortals[host] + if ok { + url2 = url[:istart] + portal + url[istart+n:] + } + return +} + +func (p *hostsMgr) newRequest(method, url string, body io.Reader) (req *http.Request, err error) { + + if host, url2, ok := p.hostOf(url); ok { + req, err = http.NewRequest(method, url2, body) + req.Host = host + return + } + return http.NewRequest(method, url, body) +} + +func (p *hostsMgr) SetHost(host string, portal string) { + + p.hostToPortals[host] = portal +} + +// --------------------------------------------------------------------------- + diff --git a/response.go b/response.go new file mode 100644 index 0000000..aeb791a --- /dev/null +++ b/response.go @@ -0,0 +1,205 @@ +package httptest + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "reflect" + "strings" + + "gopkg.in/mgo.v2/bson" +) + +// --------------------------------------------------------------------------- + +func matchJsonValue(expected, real interface{}) (string, bool) { + + if vexpected, ok := expected.(map[string]interface{}); ok { + if vreal, ok2 := real.(map[string]interface{}); ok2 { + for k, v := range vexpected { + v3, ok3 := vreal[k] + if !ok3 || !reflect.DeepEqual(v, v3) { + return "unmatched json response body - field " + k, false + } + } + return "", true + } + } else if reflect.DeepEqual(expected, real) { + return "", true + } + return "unmatched json response body", false +} + +func matchStream(a string, b []byte) bool { + + if len(b) != len(a) { + return false + } + for i, c := range b { + if a[i] != c { + return false + } + } + return true +} + +func decode(in io.Reader) (v interface{}, err error) { + + dec := json.NewDecoder(in) + err = dec.Decode(&v) + return +} + +// --------------------------------------------------------------------------- + +type Response struct { + ctx *Context + req *Request + Header http.Header + BodyType string + RawBody []byte + BodyObj interface{} + Err error + StatusCode int +} + +func newResponse(req *Request, resp *http.Response, err error) (p *Response) { + + p = &Response{ + req: req, + ctx: req.ctx, + Err: err, + } + if err != nil { + p.ctx.Fatal("http request failed:", err) + return + } + p.StatusCode = resp.StatusCode + p.Header = resp.Header + defer resp.Body.Close() + + p.RawBody, p.Err = ioutil.ReadAll(resp.Body) + if p.Err != nil { + p.ctx.Fatal("read response body failed:", p.Err) + return + } + p.BodyType = p.Header.Get("Content-Type") + if len(p.RawBody) > 0 { + switch p.BodyType { + case "application/json": + p.Err = json.Unmarshal(p.RawBody, &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) + } + } + return +} + +func (p *Response) matchCode(code int) (resp *Response) { + + resp = p + if code != 0 && code != resp.StatusCode { + p.matchRespError("unmatched status code") + } + return +} + +func (p *Response) WithHeader(k string, v ...string) (resp *Response) { + + resp = p + if !matchHeader(p.Header, k, v) { + p.matchRespError("unmatched response header: " + k) + } + return +} + +func matchHeader(header http.Header, k string, v []string) bool { + + if realv, ok := header[k]; ok { + if reflect.DeepEqual(v, realv) { + return true + } + } + return false +} + +func (p *Response) WithBodyf(bodyType, format string, v ...interface{}) (resp *Response) { + + return p.WithBody(bodyType, fmt.Sprintf(format, v...)) +} + +func (p *Response) WithBody(bodyType, body string) (resp *Response) { + + resp = p + if message, ok := matchBody(mimeType(bodyType), body, p); !ok { + p.matchRespError(message) + } + return +} + +func matchBody(expectedBodyType, expectedBody string, resp *Response) (string, bool) { + + if expectedBodyType != "" { + if expectedBodyType != resp.BodyType { + return "unmatched Content-Type", false + } + } + + if expectedBodyType == "application/json" && expectedBody != "" { + v, err := decode(strings.NewReader(expectedBody)) + if err != nil { + return "expected body isn't a valid json: " + err.Error(), false + } + return matchJsonValue(v, resp.BodyObj) + } + + if !matchStream(expectedBody, resp.RawBody) { + return "unmatched response body", false + } + return "", true +} + +func (p *Response) matchRespError(message string) { + + p.Err = errors.New(message) + p.ctx.MatchResponseError(message, p.req, p) +} + +func matchRespError(message string, p *Request, resp *Response) { + + p.ctx.Fatal( + message, "- req:", *p, "- resp:", resp.StatusCode, resp.Header, string(resp.RawBody)) +} + +// --------------------------------------------------------------------------- + +func (p *Response) GetBody(v interface{}) (resp *Response) { + + resp = p + switch ct := p.Header.Get("Content-Type"); ct { + case "application/json": + err := json.Unmarshal(p.RawBody, v) + if err != nil { + p.ctx.Fatal("GetBody failed:", err) + } + default: + p.ctx.Fatal("GetBody failed: unsupported Content-Type", ct) + } + return +} + +// --------------------------------------------------------------------------- diff --git a/response_test.go b/response_test.go new file mode 100644 index 0000000..8eff9ad --- /dev/null +++ b/response_test.go @@ -0,0 +1,160 @@ +package httptest + +import ( + "encoding/json" + "testing" + "net/http" +) + +// --------------------------------------------------------------------------- + +type caseMatchHeader struct { + expected http.Header + real http.Header + message string + ok bool +} + +func matchHeaders(expected, real http.Header) (message string, ok bool) { + + for k, v := range expected { + if !matchHeader(real, k, v) { + return "unmatched response header: " + k, false + } + } + return "", true +} + +func TestMatchHeader(t *testing.T) { + + cases := []caseMatchHeader{ + { + expected: http.Header{"a": {"a1"}, "b": {"b1", "b2"}}, + real: http.Header{"c": {"c1"}, "a": {"a1"}, "b": {"b1", "b2"}, "d": {"d1"}}, + ok: true, + }, + { + expected: http.Header{"a": {"a1"}, "b": {"b1", "b2"}}, + real: http.Header{"c": {"c1"}, "a": {"a1"}, "b": {"b1"}, "d": {"d1"}}, + message: "unmatched response header: b", + }, + { + expected: http.Header{"a": {"a1"}, "b": {"b1", "b2"}}, + real: http.Header{"c": {"c1"}, "a": {"a1"}, "d": {"d1"}}, + message: "unmatched response header: b", + }, + { + expected: http.Header{}, + real: http.Header{"c": {"c1"}, "a": {"a1"}, "d": {"d1"}}, + ok: true, + }, + } + for _, c := range cases { + message, ok := matchHeaders(c.expected, c.real) + if message != c.message || ok != c.ok { + t.Fatal("matchHeader failed:", c, message, ok) + } + } +} + +// --------------------------------------------------------------------------- + +type caseMatchBody struct { + expBody string + expBodyType string + respBody string + respBodyType string + message string + ok bool +} + +func TestMatchBody(t *testing.T) { + + cases := []caseMatchBody{ + { + expBody: ``, + expBodyType: "application/json", + respBody: ``, + respBodyType: "application/json", + ok: true, + }, + { + expBody: `abc`, + expBodyType: "", + respBody: `abc`, + respBodyType: "application/json", + ok: true, + }, + { + expBody: `abc`, + expBodyType: "application/text", + respBody: `abc`, + respBodyType: "application/text", + ok: true, + }, + { + expBody: `abc`, + expBodyType: "application/text", + respBody: `abc`, + respBodyType: "application/json", + message: "unmatched Content-Type", + }, + { + expBody: `{}`, + expBodyType: "application/json", + respBody: `{ }`, + respBodyType: "application/json", + ok: true, + }, + { + expBody: `{}`, + expBodyType: "application/json", + respBody: `{"aaa": "123"}`, + respBodyType: "application/json", + ok: true, + }, + { + expBody: `{"a": "a1", "b": ["b1", "b2"]}`, + expBodyType: "application/json", + respBody: `{"c": 1, "a": "a1", "b": ["b1", "b2"], "d": 2.0}`, + respBodyType: "application/json", + ok: true, + }, + { + expBody: `{"a": "a1", "b": ["b1", "b2"]}`, + expBodyType: "application/json", + respBody: `{"c": 1, "a": "a1", "d": 2.0}`, + respBodyType: "application/json", + message: "unmatched json response body - field b", + }, + { + expBody: `{"a": "a1", "b": ["b1", "b2"]}`, + expBodyType: "application/json", + respBody: `{"c": 1, "a": "a1", "b": [1], "d": 2.0}`, + respBodyType: "application/json", + message: "unmatched json response body - field b", + }, + } + for _, c := range cases { + resp := &Response{ + RawBody: []byte(c.respBody), + Header: make(http.Header), + } + if c.respBodyType != "" { + resp.BodyType = c.respBodyType + resp.Header["Content-Type"] = []string{c.respBodyType} + } + if len(resp.RawBody) > 0 { + if resp.BodyType == "application/json" { + resp.Err = json.Unmarshal(resp.RawBody, &resp.BodyObj) + } + } + message, ok := matchBody(c.expBodyType, c.expBody, resp) + if message != c.message || ok != c.ok { + t.Fatal("matchBody failed:", c, message, ok) + } + } +} + +// --------------------------------------------------------------------------- + diff --git a/var.go b/var.go new file mode 100644 index 0000000..13f2870 --- /dev/null +++ b/var.go @@ -0,0 +1,168 @@ +package httptest + +import ( + "fmt" + "io" + "strings" + + "github.com/qiniu/dyn/jsonext" + "github.com/qiniu/dyn/vars" + "github.com/qiniu/x/log" + + . "github.com/qiniu/dyn/cmdarg" +) + +const ( + Fmttype_Json = vars.Fmttype_Json + Fmttype_Form = vars.Fmttype_Form + Fmttype_Text = vars.Fmttype_Text + Fmttype_Jsonstr = vars.Fmttype_Jsonstr // 在json的字符串内 +) + +// --------------------------------------------------------------------------- + +func PrettyPrintln(fprintln func(...interface{}) (int, error), values ...interface{}) { + + texts := make([]interface{}, len(values)) + for i, val := range values { + if str, ok := val.(string); ok { + texts[i] = str + continue + } + text, err := jsonext.MarshalIndentToString(val, "", " ") + if err != nil { + log.Warn("Fprintln: MarshalToString failed -", err, "val:", val) + } + texts[i] = text + } + fprintln(texts...) +} + +func Fprintln(writer io.Writer, values ...interface{}) { + + fprintln := func(v ...interface{}) (int, error) { + return fmt.Fprintln(writer, v...) + } + PrettyPrintln(fprintln, values...) +} + +func Println(values ...interface{}) { + + PrettyPrintln(fmt.Println, values...) +} + +// --------------------------------------------------------------------------- + +type varsMgr struct { + *vars.Context +} + +func (p *varsMgr) initVarsMgr() { + + p.Context = vars.New() +} + +// --------------------------------------------------------------------------- + +func (p *Context) GetVar(key string) Var { + + v1, ok := p.varsMgr.GetVar(key) + return Var{v1, ok} +} + +func (p *Context) Requestv(method, urlWithVar string) *Request { + + url, err := p.SubstText(urlWithVar, Fmttype_Form) + if err != nil { + p.Fatal("invalid request url:", err) + } + return NewRequest(p, method, url) +} + +// --------------------------------------------------------------------------- + +func (p *Request) WithHeaderv(key string, valuesVar ...string) (resp *Request) { + + if len(valuesVar) == 1 { + valVar := valuesVar[0] + if strings.HasPrefix(valVar, "$(") && strings.HasSuffix(valVar, ")") { + valKey := valVar[2 : len(valVar)-1] + if val, ok := p.ctx.varsMgr.GetVar(valKey); ok { + if varr, ok := val.([]string); ok { + return p.WithHeader(key, varr...) + } + } + } + } + + values := make([]string, len(valuesVar)) + for i, valVar := range valuesVar { + val, err := p.ctx.SubstText(valVar, Fmttype_Text) + if err != nil { + p.ctx.Fatal("invalid request header:", err, "key:", key, "value:", valVar) + return p + } + values[i] = val + } + + return p.WithHeader(key, values...) +} + +func (p *Request) WithBodyv(bodyType, bodyWithVar string) *Request { + + var ft int + + bodyType = mimeType(bodyType) + switch bodyType { + case "application/json": + ft = Fmttype_Json + case "application/x-www-form-urlencoded": + ft = Fmttype_Form + default: + ft = Fmttype_Text + } + body, err := p.ctx.SubstText(bodyWithVar, ft) + if err != nil { + p.ctx.Fatal("invalid request body:", err) + return p + } + return p.WithBody(bodyType, body) +} + +// --------------------------------------------------------------------------- + +func (p *Response) WithHeaderv(key string, valuesVar ...string) (resp *Response) { + + values := make([]interface{}, len(valuesVar)) + for i, valVar := range valuesVar { + val, err := UnmarshalText(valVar) + if err != nil { + p.matchRespError("unmarshal failed: " + err.Error() + " - text: " + valVar) + return p + } + values[i] = val + } + + err := p.ctx.Match(values, p.Header[key]) + if err != nil { + p.matchRespError("match header failed: " + err.Error()) + } + return p +} + +func (p *Response) WithBodyv(bodyType, bodyVar string) (resp *Response) { + + bodyExpected, err := Unmarshal(bodyVar) + if err != nil { + p.matchRespError("unmarshal failed: " + err.Error() + " - json text: " + bodyVar) + return p + } + + err = p.ctx.Match(bodyExpected, p.BodyObj) + if err != nil { + p.matchRespError("match response body failed: " + err.Error()) + } + return p +} + +// ---------------------------------------------------------------------------