feat(runner): before_all, before_each, after_each, after_all hooks (#10) #28

Merged
charles merged 1 commit from feat/10-runner-hooks into main 2026-04-11 18:43:21 +00:00
Owner

Closes #10. Stacked on #27.

Summary

Adds the four lifecycle hooks with the exact failure semantics from the issue and spec-review §4/§5.

Semantics

Hook When On failure
before_all once, after harness ready aborts run, no tests execute
before_each before every test marks that test as Failed, skips body, after_each still runs
after_each after every test (regardless of pass/fail) logged to stderr, test outcome unchanged
after_all once, before harness stop logged, sets TestReport.after_all_failed = true

API

runner.before_all(|c| async move { c.call("seed", json!({})).await?; Ok(()) });
runner.before_each(|c| async move { c.call("reset", json!({})).await?; Ok(()) });
runner.after_each(|c| async move { c.call("flush", json!({})).await?; Ok(()) });
runner.after_all(|c| async move { c.call("cleanup", json!({})).await?; Ok(()) });

Internals

  • Four Option<BoxedTestFn> fields on TestRunner (same closure shape as test bodies).
  • run() destructures &mut self into disjoint field borrows at the top, so iterating tests.iter_mut() doesn't conflict with calling the hook closures on the side.
  • New TestReport.after_all_failed: bool field, false by default.

Checklist (from issue #10)

  • before_all runs once, failure aborts the run
  • before_each runs before each test; failure marks that test Failed, body skipped, after_each still runs
  • after_each runs after each test regardless; failure logged, outcome preserved
  • after_all runs once after all tests; failure logged + after_all_failed flag, harness still stops
  • All hooks receive Arc<RpcClient> and return Future<Output = TestResult>
  • Each hook is optional (no-op if absent)
  • Self-tests cover all four hooks + failure paths

Test plan (5 new tests)

  • hooks_run_in_expected_order — registers all four hooks + two tests, uses Arc<Mutex<Vec<String>>> to log execution, asserts the exact sequence [before_all, before_each, t1, after_each, before_each, t2, after_each, after_all]
  • before_all_failure_aborts_run — asserts run() returns Err and no test body ran
  • before_each_failure_marks_test_failed_and_after_each_runs — body must NOT run, after_each must run
  • after_each_failure_is_logged_but_test_still_passes — test body is Ok, status stays Passed
  • after_all_failure_sets_flag_but_tests_still_recordedpassed == 1, after_all_failed == true
  • just qa green locally — 74/74 unit tests
  • CI green on Forgejo

Notes for the reviewer

  • Resolves spec-review §4 (missing before_all) and §5 (missing after_each). The original spec only listed before_each + after_all, which was insufficient for common patterns.
  • The disjoint-field borrow pattern (let Self { harness, tests, .., before_each, .. } = self;) is the cleanest way I found to iterate tests.iter_mut() while also calling before_each(Arc::clone(&client)).await in the same loop. An alternative would be mem::take()ing the hooks, but destructuring is less surgical.
  • The hook execution order in the test assertion is the canonical testing-framework order from e.g. JUnit / Jest, so users coming from those ecosystems won't be surprised.
Closes #10. **Stacked on #27.** ## Summary Adds the four lifecycle hooks with the exact failure semantics from the issue and spec-review §4/§5. ### Semantics | Hook | When | On failure | |---|---|---| | `before_all` | once, after harness ready | aborts run, no tests execute | | `before_each` | before every test | marks that test as Failed, skips body, `after_each` still runs | | `after_each` | after every test (regardless of pass/fail) | logged to stderr, test outcome unchanged | | `after_all` | once, before harness stop | logged, sets `TestReport.after_all_failed = true` | ### API ```rust runner.before_all(|c| async move { c.call("seed", json!({})).await?; Ok(()) }); runner.before_each(|c| async move { c.call("reset", json!({})).await?; Ok(()) }); runner.after_each(|c| async move { c.call("flush", json!({})).await?; Ok(()) }); runner.after_all(|c| async move { c.call("cleanup", json!({})).await?; Ok(()) }); ``` ### Internals - Four `Option<BoxedTestFn>` fields on `TestRunner` (same closure shape as test bodies). - `run()` destructures `&mut self` into disjoint field borrows at the top, so iterating `tests.iter_mut()` doesn't conflict with calling the hook closures on the side. - New `TestReport.after_all_failed: bool` field, `false` by default. ## Checklist (from issue #10) - [x] `before_all` runs once, failure aborts the run - [x] `before_each` runs before each test; failure marks that test Failed, body skipped, `after_each` still runs - [x] `after_each` runs after each test regardless; failure logged, outcome preserved - [x] `after_all` runs once after all tests; failure logged + `after_all_failed` flag, harness still stops - [x] All hooks receive `Arc<RpcClient>` and return `Future<Output = TestResult>` - [x] Each hook is optional (no-op if absent) - [x] Self-tests cover all four hooks + failure paths ## Test plan (5 new tests) - [x] `hooks_run_in_expected_order` — registers all four hooks + two tests, uses `Arc<Mutex<Vec<String>>>` to log execution, asserts the exact sequence `[before_all, before_each, t1, after_each, before_each, t2, after_each, after_all]` - [x] `before_all_failure_aborts_run` — asserts `run()` returns `Err` and no test body ran - [x] `before_each_failure_marks_test_failed_and_after_each_runs` — body must NOT run, `after_each` must run - [x] `after_each_failure_is_logged_but_test_still_passes` — test body is `Ok`, status stays `Passed` - [x] `after_all_failure_sets_flag_but_tests_still_recorded` — `passed == 1`, `after_all_failed == true` - [x] `just qa` green locally — 74/74 unit tests - [ ] CI green on Forgejo ## Notes for the reviewer - Resolves spec-review §4 (missing `before_all`) and §5 (missing `after_each`). The original spec only listed `before_each` + `after_all`, which was insufficient for common patterns. - The disjoint-field borrow pattern (`let Self { harness, tests, .., before_each, .. } = self;`) is the cleanest way I found to iterate `tests.iter_mut()` while also calling `before_each(Arc::clone(&client)).await` in the same loop. An alternative would be `mem::take()`ing the hooks, but destructuring is less surgical. - The hook execution order in the test assertion is the canonical testing-framework order from e.g. JUnit / Jest, so users coming from those ecosystems won't be surprised.
Implements ticket #10 on top of #9's baseline runner.

Hook storage
- TestRunner gains four Option<BoxedTestFn> fields: before_all,
  before_each, after_each, after_all. Each hook has the same signature
  as a test body: Fn(Arc<RpcClient>) -> Future<Output = TestResult>.

Registration API
- runner.before_all(|c| async move { ... }) → &mut Self
- runner.before_each(|c| async move { ... }) → &mut Self
- runner.after_each(|c| async move { ... }) → &mut Self
- runner.after_all(|c| async move { ... }) → &mut Self
- Each hook is optional; absence is a no-op.

Run semantics (spec §4.3 + spec review §4/§5)
- before_all runs once after harness start. Failure aborts the run
  with a clear error; no tests execute and the harness is stopped.
- before_each runs before every selected test. If it fails, the
  associated test is marked Failed with the hook's error, the body
  is NOT executed, and after_each still runs.
- after_each runs after every test regardless of pass/fail. Failure
  is logged to stderr but does not change the test's recorded outcome
  (the original result wins).
- after_all runs once before harness stop. Failure is logged and
  sets the new TestReport.after_all_failed flag so callers can
  surface it in their exit code. The harness still stops cleanly.

Internal mechanics
- run() now destructures &mut self into disjoint field borrows at
  the top (let Self { harness, tests, .., before_each, after_each,
  .. } = self;) so we can iterate tests.iter_mut() while calling
  hooks on the side without borrow-checker conflicts.

TestReport
- New after_all_failed: bool field (default false, set when after_all
  errors).

Tests (5 new in src/runner.rs)
- hooks_run_in_expected_order: registers all four hooks + two tests,
  uses an Arc<Mutex<Vec<String>>> to record execution order, then
  asserts the exact sequence
    before_all, before_each, t1, after_each, before_each, t2,
    after_each, after_all
- before_all_failure_aborts_run: asserts run() returns Err and no
  test body ran
- before_each_failure_marks_test_failed_and_after_each_runs:
  body must NOT run, after_each must run
- after_each_failure_is_logged_but_test_still_passes: test body
  returned Ok; even though after_each errors, test status is Passed
- after_all_failure_sets_flag_but_tests_still_recorded: report.passed
  preserved, after_all_failed flag set

just qa green: 74/74 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 — before_all, before_each, after_each, after_all hooks (#10)

Destructuring self pour les emprunts disjoints

let Self { harness, tests, config, before_all, before_each, after_each, after_all } = self;

Pattern correct pour itérer mutablement tests pendant qu'on appelle les hooks. Idiomatic Rust pour ce cas.

before_each failure → after_each toujours exécuté

Comportement conforme au cahier des charges : si before_each échoue, le corps du test est ignoré mais after_each tourne quand même. Le timing de started.elapsed() est capturé avant before_each — le temps du hook de setup est donc inclus dans la durée reportée. C'est acceptable mais ça mérite peut-être un commentaire.

Un seul hook de chaque type

Enregistrer un deuxième before_all écrase silencieusement le premier. C'est intentionnel (doc-comment absent mais simple à ajouter). Pour v0.1 c'est suffisant. À documenter.

after_all_failed — flag sans impact sur les tests

Bon choix de séparation des concerns : les outcomes des tests ne sont pas affectés, mais le runner peut propager ça dans le code de sortie.

Tests

  • hooks_run_in_expected_order : log partagé via Arc<Mutex<Vec<String>>> — pattern propre pour vérifier l'ordre d'exécution.
  • before_all_failure_aborts_run + before_each_failure_marks_test_failed_and_after_each_runs + after_each_failure_is_logged_but_test_still_passes — les quatre variantes de failure sont couvertes.

Aucun bloquant.

## Review — before_all, before_each, after_each, after_all hooks (#10) ### Destructuring `self` pour les emprunts disjoints ```rust let Self { harness, tests, config, before_all, before_each, after_each, after_all } = self; ``` Pattern correct pour itérer mutablement `tests` pendant qu'on appelle les hooks. Idiomatic Rust pour ce cas. ### `before_each` failure → `after_each` toujours exécuté Comportement conforme au cahier des charges : si `before_each` échoue, le corps du test est ignoré mais `after_each` tourne quand même. Le timing de `started.elapsed()` est capturé avant `before_each` — le temps du hook de setup est donc inclus dans la durée reportée. C'est acceptable mais ça mérite peut-être un commentaire. ### Un seul hook de chaque type Enregistrer un deuxième `before_all` écrase silencieusement le premier. C'est intentionnel (doc-comment absent mais simple à ajouter). Pour v0.1 c'est suffisant. À documenter. ### `after_all_failed` — flag sans impact sur les tests Bon choix de séparation des concerns : les outcomes des tests ne sont pas affectés, mais le runner peut propager ça dans le code de sortie. ### Tests - `hooks_run_in_expected_order` : log partagé via `Arc<Mutex<Vec<String>>>` — pattern propre pour vérifier l'ordre d'exécution. - `before_all_failure_aborts_run` + `before_each_failure_marks_test_failed_and_after_each_runs` + `after_each_failure_is_logged_but_test_still_passes` — les quatre variantes de failure sont couvertes. Aucun bloquant.
charles left a comment

Pas de bloquant — lifecycle des hooks correct, ordre d'exécution vérifié rigoureusement.

✅ Pas de bloquant — lifecycle des hooks correct, ordre d'exécution vérifié rigoureusement.
charles changed target branch from feat/9-runner-sequential to main 2026-04-11 18:43:13 +00:00
charles deleted branch feat/10-runner-hooks 2026-04-11 18:43:21 +00:00
Sign in to join this conversation.
No description provided.