Skip to content

Report as you go - #59

Merged
dogenkigen merged 6 commits into
mainfrom
reporting1
Oct 6, 2026
Merged

dogenkigen merged 6 commits into
mainfrom
reporting1

Conversation

@dogenkigen

@dogenkigen dogenkigen commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor

Summary

doc-assert now reports results as they happen, like cargo test does, instead of printing one block when the whole run is over. The library API is reworked around this, so the crate goes out as 0.2.0 (breaking).

Reporting as you go

Each test case is named as soon as its request is sent and marked ✅ or ❌ once it is done, so a slow or retried request shows what is being waited for. The details of the failures follow at the end:

2 tests
GET /blog (README.md:12) ✅
POST /blog (README.md:30) ❌

failures:
-------------
POST /blog (README.md:30): response at line 36: expected response code 201, got 500

test result: FAILED. 1 passed; 1 failed

The CLI and DocAssert::assert() print the same output. Inside a test, cargo captures it like any test output: it is shown when the test fails, or as it is printed with cargo test -- --nocapture.

All of this is built on Run:

  • DocAssert::start() parses every documentation file up front, so a parse error is reported before any request is sent and the number of test cases is known in advance.
  • run.next().await executes one test case and returns its TestCaseResult.
  • run.print_progress().await executes the rest, printing the output above.
  • run.finish() returns the Report of what was executed so far.

API changes (breaking)

Method Returns Use it when
assert() (); prints the progress, panics unless every test case passed Inside a #[tokio::test] (the common case)
run() Result<Report, Error>; prints nothing You want to inspect the report yourself
start() Result<Run, Error> You want each result as soon as it is executed

Err only means the run could not happen at all: no documentation file was given, or a file could not be parsed. Failed test cases are reported in the Report, and Report::passed() gives the verdict.

Migration

Before After
DocAssert::new().with_url(url) DocAssert::new(url)
assert().await returning Result<Report, AssertionError> assert().await, panicking on failure, or run().await returning Result<Report, Error>
Err(AssertionError::TestSuiteError(report)) Ok(report) with report.passed() == false
Err(AssertionError::ParsingError(String)) Err(Error::Parse { doc_path, reason })
insert_string, insert_int, insert_float, insert_bool, insert_value insert(name, value), which accepts anything Into<serde_json::Value>
insert_null(name) insert(name, Value::Null)
Variables::from_json(..) -> Result<Self, String> Variables::from_json(..) -> Option<Self>
DocAssert<'a> borrowing &str arguments DocAssert taking owned impl Into<String> arguments

Structured results

  • Report has results(), failures() (yielding (&TestCaseId, &Failure) pairs), total_count(), executed_count(), passed_count(), failed_count(), not_run_count(), passed() and summary().
  • TestCaseResult has id(), passed() and failure(). A TestCaseId holds the method, URI, doc path and line.
  • Failure says why a test case did not pass:
    • UnresolvedVariables { names }
    • InvalidDocumentation { line_number, reason }
    • RequestFailed { reason }
    • ResponseMismatch { line_number, cause: Mismatch }
  • Mismatch covers what the server got wrong: status code, header, missing header, body, variable not found, unreadable or malformed body.
  • Error, Failure and Mismatch are #[non_exhaustive], so variants can be added without a breaking release.
matches!(
    failure,
    Failure::ResponseMismatch { cause: Mismatch::StatusCode { actual: 500..=599, .. }, .. }
)

Behaviour changes

  • Partial runs: a run stopped early reports the test cases it did not get to as not run. Such a report never passes, and its result line ends with ; N not run.
  • Documentation mistakes are not retried.
    • An invalid [ignore] path is now a parse error.
    • An expected body that is not valid JSON, or an invalid request header, fails the test case as InvalidDocumentation before any request is sent. These used to be sent and retried as often as the retry policy allowed.
  • Variables are only extracted from a response once the test case passes, not part way through a failed attempt.
  • Unresolved variables are reported by name rather than with the whole substituted input, which could contain the values of other variables, such as tokens. A lone backtick is no longer mistaken for a placeholder.
  • Non-ASCII header values: a non-ASCII header value from the server is reported as a header mismatch instead of panicking.
  • Retry policy: [retry]: # (0, …) is rejected as a parse error that names its line. Previously it produced a test case that could never run. The README now says that the first number is the number of attempts, the first one included, which is what the code always did.
  • CLI:
    • Errors are printed to stderr.
    • A parse error reads Error: cannot parse <file>: ….
    • Running without a documentation file exits with 2.
    • Failure lines no longer repeat the method and URI.
    • The other exit codes are unchanged and now documented in the README: 0 when everything passed, 2 for invalid arguments, 3 when a document can't be parsed, 4 when a test case failed.

Internal changes

  • lib.rs contains only the public entry point: DocAssert, Run, Error, and the re-exports. Results and their rendering live in report.rs; Variables and placeholder substitution live in variables.rs.
  • The executor checks what the documentation describes once, before the retry loop. It returns Failure and Mismatch values instead of strings.
  • CI now runs clippy and the tests with --features binary, so the binary is linted and tested. Previously it was only built.

Known limitation

Run::next is not cancellation safe.

  • What happens: if its future is dropped mid-request, for example by tokio::time::timeout or select!, that test case is abandoned.
  • How it shows up: the abandoned test case is reported as not run, so the report does not pass, and the variables it would have extracted are not defined. Its request may still have reached the server.
  • This is documented on the method and in the README, and covered by a test.

Implementing Stream for Run was considered and rejected:

  • Keeping the in-flight request inside Run would make next resumable after a drop, but resuming the abandoned request is not what someone applying a timeout wants.
  • The while let loop already covers what stream combinators would add.

The planned follow-up is a per-request timeout, for example DocAssert::with_timeout. A timed-out request would then be reported as an ordinary RequestFailed.

Testing

  • cargo fmt --check, cargo clippy --all-targets --features binary -- -D warnings and cargo doc pass with no warnings.
  • cargo test --features binary passes:
    • 37 unit tests;
    • 11 tests in tests/api.rs, against the public API only;
    • 6 tests in tests/cli.rs, against the binary;
    • 17 doctests. These include the README examples, two of which were previously skipped because they sat inside #[cfg(test)].
  • tests/cli.rs runs the binary against a local server that holds an endpoint open. This proves:
    • earlier results, and the name of the test case in progress, are printed before that endpoint answers;
    • the exact failure output;
    • the exit codes 0, 2, 3 and 4.
  • New executor tests cover the retry loop: number of attempts, the last failure is the one reported, no delay after the last attempt, and an unreachable server.
  • make sanity passes against sample-api. tests/functional/readmes/2_README_in.md and 3_README_in.md fail on main too, because the expected "My First Blog- UPDATED" is missing a space. They are not changed here.

🤖 Generated with Claude Code

- Move Variables and the placeholder substitution into variables.rs
- Take the URL in DocAssert::new instead of with_url, dropping Error::NoUrl
- Return Option from Variables::from_json, dropping Error::VariablesNotAnObject
- Fold Run::run_to_end into DocAssert::run
- Move the public API tests to tests/api.rs, the render test to report.rs
- Drop the crate-wide while_let_on_iterator allow

@VashingMachine VashingMachine left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed all 14 changed files against main, at commit 21ecb4c8aa92d5632d3c72f20f725f4a179d0368. I found no actionable regressions or changes needed before merging.

Review covered:

  • Public API and reports: the assert / run / start contracts agree with the implementation and updated examples. All files are parsed before execution, failed cases remain structured results, and incomplete runs cannot report success. The API break is reflected in the 0.2.0 version bump.
  • Execution and variables: retry limits count attempts, successful attempts stop immediately, and no delay is added after the final failure. Invalid expected JSON and request headers are checked before sending. Extracted variables are committed together only after the response passes, so a failed attempt cannot partially update shared state. Non-ASCII response header mismatches no longer require a fallible conversion followed by unwrap.
  • CLI and progress: test identifiers are printed and flushed before awaiting requests, failure details are rendered at the end, and argument/parse errors go to stderr with the documented exit codes. The integration tests exercise output timing against a held response, exact failure output, sequential execution, and variables shared across files.
  • Documentation and CI: the examples use the new API, and CI now lints and tests the feature-gated binary. The cancellation behavior of Run::next is explicitly documented and tested; I treated it as the declared limitation of this API.

Local validation passed:

  • cargo test --features binary: 37 unit tests, 11 public API tests, 6 CLI tests, and 17 doctests.
  • cargo fmt --all -- --check.
  • cargo clippy --all-targets --features binary -- -D warnings.
  • cargo doc --no-deps --features binary.
  • git diff --check for the PR diff.

GitHub CI is also green, and the PR head still matches the reviewed commit. Approved.

@dogenkigen
dogenkigen merged commit 9eb1cf3 into main Oct 6, 2026
1 check passed
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.

2 participants