diff --git a/README.md b/README.md index 30fc103..d45cb46 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ run ":8080" ### yaptest: HTTP Test Framework -This classfile has the file suffix `_ytest.gox`. +yaptest is a web server testing framework. This classfile has the file suffix `_ytest.gox`. Suppose we have a web server ([foo/get_p_#id.yap](ytest/demo/foo/get_p_%23id.yap)): diff --git a/gop.mod b/gop.mod index e656abd..750bc58 100644 --- a/gop.mod +++ b/gop.mod @@ -14,7 +14,7 @@ import github.com/goplus/yap/ytest/auth/jwt project _ytest.gox App github.com/goplus/yap/ytest github.com/goplus/yap/test -class _ytest.gox Case +class _ytest.gox CaseApp import github.com/goplus/yap/ytest/auth/jwt diff --git a/test/match.go b/test/match.go index 78c22e4..5d18c1c 100644 --- a/test/match.go +++ b/test/match.go @@ -40,6 +40,27 @@ func toMapAny[T basetype](val map[string]T) map[string]any { return ret } +func tryToMapAny(val any) (ret map[string]any, ok bool) { + v := reflect.ValueOf(val) + return castMapAny(v) +} + +func castMapAny(v reflect.Value) (ret map[string]any, ok bool) { + if v.Kind() != reflect.Map || v.Type().Key() != tyString { + return + } + ret, ok = make(map[string]any, v.Len()), true + for it := v.MapRange(); it.Next(); { + key := it.Key().String() + ret[key] = it.Value().Interface() + } + return +} + +var ( + tyString = reflect.TypeOf("") +) + // ----------------------------------------------------------------------------- type baseelem interface { @@ -234,6 +255,11 @@ retry: case *Var__1[map[string]any]: Gopt_Case_MatchMap(t, ev, gv.Val(), name...) return + default: + if gv, ok := tryToMapAny(got); ok { + Gopt_Case_MatchMap(t, ev, gv, name...) + return + } } case []any: switch gv := got.(type) { @@ -351,6 +377,10 @@ retry: // other types: default: + if v, ok := tryToMapAny(expected); ok { + expected = v + goto retry + } if reflect.DeepEqual(expected, got) { return } diff --git a/ytest/README.md b/ytest/README.md new file mode 100644 index 0000000..26f3cb2 --- /dev/null +++ b/ytest/README.md @@ -0,0 +1,112 @@ +yaptest - Go+ HTTP Test Framework +===== + +yaptest is a web server testing framework. This classfile has the file suffix `_ytest.gox`. + +Before using `yaptest`, you need to add `github.com/goplus/yap` to `go.mod`: + +``` +gop get github.com/goplus/yap@latest +``` + +Suppose we have a web server ([foo/get_p_#id.yap](demo/foo/get_p_%23id.yap)): + +```go +json { + "id": ${id}, +} +``` + +Then we create a yaptest file ([foo/foo_ytest.gox](demo/foo/foo_ytest.gox)): + +```go +mock "foo.com", new(AppV2) // name of any YAP v2 web server is `AppV2` + +id := "123" +get "http://foo.com/p/${id}" +ret 200 +json { + "id": id, +} +``` + +The directive `mock` creates the web server by [mockhttp](https://pkg.go.dev/github.com/qiniu/x/mockhttp). Then we write test code directly. + +You can change the directive `mock` to `testServer` (see [foo/bar_ytest.gox](demo/foo/bar_ytest.gox)), and keep everything else unchanged: + +```go +testServer "foo.com", new(AppV2) + +id := "123" +get "http://foo.com/p/${id}" +ret 200 +json { + "id": id, +} +``` + +The directive `testServer` creates the web server by [net/http/httptest](https://pkg.go.dev/net/http/httptest#NewServer) and obtained a random port as the service address. Then it calls the directive [host](https://pkg.go.dev/github.com/goplus/yap/ytest#App.Host) to map the random service address to `foo.com`. This makes all other code no need to changed. + +## yaptest User Manual + +### match + +This is almost the core concept in `yaptest`. It matches two objects. + +Let’s look at [a simple example](demo/match/simple/simple_yapt.gox) first: + +```go +id := Var(int) +match id, 1+2 +echo id +``` + +Here we define a variable called `id` and match it with expression `1+2`. If the variable is unbound, it is assigned the value of the expression. + +So far, you've seen `match` like the assignment side. But you cannot assign a different value to a variable that has been bound: + +```go +id := Var(int) +match id, 1+2 +match id, 3 +echo id + +match id, 5 // unmatched value - expected: 3, got: 5 +``` + +In the second match statement, the variable `id` has been bound. At this time, it will be compared with the expression value. If it is equal, it will succeed, otherwise an error will be reported (such as the third match statement above). + +The `match` statement [can be complex](demo/match/complex/complex_yap.gox), such as: + +```go +d := Var(string) + +match { + "c": {"d": d}, +}, { + "a": 1, + "b": 3.14, + "c": {"d": "hello", "e": "world"}, + "f": 1, +} + +echo d +match d, "hello" +``` + +Generally, the syntax of the match command is: + +```go +match +``` + +Unbound variables are allowed in ``, but cannot appear in ``. `` and `` do not have to be exactly the same, but what appears in `` must also appear in ``. That is, it is required to be a subset relationship (`` is a subset of ``). If a variable in `` has not been bound, it will be bound according to the value of the corresponding ``; if the variable has been bound, the values on both sides must match. + +The cornerstone of `yaptest` is matching grammar. Let's look at the next example you saw at the beginning: + +```go +ret 200 +json { + "id": id, +} +``` diff --git a/ytest/case.go b/ytest/case.go index e5b2897..67639da 100644 --- a/ytest/case.go +++ b/ytest/case.go @@ -29,21 +29,14 @@ type CaseT = test.CaseT type Case struct { *Request - *App test.Case + app *App DefaultHeader http.Header } -// Gopt_Case_TestMain is required by Go+ compiler as the entry of a YAP test case. -func Gopt_Case_TestMain(c interface{ initCase(*App, CaseT) }, t *testing.T) { - app := new(App).initApp() - c.initCase(app, test.NewT(t)) - c.(interface{ Main() }).Main() -} - func (p *Case) initCase(app *App, t CaseT) { - p.App = app + p.app = app p.CaseT = t p.DefaultHeader = make(http.Header) } @@ -133,3 +126,22 @@ func (p *Case) DELETE(url string) *Request { } // ----------------------------------------------------------------------------- + +type CaseApp struct { + Case + *App +} + +// Gopt_CaseApp_TestMain is required by Go+ compiler as the entry of a YAP test case. +func Gopt_CaseApp_TestMain(c interface{ initCaseApp(*App, CaseT) }, t *testing.T) { + app := new(App).initApp() + c.initCaseApp(app, test.NewT(t)) + c.(interface{ Main() }).Main() +} + +func (p *CaseApp) initCaseApp(app *App, t CaseT) { + p.initCase(app, t) + p.App = app +} + +// ----------------------------------------------------------------------------- diff --git a/ytest/classfile.go b/ytest/classfile.go index 092e826..746419e 100644 --- a/ytest/classfile.go +++ b/ytest/classfile.go @@ -21,16 +21,19 @@ import ( "net/http" "net/http/httptest" "os" + "reflect" "strings" "testing" "github.com/goplus/yap" "github.com/goplus/yap/test" + "github.com/goplus/yap/test/logt" "github.com/qiniu/x/mockhttp" ) const ( - GopPackage = "github.com/goplus/yap/test" + GopPackage = "github.com/goplus/yap/test" + GopTestClass = true ) // ----------------------------------------------------------------------------- @@ -96,8 +99,10 @@ func Gopt_App_Main(app interface{ initApp() *App }, workers ...interface{ initCa if me, ok := app.(interface{ MainEntry() }); ok { me.MainEntry() } + t := logt.New() for _, worker := range workers { - worker.initCase(a, nil) + worker.initCase(a, t) + reflect.ValueOf(worker).Elem().Field(1).Set(reflect.ValueOf(app)) // (*worker).App = app worker.(interface{ Main() }).Main() } } diff --git a/ytest/demo/basic/gop_autogen_test.go b/ytest/demo/basic/gop_autogen_test.go index 4dfb8b0..4dfa69a 100644 --- a/ytest/demo/basic/gop_autogen_test.go +++ b/ytest/demo/basic/gop_autogen_test.go @@ -9,7 +9,7 @@ import ( ) type case_foo struct { - ytest.Case + ytest.CaseApp } //line ytest/demo/basic/foo_ytest.gox:1 func (this *case_foo) Main() { @@ -35,5 +35,5 @@ func (this *case_foo) Classfname() string { return "foo" } func Test_foo(t *testing.T) { - ytest.Gopt_Case_TestMain(new(case_foo), t) + ytest.Gopt_CaseApp_TestMain(new(case_foo), t) } diff --git a/ytest/demo/foo/gop_autogen_test.go b/ytest/demo/foo/gop_autogen_test.go index cbb0056..bff1043 100644 --- a/ytest/demo/foo/gop_autogen_test.go +++ b/ytest/demo/foo/gop_autogen_test.go @@ -10,10 +10,10 @@ import ( ) type case_bar struct { - ytest.Case + ytest.CaseApp } type case_foo struct { - ytest.Case + ytest.CaseApp } //line ytest/demo/foo/bar_ytest.gox:1 func (this *case_bar) Main() { @@ -50,8 +50,8 @@ func (this *case_foo) Classfname() string { return "foo" } func Test_bar(t *testing.T) { - ytest.Gopt_Case_TestMain(new(case_bar), t) + ytest.Gopt_CaseApp_TestMain(new(case_bar), t) } func Test_foo(t *testing.T) { - ytest.Gopt_Case_TestMain(new(case_foo), t) + ytest.Gopt_CaseApp_TestMain(new(case_foo), t) } diff --git a/ytest/demo/jwtdemo/gop_autogen_test.go b/ytest/demo/jwtdemo/gop_autogen_test.go index 3eb64ab..d565d4f 100644 --- a/ytest/demo/jwtdemo/gop_autogen_test.go +++ b/ytest/demo/jwtdemo/gop_autogen_test.go @@ -11,7 +11,7 @@ import ( ) type case_jwtdemo struct { - ytest.Case + ytest.CaseApp } //line ytest/demo/jwtdemo/jwtdemo_ytest.gox:7 func (this *case_jwtdemo) Main() { @@ -34,5 +34,5 @@ func (this *case_jwtdemo) Classfname() string { return "jwtdemo" } func Test_jwtdemo(t *testing.T) { - ytest.Gopt_Case_TestMain(new(case_jwtdemo), t) + ytest.Gopt_CaseApp_TestMain(new(case_jwtdemo), t) } diff --git a/ytest/demo/match/complex/complex_yapt.gox b/ytest/demo/match/complex/complex_yapt.gox new file mode 100644 index 0000000..a35fa2b --- /dev/null +++ b/ytest/demo/match/complex/complex_yapt.gox @@ -0,0 +1,13 @@ +d := Var(string) + +match { + "c": {"d": d}, +}, { + "a": 1, + "b": 3.14, + "c": {"d": "hello", "e": "world"}, + "f": 1, +} + +echo d +match d, "hello" diff --git a/ytest/demo/match/complex/gop_autogen.go b/ytest/demo/match/complex/gop_autogen.go new file mode 100644 index 0000000..d5a6b21 --- /dev/null +++ b/ytest/demo/match/complex/gop_autogen.go @@ -0,0 +1,39 @@ +// Code generated by gop (Go+); DO NOT EDIT. + +package main + +import ( + "fmt" + "github.com/goplus/yap/test" + "github.com/goplus/yap/ytest" +) + +const _ = true + +type complex struct { + ytest.Case + *App +} +type App struct { + ytest.App +} +//line ytest/demo/match/complex/complex_yapt.gox:1 +func (this *complex) Main() { +//line ytest/demo/match/complex/complex_yapt.gox:1:1 + d := test.Gopx_Var_Cast__0[string]() +//line ytest/demo/match/complex/complex_yapt.gox:3:1 + test.Gopt_Case_MatchAny(this, map[string]map[string]*test.Var__0[string]{"c": map[string]*test.Var__0[string]{"d": d}}, map[string]interface{}{"a": 1, "b": 3.14, "c": map[string]string{"d": "hello", "e": "world"}, "f": 1}) +//line ytest/demo/match/complex/complex_yapt.gox:12:1 + fmt.Println(d) +//line ytest/demo/match/complex/complex_yapt.gox:13:1 + test.Gopt_Case_MatchAny(this, d, "hello") +} +func (this *complex) Classfname() string { + return "complex" +} +func (this *App) Main() { + ytest.Gopt_App_Main(this, new(complex)) +} +func main() { + new(App).Main() +} diff --git a/ytest/demo/match/hello/get_p_#id.yap b/ytest/demo/match/hello/get_p_#id.yap new file mode 100644 index 0000000..c0f07b6 --- /dev/null +++ b/ytest/demo/match/hello/get_p_#id.yap @@ -0,0 +1,3 @@ +json { + "id": ${id}, +} diff --git a/ytest/demo/match/hello/gop_autogen.go b/ytest/demo/match/hello/gop_autogen.go new file mode 100644 index 0000000..6e47be0 --- /dev/null +++ b/ytest/demo/match/hello/gop_autogen.go @@ -0,0 +1,64 @@ +// Code generated by gop (Go+); DO NOT EDIT. + +package main + +import ( + "fmt" + "github.com/goplus/yap" + "github.com/goplus/yap/ytest" + "github.com/qiniu/x/stringutil" +) + +const _ = true + +type get_p_id struct { + yap.Handler + *AppV2 +} +type hello struct { + ytest.Case + *App +} +type AppV2 struct { + yap.AppV2 +} +//line ytest/demo/match/hello/get_p_#id.yap:1 +func (this *get_p_id) Main(_gop_arg0 *yap.Context) { + this.Handler.Main(_gop_arg0) +//line ytest/demo/match/hello/get_p_#id.yap:1:1 + this.Json__1(map[string]string{"id": this.Gop_Env("id")}) +} +func (this *get_p_id) Classfname() string { + return "get_p_#id" +} + +type App struct { + ytest.App +} +//line ytest/demo/match/hello/hello_yapt.gox:1 +func (this *hello) Main() { +//line ytest/demo/match/hello/hello_yapt.gox:1:1 + this.Mock("foo.com", new(AppV2)) +//line ytest/demo/match/hello/hello_yapt.gox:3:1 + id := "123" +//line ytest/demo/match/hello/hello_yapt.gox:4:1 + this.Get(stringutil.Concat("http://foo.com/p/", id)) +//line ytest/demo/match/hello/hello_yapt.gox:5:1 + this.RetWith(200) +//line ytest/demo/match/hello/hello_yapt.gox:6:1 + this.Json(map[string]string{"id": id}) +//line ytest/demo/match/hello/hello_yapt.gox:9:1 + fmt.Println("OK") +} +func (this *hello) Classfname() string { + return "hello" +} +func (this *AppV2) Main() { + yap.Gopt_AppV2_Main(this, new(get_p_id)) +} +func (this *App) Main() { + ytest.Gopt_App_Main(this, new(hello)) +} +func main() { + new(App).Main() +} diff --git a/ytest/demo/match/hello/hello_yapt.gox b/ytest/demo/match/hello/hello_yapt.gox new file mode 100644 index 0000000..e661cdb --- /dev/null +++ b/ytest/demo/match/hello/hello_yapt.gox @@ -0,0 +1,9 @@ +mock "foo.com", new(AppV2) + +id := "123" +get "http://foo.com/p/${id}" +ret 200 +json { + "id": id, +} +echo "OK" diff --git a/ytest/demo/match/simple/gop_autogen.go b/ytest/demo/match/simple/gop_autogen.go new file mode 100644 index 0000000..6943216 --- /dev/null +++ b/ytest/demo/match/simple/gop_autogen.go @@ -0,0 +1,41 @@ +// Code generated by gop (Go+); DO NOT EDIT. + +package main + +import ( + "fmt" + "github.com/goplus/yap/test" + "github.com/goplus/yap/ytest" +) + +const _ = true + +type simple struct { + ytest.Case + *App +} +type App struct { + ytest.App +} +//line ytest/demo/match/simple/simple_yapt.gox:1 +func (this *simple) Main() { +//line ytest/demo/match/simple/simple_yapt.gox:1:1 + id := test.Gopx_Var_Cast__0[int]() +//line ytest/demo/match/simple/simple_yapt.gox:2:1 + test.Gopt_Case_MatchAny(this, id, 1+2) +//line ytest/demo/match/simple/simple_yapt.gox:3:1 + test.Gopt_Case_MatchAny(this, id, 3) +//line ytest/demo/match/simple/simple_yapt.gox:4:1 + fmt.Println(id) +//line ytest/demo/match/simple/simple_yapt.gox:6:1 + test.Gopt_Case_MatchAny(this, id, 5) +} +func (this *simple) Classfname() string { + return "simple" +} +func (this *App) Main() { + ytest.Gopt_App_Main(this, new(simple)) +} +func main() { + new(App).Main() +} diff --git a/ytest/demo/match/simple/simple_yapt.gox b/ytest/demo/match/simple/simple_yapt.gox new file mode 100644 index 0000000..3466a48 --- /dev/null +++ b/ytest/demo/match/simple/simple_yapt.gox @@ -0,0 +1,6 @@ +id := Var(int) +match id, 1+2 +match id, 3 +echo id + +match id, 5 // unmatched value - expected: 3, got: 5 diff --git a/ytest/request.go b/ytest/request.go index 254762c..29d3fe1 100644 --- a/ytest/request.go +++ b/ytest/request.go @@ -243,7 +243,7 @@ func mergeHeader(to, from http.Header) { func (p *Request) doSend() (resp *http.Response, err error) { body := p.body - req, err := p.ctx.newRequest(p.method, p.url, body) + req, err := p.ctx.app.newRequest(p.method, p.url, body) if err != nil { log.Fatalf("newRequest(%s, %s) failed: %v\n", p.method, p.url, err) } @@ -257,7 +257,7 @@ func (p *Request) doSend() (resp *http.Response, err error) { } req.ContentLength = body.Size() } - tr := p.ctx.transport + tr := p.ctx.app.transport if p.auth != nil { tr = p.auth.Compose(tr) }