feat(testing): pre-construction setup hook for TestRunner (separate from .setup())
Summary
Add an additive, opt-in way to run a function before the module-under-test is
constructed, so tests can install spies/doubles and observe a module's
construction-time behavior. This does not change .setup(), which
intentionally runs after the graph is wired (full app available, for seeding early
state before the test body). The two are complementary: .setup() = "post-wire state
seeding", the new hook = "pre-construction interception".
Related: #88 (appendLibrary ordering). That issue's "Related" note flagged that
.setup() follows the post-construction ordering; this is the dedicated feature to
cover the pre-construction case ergonomically.
Background: bootstrap happens in two waves
- Construction — every service factory runs (
function MyService({...}) { …body… }).
Factories run in dependency order: a library constructs after the libraries it
depends on. Construction-time logic lives here (reading a dep's API, calling it,
registering lifecycle hooks).
- Lifecycle — after all construction, the staged hooks fire:
onPreInit → onPostConfig → onBootstrap → onReady.
To intercept a module's construction, your double has to be wired before that
module constructs. Once its factory has run, you've missed it.
Current .setup() semantics (intended — keep as-is)
.setup(fn) reads like "run first" but runs after the whole module graph is
constructed. From src/testing/test-module.mts (buildApp):
// .setup(fn) collects functions
setup(fn) { runFirst.add(fn); return runner; }
// buildApp wraps them in a synthetic library that DEPENDS ON the module-under-test
LIB_RUN_FIRST = CreateLibrary({
name: "run_first",
depends: [testLibrary, ...depends], // arrow points AT the module-under-test
services: { ...setup fns... },
})
Because run_first depends on testLibrary, buildSortOrder forces it to
construct last:
boilerplate → [deps] → testLibrary (module) → run_first (.setup) → app:{ test } → lifecycle…
└ module constructs └ .setup fn runs here
This is the intended contract: a .setup() function "runs with the full power of
the app graph available, in order to make early state changes to the system prior to
unit testing." That is valuable and should not change.
The gap / motivation
There is no first-class way to run an inline function before the module-under-test
constructs. Concretely, you cannot easily assert/stub construction-time behavior:
function ExampleService({ some_dep }) {
some_dep.connect(); // construction-time call we want to assert/stub
return { ... };
}
createModule.fromLibrary(example).extend().toTest()
.setup(({ some_dep }) => vi.spyOn(some_dep, "connect")) // installs spy…
.run(() => {});
// …but `example` already constructed and already called connect() BEFORE the spy
// existed, because run_first runs after example. spy.mock.calls === [] ❌
Today the workaround is to hand-roll a spy library and .appendLibrary() it (which,
post-#88, reliably constructs before the module on both call sites). That works but is
verbose for a one-off inline stub.
Proposed API
Two shapes; pick one (see Open questions).
Option A — flag on .setup() (smallest surface):
.setup(fn) // unchanged: runs AFTER construction (full graph)
.setup(fn, { before: true })// new: runs BEFORE the module-under-test constructs
Option B — dedicated method (most explicit, no overload ambiguity):
.setup(fn) // post-construction (unchanged)
.beforeConstruct(fn)// pre-construction (new) // or .setupBefore(fn)
Either way: chainable, multiple calls allowed, returns the runner.
Implementation sketch
Mirror the existing run_first machinery, but flip the dependency arrow — instead
of the setup library depending on the module, make the module (testLibrary) depend on
the setup library. In buildApp:
// collect "before" fns separately from runFirst
const runBefore = new Set(); // populated by the new API
// … inside buildApp …
let LIB_SETUP_BEFORE;
if (!is.empty(runBefore)) {
LIB_SETUP_BEFORE = CreateLibrary({
name: "setup_before",
depends: [...depends], // after the module's real deps so their APIs exist to spy on
services: Object.fromEntries(
[...runBefore.values()].map(fn => [fn.name || v4(), fn]),
),
});
}
// the module-under-test now OPTIONALLY DEPENDS ON setup_before (arrow reversed)
const optionalDepends = [
...(optional ?? []),
...appendLibraries.values(), // (#88 fix)
...(LIB_SETUP_BEFORE ? [LIB_SETUP_BEFORE] : []),
];
const testLibrary = target
? CreateLibrary({ ...target, depends, optionalDepends, services: target.services })
: undefined;
// pass LIB_SETUP_BEFORE through bootstrap appendLibrary alongside the others
// appendLibrary: [...appendLibraries.values(), LIB_SETUP_BEFORE?, LIB_RUN_FIRST?]
Resulting wire order:
boilerplate → [deps] → setup_before (new) → testLibrary (module) → run_first (.setup) → app:{ test }
└ spies installed └ module constructs └ post-wire .setup
(deps already exist) (spies catch it ✅)
Placement nuance
setup_before is deliberately between the deps and the module: it depends on
depends, so the module's dependencies are already constructed (you have real API
objects to vi.spyOn), but it runs before the module so the spy is live when the
module's factory calls them. "Before everything, including deps" is a different,
larger ask — see Open questions.
Worked example (target behavior)
const runner = createModule.fromLibrary(example).extend().toTest()
.beforeConstruct(({ some_dep }) => vi.spyOn(some_dep, "connect"));
await runner.run(({ example }) => {
// example.connect was spied BEFORE example constructed
});
// expect(connectSpy).toHaveBeenCalledTimes(1) ✅
await runner.teardown();
Relationship to #88 and appendLibrary
Alternatives considered
- Do nothing — keep using
.appendLibrary() with a hand-rolled spy library. Works
today; just verbose.
- Change
.setup() default to pre-construction — rejected. Breaks the intended
"full graph available" contract and every existing setup that relies on it.
- A general
priority/ordering knob on .setup() — more flexible but more rope;
the binary before/after covers the real use cases.
Open questions (for design discussion)
- API: Option A (
{ before } flag) vs Option B (beforeConstruct / setupBefore)?
- Params available pre-construction: a "before" fn sees boilerplate + the module's
dependencies, but not the module-under-test's own services (not constructed
yet). Is that the right/clear contract? Should we type/doc it distinctly from
.setup()'s params?
- Ordering among multiple "before" fns — declaration order? expose a priority?
- "Before deps too" — should there be a mode that runs before the dependencies
construct (to intercept a dep's own construction)? Probably a separate follow-up.
- Interaction with
bootLibrariesFirst — verify ordering still holds when the app
services are deferred to after Bootstrap.
- Error handling — a throw in a "before" fn happens mid-wire; confirm it surfaces as
a clean bootstrap rejection (test/library mode re-throws rather than process.exit).
Test plan
beforeConstruct fn runs before the module's factory (order array: ["before", "module"]).
vi.spyOn installed in a "before" fn catches a construction-time call.
- Multiple "before" fns run in a defined order.
.setup() (post-wire) behavior unchanged — existing "runs setup functions first"
test still passes.
- Mixing
.setup() and the new hook: order is before → module → setup → test.
- Teardown still clean (no leaked handles).
Non-goals
- No change to
.setup()'s existing post-construction semantics.
- No change to the shared
bootstrap() engine; this stays inside TestRunner.buildApp.
feat(testing): pre-construction setup hook for
TestRunner(separate from.setup())Summary
Add an additive, opt-in way to run a function before the module-under-test is
constructed, so tests can install spies/doubles and observe a module's
construction-time behavior. This does not change
.setup(), whichintentionally runs after the graph is wired (full app available, for seeding early
state before the test body). The two are complementary:
.setup()= "post-wire stateseeding", the new hook = "pre-construction interception".
Related: #88 (appendLibrary ordering). That issue's "Related" note flagged that
.setup()follows the post-construction ordering; this is the dedicated feature tocover the pre-construction case ergonomically.
Background: bootstrap happens in two waves
function MyService({...}) { …body… }).Factories run in dependency order: a library constructs after the libraries it
depends on. Construction-time logic lives here (reading a dep's API, calling it,
registering lifecycle hooks).
onPreInit → onPostConfig → onBootstrap → onReady.To intercept a module's construction, your double has to be wired before that
module constructs. Once its factory has run, you've missed it.
Current
.setup()semantics (intended — keep as-is).setup(fn)reads like "run first" but runs after the whole module graph isconstructed. From
src/testing/test-module.mts(buildApp):Because
run_firstdepends ontestLibrary,buildSortOrderforces it toconstruct last:
This is the intended contract: a
.setup()function "runs with the full power ofthe app graph available, in order to make early state changes to the system prior to
unit testing." That is valuable and should not change.
The gap / motivation
There is no first-class way to run an inline function before the module-under-test
constructs. Concretely, you cannot easily assert/stub construction-time behavior:
Today the workaround is to hand-roll a spy library and
.appendLibrary()it (which,post-#88, reliably constructs before the module on both call sites). That works but is
verbose for a one-off inline stub.
Proposed API
Two shapes; pick one (see Open questions).
Option A — flag on
.setup()(smallest surface):Option B — dedicated method (most explicit, no overload ambiguity):
Either way: chainable, multiple calls allowed, returns the runner.
Implementation sketch
Mirror the existing
run_firstmachinery, but flip the dependency arrow — insteadof the setup library depending on the module, make the module (
testLibrary) depend onthe setup library. In
buildApp:Resulting wire order:
Placement nuance
setup_beforeis deliberately between the deps and the module: it depends ondepends, so the module's dependencies are already constructed (you have real APIobjects to
vi.spyOn), but it runs before the module so the spy is live when themodule's factory calls them. "Before everything, including deps" is a different,
larger ask — see Open questions.
Worked example (target behavior)
Relationship to #88 and
appendLibrary.appendLibrary(lib)(post-module.appendLibrary produces different results from module.extend.toTest.appendLibrary #88) already constructs the appended library beforethe module-under-test, consistently from both call sites. The new hook is the
inline-function ergonomic version of that — no need to author a throwaway library
for a one-line stub.
optionalDependsonthe wrapped
testLibrary) to express ordering, so it composes cleanly.Alternatives considered
.appendLibrary()with a hand-rolled spy library. Workstoday; just verbose.
.setup()default to pre-construction — rejected. Breaks the intended"full graph available" contract and every existing setup that relies on it.
priority/ordering knob on.setup()— more flexible but more rope;the binary before/after covers the real use cases.
Open questions (for design discussion)
{ before }flag) vs Option B (beforeConstruct/setupBefore)?dependencies, but not the module-under-test's own services (not constructed
yet). Is that the right/clear contract? Should we type/doc it distinctly from
.setup()'s params?construct (to intercept a dep's own construction)? Probably a separate follow-up.
bootLibrariesFirst— verify ordering still holds when the appservices are deferred to after
Bootstrap.a clean bootstrap rejection (test/library mode re-throws rather than
process.exit).Test plan
beforeConstructfn runs before the module's factory (order array:["before", "module"]).vi.spyOninstalled in a "before" fn catches a construction-time call..setup()(post-wire) behavior unchanged — existing "runs setup functions first"test still passes.
.setup()and the new hook: order isbefore → module → setup → test.Non-goals
.setup()'s existing post-construction semantics.bootstrap()engine; this stays insideTestRunner.buildApp.