Skip to content

refactor(assert): Give assertions a kind and failures their parts - #89

Merged
korya merged 2 commits into
masterfrom
korya-refactor-assertion-identity
Aug 11, 2026
Merged

refactor(assert): Give assertions a kind and failures their parts#89
korya merged 2 commits into
masterfrom
korya-refactor-assertion-identity

Conversation

@korya

@korya korya commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Problem

A monitoring agent that wants to know which assertion failed has to parse English, because a failure is only ever a sentence.

Assertion is a func(*httpResponse) error, so everything a consumer might want — which check ran, what it wanted, what it got — exists solely inside a formatted string. That is why --json (#45) has been blocked: there is nothing to serialize.

The error return also conflates two answers that are not the same:

The response was read and disagreed The assertion could not be evaluated
body: expected "zzz", got "boom" body: response is br-encoded and was not decoded
jq[.n == 1]: expected true, got false jq[.n == 1]: <gojq runtime fault>

"The service is wrong" and "we could not tell" are different operational conclusions, and today nothing downstream can distinguish them.

Note that this ticket's other half — making the pattern constructors return errors instead of panicking — shipped in 4d5dc06 three hours after #56 was filed, as part of closing #17. The ticket body has been rewritten to match.

Solution

Make Assertion an interface whose Check returns the failure's parts, not just its prose.

Before:

type Assertion func(res *httpResponse) error

After:

type Assertion interface {
	Kind() string                              // "ok", "nok", "status", "header", "body", "redirect", "jq"
	Check(res *httpResponse) (*Failure, error) // (nil, nil) when it holds
}

type Failure struct {
	Kind, Target   string
	Expected, Actual any
	Message        string
}

(nil, nil) holds, (*Failure, nil) disagreed, (nil, error) could not be evaluated. Both failure paths still collect into one list in doOnce and still exit 93 — the split is for #45, not for the exit codes.

No output changes, and that is checked rather than asserted

Both binaries were run against thirteen failing scenarios covering all seven kinds, and the output diffed:

IDENTICAL — 13 scenarios, every failure message and exit code byte-for-byte equal

The twelve distinct messages compared include every shape that does not decompose into expected/actual — header[X-Missing]: expected to match ".*", missing, redirect: wrong HTTP status: got 500 (…), jq[.missing]: expected true, got null. The end-to-end suite passes unchanged, which is the same check enforced in CI.

Two deliberate choices

Failure keeps Message rather than deriving it. Sentences like expected to be non-empty, got nothing and expected OK, got 500 ("500 Internal Server Error") do not reconstruct from Expected and Actual. A formatter that tried would drift the first time a wording changed, and the drift would surface as a failing end-to-end test rather than a compile error. The parts and the prose are written together, at the same site, and neither derives from the other.

Constructors stay closures behind a small adapter instead of becoming thirteen one-method structs — the functional style is what keeps each one readable as a single expression, and only the result ever needed structure. Check stamps the kind onto the failure, so Kind() and Failure.Kind cannot drift apart and no constructor repeats itself.

Other Changes

  • The redirect precondition (wrong status / no Location) is now shared by both redirect assertions, which is what keeps their wording identical rather than merely equal today.
  • Tests gain a check(a, res) helper flattening the two returns to the single error the existing tables compare against, so those tables are otherwise untouched.
  • No new end-to-end test. This change is behaviour-preserving, and the one invariant it could have broken — an undecodable body failing the body assertions with exit 93 while status and header assertions still pass — is already covered end-to-end and passes unchanged.
  • No visual change; this is a CLI with no rendered UI.

Related:

🤖 Generated with Claude Code

korya and others added 2 commits August 10, 2026 23:44
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrknafJSP5hF8u865cbnqX
@korya
korya marked this pull request as ready for review August 11, 2026 10:52
@korya
korya merged commit 5fa25cd into master Aug 11, 2026
8 checks passed
@korya
korya deleted the korya-refactor-assertion-identity branch August 11, 2026 10:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant