Digital-Alchemy-TS / Digital-Alchemy-TS/core
feat(testing): pre-construction setup hook for TestRunner (separate from .setup())
- Dominant language
- TypeScript
- Stars
- 53
- Forks
- 1
- Avg merge
- 1m
- Merged PRs (30d)
- 1
Description
# 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. **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`):
```js
// .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:
```js
function ExampleService({ some_dep }) {
some_dep.connect(); // construction-time call we want to assert/stub
return { ... };
}
```
```js
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):
```js
.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):
```js
.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`:
```js
// 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)
```js
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`
- `.appendLibrary(lib)` (post-#88) already constructs the appended **library** before
the 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.
- The implementation reuses the same primitive the #88 fix uses (`optionalDepends` on
the wrapped `testLibrary`) to express ordering, so it composes cleanly.
## 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`.
Contributor guide
Assessment
This issue has not been assessed yet.