| // Copyright 2021 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // Package webtest implements script-based testing for web servers. |
| // |
| // The scripts, described below, can be run against http.Handler |
| // implementations or against running servers. Testing against an |
| // http.Handler makes it easier to test handlers serving multiple sites |
| // as well as scheme-based features like redirecting to HTTPS. |
| // Testing against a running server provides a more complete end-to-end test. |
| // |
| // The test functions TestHandler and TestServer take a *testing.T |
| // and a glob pattern, which must match at least one file. |
| // They create a subtest of the top-level test for each script. |
| // Within each per-script subtest, they create a per-case subtest |
| // for each case in the script, making it easy to run selected cases. |
| // |
| // The functions CheckHandler and CheckServer are similar but do |
| // not require a *testing.T, making them suitable for use in other contexts. |
| // They run the entire script and return a multiline error summarizing |
| // any problems. |
| // |
| // # Scripts |
| // |
| // A script is a text file containing a sequence of cases, separated by blank lines. |
| // Lines beginning with # characters are ignored as comments. |
| // A case is a sequence of lines describing a request, along with checks to be |
| // applied to the response. For example, here is a trivial script: |
| // |
| // GET / |
| // body contains Go is an open source programming language |
| // |
| // This script has a single case. The first line describes the request. |
| // The second line describes a single check to be applied to the response. |
| // In this case, the request is a GET of the URL /, and the response body |
| // must contain the text “Go is an open source programming language”. |
| // |
| // # Requests |
| // |
| // Each case begins with a line starting with GET, HEAD, or POST. |
| // The argument (the remainder of the line) is the URL to be used in the request. |
| // Following this line, the request can be further customized using |
| // lines of the form |
| // |
| // <verb> <text> |
| // |
| // where the verb is a single space-separated word and the text is arbitrary text |
| // to the end of the line, or multiline text (described below). |
| // |
| // The possible values for <verb> are as follows. |
| // |
| // The verb “hint” specifies text to be printed if the test case fails, as a |
| // hint about what might be wrong. |
| // |
| // The verbs “postbody”, “postquery”, and “posttype” customize a POST request. |
| // |
| // For example: |
| // |
| // POST /api |
| // posttype application/json |
| // postbody {"go": true} |
| // |
| // This describes a POST request with a posted Content-Type of “application/json” |
| // and a body “{"go": true}”. |
| // |
| // The “postquery” verb specifies a post body in the form of a sequence of |
| // key-value pairs, query-encoded and concatenated automatically as a |
| // convenience. Using “postquery” also sets the default posted Content-Type |
| // to “application/x-www-form-urlencoded”. |
| // |
| // For example: |
| // |
| // POST /api |
| // postquery |
| // x=hello world |
| // y=Go & You |
| // |
| // This stanza sends a request with post body “x=hello+world&y=Go+%26+You”. |
| // (The multiline syntax is described in detail below.) |
| // |
| // # Checks |
| // |
| // By default, a stanza like the ones above checks only that the request |
| // succeeds in returning a response with HTTP status code 200 (OK). |
| // Additional checks are specified by more lines of the form |
| // |
| // <value> [<key>] <op> <text> |
| // |
| // In the example above, <value> is “body”, there is no <key>, |
| // <op> is “contains”, and <text> is “Go is an open source programming language”. |
| // Whether there is a <key> depends on the <value>; “body” does not have one. |
| // |
| // The possible values for <value> are: |
| // |
| // body - the full response body |
| // code - the HTTP status code |
| // header <key> - the value in the header line with the given key |
| // redirect - the target of a redirect, as found in the Location header |
| // trimbody - the response body, trimmed |
| // |
| // If a case contains no check of “code”, then it defaults to checking that |
| // the HTTP status code is 200, as described above, with one exception: |
| // if the case contains a check of “redirect”, then the code is required to |
| // be a 30x code. |
| // |
| // The “trimbody” value is the body with all runs of spaces and tabs |
| // reduced to single spaces, leading and trailing spaces removed on |
| // each line, and blank lines removed. |
| // |
| // The possible operators for <op> are: |
| // |
| // == - the value must be equal to the text |
| // != - the value must not be equal to the text |
| // ~ - the value must match the text interpreted as a regular expression |
| // !~ - the value must not match the text interpreted as a regular expression |
| // contains - the value must contain the text as a substring |
| // !contains - the value must not contain the text as a substring |
| // |
| // For example: |
| // |
| // GET /change/75944e2e3a63 |
| // hint no change redirect - hg to git mapping not registered? |
| // code == 302 |
| // redirect contains bdb10cf |
| // body contains bdb10cf |
| // body !contains UA- |
| // |
| // GET /pkg/net/http/httptrace/ |
| // body ~ Got1xxResponse.*// Go 1\.11 |
| // body ~ GotFirstResponseByte func\(\)\s*$ |
| // |
| // # Multiline Texts |
| // |
| // The <text> in a request or check line can take a multiline form, |
| // by omitting it from the original line and then specifying the text |
| // as one or more following lines, each indented by a single tab. |
| // The text is taken to be the sequence of indented lines, including |
| // the final newline, but with the leading tab removed from each. |
| // |
| // The “postquery” example above showed the multiline syntax. |
| // Another common use is for multiline “body” checks. For example: |
| // |
| // GET /hello |
| // body == |
| // <!DOCTYPE html> |
| // hello, world |
| package webtest |
| |
| import ( |
| "bytes" |
| "errors" |
| "fmt" |
| "io" |
| "io/fs" |
| "io/ioutil" |
| "net/http" |
| "net/http/httptest" |
| "net/url" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "testing" |
| "unicode/utf8" |
| ) |
| |
| // HandlerWithCheck returns an http.Handler that responds to each request |
| // by running the test script files mached by glob against the handler h. |
| // If the tests pass, the returned http.Handler responds with status code 200. |
| // If they fail, it prints the details and responds with status code 503 |
| // (service unavailable). |
| func HandlerWithCheck(h http.Handler, path string, fsys fs.FS, glob string) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if r.URL.Path == path { |
| err := CheckHandler(fsys, glob, h) |
| if err != nil { |
| http.Error(w, "webtest.CheckHandler failed:\n"+err.Error()+"\n", http.StatusInternalServerError) |
| } else { |
| fmt.Fprintf(w, "ok\n") |
| } |
| return |
| } |
| h.ServeHTTP(w, r) |
| }) |
| } |
| |
| // CheckHandler runs the test script files in fsys matched by glob |
| // against the handler h. If any errors are encountered, |
| // CheckHandler returns an error listing the problems. |
| func CheckHandler(fsys fs.FS, glob string, h http.Handler) error { |
| return check(fsys, glob, func(c *case_) error { return c.runHandler(h) }) |
| } |
| |
| func check(fsys fs.FS, glob string, do func(*case_) error) error { |
| files, err := fs.Glob(fsys, glob) |
| if err != nil { |
| return err |
| } |
| if len(files) == 0 { |
| return fmt.Errorf("no files match %#q", glob) |
| } |
| var buf bytes.Buffer |
| for _, file := range files { |
| data, err := fs.ReadFile(fsys, file) |
| if err != nil { |
| fmt.Fprintf(&buf, "# %s\n%v\n", file, err) |
| continue |
| } |
| script, err := parseScript(file, string(data)) |
| if err != nil { |
| fmt.Fprintf(&buf, "# %s\n%v\n", file, err) |
| continue |
| } |
| hdr := false |
| for _, c := range script.cases { |
| if err := do(c); err != nil { |
| if !hdr { |
| fmt.Fprintf(&buf, "# %s\n", file) |
| hdr = true |
| } |
| fmt.Fprintf(&buf, "## %s %s\n", c.method, c.url) |
| fmt.Fprintf(&buf, "%v\n", err) |
| } |
| } |
| } |
| if buf.Len() > 0 { |
| return errors.New(buf.String()) |
| } |
| return nil |
| } |
| |
| // TestHandler runs the test script files matched by glob |
| // against the handler h. |
| func TestHandler(t *testing.T, glob string, h http.Handler) { |
| test(t, glob, func(c *case_) error { return c.runHandler(h) }) |
| } |
| |
| func test(t *testing.T, glob string, do func(*case_) error) { |
| files, err := filepath.Glob(glob) |
| if err != nil { |
| t.Fatal(err) |
| } |
| if len(files) == 0 { |
| t.Fatalf("no files match %#q", glob) |
| } |
| for _, file := range files { |
| t.Run(filepath.Base(file), func(t *testing.T) { |
| data, err := ioutil.ReadFile(file) |
| if err != nil { |
| t.Fatal(err) |
| } |
| script, err := parseScript(file, string(data)) |
| if err != nil { |
| t.Fatal(err) |
| } |
| for _, c := range script.cases { |
| t.Run(c.method+"/"+strings.TrimPrefix(c.url, "/"), func(t *testing.T) { |
| if err := do(c); err != nil { |
| t.Fatal(err) |
| } |
| }) |
| } |
| }) |
| } |
| } |
| |
| // A script is a parsed test script. |
| type script struct { |
| cases []*case_ |
| } |
| |
| // A case_ is a single test case (GET/HEAD/POST) in a script. |
| type case_ struct { |
| file string |
| line int |
| method string |
| url string |
| postbody string |
| postquery string |
| posttype string |
| hint string |
| checks []*cmpCheck |
| } |
| |
| // A cmp is a single comparison (check) made against a test case. |
| type cmpCheck struct { |
| file string |
| line int |
| what string |
| whatArg string |
| op string |
| want string |
| wantRE *regexp.Regexp |
| } |
| |
| // runHandler runs a test case against the handler h. |
| func (c *case_) runHandler(h http.Handler) error { |
| w := httptest.NewRecorder() |
| r, err := c.newRequest(c.url) |
| if err != nil { |
| return err |
| } |
| h.ServeHTTP(w, r) |
| return c.check(w.Result(), w.Body.String()) |
| } |
| |
| // runServer runs a test case against the server at address addr. |
| func (c *case_) runServer(addr string) error { |
| baseURL := "" |
| if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") { |
| // addr is a base URL |
| if !strings.HasSuffix(addr, "/") { |
| addr += "/" |
| } |
| baseURL = addr |
| } else { |
| // addr is an HTTP proxy |
| baseURL = "http://" + addr + "/" |
| } |
| |
| // Build full URL for request. |
| u := c.url |
| if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") { |
| u = strings.TrimSuffix(baseURL, "/") |
| if !strings.HasPrefix(c.url, "/") { |
| u += "/" |
| } |
| u += c.url |
| } |
| req, err := c.newRequest(u) |
| |
| if err != nil { |
| return fmt.Errorf("%s:%d: %s %s: %s", c.file, c.line, c.method, c.url, err) |
| } |
| tr := &http.Transport{} |
| if !strings.HasPrefix(u, baseURL) { |
| // If u does not begin with baseURL, then we're in the proxy case |
| // and we try to tunnel the network activity through the proxy's address. |
| proxyURL, err := url.Parse(baseURL) |
| if err != nil { |
| return fmt.Errorf("invalid addr: %v", err) |
| } |
| tr.Proxy = func(*http.Request) (*url.URL, error) { return proxyURL, nil } |
| } |
| resp, err := tr.RoundTrip(req) |
| if err != nil { |
| return fmt.Errorf("%s:%d: %s %s: %s", c.file, c.line, c.method, c.url, err) |
| } |
| body, err := ioutil.ReadAll(resp.Body) |
| resp.Body.Close() |
| if err != nil { |
| return fmt.Errorf("%s:%d: %s %s: reading body: %s", c.file, c.line, c.method, c.url, err) |
| } |
| return c.check(resp, string(body)) |
| } |
| |
| // newRequest creates a new request for the case c, |
| // using the URL u. |
| func (c *case_) newRequest(u string) (*http.Request, error) { |
| body := c.requestBody() |
| r, err := http.NewRequest(c.method, u, body) |
| if err != nil { |
| return nil, err |
| } |
| typ := c.posttype |
| if body != nil && typ == "" { |
| typ = "application/x-www-form-urlencoded" |
| } |
| if typ != "" { |
| r.Header.Set("Content-Type", typ) |
| } |
| return r, nil |
| } |
| |
| // requestBody returns the body for the case's request. |
| func (c *case_) requestBody() io.Reader { |
| if c.postbody == "" { |
| return nil |
| } |
| return strings.NewReader(c.postbody) |
| } |
| |
| // check checks the response against the comparisons for the case. |
| func (c *case_) check(resp *http.Response, body string) error { |
| var msg bytes.Buffer |
| for _, chk := range c.checks { |
| what := chk.what |
| if chk.whatArg != "" { |
| what += " " + chk.whatArg |
| } |
| var value string |
| switch chk.what { |
| default: |
| value = "unknown what: " + chk.what |
| case "body": |
| value = body |
| case "trimbody": |
| value = trim(body) |
| case "code": |
| value = fmt.Sprint(resp.StatusCode) |
| case "header": |
| value = resp.Header.Get(chk.whatArg) |
| case "redirect": |
| if resp.StatusCode/10 == 30 { |
| value = resp.Header.Get("Location") |
| } |
| } |
| |
| switch chk.op { |
| default: |
| fmt.Fprintf(&msg, "%s:%d: unknown operator %s\n", chk.file, chk.line, chk.op) |
| case "==": |
| if value != chk.want { |
| fmt.Fprintf(&msg, "%s:%d: %s = %q, want %q\n", chk.file, chk.line, what, value, chk.want) |
| } |
| case "!=": |
| if value == chk.want { |
| fmt.Fprintf(&msg, "%s:%d: %s == %q (but want !=)\n", chk.file, chk.line, what, value) |
| } |
| case "~": |
| if !chk.wantRE.MatchString(value) { |
| fmt.Fprintf(&msg, "%s:%d: %s does not match %#q (but should)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) |
| } |
| case "!~": |
| if chk.wantRE.MatchString(value) { |
| fmt.Fprintf(&msg, "%s:%d: %s matches %#q (but should not)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) |
| } |
| case "contains": |
| if !strings.Contains(value, chk.want) { |
| fmt.Fprintf(&msg, "%s:%d: %s does not contain %#q (but should)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) |
| } |
| case "!contains": |
| if strings.Contains(value, chk.want) { |
| fmt.Fprintf(&msg, "%s:%d: %s contains %#q (but should not)\n\t%s\n", chk.file, chk.line, what, chk.want, indent(value)) |
| } |
| } |
| } |
| if msg.Len() > 0 && c.hint != "" { |
| fmt.Fprintf(&msg, "hint: %s\n", indent(c.hint)) |
| } |
| |
| if msg.Len() > 0 { |
| return fmt.Errorf("%s:%d: %s %s\n%s", c.file, c.line, c.method, c.url, msg.String()) |
| } |
| return nil |
| } |
| |
| // trim returns a trimming of s, in which all runs of spaces and tabs have |
| // been collapsed to a single space, leading and trailing spaces have been |
| // removed from each line, and blank lines are removed entirely. |
| func trim(s string) string { |
| s = regexp.MustCompile(`[ \t]+`).ReplaceAllString(s, " ") |
| s = regexp.MustCompile(`(?m)(^ | $)`).ReplaceAllString(s, "") |
| s = strings.TrimLeft(s, "\n") |
| s = regexp.MustCompile(`\n\n+`).ReplaceAllString(s, "\n") |
| return s |
| } |
| |
| // indent indents text for formatting in a message. |
| func indent(text string) string { |
| if text == "" { |
| return "(empty)" |
| } |
| if text == "\n" { |
| return "(blank line)" |
| } |
| text = strings.TrimRight(text, "\n") |
| if text == "" { |
| return "(blank lines)" |
| } |
| text = strings.ReplaceAll(text, "\n", "\n\t") |
| return text |
| } |
| |
| // parseScript parses the test script in text. |
| // Errors are reported as being from file, but file is not directly read. |
| func parseScript(file, text string) (*script, error) { |
| var current struct { |
| Case *case_ |
| Multiline *string |
| } |
| script := new(script) |
| lastLineWasBlank := true |
| lineno := 0 |
| line := "" |
| errorf := func(format string, args ...interface{}) error { |
| if line != "" { |
| line = "\n" + line |
| } |
| return fmt.Errorf("%s:%d: %v%s", file, lineno, fmt.Sprintf(format, args...), line) |
| } |
| for text != "" { |
| lineno++ |
| prevLine := line |
| line, text, _ = cut(text, "\n") |
| if strings.HasPrefix(line, "#") { |
| continue |
| } |
| line = strings.TrimRight(line, " \t") |
| if line == "" { |
| lastLineWasBlank = true |
| continue |
| } |
| what, args := splitOneField(line) |
| |
| // Add indented line to current multiline check, or else it ends. |
| if what == "" { |
| // Line is indented. |
| if current.Multiline != nil { |
| lastLineWasBlank = false |
| *current.Multiline += args + "\n" |
| continue |
| } |
| return nil, errorf("unexpected indented line") |
| } |
| |
| // Multiline text is over; must be present. |
| if current.Multiline != nil && *current.Multiline == "" { |
| lineno-- |
| line = prevLine |
| return nil, errorf("missing multiline text") |
| } |
| current.Multiline = nil |
| |
| // Look for start of new check. |
| switch what { |
| case "GET", "HEAD", "POST": |
| if !lastLineWasBlank { |
| return nil, errorf("missing blank line before start of case") |
| } |
| if args == "" { |
| return nil, errorf("missing %s URL", what) |
| } |
| cas := &case_{method: what, url: args, file: file, line: lineno} |
| script.cases = append(script.cases, cas) |
| current.Case = cas |
| lastLineWasBlank = false |
| continue |
| } |
| |
| if lastLineWasBlank || current.Case == nil { |
| return nil, errorf("missing GET/HEAD/POST at start of check") |
| } |
| |
| // Look for case metadata. |
| var targ *string |
| switch what { |
| case "postbody": |
| targ = ¤t.Case.postbody |
| case "postquery": |
| targ = ¤t.Case.postquery |
| case "posttype": |
| targ = ¤t.Case.posttype |
| case "hint": |
| targ = ¤t.Case.hint |
| } |
| if targ != nil { |
| if strings.HasPrefix(what, "post") && current.Case.method != "POST" { |
| return nil, errorf("need POST (not %v) for %v", current.Case.method, what) |
| } |
| if args != "" { |
| *targ = args |
| } else { |
| current.Multiline = targ |
| } |
| continue |
| } |
| |
| // Start a comparison check. |
| chk := &cmpCheck{file: file, line: lineno, what: what} |
| current.Case.checks = append(current.Case.checks, chk) |
| switch what { |
| case "body", "code", "redirect": |
| // no WhatArg |
| case "header": |
| chk.whatArg, args = splitOneField(args) |
| if chk.whatArg == "" { |
| return nil, errorf("missing header name") |
| } |
| } |
| |
| // Opcode, with optional leading "not" |
| chk.op, args = splitOneField(args) |
| switch chk.op { |
| case "==", "!=", "~", "!~", "contains", "!contains": |
| // ok |
| default: |
| return nil, errorf("unknown check operator %q", chk.op) |
| } |
| |
| if args != "" { |
| chk.want = args |
| } else { |
| current.Multiline = &chk.want |
| } |
| } |
| |
| // Finish each case. |
| // Compute POST body from POST query. |
| // Check that each regexp compiles, and insert "code equals 200" |
| // in each case that doesn't already have a code check. |
| for _, cas := range script.cases { |
| if cas.postquery != "" { |
| if cas.postbody != "" { |
| line = "" |
| lineno = cas.line |
| return nil, errorf("case has postbody and postquery") |
| } |
| for _, kv := range strings.Split(cas.postquery, "\n") { |
| kv = strings.TrimSpace(kv) |
| if kv == "" { |
| continue |
| } |
| k, v, ok := cut(kv, "=") |
| if !ok { |
| lineno = cas.line // close enough |
| line = kv |
| return nil, errorf("postquery has non key=value line") |
| } |
| if cas.postbody != "" { |
| cas.postbody += "&" |
| } |
| cas.postbody += url.QueryEscape(k) + "=" + url.QueryEscape(v) |
| } |
| } |
| sawCode := false |
| for _, chk := range cas.checks { |
| if chk.what == "code" || chk.what == "redirect" { |
| sawCode = true |
| } |
| if chk.op == "~" || chk.op == "!~" { |
| re, err := regexp.Compile(`(?m)` + chk.want) |
| if err != nil { |
| lineno = chk.line |
| line = chk.want |
| return nil, errorf("invalid regexp: %s", err) |
| } |
| chk.wantRE = re |
| } |
| } |
| if !sawCode { |
| line := cas.line |
| if len(cas.checks) > 0 { |
| line = cas.checks[0].line |
| } |
| chk := &cmpCheck{file: cas.file, line: line, what: "code", op: "==", want: "200"} |
| cas.checks = append(cas.checks, chk) |
| } |
| } |
| return script, nil |
| } |
| |
| // cut returns the result of cutting s around the first instance of sep. |
| func cut(s, sep string) (before, after string, ok bool) { |
| if i := strings.Index(s, sep); i >= 0 { |
| return s[:i], s[i+len(sep):], true |
| } |
| return s, "", false |
| } |
| |
| // cutAny returns the result of cutting s around the first instance of |
| // any code point from any. |
| func cutAny(s, any string) (before, after string, ok bool) { |
| if i := strings.IndexAny(s, any); i >= 0 { |
| _, size := utf8.DecodeRuneInString(s[i:]) |
| return s[:i], s[i+size:], true |
| } |
| return s, "", false |
| } |
| |
| // splitOneField splits text at the first space or tab |
| // and returns that first field and the remaining text. |
| func splitOneField(text string) (field, rest string) { |
| i := strings.IndexAny(text, " \t") |
| if i < 0 { |
| return text, "" |
| } |
| return text[:i], strings.TrimLeft(text[i:], " \t") |
| } |