From 119b323f60790acba21355d2d3473b3620fdcf7c Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:44:32 -0400 Subject: [PATCH 1/2] refactor(assert): Give assertions a kind and failures their parts An Assertion was a func returning error, so a failure could only ever be a sentence. Nothing downstream could ask which assertion failed, what it wanted or what it got without parsing English, which is why machine-readable output (#45) had nothing to serialize. Assertion is now an interface: Kind() names the family, and Check returns (*Failure, error). Failure carries Kind, Target, Expected and Actual alongside the Message, and the message is kept rather than derived from the parts -- sentences like "expected to be non-empty, got nothing" do not decompose into Expected and Actual, and a formatter that tried would drift the first time a wording changed. The two returns separate outcomes that a single error conflated. A *Failure means the response was read and disagreed. An error means the assertion could not be evaluated at all: an undecodable body, a gojq runtime fault, a body that is not JSON. Both still fail the run and still print identically -- doOnce collects them into one list, so --help's promise that an undecodable body fails the body assertions and leaves the others alone is unchanged, exit code and all. The distinction exists for #45, where "the service is wrong" and "we could not tell" are different answers. Constructors stay closures behind a small adapter rather than becoming thirteen one-method structs, which is what keeps each one readable as a single expression. Check stamps the kind onto the failure, so Kind() and Failure.Kind cannot disagree. No output changes. Every failure message is byte-identical to before, verified by diffing both binaries across thirteen failing scenarios covering all seven kinds, and by the end-to-end suite passing unchanged. Refs #56 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX --- assertions.go | 349 +++++++++++++++++++++++++++++++++----------- assertions_test.go | 46 +++--- compression_test.go | 2 +- helpers_test.go | 18 +++ jq_test.go | 12 +- main.go | 11 +- 6 files changed, 323 insertions(+), 115 deletions(-) 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..55905eb 100644 --- a/assertions_test.go +++ b/assertions_test.go @@ -50,8 +50,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 +96,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 +211,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 +277,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 +297,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 +310,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 +319,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 +501,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 +540,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 +550,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)) } } 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 { From b9d018fe03ff1b75bb361c6d02e8959184ccc8ff Mon Sep 17 00:00:00 2001 From: korya <148461+korya@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:44:47 -0400 Subject: [PATCH 2/2] test(assert): Pin the structure a failure now carries The refactor's own tables assert on the message, which is exactly the part that did not change; nothing covered the fields that did. These are what #45 will serialize, so a change to them is a change to a public contract rather than an internal detail, and it should fail a test rather than surprise a consumer. Two tests. The first walks one failing case per kind and checks Kind, Target, Expected and Actual, including that Check stamps the kind so Kind() and Failure.Kind cannot drift apart. The second covers the split the interface exists to draw: an assertion that holds reports neither return, a body that could not be decoded is an error rather than a Failure with invented Expected/Actual, and a status assertion against that same response still passes. No end-to-end test accompanies this. The refactor changes no observable behaviour, and the one invariant it could have broken -- an undecodable body failing the body assertions with exit 93 while the others pass -- is already covered end-to-end and passes unchanged. Refs #56 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX --- assertions_test.go | 169 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) diff --git a/assertions_test.go b/assertions_test.go index 55905eb..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" @@ -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) + } + }) +}