Skip to content
Logo

Extern

@ghostry/extern wraps calls to external systems so tests can replace them. On its own, it doesn't accept a fabricator schema as a block's identity.

@ghostry/extern-extension-fabricator-v0 lets one extern instance accept a fabricator Schema as an identity. By default, such a block mocks itself: inside extern.testing(...) it fabricates a value from its schema instead of throwing NotMockedError. An instance without the extension still rejects fabricator schemas.

Setup

Pass one fabricator instance to the extension. Pin a clock as the harness guide does. Otherwise the clock is the time of the initialize() call, and every run of the suite gets different data:

import { initialize as initializeExtern } from "@ghostry/extern";
import { initialize as initializeFabricator } from "@ghostry/fabricator";
import { fabricatorExtension } from "@ghostry/extern-extension-fabricator-v0";
 
export const fabricator = initializeFabricator({
  salt: "my-suite",
  clock: new Date("2024-01-01T00:00:00Z"),
});
 
export const extern = await initializeExtern({
  extensions: [fabricatorExtension({ instance: fabricator })],
});

Using it

The fabricator schema is the block's identity. The function you pass to .will() runs outside tests. Inside extern.testing it doesn't run, because the schema is enough:

const { T } = fabricator;
 
const user = T.object({
  id: T.number,
  name: T.string.whereby({ length: { max: 10 } }),
});
 
const load = () => extern.typed.by(user).will(() => fetchUser());
await extern.testing(() => {
  expect(load()).toMatchObject({ id: expect.any(Number) });
});

The identity is the schema object itself, so store the schema in a variable and use that variable for the block and for its mock. Each read of a built-in entry such as T.number gives a new schema, so extern.typed.by(T.number) in two places makes two separate blocks, and mock(T.number) matches neither. An entry you add with registry.extend is the same object on every read, unless you write it as a getter.

A built Fabricator isn't an identity, so pass the schema it was built from. Given a Fabricator, a block couldn't tell whether to keep drawing from it or build a new one from its schema.

An effect block has no value to fabricate, so this extension ignores it. A Standard Schema or extern.T<number>() isn't a fabricator identity either, so those still throw NotMockedError unless mocked.

Overriding and shaping

Overriding outright

await extern.testing((mock) => {
  mock(user).with({ id: 1, name: "Ada" });
});

This replaces the value outright; nothing is fabricated. UnusedMocksError still applies: a with() or produce() that never runs fails the test.

Shaping the fabrication

produce() fabricates a value and returns a spy. If you pass a callback, it receives the built Fabricator, so you write overrides with fabricator's own API. This package adds nothing of its own:

await extern.testing((mock) => {
  mock(user).produce(({ via }) => via.fabricate({ name: "Ada" }));
 
  expect(load().name).toBe("Ada");
});

via is the built Fabricator, so an object's fabricate(overrides) takes fabricator's usual overrides, including nested objects and Omitted. Everything else on it is available too, including trace and schema.

The callback works with every schema, not just objects. What changes is what the Fabricator accepts: an object's fabricate takes overrides, and a T.number's takes none. That comes from fabricator's own types, not from this package.

Both produce() and produce(fn) are cached per identity and name. The callback runs once, and reading the block twice in one test gives the same value. Plain produce() fabricates exactly what an unmocked block would.

Requiring an explicit mock

fabricatorExtension({ instance: fabricator, unmocked: "error" });

Blocks then throw NotMockedError unless mocked, while produce() still fabricates when you ask.

Determinism

Every testing block runs inside instance.wrap(...), with one fixed string layered onto the salt in effect:

[...enclosingSalt, "@ghostry/extern"];

Each wrap also starts a fresh construction counter, so:

  • the same block fabricates the same value in separate testing() calls
  • the same schema used from a different test file gets the same value
  • within one testing block, unnamed values depend on construction order

Your own new fabricator.Fabricator(schema) inside a testing block uses the same source as the block, but it is a separate draw, the next construction in the same sequence. It is not another view of the block's value.

One written outside the block draws from the enclosing scope instead. The fixed salt keeps the two apart, so a fabrication outside a testing block never collides with one inside it.

Two blocks with the same schema in one test are a single value. Use named(...) to tell them apart:

const namedLoad = (name: string) =>
  extern.typed
    .by(user)
    .named(name)
    .will(() => fetchUser());
 
await extern.testing(() => {
  expect(namedLoad("a")).not.toEqual(namedLoad("b"));
});

A name does more than tell blocks apart. A named block's value depends only on its identity and its name, not on construction order. If you add another fixture above it, or a helper that fabricates along the way, a named block keeps its value while an unnamed one changes. Two named blocks run with Promise.all keep their values whichever finishes first. That is the difference between the two forms, and why the last bullet above only applies to unnamed values.

Why this uses wrap

The reproducibility guide recommends fork over wrap in ordinary code, because a call through a fork always uses that fork's configuration. This extension is the exception the guide describes. It has to apply a per-block configuration to a testing body it didn't write and can't pass a parameter into. Applying it automatically is the only way to reach both load() and your own new Fabricator(...) inside that body, so wrap is the right tool here.

The extension wraps context.scope(), not the instance you passed to fabricatorExtension({ instance }). That instance is fixed before any test runs. Wrapping it would start over from its configuration and silently drop any scope already wrapping it, while still taking over every draw. context.scope() is the scope in effect, or the instance itself if there is none, so it is right in both cases.

With a test harness

@ghostry/fabricator/harnessing opens its own wrap for each test, salted with the test's identity. This extension's scope runs inside it, and the two nest instead of conflicting. Blocks inside extern.testing inherit the test's salt and its clock.

The construction counter doesn't carry through, because every testing block starts fresh, as described above. But the fixed salt keeps the block separate from the surrounding test, so a fabricate() outside extern.testing never collides with one inside it.

The harness's context.fabricator still works inside a block. It holds the harness's scope rather than this extension's, but a construction always uses the innermost active wrap, so it draws in step with the blocks around it.

Both halves must come from one initialize():

const fabricator = initializeFabricator({
  salt: "my-suite",
  clock: new Date("2024-01-01T00:00:00Z"),
});
 
// harness integration and extension, one lineage
integration(fabricator);
fabricatorExtension({ instance: fabricator });

The extension's instance must be the harness integration's instance or one of its ancestors. The per-test wrap applies to the instance passed to integration(...) and its ancestors, never to forks of it. So giving the extension a fork() of the integrated instance loses the per-test salt, the same as using a separate instance. Giving the integration a fork of the extension's instance is fine. Two initialize() calls never see each other's wraps, even when given the same stack, which only chooses the carrier. An extension given a second instance never sees the harness's wrap and uses its own configuration. The per-test salt is lost and every test fabricates the same data, with no error.

The data is still deterministic, but it is no longer distinct between tests. You'll see tests that write a fabricated id into shared state collide with each other: they pass alone, fail as a suite, and pass again under .only. That points you toward shared-state pollution instead of the setup.

When both instances are in scope, root catches the separate-instance case. It tells you whether two instances share a lineage:

if (integrationInstance.root !== extensionInstance.root) {
  throw new Error("fabricator instances are from different lineages");
}

It doesn't catch a fork of the integrated instance passed to the extension, since that shares the root. One guard test catches both, and also works when the two halves are set up in separate modules, which is usually how this goes wrong: fabricate the same schema in two differently named tests and assert the values differ.

Runtime

Async testing bodies need a runtime with node:async_hooks, such as Bun, Node, or Deno. This extension runs the testing block inside fabricator's wrap, which rejects an async block on the synchronous carrier that a browser bundle uses. A synchronous body works there too. See Runtime support.