Skip to content

feat(testing): pre-construction setup hook for TestRunner (separate from .setup()) #329

Description

@zoe-codez

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

  1. 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).
  2. Lifecycleafter 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

  1. Do nothing — keep using .appendLibrary() with a hand-rolled spy library. Works
    today; just verbose.
  2. Change .setup() default to pre-construction — rejected. Breaks the intended
    "full graph available" contract and every existing setup that relies on it.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions