npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 undefined replaces 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:

  1. The defaults function is evaluated.
  2. Builder operations and build overrides are applied.
  3. clone creates the fixture instance.
  4. postBuild performs 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.