From ad2ec20abbbd41d482a78801189979e9de7426bc Mon Sep 17 00:00:00 2001 From: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com> Date: Sat, 28 Mar 2026 16:43:20 +0100 Subject: [PATCH 1/2] doc(proposal): suite-level hooks --- proposals/suite-level hooks.md | 89 ++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 proposals/suite-level hooks.md diff --git a/proposals/suite-level hooks.md b/proposals/suite-level hooks.md new file mode 100644 index 0000000..9818142 --- /dev/null +++ b/proposals/suite-level hooks.md @@ -0,0 +1,89 @@ +# Suite-level hooks + +## Problem(s) to solve + +* Hooks (`after`/`afterEach`, `before`/`beforeEach`) are currently global, which causes them to trigger for subtests. That is often counter-productive, rendering subtests useless (they end up clobbering each other). +* Tests often need common setup but are affected by concurrency (for instance, mocks). In those scenarios, either concurrency must be disabled or a lot of code must be repeated. + * For mocks, it is generally desirable to reset them between tests. + +## API & Behaviour + +Note in the below: + +* `s` is `SuiteContext` +* `th` is `TestHookContext` +* `t` is `TestContext` + +```ts +type SuiteContextHook = ( + c?: (th: TestHookContext): void, + options: HookOptions, +); +type SuiteContext = { + // … + after: SuiteContextHook, + afterEach: SuiteContextHook, + before: SuiteContextHook, + beforeEach: SuiteContextHook, + mock: SuiteContextMockTracker → MockTracker, +} +``` +```ts +type TestHookContext = Record; +``` +```ts +type TestContext = { + // … + bikeshed: TestHookContext, +} +``` + +```js +describe('Suite-level hooks', (s) => { + s.before((th) => { + const foo = s.mock.fn(); + s.mock.module('foo', { exports: { default: foo } }); + const bar = s.mock.fn(); + s.mock.module('bar', { exports: { default: bar } }); + + th.mocks = { + bar: bar.mock, + foo: foo.mock, + }; + }); + + it('should abort on error', (t) => { + t.bikeshed.mocks.foo.mockImplementation(() => false); + + widget(); + + t.test('call foo', () => assert.equal(t.bikeshed.mocks.foo.callCount(), 1)); + t.test('call bar', () => assert.equal(t.bikeshed.mocks.bar.callCount(), 0)); + }); + + it('should succeed on happy-path', (t) => { + widget(); + + t.test('call foo', () => assert.equal(t.bikeshed.mocks.foo.callCount(), 1)); + t.test('call bar', () => assert.equal(t.bikeshed.mocks.bar.callCount(), 1)); + }); +}); +``` + +A lot of subtle things are happening in the above: + +Each test (`it`) receives a clone of `TestHookContext` (nested on a known/reserved slot currently called `bikeshed` because I couldn't think of a good name). This means each mock is distinct: +* The `mockImplementation` from _'should abort on error'_ applies to only its own copy of the mock of `foo` and does NOT affect the `foo` mock in _'should succeed on happy-path'_. +* A mock's calls (counters, arguments, etc) are isolated to the test. +* Mutations to the `TestHookContext` a test receives do not affect `TestHookContext` in other tests. + +| | between tests (`it`) | between subtests (`t.test`) +:-- | :--: | :--: +`SuiteContext.mock.resetCalls()` | ✅ | ❌ +`SuiteHook`s | ✅ | ❌ + +## Under the hood + +The `SuiteContextHookFn` passed to a `SuiteContextHook` is run for every test, receiving a fresh `TestHookContext` mapped to the test to which it will be provided. + +Each `SuiteContextMockTracker` will need to intelligently apply appropriate mocks. From 1bede5f602096ff325096b236b659f3d08f1a4f3 Mon Sep 17 00:00:00 2001 From: Jacob Smith <3012099+JakobJingleheimer@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:22:56 +0200 Subject: [PATCH 2/2] remove `before` & `after` cuz they don't make sense --- proposals/suite-level hooks.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/proposals/suite-level hooks.md b/proposals/suite-level hooks.md index 9818142..b1d8661 100644 --- a/proposals/suite-level hooks.md +++ b/proposals/suite-level hooks.md @@ -2,7 +2,7 @@ ## Problem(s) to solve -* Hooks (`after`/`afterEach`, `before`/`beforeEach`) are currently global, which causes them to trigger for subtests. That is often counter-productive, rendering subtests useless (they end up clobbering each other). +* Hooks (`afterEach`, `beforeEach`) are currently global, which causes them to trigger for subtests. That is often counter-productive, rendering subtests useless (they end up clobbering each other). * Tests often need common setup but are affected by concurrency (for instance, mocks). In those scenarios, either concurrency must be disabled or a lot of code must be repeated. * For mocks, it is generally desirable to reset them between tests. @@ -21,9 +21,7 @@ type SuiteContextHook = ( ); type SuiteContext = { // … - after: SuiteContextHook, afterEach: SuiteContextHook, - before: SuiteContextHook, beforeEach: SuiteContextHook, mock: SuiteContextMockTracker → MockTracker, } @@ -40,7 +38,7 @@ type TestContext = { ```js describe('Suite-level hooks', (s) => { - s.before((th) => { + s.beforeEach((th) => { const foo = s.mock.fn(); s.mock.module('foo', { exports: { default: foo } }); const bar = s.mock.fn();