@ghostry/extern
v0.0.14
Published
Dependency injection made seamless.
Maintainers
Readme
Provides the most seamless approach to typed dependency injection for test suites.
Example
Initialization (extern.ts)
import { initialize } from "@ghostry/extern";
/**
* Initialize an instance of the library with optional
* configuration.
*/
export const extern = await initialize({});Source (source.ts)
/**
* Import the library from the initialization module.
*/
import { extern } from "./extern.ts";
/**
* The function to be mocked.
*
* For demonstration, it performs some simple math, but it represents
* an interaction with an external system which would not be within
* the intended scope of a unit test.
*/
function computeSomething(x: string) {
return x + " brown";
}
/**
* A function that represents interaction with an external system,
* but unlike the other example function above, this does not return
* anything useful to the calling code.
*/
function doSomething() {
console.log("jumped over the lazy dog");
}
/**
* A library-native type identity (or a Standard Schema) is used to:
*
* 1. Identify the wrapped code block and describe its return type.
* 2. Associate with correctly-typed mocks in a test suite.
*/
export const identity = extern.T<string>();
export function example() {
/**
* Wrap external interactions that produce a value.
*/
const result = extern.typed
.by(identity)
.will(() => computeSomething("quick"));
/**
* Wrap external interactions that perform an effect.
*/
extern.effect.will(doSomething);
return result + " fox";
}If you wish to also perform data validation with this library, bring one or more of your choice of validation library that conforms to Standard Schema, and make these changes in the source code:
/**
* Your chosen validation library.
*/
import * as S from "sury";
/**
* Instead of a library-native type identity, use a schema.
*/
export const identity = S.string;
/**
* Instead of `.typed`, use `.validated`.
*/
const result = extern.validated
.by(identity)
.will(() => computeSomething("quick"));Test (test.ts)
import { extern } from "./extern.ts";
import { example, identity } from "./source.ts";
import { test, expect } from "bun:test";
test("example test", async () => {
/**
* By default, the function to test will still execute its
* external interaction even though it was wrapped at its
* source. This prevents immediately breaking any existing
* tests written for the function.
*/
expect(example()).toEqual("quick brown fox");
/**
* Enable dependency-injection style mocking.
*
* This begins tracking uses of `extern` at runtime with
* their associated schema definition.
*
* The `testing` function receives a function to mock uses
* of `extern` with static test data.
*
* It identifies which external interaction to mock by the
* schema definition used. (In many cases, this will be
* sufficient, but disambiguation is possible as necessary.)
*
* Effect blocks (unlike value blocks) do not need to be
* mocked as they will always be skipped. (Spying on
* them is still possible though.)
*/
await extern.testing((mock) => {
/**
* The mocking data must conform to the schema type. This
* is enforced by TypeScript.
*
* Each mock returns a dedicated spy which can be used
* later to inspect executions that used the mock.
*
* The definition of mocks is the same whether using
* `typed` or `validated` mode.
*/
const spy = mock(identity).with("a");
/**
* The function will not execute the external interactions.
* Instead, the mocked data will be returned in its place.
* Thus, the result is different, as expected.
*/
expect(example()).toEqual("a fox");
/**
* Assertions can be made about the mocked executions
* that have occurred.
*/
expect(spy.executions).toHaveLength(1);
});
/**
* Outside of the testing block, all wrapped interactions
* will run normally, including effects.
*/
expect(example()).toEqual("quick brown fox");
});Philosophy
This library was born from a philosophy of testing an application system in isolation, in contrast to testing it in the live context of other systems. In other words: "unit testing" individual systems of an application as opposed to "integration testing" the collection of them.
The conventional approach to automated testing of an application is to construct a testing environment where all of the relevant interdependent systems are brought online and configured to use each other.
As a result, test suites can often suffer in performance because that approach does not scale well as the test suite expands. With decent test coverage of a moderately complex application, there will be a lot of cross-system communication and a lot of repeated testing of the same code paths. Both of these can significantly slow the suite as a whole.
Of course, holistic, integrated, end-to-end testing does have its place, but most tests do not need to actually excercise common code paths and cross-system communication repeatedly for the test to be useful and effective. It can often be perfectly sufficient to know that cross-system communication is attempted rather than truly performed.
One approach to achieve this would be traditional dependency injection. However, that often pollutes the interfaces of source code by altering function signatures or requiring unnatural abstractions to get an injection to where it needs to be. This library aims to provide dependency injection transparently with the minimal amount of "source code pollution" necessary.
API
There are 2 primary APIs for wrapping code that works with external systems. Which one to use depends on whether the wrapped code will produce a value or not.
For code that produces a value
If the source code block produces a value that must be substituted during tests, use extern.validated or extern.typed.
validated vs typed
When wrapping a source code block, there are two modes available to determine how the provided identity is used.
Using validated causes the return data of the extern block to be validated by the associated schema. This ensures that the resulting data is of the type defined by the schema. If validation fails, an InvalidDataTypeError will be thrown. Due to the Standard Schema specification, schema validation may or may not return a Promise. To account for this, an extern.validated block will always return the result wrapped in a Promise.
Using typed will not invoke any runtime validation of the data returned by the extern block. The associated identity will only be used to declare the type in TypeScript, but the actual data returned by the block may not be of that type at all. This can be preferred if the data type is already being validated within the block, as there is no need to incur additional runtime overhead of revalidating it. This may also be ideal if you wish to perform validation manually. The result of the block function will be returned directly as-is.
Note that schemas can be used with extern.typed, but they will only function for validation of TypeScript types, not for data validation at runtime.
by
Determines the schema used for the extern block.
The schema must support Standard Schema v1, but there are no other requirements. You can use multiple different schema libraries if you wish.
The exact same schema object should be available to both source code and tests since there is no standardized way of determining equality between different schema objects that represent the same schema structure.
[!CAUTION]
Be careful with an empty object schema, especially with an async block function!
A Promise itself satisfies the empty interface regardless of what it may wrap which can cause type confusion with the resulting value.
See https://typescript-eslint.io/rules/no-empty-object-type
named
Names the extern block for the sole purpose of disambiguating its mock in a test suite.
given
An extern block will usually need to reference parameters outside of itself to perform the desired external interaction. One way of making these references is to make a closure over outside variables:
function comments(postId: number) {
return extern.typed.by(schema).will(
/**
* Makes a closure over the `postId` variable.
*/
() => fetchComments(postId),
);
}But you may wish to make assertions in your tests about the data that was provided to an extern block. So instead, you can pass data into the extern block function from the extern chain with given:
function comments(postId: number) {
/**
* Parameter for extern block function is provided without
* the need for a closure.
*/
return extern.typed.by(schema).given(postId).will(fetchComments);
}The given data is then available for assertions in your tests:
await extern.testing((mock) => {
const spy = mock(schema).with([]);
comments(123);
expect(spy.executions[0]).toMatchObject({ given: 123 });
});will
Defines the extern block function to be executed according to the extern chain preceding it.
It will receive the given data as its only parameter.
The return value is subject to the type defined by the associated schema and the mode defined in its extern chain.
Mocking requirements
A sync extern.testing() body completes synchronously, so await is only needed when the body itself is asynchronous.
Within extern.testing(), all value-producing extern blocks must be mocked. If such a block is used without a registered mock, an error will be thrown, even if the test would not otherwise fail. The expectation of this library is that no external interactions will actually occur during tests since testing scopes should be isolated for the sake of performance and reliability. If external interactions do need to occur during a test, the requirement can be disabled as necessary via passthrough() (as a later section covers).
Also, by default, any defined mock must end up being used by the end of the extern.testing() block, otherwise an UnusedMocksError will be thrown, even if the test would not otherwise fail. This prevents superfluous mocking that results in confusion about what setup is actually needed to run a test. To disable this requirement, pass an options object as the final argument for a mock registration to allow that particular mock to go unused: { unused: "allow" }. This can be useful for asserting that the corresponding source code block did not get executed.
Schema requirements
The schema used between an extern block and its corresponding mock must be the same JavaScript object (satisfying SameValueZero comparison). Therefore, the schema should be defined separately and exported in a way to be accessible to both the source code and tests.
Disambiguating mocks
If a test will be executing multiple external interactions that use the same schema definition within a single extern.testing() block, mock registration for that schema definition may need to be disambiguated. Without disambiguation, the same data will be used for all extern blocks using that schema.
Currently, only named is supported for disambiguation. To use this, define the name on the extern block and on the mock:
extern.typed
.by(schema)
.named("abc")
.will(() => 123);/**
* This mock targets all extern blocks using the schema,
* regardless of any disambiguations that may be assigned
* to the extern block.
*/
mock(schema).with(321);
/**
* This mock targets all extern blocks using the schema
* that have been named "abc", taking priority over
* less-specific mocks that would otherwise apply.
*/
mock(schema).named("abc").with(789);Registering more than one mock with the same disambiguation for the same schema will immediately throw an error.
Skipping mocks
In some tests, you may wish to run the original code instead of mocking it, but not defining a mock will throw an UnusedMocksError.
You can achieve this by explicitly skipping that mock:
/** Disables the mocking requirement and runs the original code block. */
mock(schema).skip();
/** Disambiguation is also supported here. */
mock(schema).named("abc").skip();For code that only performs an effect
If the source code block does not produce a value, then wrapping it with extern.effect allows for a simpler integration.
extern.effect.will(() => sendMetric("login.success"));Because there is no return data, no schema is involved and no by is needed. The wrapped function must return void or Promise<void>.
By default, source code wrapped with extern.effect will be skipped within extern.testing(). Unlike value-producing blocks, there is no requirement to register a mock for it; an unmocked effect simply does nothing.
Outside of extern.testing(), the wrapped function runs normally as if extern were not involved.
named
Names the effect block so that it can be spied on in tests:
extern.effect
.named("send login metric")
.will(() => sendMetric("login.success"));Unnamed effect blocks cannot be observed in tests. They are always skipped within extern.testing() with no opportunity to inspect them.
given
As with value-producing blocks, given provides data to the block function from the extern chain instead of through a closure, which makes the data available for assertions in tests:
function trackView(postId: number) {
return extern.effect.named("track view").given(postId).will(sendViewMetric);
}await extern.testing((mock) => {
const spy = mock.effect.named("track view").observe();
trackView(123);
expect(spy.executions[0]).toMatchObject({ given: 123 });
});given is only available after named, since spying on the captured data requires that the block be identifiable.
will
Defines the effect block function to be executed according to the extern chain preceding it.
It will receive the given data as its only parameter.
The return type must be void or Promise<void>.
Spying on effects
Named effects can be observed during a test in one of two ways:
/**
* Tracks executions and continues to suppress the original
* function (the default behavior for effects in tests).
*/
const spy = mock.effect.named("track view").observe();
/**
* Tracks executions and allows the original function to run.
*/
const spy = mock.effect.named("track view").passthrough();Each returns a spy whose executions array records every call to the matching effect block, with any given data attached. As with value-producing mocks, a registered effect spy must be exercised at least once before the extern.testing() block ends, otherwise an UnusedMocksError will be thrown.
Extensions
An extension widens what one instance accepts as a block identity, so that a block built from another library's schema can produce its own value rather than requiring a mock.
import { fabricatorExtension } from "@ghostry/extern-extension-fabricator-v0";
export const extern = await initialize({
extensions: [fabricatorExtension({ instance: fabricator })],
});The widening is per instance, not global. An initialize() elsewhere in the
same project that was given no extensions still rejects those schemas, exactly
as before. Extensions compose by appending to the list — nothing else is needed
to combine two.
Inside a testing block, an identity an extension claims gains one extra terminal:
await extern.testing((mock) => {
mock(user).produce();
mock(user)
.named("admin")
.produce(({ via }) => via.fabricate({ role: "admin" }));
});produce() lets the extension decide the value; the callback form hands you the
extension's own object for that identity — via — so overrides are expressed in
that library's vocabulary rather than anything extern models. Both forms cache
per (identity, name), so reading a block twice in one test agrees and the
callback runs once.
Writing an extension
An Extension is one of two variants, discriminated by kind.
A producer claims identities and must serve them:
import type { Extension, TypeLambda } from "@ghostry/extern";
interface MyLambda extends TypeLambda {
readonly Out: MySchema<this["In"]>;
}
const myExtension = (): Extension<MyLambda> => ({
kind: "producer",
name: "my-library",
supports: (identity) => isMySchema(identity),
*frame() {
yield { produce: (identity, named) => buildFrom(identity, named) };
},
});An observer claims nothing and only wants a scope per testing block — a
debugging helper recording which blocks ran, say. It contributes no lambda, so
Identity is unwidened and every block behaves exactly as it would with no
extension configured:
const recorder = (): Extension => ({
kind: "observer",
name: "recorder",
*frame() {
record("entered");
yield {};
},
});Extension<SomeLambda> is Extension.Producer<SomeLambda>, and a bare
Extension is Extension.Observer. Neither admits the other: a producer
without supports, an unmocked on an observer, or a producer whose session
carries no produce are all type-level compile errors rather than runtime surprises.
frame runs once per extern.testing() block, for every extension. Each
extension's frame encloses the next, so every one is open by the time the block
runs.
The shape of a frame
A frame is a generator with one suspension point. Everything before the
yield is setup, the block runs at the yield, and everything after it is
teardown:
const profiler = (): Extension => ({
kind: "observer",
name: "profiler",
*frame() {
const started = performance.now();
const outcome = yield {};
report(performance.now() - started, outcome.ok);
},
});Because setup and teardown share one function — and, when you want one, one
try — a finally after the yield runs on every path: the block passed, the
block failed, or your own setup threw on the way in before the block ever ran.
*frame() {
const conn = pool.acquire();
try {
yield { produce: buildFrom(conn) };
} finally {
pool.release(conn);
}
}outcome is { ok: true } or { ok: false, error }, so teardown can tell a
passing block from a failing one. A catch around the yield never fires:
extern resumes the frame with the outcome rather than throwing into it, so an
extension observes a failure without being able to intercept or replace it. Use
try/finally, not try/catch.
async function* works, and promotes the block to a promise — the only honest
way for a synchronous body with asynchronous teardown to report that it is done.
A synchronous generator resumes synchronously and so cannot await its
teardown; teardown that must be awaited needs async function*.
Running the block inside something
When the block must run inside something the extension opens — an
AsyncLocalStorage scope, a library's own wrap, a pooled connection's
callback — yield a wrapper instead of a session. It receives a body it
must call exactly once, passing the session forward, and whose value it must
return unchanged:
*frame() {
yield (body) => library.wrap(
options,
(scoped) => body({ produce: from(scoped) }),
);
}That is how a scoped thing reaches produce with no mutable variable in
between. Extern settles your teardown inside your own wrapper, so a scope you
opened is still live after the yield.
Returning the block's value unchanged is what keeps extern.testing()
synchronous for a synchronous test body. A wrapper that awaits promotes every
block on the instance to a promise.
Teardown across extensions
Ordering needs no coordination. Each frame encloses the next, so an inner teardown has already settled by the time an outer one is reached. Teardown unwinds innermost-first, and an outer frame waits for an inner asynchronous one.
A throwing teardown does not stop a sibling. Every failure is collected and
reported once as a CleanupFailedError carrying them all. It is thrown when the
block itself passed, and merely logged when the block failed, so the failure
worth reading is never displaced by a teardown fault.
Three mistakes are refused rather than left to surface as a suite that passes
with teardown quietly skipped: a frame that returns something other than a
generator (ExtensionFrameResultError), a yield carrying neither a session nor
a wrapper (ExtensionFrameSessionError), and a second yield
(ExtensionFrameYieldError) — there is one block, so there is one suspension
point.
Type lambdas
Identity is widened by a type-level function, encoded with the this-type
trick: Apply intersects a concrete In onto the lambda and reads Out back.
A union of lambdas composes for free, which is what makes several extensions
work together with no combinator.
Three authoring rules, each established by compiling against real schema types:
this["In"]must pass through a named generic alias.MySchema<this["In"]>resolves;{ produces?: this["In"] }does not.Outmust be structurally narrow, keyed on a nominal marker your own values carry. A shape as loose as{ build: () => this["In"] }accepts any object with a same-named method, silently claiming identities that were never yours. Intersect your library's branded symbols instead.Overlapping lambdas are unsupported. If two extensions both match one identity, TypeScript picks the produced type by its own inference-candidate selection regardless of the order you list them in, so no runtime dispatch can be made to agree with the type the caller was handed. Extern counts matches rather than immediately taking the first, and throws
AmbiguousIdentityErrornaming the extensions when multiple match.
Offering produce(({ via }) => ...)
The callback form appears only if your lambda also extends HandleLambda, a
second, independent pair mapping a concrete identity to what its callback
receives:
interface MyLambda extends TypeLambda, HandleLambda {
readonly Out: MySchema<this["In"]>;
readonly Handle: MyBuilderFor<this["Of"]>;
}It is a separate interface rather than two more slots on TypeLambda for a
concrete reason: an interface member is inherited whether or not you redeclare
it, so a Handle on the base would land on every lambda including handle-less
ones — and MyBuilder | unknown is unknown, which would collapse via to
unknown for every identity as soon as one observer-style extension was
configured alongside yours.
Only your extension can construct a handle, so extern routes the caller's
callback to you rather than invoking it itself. produce receives it as a third
argument:
produce: (identity, named, using) => {
const built = buildFrom(identity, named);
return using === undefined ? built.value() : using(built);
},An extension with no handle simply ignores using; leaving Handle undeclared
withdraws the callback form from your identities while leaving produce()
itself in place.
Harnessing
@ghostry/harness wraps a
Jest-compatible test framework so that libraries can contribute to every test
body. @ghostry/extern/harnessing supplies extern's integration: each test body
is a testing block, and its mocker arrives on the test context.
// harness.ts
import { integration as externIntegration } from "@ghostry/extern/harnessing";
import { initialize as initializeHarness } from "@ghostry/harness";
import * as bunTest from "bun:test";
import { extern } from "./extern.ts";
export const { describe, it, expect, beforeAll, beforeEach, framework } =
initializeHarness({
framework: bunTest,
integrations: [externIntegration(extern)],
});import { it, expect } from "./harness.ts";
import { example, identity } from "./source.ts";
it("substitutes the external interaction", ({ extern: { mock } }) => {
const spy = mock(identity).with("a");
expect(example()).toEqual("a fox");
expect(spy.executions).toHaveLength(1);
});No extern.testing(...) and no await around the body: the block is already
open, and a synchronous test stays synchronous.
Every test is now a testing block
Weigh this before adopting it. Outside a testing block an extern block runs its
original function; inside one, an unmocked block raises NotMockedError. With
the integration installed there is no longer an outside, so every test that
reaches a wrapped interaction needs a mock for it — including tests nobody
thinks of as extern tests, which are exactly the ones that will fail first.
Two ways out, per block and per test:
it("wants the real thing", ({ extern: { mock } }) => {
mock(identity).passthrough();
});import { framework } from "./harness.ts";
/** The unchanged test module: bodies registered through it are never framed. */
framework.it("is not framed at all", () => {});The first keeps the block under extern's eye — it still records executions — and runs the original function. The second opts the whole test out.
Suite hooks are not framed, but test hooks are
beforeAll and afterAll run outside any testing block, so blocks reached from
them run their original functions, exactly as they would with no integration
installed. Reaching for the mocker there raises MockingUnavailableError rather
than quietly accomplishing nothing:
beforeAll(({ extern: { mock } }) => {
mock(identity).with("a"); // MockingUnavailableError
});That refusal is deliberate. A testing block mints its own spies, so a mock
defined in beforeAll could never reach a test even if one were handed out. A
hook that genuinely wants a block can open one itself:
beforeAll(async () => {
await extern.testing((mock) => {
mock(identity).with("a");
seed();
});
});beforeEach and afterEach are different: harness runs them inside the
frame, around the body, so a mock defined in a beforeEach is live for the test
and an afterEach can still read its spies.
describe("the suite", () => {
beforeEach(({ extern: { mock } }) => {
mock(identity).with("a", { unused: "allow" });
});
});A shared mock counts toward the unused-mock check of each test that does not
reach it, which is what { unused: "allow" } is for.
Harness requires beforeEach/afterEach to be registered inside a describe —
a top-level one raises AmbientHookError, since there is no suite to attach it
to.
Composing with other integrations
Integrations apply outside-in, index 0 outermost:
initializeHarness({
framework,
integrations: [fabricatorIntegration(fabricator), externIntegration(extern)],
});Extern's own extensions open their scopes inside testing, so the order of the
integrations decides which scope encloses which: extern last layers the
extension's scope over the other integration's per-test scope, and reversing
them layers the per-test scope over the extension's. Both keep every layer, and
both stay deterministic and per-test — the order changes only how the layers
nest. They do not compose to the same configuration, though, so flipping the
order moves every generated value in the suite. Pick one and stay with it; the
examples here register extern last.
To derive fabricator data during a test, fork context.fabricator (or
fabricator.context.scope()), not fabricator itself — a fork of the
integrated instance draws the same data in every test. The fabricator
extension's README works that case through.
Under the sync scope
scope: "sync" permits one testing block at a time. Since every test is now a
block, that becomes a rule about tests: concurrent test bodies raise
IllegalConcurrencyTestingError, and so does an explicit extern.testing(...)
written inside a framed body. An existing suite that calls extern.testing
itself will meet this immediately — under the default async scope that same
nesting is permitted, but the inner block shadows rather than merges, so the
enclosing test's mocks are invisible within it.
Requirements
initialize is asynchronous and the instance is needed at registration time, so
the wiring module needs top-level await — ESM only.
