@hexdspace/testing
v0.1.1
Published
Type-safe fixture builders for tests
Readme
@hexdspace/testing
Install with pnpm add -D @hexdspace/testing, then import the fixture helpers:
import { defineFixtureBuilder, derive, entropy, patch, seq, update } from '@hexdspace/testing';Overview
The goal of this library is reducing the friction associated with creating test fixtures for your objects.
In a few words, it implements type-safe fixture builders (with a few bells and whistles). The builders can be used to compose test fixtures through ergonomic, immutable, ordered field-level operations.
Quick start
- Create defaults for your type:
interface User {
id: string;
username: string;
email: string;
role: 'USER' | 'ADMIN';
config: {
address: {
city: string;
country: string;
}
}
}
const userDefaults = (): User => ({
id: '0',
username: 'johnd',
email: '[email protected]',
role: 'USER',
config: { address: { city: 'Kraków', country: 'Poland' }}
});- Define a builder:
const userBuilder = defineFixtureBuilder<User>().defaults(userDefaults).done()- Build fixtures from the defaults:
const defaultUser = userBuilder.build();
const admin = userBuilder
.with_username('rootguy')
.with_role('ADMIN')
.build();
const users = userBuilder.buildMany(10, (index) => ({ id: String(index) }))The builder is immutable and re-usable. Every build call composes a new fixture from a fresh value provided by the defaults function.
Building fixtures
First define defaults (see quick start) and create the builder:
const userBuilder = defineFixtureBuilder<User>().defaults(userDefaults).done();Single fixture
The builder can be used directly to create a fresh User instance from the provided defaults:
const testUser = userBuilder.build();
expect(testUser.username).to.equal('johnd');The fields can be updated by specifying an override in the build command:
const testUser = userBuilder.build({ username: 'jdoe' });Arrays of fixtures
Use buildMany(count: number) to generate multiple objects. The function also takes the second argument,
which can be either an override, or an indexed callback returning an override.
const users = userBuilder.buildMany(5);
const adminUsers = userBuilder.buildMany(5, { role: 'ADMIN' });
const increasingUsers = userBuilder.buildMany(5, (index) => ({ id: String(index) }));Changing field values
There are three ways to change default field values. All three support the same field operations.
Generated with_<property> setters
Each property on your type will receive a corresponding with_<property> setter:
const user = userBuilder.with_username('Bob').build();Direct build override
build and buildMany both accept override objects:
const user = userBuilder.build({ username: 'Bob' });
const users = userBuilder.buildMany(10, { username: 'Bob' });set() for arbitrary keys
Use set() when a generated setter is inconvenient or unavailable, e.g. when a property has an unusual name:
const user = userBuilder.set('username', 'Bob').build();Field operations
Generated setters, set(), and build overrides support the following operations.
update()
update() calculates a new field value from its current value:
const user = userBuilder.with_id(update((prev) => prev + 10)).build();derive()
derive() calculates a field value from the current fixture. It observes changes made by earlier operations:
const user = userBuilder
.with_username('alice')
.with_email(
derive((current) => `${current.username}@test.mail`),
)
.build();
expect(user.email).to.equal('[email protected]');patch()
A direct object value replaces the complete field. Use patch() to update selected nested properties
while preserving the remaining values:
const user = userBuilder
.with_config(
patch({
address: {
city: 'Sydney',
},
}),
)
.build();
expect(user.config.address).to.deep.equal({
city: 'Sydney',
country: 'Poland',
});Patch behaviour
patch() recursively merges plain objects while preserving properties that are not included in the patch.
The following rules apply:
- Plain objects are merged recursively.
- Arrays are replaced as complete values, they are not merged by index.
- Dates, maps, sets, class instances, and other non-plain objects are replaced.
- An explicit
undefinedreplaces the existing value. - Every object branch being patched must already contain a plain object.
patch() is intended for object fields. Use a direct value or update() to change primitive, array,
or other non-patchable fields.
Traits
Traits are reusable, named fixture variations. They are useful for representing states that appear throughout a test suite, e.g.
- an administrator,
- an inactive account,
- an invalid session,
- an error-producing state.
Defining traits
Traits are registered when defining the builder:
const userBuilder = defineFixtureBuilder<User>()
.defaults(userDefaults)
.trait('admin', () => ({ role: 'ADMIN' }))
.trait('inCity', (_current, city: string) => patch({
config: {
address: {
city,
},
},
}),
)
.trait('emailFromUsername', (current) => ({
email: `${current.username}@test.mail`,
})
)
.done();Each trait callback receives the current fixture and returns an override using the same rules as a build() override.
A direct object field replaces the complete field. Use patch() when a trait should update selected nested properties
while preserving the rest of the fixture.
In the inCity trait above, only city is changed. The existing country value is preserved.
Applying traits
Each registered trait becomes a method on the builder:
const admin = userBuilder
.admin()
.build();
const userInSydney = userBuilder
.inCity('Sydney')
.build();
expect(admin.role).to.equal('ADMIN');
expect(userInSydney.config.address).to.deep.equal({
city: 'Sydney',
country: 'Poland',
});Ordering and composition
Traits can be combined with setters, field operations, and other traits. They are applied in the order in which they are called.
const user = userBuilder
.with_username('alice')
.emailFromUsername()
.admin()
.build();
expect(user.email).to.equal('[email protected]');
expect(user.role).to.equal('ADMIN');A trait observes changes made by earlier operations. Later operations can, in turn, replace or update values produced by the trait.
Like other builder operations, applying a trait returns a new builder and does not modify the original one.
Trait results support the same field operations as build overrides. A trait can return patch() around the whole
override, as shown by inCity, or use field-level operations such as
{ config: patch({ address: { city: 'Sydney' } }) }.
Build options
build() accepts an optional options object after its overrides. buildMany() accepts the same options as its third argument.
userBuilder.build(overrides, options);
userBuilder.buildMany(count, overrides, options);Each fixture is constructed in the following order:
- The defaults function is evaluated.
- Builder operations and build overrides are applied.
clonecreates the fixture instance.postBuildperforms final processing.
clone
clone controls how the composed fixture is copied before it is returned. It receives the fixture produced from the defaults and all applied operations, and must return a complete fixture.
const user = userBuilder.build(
{ username: 'alice' },
{
clone: (fixture) => structuredClone(fixture),
},
);A custom clone function can be useful when fixtures contain values that require special copying behavior, such as class instances.
clone runs once for every fixture created by buildMany().
postBuild
postBuild runs after the fixture has been cloned. It receives the completed fixture and must return the final fixture.
It is useful for calculations or transformations that must observe all previously applied operations. For example, an ID can be calculated from the final username and email:
import { createHash } from 'node:crypto';
const calculateHash = (value: string): string =>
createHash('sha256').update(value).digest('hex');
const user = userBuilder.build(
{
username: 'alice',
email: '[email protected]',
},
{
postBuild: (fixture) => ({
...fixture,
id: calculateHash(`${fixture.username}:${fixture.email}`),
}),
},
);
expect(user.id).to.equal(
calculateHash('alice:[email protected]'),
);Because postBuild runs last, it observes values produced by setters, traits, field operations, and build overrides.
When used with buildMany(), it runs independently for every generated fixture and receives the build context, including the current index:
const users = userBuilder.buildMany(
5,
(index) => ({
username: `user-${index}`,
}),
{
postBuild: (fixture, context) => ({
...fixture,
id: calculateHash(
`${context.index}:${fixture.username}:${fixture.email}`,
),
}),
},
);Options supplied to an individual build() or buildMany() call override clone and postBuild configured on the builder. seed is builder-level only.
Generated values
Generated values are evaluated while each fixture is built. They can be
used in defaults, generated setters, set(), and build overrides.
Sequences
seq() receives the current builder sequence. The sequence starts at zero
and advances once for every build:
const userBuilder = defineFixtureBuilder<User>()
.defaults(() => ({
...userDefaults(),
id: seq((sequence) => String(sequence)),
username: seq((sequence) => `user-${sequence}`),
}))
.done();
const first = userBuilder.build();
const second = userBuilder.build();
expect(first.id).to.equal('0');
expect(second.id).to.equal('1');Calling seq() without a callback generates the numeric sequence directly:
const defaults = () => ({
id: seq(),
name: 'John',
});Every sequence value evaluated during the same build receives the same sequence number. Builders created through setters and traits share their sequence state.
The sequence can be reset or moved explicitly:
userBuilder.rewindSequence();
userBuilder.rewindSequence(10);Entropy
Entropy helpers generate deterministic pseudo-random fixture values.
const userBuilder = defineFixtureBuilder<User>()
.seed(42)
.defaults(() => ({
...userDefaults(),
id: entropy.string({
prefix: 'usr_',
length: 16,
}),
}))
.done();The default seed is 0. Fresh builders using the same seed and the same
build order produce identical values.
Integers
entropy.integer() generates an integer with inclusive bounds:
entropy.integer();
entropy.integer({ min: 1, max: 100 });The default bounds are 0 and 0xffffffff.
Floating-point numbers
entropy.float() generates a floating-point number whose minimum is
inclusive and maximum is exclusive:
entropy.float(); // [0, 1)
entropy.float({ min: -10, max: 10 }); // [-10, 10)Strings
entropy.string() generates an alphanumeric string of 16 characters by
default:
entropy.string();
entropy.string({
prefix: 'usr_',
suffix: '_test',
length: 24,
charset: 'abcdefghijklmnopqrstuvwxyz0123456789',
});length describes the complete output, including prefix and
suffix. String length and charset selection operate on Unicode code
points.
The charset may only be empty when the prefix and suffix already occupy the complete requested length.
Pick
entropy.pick() selects one item from a fixed list:
entropy.pick({ items: ['USER', 'ADMIN'] as const });The item list must not be empty.
Custom generators
entropy.custom() creates a generated value from the builder's seeded
random source:
const user = userBuilder
.with_role(entropy.custom((random) => (random() < 0.8 ? 'USER' : 'ADMIN')))
.build();Use it to define project-specific entropy helpers:
function weightedPick<T>(
choices: ReadonlyArray<{ value: T; weight: number }>,
): Generated<T> {
const totalWeight = choices.reduce((total, { weight }) => total + weight, 0);
return entropy.custom((random) => {
let remaining = random() * totalWeight;
for (const choice of choices) {
if (remaining < choice.weight) {
return choice.value;
}
remaining -= choice.weight;
}
throw new Error('Could not pick a weighted choice.');
});
}
const user = userBuilder
.with_role(
weightedPick([
{ value: 'USER', weight: 5 },
{ value: 'ADMIN', weight: 1 },
]),
)
.build();Reproducibility
Entropy values consume the builder's PRNG stream in evaluation order. This means adding, removing, or reordering generated fields changes later values in the stream.
Builders created through setters and traits share the same entropy stream. To replay it, create a fresh builder using the same seed:
const definition = defineFixtureBuilder<User>()
.seed(42)
.defaults(userDefaults);
const firstRun = definition.done();
const replay = definition.done();rewindSequence() only resets the sequence counter; it does not reset the
entropy stream.
