diff --git a/assertions.go b/assertions.go index f12d0fa..259a7bc 100644 --- a/assertions.go +++ b/assertions.go @@ -10,80 +10,180 @@ import ( "github.com/itchyny/gojq" ) -type Assertion func(res *httpResponse) error +// Assertion checks one property of a response. +// +// Check separates the two things an assertion can report, which a single error +// return conflated: a *Failure means the response was read and did not hold up, +// an error means the assertion could not be evaluated at all -- an undecodable +// body, a jq runtime fault, a body that is not JSON. Both still count as a +// failed run; the distinction exists so a machine-readable consumer can tell +// "the service is wrong" from "we could not tell" (#45). +type Assertion interface { + // Kind names the family this assertion belongs to: "ok", "nok", + // "status", "header", "body", "redirect" or "jq". + Kind() string + + // Check reports (nil, nil) when the assertion holds. + Check(res *httpResponse) (*Failure, error) +} + +// Failure describes an assertion that did not hold, in parts as well as prose. +// +// Message is the human sentence, unchanged from when assertions returned a bare +// error, and is what the failure dump prints. The parts around it exist because +// prose cannot be serialized into anything a dashboard can query; they are not +// a second source of truth for the message, and no formatter derives one from +// the other. Reconstructing sentences like "expected to be non-empty, got +// nothing" from Expected and Actual alone would drift the moment a wording +// changed, and the drift would surface as a failing end-to-end test rather than +// as a compile error. +type Failure struct { + Kind string // filled in by Check; never set by a constructor + Target string // header name, jq query, or "" when the kind needs no subject + Expected any + Actual any + Message string +} + +// Error lets a Failure travel the same path as an evaluation error, so the +// caller can collect both into one list and print them in the order the +// assertions were given. +func (f *Failure) Error() string { return f.Message } + +// assertionFunc adapts a closure to the Assertion interface. +// +// The functional style is what makes each constructor readable as a single +// expression, and structure was never the reason to give it up -- only the +// result needed to carry more than a string. Thirteen one-method structs would +// have said the same thing at ten times the length. +type assertionFunc struct { + kind string + check func(res *httpResponse) (*Failure, error) +} + +func (a assertionFunc) Kind() string { return a.kind } + +// Check stamps the failure with the assertion's kind, so Kind() and +// Failure.Kind cannot disagree and no constructor has to repeat itself. +func (a assertionFunc) Check(res *httpResponse) (*Failure, error) { + f, err := a.check(res) + if f != nil { + f.Kind = a.kind + } + + return f, err +} + +func newAssertion(kind string, check func(res *httpResponse) (*Failure, error)) Assertion { + return assertionFunc{kind: kind, check: check} +} // Pattern-based assertions are built from user input and so can fail before any // response exists. They return an error rather than panicking; every other // constructor in this file is infallible and returns an Assertion directly. func AssertStatusOK() Assertion { - return func(res *httpResponse) error { + return newAssertion("ok", func(res *httpResponse) (*Failure, error) { if s := res.StatusCode; s < 200 || s >= 400 { - return fmt.Errorf("ok: expected OK, got %d (%q)", - res.StatusCode, res.Status) + return &Failure{ + Expected: "2xx-3xx", + Actual: res.StatusCode, + Message: fmt.Sprintf("ok: expected OK, got %d (%q)", + res.StatusCode, res.Status), + }, nil } - return nil - } + return nil, nil + }) } func AssertStatusNOK() Assertion { - return func(res *httpResponse) error { + return newAssertion("nok", func(res *httpResponse) (*Failure, error) { if s := res.StatusCode; s >= 200 && s < 400 { - return fmt.Errorf("nok: expected NOK, got %d (%q)", - res.StatusCode, res.Status) + return &Failure{ + Expected: "not 2xx-3xx", + Actual: res.StatusCode, + Message: fmt.Sprintf("nok: expected NOK, got %d (%q)", + res.StatusCode, res.Status), + }, nil } - return nil - } + return nil, nil + }) } func AssertStatusEqual(expStatus int) Assertion { - return func(res *httpResponse) error { + return newAssertion("status", func(res *httpResponse) (*Failure, error) { if res.StatusCode != expStatus { - return fmt.Errorf("status: expected %d, got %d (%q)", - expStatus, res.StatusCode, res.Status) + return &Failure{ + Expected: expStatus, + Actual: res.StatusCode, + Message: fmt.Sprintf("status: expected %d, got %d (%q)", + expStatus, res.StatusCode, res.Status), + }, nil } - return nil - } + return nil, nil + }) } func AssertHeaderPresent(name string) Assertion { - return func(res *httpResponse) error { + return newAssertion("header", func(res *httpResponse) (*Failure, error) { if res.Header.Values(name) == nil { - return fmt.Errorf("header[%s]: expected to be present, missing", name) + return &Failure{ + Target: name, + Expected: "present", + Message: fmt.Sprintf("header[%s]: expected to be present, missing", + name), + }, nil } - return nil - } + return nil, nil + }) } func AssertHeaderMissing(name string) Assertion { - return func(res *httpResponse) error { + return newAssertion("header", func(res *httpResponse) (*Failure, error) { if vs := res.Header.Values(name); vs != nil { - return fmt.Errorf("header[%s]: expected to be missing, got %q", name, vs) + return &Failure{ + Target: name, + Expected: "missing", + Actual: vs, + Message: fmt.Sprintf("header[%s]: expected to be missing, got %q", + name, vs), + }, nil } - return nil - } + return nil, nil + }) } func AssertHeaderEqual(name, expValue string) Assertion { - return func(res *httpResponse) error { + return newAssertion("header", func(res *httpResponse) (*Failure, error) { vs := res.Header.Values(name) if vs == nil { - return fmt.Errorf("header[%s]: expected %q, missing", name, expValue) + return &Failure{ + Target: name, + Expected: expValue, + Message: fmt.Sprintf("header[%s]: expected %q, missing", + name, expValue), + }, nil } for _, v := range vs { if v == expValue { - return nil + return nil, nil } } - return fmt.Errorf("header[%s]: expected %q, got %q", name, expValue, vs) - } + return &Failure{ + Target: name, + Expected: expValue, + Actual: vs, + Message: fmt.Sprintf("header[%s]: expected %q, got %q", + name, expValue, vs), + }, nil + }) } func AssertHeaderMatch(name, expPattern string) (Assertion, error) { @@ -92,21 +192,31 @@ func AssertHeaderMatch(name, expPattern string) (Assertion, error) { return nil, err } - return func(res *httpResponse) error { + return newAssertion("header", func(res *httpResponse) (*Failure, error) { vs := res.Header.Values(name) if vs == nil { - return fmt.Errorf("header[%s]: expected to match %q, missing", - name, expPattern) + return &Failure{ + Target: name, + Expected: expPattern, + Message: fmt.Sprintf("header[%s]: expected to match %q, missing", + name, expPattern), + }, nil } for _, v := range vs { if re.MatchString(v) { - return nil + return nil, nil } } - return fmt.Errorf("header[%s]: expected to match %q, got %q", name, expPattern, vs) - }, nil + return &Failure{ + Target: name, + Expected: expPattern, + Actual: vs, + Message: fmt.Sprintf("header[%s]: expected to match %q, got %q", + name, expPattern, vs), + }, nil + }), nil } // bodyOf returns the payload, or an error saying why there is none to assert @@ -126,18 +236,23 @@ func bodyOf(res *httpResponse) ([]byte, error) { } func AssertBodyEmpty() Assertion { - return func(res *httpResponse) error { + return newAssertion("body", func(res *httpResponse) (*Failure, error) { body, err := bodyOf(res) if err != nil { - return err + return nil, err } if len(body) > 0 { - return fmt.Errorf("body: expected to be empty, got %q", string(body)) + return &Failure{ + Expected: "empty", + Actual: string(body), + Message: fmt.Sprintf("body: expected to be empty, got %q", + string(body)), + }, nil } - return nil - } + return nil, nil + }) } // jqTimeout bounds the evaluation of one --assert-jq query. @@ -176,17 +291,17 @@ func AssertJQ(query string) (Assertion, error) { return nil, err } - return func(res *httpResponse) error { + return newAssertion("jq", func(res *httpResponse) (*Failure, error) { return runJQ(code, query, res, jqTimeout) - }, nil + }), nil } // runJQ evaluates one compiled query. The deadline is a parameter so the test // for it need not wait out the real one; every caller passes jqTimeout. -func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Duration) error { +func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Duration) (*Failure, error) { doc, err := res.decodeJSON() if err != nil { - return err + return nil, err } ctx, cancel := context.WithTimeout(context.Background(), timeout) @@ -205,12 +320,22 @@ func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Durati // stream rather than as a Go error. Untyped it would read as "not // true", turning a broken query into a failed assertion and // sending the reader to inspect a service that answered correctly. + // + // It is an error rather than a Failure for the same reason: the + // query never reached a verdict, so there is nothing for Expected + // and Actual to describe. if e, isErr := v.(error); isErr { - return fmt.Errorf("jq[%s]: %s", query, e) + return nil, fmt.Errorf("jq[%s]: %s", query, e) } if b, isBool := v.(bool); !isBool || !b { - return fmt.Errorf("jq[%s]: expected true, got %s", query, jqValue(v)) + return &Failure{ + Target: query, + Expected: true, + Actual: v, + Message: fmt.Sprintf("jq[%s]: expected true, got %s", + query, jqValue(v)), + }, nil } } @@ -219,10 +344,14 @@ func runJQ(code *gojq.Code, query string, res *httpResponse, timeout time.Durati // reachable by accident: `.users[] | select(.id == 99) | .active` // yields no output at all when no user has that id. if outputs == 0 { - return fmt.Errorf("jq[%s]: expected true, got no output", query) + return &Failure{ + Target: query, + Expected: true, + Message: fmt.Sprintf("jq[%s]: expected true, got no output", query), + }, nil } - return nil + return nil, nil } // jqValue renders a query's output for the failure message. jq's own notation @@ -237,25 +366,28 @@ func jqValue(v any) string { } func AssertBodyNotEmpty() Assertion { - return func(res *httpResponse) error { + return newAssertion("body", func(res *httpResponse) (*Failure, error) { body, err := bodyOf(res) if err != nil { - return err + return nil, err } if len(body) == 0 { - return fmt.Errorf("body: expected to be non-empty, got nothing") + return &Failure{ + Expected: "non-empty", + Message: "body: expected to be non-empty, got nothing", + }, nil } - return nil - } + return nil, nil + }) } func AssertBodyEqual(expContent string) Assertion { - return func(res *httpResponse) error { + return newAssertion("body", func(res *httpResponse) (*Failure, error) { body, err := bodyOf(res) if err != nil { - return err + return nil, err } if c := string(body); expContent != c { @@ -264,14 +396,22 @@ func AssertBodyEqual(expContent string) Assertion { // choice inside the failure -- deciding the verdict on it is what // made --assert-body-eq '' impossible to satisfy (#22). if len(body) == 0 { - return fmt.Errorf("body: expected %q, missing", expContent) + return &Failure{ + Expected: expContent, + Message: fmt.Sprintf("body: expected %q, missing", + expContent), + }, nil } - return fmt.Errorf("body: expected %q, got %q", expContent, c) + return &Failure{ + Expected: expContent, + Actual: c, + Message: fmt.Sprintf("body: expected %q, got %q", expContent, c), + }, nil } - return nil - } + return nil, nil + }) } func AssertBodyMatch(expPattern string) (Assertion, error) { @@ -280,10 +420,10 @@ func AssertBodyMatch(expPattern string) (Assertion, error) { return nil, err } - return func(res *httpResponse) error { + return newAssertion("body", func(res *httpResponse) (*Failure, error) { body, err := bodyOf(res) if err != nil { - return err + return nil, err } if c := string(body); !re.MatchString(c) { @@ -291,34 +431,67 @@ func AssertBodyMatch(expPattern string) (Assertion, error) { // `^$`, `.*` and `\A\z` all match it, and none of them could pass // while emptiness was checked before the pattern was. if len(body) == 0 { - return fmt.Errorf("body: expected to match %q, missing", expPattern) + return &Failure{ + Expected: expPattern, + Message: fmt.Sprintf("body: expected to match %q, missing", + expPattern), + }, nil } - return fmt.Errorf("body: expected to match %q, got %q", expPattern, c) + return &Failure{ + Expected: expPattern, + Actual: c, + Message: fmt.Sprintf("body: expected to match %q, got %q", + expPattern, c), + }, nil } - return nil - }, nil + return nil, nil + }), nil } -func AssertRedirectEqual(expLocation string) Assertion { - return func(res *httpResponse) error { - if s := res.StatusCode; s < 300 || s >= 400 { - return fmt.Errorf("redirect: wrong HTTP status: got %d (%q)", - res.StatusCode, res.Status) +// redirectPrecondition reports the two ways a redirect assertion fails before +// its Location is compared at all. Both redirect assertions share them, and +// sharing the code is what keeps their wording identical. +func redirectPrecondition(res *httpResponse, expected any) *Failure { + if s := res.StatusCode; s < 300 || s >= 400 { + return &Failure{ + Expected: "3xx", + Actual: res.StatusCode, + Message: fmt.Sprintf("redirect: wrong HTTP status: got %d (%q)", + res.StatusCode, res.Status), } + } - if vs := res.Header.Values("Location"); vs == nil { - return fmt.Errorf("redirect: no Location header") + if vs := res.Header.Values("Location"); vs == nil { + return &Failure{ + Target: "Location", + Expected: expected, + Message: "redirect: no Location header", + } + } + + return nil +} + +func AssertRedirectEqual(expLocation string) Assertion { + return newAssertion("redirect", func(res *httpResponse) (*Failure, error) { + if f := redirectPrecondition(res, expLocation); f != nil { + return f, nil } if l := res.Header.Get("Location"); l != expLocation { - return fmt.Errorf("redirect: wrong Location: expected %q, got %q", - expLocation, l) + return &Failure{ + Target: "Location", + Expected: expLocation, + Actual: l, + Message: fmt.Sprintf("redirect: wrong Location: expected %q, got %q", + expLocation, l), + }, nil } - return nil - } + return nil, nil + }) } func AssertRedirectMatch(expPattern string) (Assertion, error) { @@ -327,21 +500,21 @@ func AssertRedirectMatch(expPattern string) (Assertion, error) { return nil, err } - return func(res *httpResponse) error { - if s := res.StatusCode; s < 300 || s >= 400 { - return fmt.Errorf("redirect: wrong HTTP status: got %d (%q)", - res.StatusCode, res.Status) - } - - if vs := res.Header.Values("Location"); vs == nil { - return fmt.Errorf("redirect: no Location header") + return newAssertion("redirect", func(res *httpResponse) (*Failure, error) { + if f := redirectPrecondition(res, expPattern); f != nil { + return f, nil } if l := res.Header.Get("Location"); !re.MatchString(l) { - return fmt.Errorf("redirect: wrong Location: expected to match %q, got %q", - expPattern, l) + return &Failure{ + Target: "Location", + Expected: expPattern, + Actual: l, + Message: fmt.Sprintf("redirect: wrong Location: expected to match %q, got %q", + expPattern, l), + }, nil } - return nil - }, nil + return nil, nil + }), nil } diff --git a/assertions_test.go b/assertions_test.go index 84137c8..dd32cad 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -1,8 +1,10 @@ package main import ( + "errors" "fmt" "net/http" + "reflect" "strconv" "strings" "testing" @@ -50,8 +52,8 @@ func Test_AssertStatusOK(t *testing.T) { wantOK, wantNOK = fmt.Sprintf("ok: expected OK, got %d (%q)", tc.StatusCode, tc.Status), "" } - checkErr(t, "ok", ok(res), wantOK) - checkErr(t, "nok", nok(res), wantNOK) + checkErr(t, "ok", check(ok, res), wantOK) + checkErr(t, "nok", check(nok, res), wantNOK) }) } } @@ -96,7 +98,7 @@ func Test_AssertStatusEqual(t *testing.T) { if tc.StatusCode != expected { want = fmt.Sprintf("status: expected %d, got %d (%q)", expected, tc.StatusCode, tc.Status) } - checkErr(t, fmt.Sprintf("expected %d", expected), assertions[expected](res), want) + checkErr(t, fmt.Sprintf("expected %d", expected), check(assertions[expected], res), want) } }) } @@ -211,17 +213,17 @@ func Test_AssertHeader(t *testing.T) { } if tc.ExpMissing { - checkErr(t, "present", present(res), `header[taRgEt]: expected to be present, missing`) - checkErr(t, "missing", missing(res), "") + checkErr(t, "present", check(present, res), `header[taRgEt]: expected to be present, missing`) + checkErr(t, "missing", check(missing, res), "") } else { - checkErr(t, "present", present(res), "") + checkErr(t, "present", check(present, res), "") // The values are echoed back, so match rather than pin them. - checkErrMatch(t, "missing", missing(res), + checkErrMatch(t, "missing", check(missing, res), `header\[taRgEt\]: expected to be missing, got \[.*\]$`) } - checkErr(t, "equal", equal(res), tc.ExpEqualError) - checkErr(t, "match", match(res), tc.ExpMatchError) + checkErr(t, "equal", check(equal, res), tc.ExpEqualError) + checkErr(t, "match", check(match, res), tc.ExpMatchError) }) } } @@ -277,9 +279,9 @@ func Test_AssertBody(t *testing.T) { t.Run(tc.CaseName, func(t *testing.T) { res := &httpResponse{BodyBytes: tc.Body} - checkErr(t, "empty", empty(res), tc.ExpEmptyError) - checkErr(t, "equal", equal(res), tc.ExpEqualError) - checkErr(t, "match", match(res), tc.ExpMatchError) + checkErr(t, "empty", check(empty, res), tc.ExpEmptyError) + checkErr(t, "equal", check(equal, res), tc.ExpEqualError) + checkErr(t, "match", check(match, res), tc.ExpMatchError) }) } } @@ -297,10 +299,10 @@ func Test_AssertBody_emptyIsAssertable(t *testing.T) { t.Run("equal to the empty string", func(t *testing.T) { res := &httpResponse{BodyBytes: []byte{}} - checkErr(t, "equal", AssertBodyEqual("")(res), "") + checkErr(t, "equal", check(AssertBodyEqual(""), res), "") // And a nil body, which is what a 204 produces. - checkErr(t, "equal, nil body", AssertBodyEqual("")(&httpResponse{}), "") + checkErr(t, "equal, nil body", check(AssertBodyEqual(""), &httpResponse{}), "") }) for _, p := range patterns { @@ -310,8 +312,8 @@ func Test_AssertBody_emptyIsAssertable(t *testing.T) { t.Fatalf("cannot build the assertion: %s", err) } - checkErr(t, "match", a(&httpResponse{BodyBytes: []byte{}}), "") - checkErr(t, "match, nil body", a(&httpResponse{}), "") + checkErr(t, "match", check(a, &httpResponse{BodyBytes: []byte{}}), "") + checkErr(t, "match, nil body", check(a, &httpResponse{}), "") }) } @@ -319,19 +321,19 @@ func Test_AssertBody_emptyIsAssertable(t *testing.T) { // something was expected still reads as "missing" rather than `got ""`. t.Run("an empty body still reports as missing", func(t *testing.T) { res := &httpResponse{BodyBytes: []byte{}} - checkErr(t, "equal", AssertBodyEqual("value")(res), `body: expected "value", missing`) + checkErr(t, "equal", check(AssertBodyEqual("value"), res), `body: expected "value", missing`) a, err := AssertBodyMatch("^value$") if err != nil { t.Fatalf("cannot build the assertion: %s", err) } - checkErr(t, "match", a(res), `body: expected to match "^value$", missing`) + checkErr(t, "match", check(a, res), `body: expected to match "^value$", missing`) }) // The inverse must keep failing: a non-empty body is not the empty string. t.Run("a non-empty body does not equal the empty string", func(t *testing.T) { res := &httpResponse{BodyBytes: []byte("x")} - checkErr(t, "equal", AssertBodyEqual("")(res), `body: expected "", got "x"`) + checkErr(t, "equal", check(AssertBodyEqual(""), res), `body: expected "", got "x"`) }) } @@ -501,8 +503,8 @@ func Test_AssertRedirect(t *testing.T) { }, } - checkErr(t, "equal", equal(res), tc.ExpEqualError) - checkErr(t, "match", match(res), tc.ExpMatchError) + checkErr(t, "equal", check(equal, res), tc.ExpEqualError) + checkErr(t, "match", check(match, res), tc.ExpMatchError) }) } } @@ -540,7 +542,7 @@ func Test_AssertBodyNotEmpty(t *testing.T) { a := AssertBodyNotEmpty() for _, tc := range tests { t.Run(tc.Name, func(t *testing.T) { - checkErr(t, "not-empty", a(&httpResponse{BodyBytes: tc.Body}), tc.Want) + checkErr(t, "not-empty", check(a, &httpResponse{BodyBytes: tc.Body}), tc.Want) }) } @@ -550,7 +552,7 @@ func Test_AssertBodyNotEmpty(t *testing.T) { empty := AssertBodyEmpty() for _, body := range [][]byte{nil, {}, []byte(" "), []byte("x"), []byte("longer body")} { res := &httpResponse{BodyBytes: body} - if (empty(res) == nil) == (a(res) == nil) { + if (check(empty, res) == nil) == (check(a, res) == nil) { t.Errorf("both agree on %q; they must disagree", string(body)) } } @@ -596,3 +598,170 @@ func Test_AssertMatchConstructorsRejectBadPatterns(t *testing.T) { }) } } + +// Test_AssertionIdentity pins the structure assertions gained when Assertion +// became an interface (#56): every assertion names its kind, and a failure +// carries the parts of its sentence as data rather than only the sentence. +// +// These are the fields #45 serializes, so a change here is a change to the +// machine-readable contract, not an internal detail. The Message is covered by +// the tables above and deliberately not repeated. +func Test_AssertionIdentity(t *testing.T) { + t.Parallel() + + statusRes := func(code int, status string) *httpResponse { + return &httpResponse{ + Response: &http.Response{StatusCode: code, Status: status}, + } + } + headerRes := func(h http.Header) *httpResponse { + return &httpResponse{ + Response: &http.Response{StatusCode: 200, Status: "200 OK", Header: h}, + } + } + + jq, err := AssertJQ(".n == 1") + if err != nil { + t.Fatalf("cannot build the jq assertion: %s", err) + } + + tests := []struct { + Name string + Assertion Assertion + Res *httpResponse + Kind string + Target string + Expected any + Actual any + }{ + { + Name: "ok", Assertion: AssertStatusOK(), + Res: statusRes(500, "500 Internal Server Error"), + Kind: "ok", Expected: "2xx-3xx", Actual: 500, + }, + { + Name: "nok", Assertion: AssertStatusNOK(), + Res: statusRes(200, "200 OK"), + Kind: "nok", Expected: "not 2xx-3xx", Actual: 200, + }, + { + Name: "status", Assertion: AssertStatusEqual(200), + Res: statusRes(500, "500 Internal Server Error"), + Kind: "status", Expected: 200, Actual: 500, + }, + { + Name: "header present", Assertion: AssertHeaderPresent("X-Absent"), + Res: headerRes(http.Header{}), + Kind: "header", Target: "X-Absent", Expected: "present", + }, + { + Name: "header equal", Assertion: AssertHeaderEqual("X-A", "want"), + Res: headerRes(http.Header{"X-A": []string{"got"}}), + Kind: "header", Target: "X-A", Expected: "want", Actual: []string{"got"}, + }, + { + Name: "body equal", Assertion: AssertBodyEqual("want"), + Res: &httpResponse{BodyBytes: []byte("got")}, + Kind: "body", Expected: "want", Actual: "got", + }, + { + Name: "redirect", Assertion: AssertRedirectEqual("/there"), + Res: headerRes(http.Header{"Location": []string{"/elsewhere"}}), + // A 200 never reaches the Location comparison, so this is the + // precondition failure, which reports the status it wanted. + Kind: "redirect", Expected: "3xx", Actual: 200, + }, + { + Name: "jq", Assertion: jq, Res: jqResponse(`{"n":2}`), + Kind: "jq", Target: ".n == 1", Expected: true, Actual: false, + }, + } + + for _, tc := range tests { + t.Run(tc.Name, func(t *testing.T) { + if got := tc.Assertion.Kind(); got != tc.Kind { + t.Errorf("Kind() = %q, want %q", got, tc.Kind) + } + + f, err := tc.Assertion.Check(tc.Res) + if err != nil { + t.Fatalf("unexpected evaluation error: %s", err) + } + if f == nil { + t.Fatal("expected a Failure, got none") + } + + // Kind is stamped by Check rather than written by each + // constructor, so the two can never drift apart. + if f.Kind != tc.Kind { + t.Errorf("Failure.Kind = %q, want %q", f.Kind, tc.Kind) + } + if f.Target != tc.Target { + t.Errorf("Target = %q, want %q", f.Target, tc.Target) + } + if !reflect.DeepEqual(f.Expected, tc.Expected) { + t.Errorf("Expected = %#v, want %#v", f.Expected, tc.Expected) + } + if !reflect.DeepEqual(f.Actual, tc.Actual) { + t.Errorf("Actual = %#v, want %#v", f.Actual, tc.Actual) + } + if f.Message == "" { + t.Error("Message is empty; the human path reads this") + } + }) + } +} + +// Test_AssertionCheckSeparatesFailureFromError covers the distinction the +// interface exists to draw: a response that was read and disagreed is a +// Failure, and one that could not be evaluated at all is an error. Both still +// fail the run -- doOnce collects them into one list -- but only one of them +// has an Expected and an Actual to report. +func Test_AssertionCheckSeparatesFailureFromError(t *testing.T) { + t.Parallel() + + t.Run("an assertion that holds reports neither", func(t *testing.T) { + f, err := AssertBodyEqual("same").Check(&httpResponse{BodyBytes: []byte("same")}) + if f != nil || err != nil { + t.Errorf("got (%v, %v), want (nil, nil)", f, err) + } + }) + + t.Run("an undecodable body is an error, not a Failure", func(t *testing.T) { + res := &httpResponse{ + Encoding: "br", + DecodeErr: errors.New(`no decoder for "br"`), + } + + for name, a := range map[string]Assertion{ + "body equal": AssertBodyEqual("x"), + "body empty": AssertBodyEmpty(), + } { + t.Run(name, func(t *testing.T) { + f, err := a.Check(res) + if f != nil { + t.Errorf("got a Failure %+v; an unevaluable assertion has no Expected/Actual", f) + } + if err == nil { + t.Fatal("expected an evaluation error, got nil") + } + if !strings.Contains(err.Error(), "was not decoded") { + t.Errorf("error = %q, want it to name the encoding problem", err) + } + }) + } + }) + + t.Run("a status assertion is unaffected by an undecodable body", func(t *testing.T) { + res := &httpResponse{ + Response: &http.Response{StatusCode: 200, Status: "200 OK"}, + Encoding: "br", + DecodeErr: errors.New(`no decoder for "br"`), + } + + f, err := AssertStatusOK().Check(res) + if f != nil || err != nil { + t.Errorf("got (%v, %v), want (nil, nil)", f, err) + } + }) +} diff --git a/compression_test.go b/compression_test.go index 11fbb88..283804e 100644 --- a/compression_test.go +++ b/compression_test.go @@ -278,7 +278,7 @@ func Test_bodyAssertionsRefuseAnEncodedBody(t *testing.T) { res := encoded("br", []byte{0x1b, 0x13, 0x00}) res.decodeBody() - checkErrMatch(t, name, a(res), `^body: response is br-encoded and was not decoded: `) + checkErrMatch(t, name, check(a, res), `^body: response is br-encoded and was not decoded: `) }) } } diff --git a/helpers_test.go b/helpers_test.go index 7cdf8e9..cfb2bd0 100644 --- a/helpers_test.go +++ b/helpers_test.go @@ -48,3 +48,21 @@ func checkErrMatch(t *testing.T, label string, err error, pattern string) { t.Errorf("%s: error = %q, want match %q", label, err.Error(), pattern) } } + +// check runs an assertion the way doOnce does, flattening Check's two returns +// into the single error these tables compare against. +// +// The nil guard is load-bearing: returning a nil *Failure through an error +// interface yields a non-nil error holding a nil pointer, which would fail +// every "expected no error" case with an unreadable message. +func check(a Assertion, res *httpResponse) error { + f, err := a.Check(res) + if err != nil { + return err + } + if f != nil { + return f + } + + return nil +} diff --git a/jq_test.go b/jq_test.go index c26ade6..dc04d49 100644 --- a/jq_test.go +++ b/jq_test.go @@ -129,7 +129,7 @@ func Test_AssertJQ(t *testing.T) { t.Fatalf("cannot build the assertion: %s", err) } - checkErr(t, "jq", a(jqResponse(tc.Body)), tc.Want) + checkErr(t, "jq", check(a, jqResponse(tc.Body)), tc.Want) }) } } @@ -206,7 +206,15 @@ func Test_AssertJQ_boundsARunawayQuery(t *testing.T) { done := make(chan error, 1) start := time.Now() - go func() { done <- runJQ(code, query, jqResponse(jqDoc), short) }() + go func() { + // Either return means the query did not succeed; the test + // only cares that it stopped, and why is asserted below. + f, err := runJQ(code, query, jqResponse(jqDoc), short) + if err == nil && f != nil { + err = f + } + done <- err + }() select { case err := <-done: diff --git a/main.go b/main.go index 859c400..b3845ea 100644 --- a/main.go +++ b/main.go @@ -932,8 +932,17 @@ func (c Client) doOnce(client *http.Client, req *http.Request, assertions []Asse var assertErrors []error for i := range assertions { - if err := assertions[i](httpRes); err != nil { + // A failed assertion and one that could not be evaluated are both + // failures of the run and both print the same way, so they share a + // list -- which is also what keeps the dump in the order the + // assertions were given. Only a machine-readable consumer needs to + // tell them apart, and that is what Check separates them for (#45). + f, err := assertions[i].Check(httpRes) + switch { + case err != nil: assertErrors = append(assertErrors, err) + case f != nil: + assertErrors = append(assertErrors, f) } } if len(assertErrors) > 0 {