feat(assert): assertion macros for JSON-RPC test bodies (#8) #26

Merged
charles merged 1 commit from feat/8-assertion-macros into main 2026-04-11 18:43:04 +00:00
Owner

Closes #8. Stacked on #25.

Summary

13 #[macro_export] assertion macros in src/assert.rs. All return Err(TestError::Assertion { expected, got, context, location }) from the enclosing function on failure, so they must be invoked inside a function returning TestResult. Each captures the source location via concat!(file!(), ":", line!()).

Macros

Value / field

  • assert_ok!(v [, ctx]) — fails if v.error exists
  • assert_field!(v, k, expected) — single-key
  • assert_field!(v, k1, k2, expected) — 2-level nested
  • assert_field!(v, k1, k2, k3, expected) — 3-level nested
  • assert_field_exists!(v, field [, ctx])
  • assert_field_absent!(v, field [, ctx])
  • assert_field_type!(v, field, "string"|"number"|"bool"|"array"|"object"|"null" [, ctx])

Array

  • assert_array!(v, range [, ctx]) — any RangeBounds<usize> (1.., ..=5, 3..=3, etc.)
  • assert_array_contains!(v, pred [, ctx])
  • assert_array_all!(v, pred [, ctx])

Numeric

  • assert_in_range!(v, range [, ctx])RangeBounds<f64>
  • assert_greater_than!(v, n [, ctx])
  • assert_less_than!(v, n [, ctx])

Error

  • assert_error!(v, code [, ctx])
  • assert_error_contains!(v, needle [, ctx])

assert_field! arity trade-off

The spec asks for both variadic nested paths AND an optional trailing context string. These are fundamentally ambiguous in macro_rules!:

assert_field!(v, "data", "name", "alice")
// — could be (v, k1="data", k2="name", expected="alice")          L2 no ctx
// — or       (v, k="data", expected="name", ctx="alice")          L1 with ctx

Both match macro_rules! patterns with equal strength. I chose to support nested paths (explicit spec requirement from §3) and drop ctx support specifically on assert_field!. Everywhere else the optional , "ctx" still works. A design note in the macro doc comment tells users to use assert_field_exists! or another macro if they need context in that spot.

Failure rendering

  • Expected and got values are rendered via serde_json::to_string_pretty with a Debug fallback.
  • context is an Option<String> populated from the trailing arg.
  • location is concat!(file!(), ":", line!()) captured at the macro call site.

Checklist (from issue #8)

  • All #[macro_export]; early-return Err(TestError::Assertion { .. })
  • Location via concat!(file!(), ":", line!())
  • Pretty-JSON expected/got rendering
  • Optional trailing , "ctx" on all macros except assert_field! (documented trade-off)
  • assert_ok! / assert_field! (1/2/3 nesting levels) — resolves spec review §3
  • assert_field_exists!, assert_field_absent!, assert_field_type!
  • assert_array! with any RangeBounds<usize>; assert_array_contains!, assert_array_all!
  • assert_in_range!, assert_greater_than!, assert_less_than!
  • assert_error!, assert_error_contains!
  • Each macro has at least one passing and one failing unit test
  • Macro hygiene: all crate refs use $crate:: paths

Test plan

  • 16 unit tests in src/assert.rs covering passing + failing paths for each macro
  • assert_ok_supports_context_message verifies ctx populates TestError::Assertion.context
  • just qa green locally — 62/62 unit tests
  • CI green on Forgejo

Notes for the reviewer

  • Macros use $crate::assert::__private::* helpers for the pretty printer and type-name lookup. The __private module is #[doc(hidden)] so it doesn't appear in rustdoc's public API.
  • assert_array! and assert_in_range! fully-qualify ::core::ops::RangeBounds calls so users don't need an explicit use at the call site.
  • All macros use ::core:: / ::std:: prefixes to avoid colliding with re-declared items in the caller's module.
  • The assert_field pattern order is Level 3 → Level 2 → Level 1 so 5-arg invocations match the longest path first (otherwise (v, k1, k2, k3, expected) would try to match L2 with expected=k3 and ctx=expected — which is exactly what was failing during development).
Closes #8. **Stacked on #25.** ## Summary 13 `#[macro_export]` assertion macros in `src/assert.rs`. All return `Err(TestError::Assertion { expected, got, context, location })` from the enclosing function on failure, so they must be invoked inside a function returning `TestResult`. Each captures the source location via `concat!(file!(), ":", line!())`. ### Macros **Value / field** - `assert_ok!(v [, ctx])` — fails if `v.error` exists - `assert_field!(v, k, expected)` — single-key - `assert_field!(v, k1, k2, expected)` — 2-level nested - `assert_field!(v, k1, k2, k3, expected)` — 3-level nested - `assert_field_exists!(v, field [, ctx])` - `assert_field_absent!(v, field [, ctx])` - `assert_field_type!(v, field, "string"|"number"|"bool"|"array"|"object"|"null" [, ctx])` **Array** - `assert_array!(v, range [, ctx])` — any `RangeBounds<usize>` (`1..`, `..=5`, `3..=3`, etc.) - `assert_array_contains!(v, pred [, ctx])` - `assert_array_all!(v, pred [, ctx])` **Numeric** - `assert_in_range!(v, range [, ctx])` — `RangeBounds<f64>` - `assert_greater_than!(v, n [, ctx])` - `assert_less_than!(v, n [, ctx])` **Error** - `assert_error!(v, code [, ctx])` - `assert_error_contains!(v, needle [, ctx])` ### `assert_field!` arity trade-off The spec asks for both variadic nested paths AND an optional trailing context string. These are fundamentally ambiguous in `macro_rules!`: ``` assert_field!(v, "data", "name", "alice") // — could be (v, k1="data", k2="name", expected="alice") L2 no ctx // — or (v, k="data", expected="name", ctx="alice") L1 with ctx ``` Both match `macro_rules!` patterns with equal strength. I chose to support nested paths (explicit spec requirement from §3) and drop ctx support **specifically on `assert_field!`**. Everywhere else the optional `, "ctx"` still works. A design note in the macro doc comment tells users to use `assert_field_exists!` or another macro if they need context in that spot. ### Failure rendering - Expected and got values are rendered via `serde_json::to_string_pretty` with a `Debug` fallback. - `context` is an `Option<String>` populated from the trailing arg. - `location` is `concat!(file!(), ":", line!())` captured at the macro call site. ## Checklist (from issue #8) - [x] All `#[macro_export]`; early-return `Err(TestError::Assertion { .. })` - [x] Location via `concat!(file!(), ":", line!())` - [x] Pretty-JSON expected/got rendering - [x] Optional trailing `, "ctx"` on all macros **except `assert_field!`** (documented trade-off) - [x] `assert_ok!` / `assert_field!` (1/2/3 nesting levels) — resolves spec review §3 - [x] `assert_field_exists!`, `assert_field_absent!`, `assert_field_type!` - [x] `assert_array!` with any `RangeBounds<usize>`; `assert_array_contains!`, `assert_array_all!` - [x] `assert_in_range!`, `assert_greater_than!`, `assert_less_than!` - [x] `assert_error!`, `assert_error_contains!` - [x] Each macro has at least one passing and one failing unit test - [x] Macro hygiene: all crate refs use `$crate::` paths ## Test plan - [x] 16 unit tests in `src/assert.rs` covering passing + failing paths for each macro - [x] `assert_ok_supports_context_message` verifies ctx populates `TestError::Assertion.context` - [x] `just qa` green locally — 62/62 unit tests - [ ] CI green on Forgejo ## Notes for the reviewer - Macros use `$crate::assert::__private::*` helpers for the pretty printer and type-name lookup. The `__private` module is `#[doc(hidden)]` so it doesn't appear in rustdoc's public API. - `assert_array!` and `assert_in_range!` fully-qualify `::core::ops::RangeBounds` calls so users don't need an explicit `use` at the call site. - All macros use `::core::` / `::std::` prefixes to avoid colliding with re-declared items in the caller's module. - The `assert_field` pattern order is **Level 3 → Level 2 → Level 1** so 5-arg invocations match the longest path first (otherwise `(v, k1, k2, k3, expected)` would try to match L2 with `expected=k3` and `ctx=expected` — which is exactly what was failing during development).
Implements ticket #8 — 13 macros in src/assert.rs, all #[macro_export]
and invoked from functions returning TestResult. On failure they return
TestError::Assertion with the expected/got JSON diff, optional context,
and file:line location captured via concat!(file!(), ":", line!()).

Value & field
- assert_ok!(v [, ctx]) — fails if v has an "error" key
- assert_field!(v, k, expected)                       (level 1)
- assert_field!(v, k1, k2, expected)                  (level 2)
- assert_field!(v, k1, k2, k3, expected)              (level 3)
- assert_field_exists!(v, field [, ctx])
- assert_field_absent!(v, field [, ctx])
- assert_field_type!(v, field, "string"|"number"|...  [, ctx])

Array
- assert_array!(v, range [, ctx])     — accepts any RangeBounds<usize>
- assert_array_contains!(v, pred [, ctx])
- assert_array_all!(v, pred [, ctx])

Numeric
- assert_in_range!(v, range [, ctx])  — RangeBounds<f64>
- assert_greater_than!(v, n [, ctx])
- assert_less_than!(v, n [, ctx])

Error
- assert_error!(v, code [, ctx])
- assert_error_contains!(v, needle [, ctx])

Design notes
- assert_field! supports up to 3 levels of nested paths (flat syntax)
  per spec review §3. Levels are declared in reverse (3, 2, 1) so the
  longest pattern matches first.
- assert_field! deliberately does NOT accept an optional trailing
  context string — the 4-arg shape collides with "L1 + ctx" vs
  "L2 without ctx" in macro_rules. A design note in the doc comment
  points users at the non-variadic macros when they need context.
- assert_field_exists / assert_field_absent / assert_field_type and
  all array / numeric / error macros DO accept the optional
  `, "ctx string"` trailing arg.
- All assertions render JSON values via serde_json::to_string_pretty,
  with a fallback to Debug if serialisation somehow fails.

Tests (16 in src/assert.rs)
- assert_ok passing + failing
- assert_field_equals_single_key (L1) + location check
- assert_field_equals_nested_two_levels (L2)
- assert_field_equals_nested_three_levels (L3)
- assert_field_exists / _absent / _type passing + failing
- assert_array length checks (1.., ..=5, 3..=3 + not-an-array)
- assert_array_contains / _all passing + failing
- assert_in_range (f64 bounds)
- assert_greater_than / assert_less_than
- assert_error (code match) + assert_error_contains (substring)
- assert_ok_supports_context_message (verifies ctx populates
  TestError::Assertion.context)

just qa green: 62/62 unit tests, fmt and clippy -D warnings clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
charles left a comment

Review — assertion macros pour JSON-RPC (#8)

Limitation documentée de assert_field!

Le macro ne peut pas accepter un contexte string à cause de l'ambiguïté avec les key paths. C'est documenté explicitement dans le doc-comment. Acceptable pour v0.1, mais les utilisateurs qui veulent un contexte sur des assertions imbriquées devront envelopper dans une variable intermédiaire ou utiliser assert_field_exists!.

assert_field_exists! — traite null comme absent

if __v.get($field).is_none() || __v[$field].is_null() {

Un champ JSON présent avec une valeur null est considéré "absent". C'est une décision de design raisonnable pour des APIs JSON-RPC qui utilisent null comme absence, mais elle devrait être documentée — quelqu'un qui veut vérifier qu'un champ existe avec la valeur null explicite sera surpris.

assert_array! — format des bornes dans le message d'erreur

format!("{} length in {:?}..{:?}", ..., start_bound, end_bound)

Debug sur Bound<usize> affiche Included(3) et non 3. Le message d'erreur sera "r length in Included(3)..Included(3)" là où 3..=3 serait attendu. Cosmétique mais pas idéal pour la lisibilité.

Module __private

Bon pattern — exposer pretty et type_name pour les macros sans les mettre dans la surface API publique.

Tests

  • Paires pass/fail pour chaque macro — excellent.
  • Test du context message sur assert_ok! — vérifie que le champ context est bien propagé.

Aucun bloquant.

## Review — assertion macros pour JSON-RPC (#8) ### Limitation documentée de `assert_field!` Le macro ne peut pas accepter un contexte string à cause de l'ambiguïté avec les key paths. C'est documenté explicitement dans le doc-comment. Acceptable pour v0.1, mais les utilisateurs qui veulent un contexte sur des assertions imbriquées devront envelopper dans une variable intermédiaire ou utiliser `assert_field_exists!`. ### `assert_field_exists!` — traite `null` comme absent ```rust if __v.get($field).is_none() || __v[$field].is_null() { ``` Un champ JSON présent avec une valeur `null` est considéré "absent". C'est une décision de design raisonnable pour des APIs JSON-RPC qui utilisent `null` comme absence, mais elle devrait être documentée — quelqu'un qui veut vérifier qu'un champ *existe* avec la valeur null explicite sera surpris. ### `assert_array!` — format des bornes dans le message d'erreur ```rust format!("{} length in {:?}..{:?}", ..., start_bound, end_bound) ``` `Debug` sur `Bound<usize>` affiche `Included(3)` et non `3`. Le message d'erreur sera `"r length in Included(3)..Included(3)"` là où `3..=3` serait attendu. Cosmétique mais pas idéal pour la lisibilité. ### Module `__private` Bon pattern — exposer `pretty` et `type_name` pour les macros sans les mettre dans la surface API publique. ### Tests - Paires pass/fail pour chaque macro — excellent. - Test du context message sur `assert_ok!` — vérifie que le champ `context` est bien propagé. Aucun bloquant.
charles left a comment

Pas de bloquant. Points à documenter : assert_field_exists! considère null comme absent ; assert_array! affiche les bornes en format Included(n) dans les messages d'erreur.

✅ Pas de bloquant. Points à documenter : `assert_field_exists!` considère `null` comme absent ; `assert_array!` affiche les bornes en format `Included(n)` dans les messages d'erreur.
charles changed target branch from feat/7-multi-event-helpers to main 2026-04-11 18:42:56 +00:00
charles deleted branch feat/8-assertion-macros 2026-04-11 18:43:05 +00:00
Sign in to join this conversation.
No description provided.