Skip to content

Zod Fixture

Fixture Generation with 1:1 Zod Parity


npm versionLicensestars


Creating test fixtures should be easy.
zod-fixture helps with the arrange phase of your tests by creating test fixtures based on a zod schema.

Table of Contents

Installation

sh
npm install -D zod-fixture
sh
pnpm add -D zod-fixture
sh
yarn add -D zod-fixture
sh
bun add -d zod-fixture

Getting Started

The easiest way to start using zod-fixture is to import the pre-configured createFixture function.

ts
import { z } from 'zod';
import { createFixture } from 'zod-fixture';

const personSchema = z.object({
	name: z.string(),
	birthday: z.date(),
	address: z.object({
		street: z.string(),
		city: z.string(),
		state: z.string(),
	}),
	pets: z.array(z.object({ name: z.string(), breed: z.string() })),
	totalVisits: z.number().int().positive(),
});

const person = createFixture(personSchema, { seed: 11 });
ts
{
	address: {
		city: 'd-iveauywljfifd',
		state: 'cetuqnbvmbkqwlt',
		street: 'wyttcnyvxpetrsa',
	},
	birthday: new Date('2089-04-19T20:26:28.411Z'),
	name: 'barmftzlcngaynw',
	pets: [
		{
			breed: 'fbmiabahyvsy-vm',
			name: 'bonzm-sjnglvkbb',
		},
		{
			breed: 'vifsztjznktjkve',
			name: 'wqbjuehl-trb-ai',
		},
		{
			breed: 'cq-jcmhccaduqmk',
			name: 'brrvbrgzmjhttzh',
		},
	],
	totalVisits: 63,
},

INFO

The examples make use of the optional seed parameter to generate the same fixture every time. This is useful for our docs, deterministic testing, and to reproduce issues, but is not necessary in your code. Simply calling createFixture with no configuration is acceptable.

Take a look at the examples to see how you can use zod-fixture in your tests.

Customizing

zod-fixture is highly customizable. We provide you with the same utility methods we use internally to give you fine-grained support for creating your own fixtures.

Extending

The easiset way to start customizing zod-fixture is to use the Fixture class directly and extend it with your own generator.

INFO

createFixture(...) is just syntactic sugar for new Fixture().fromSchema(...)

The example below uses 2 custom generators and a typical pattern for filtering based on the keys of an object.

ts
import { ZodNumber, ZodObject, z } from 'zod';
import { Fixture, Generator } from 'zod-fixture';

const totalVisitsGenerator = Generator({
	schema: ZodNumber,
	filter: ({ context }) => context.path.at(-1) === 'totalVisits',
	/**
	 * The `context` provides a path to the current field
	 *
	 * {
	 *   totalVisits: ...,
	 *   nested: {
	 *     totalVisits: ...,
	 *   }
	 * }
	 *
	 * Would match twice with the following paths:
	 *   ['totalVisits']
	 *   ['nested', 'totalVisits']
	 */

	// returns a more realistic number of visits.
	output: ({ transform }) => transform.utils.random.int({ min: 0, max: 25 }),
});

const addressGenerator = Generator({
	schema: ZodObject,
	filter: ({ context }) => context.path.at(-1) === 'address',
	// returns a custom address object
	output: () => ({
		street: 'My Street',
		city: 'My City',
		state: 'My State',
	}),
});

const personSchema = z.object({
	name: z.string(),
	birthday: z.date(),
	address: z.object({
		street: z.string(),
		city: z.string(),
		state: z.string(),
	}),
	pets: z.array(z.object({ name: z.string(), breed: z.string() })),
	totalVisits: z.number().int().positive(),
});

const fixture = new Fixture({ seed: 38 }).extend([
	addressGenerator,
	totalVisitsGenerator,
]);
const person = fixture.fromSchema(personSchema);
ts
{
	address: {
		city: 'My City',
		state: 'My State',
		street: 'My Street',
	},
	birthday: new Date('1952-01-21T17:32:42.094Z'),
	name: 'yxyzyskryqofekd',
	pets: [
		{
			breed: 'dnlwozmxaigobrz',
			name: 'vhvlrnsxroqpuma',
		},
		{
			breed: 'ifbgglityarecl-',
			name: 'c-lmtvotjcevmyi',
		},
		{
			breed: 'fmylchvprjdgelk',
			name: 'ydevqfcctdx-lin',
		},
	],
	totalVisits: 15,
},

TIP

The order the registered generators matters. The first generator that matches the conditions (schema and filter) is used to create the value.

Generators

To generate a value based on a zod type we're using what we call a Generator.

A Generator has 3 fundamental parts:

  • schema -- [optional] the zod type to match
  • filter -- [optional] a function to further refine our match (ie filtering by keys or zod checks)
  • output -- a function that's called to produce the fixture

Schema

A schema can be provided in the following ways:

  • A zod type constructor (ie ZodString)
  • An instance of a type (ie z.custom())
ts
import { z, ZodString } from 'zod';
import { Fixture, Generator } from 'zod-fixture';

// this is a custom zod type
const pxSchema = z.custom<`${number}px`>((val) => {
	return /^\d+px$/.test(val as string);
});

const StringGenerator = Generator({
	schema: ZodString,
	output: () => 'John Doe',
});

const PixelGenerator = Generator({
	schema: pxSchema,
	output: () => '100px',
});

const developerSchema = z.object({
	name: z.string().max(10),
	resolution: z.object({
		height: pxSchema,
		width: pxSchema,
	}),
});

const fixture = new Fixture({ seed: 7 }).extend([
	PixelGenerator,
	StringGenerator,
]);
const developer = fixture.fromSchema(developerSchema);
ts
{
	name: 'John Doe',
	resolution: {
		height: '100px',
		width: '100px',
	},
}

Filtering

In addition to matching schemas, zod-fixture provides robust tools for filtering, allowing you to further narrow the matches for your generator. There are two common patterns for filtering.

Filter by Check

In the case where you use a zod method like z.string().email(), zod adds what they call a "check" to the definition. These are additional constraints that are checked during parsing that don't conform to a Typescript type. (ie TS does not have the concept of an email, just a string). zod-fixture provides a type safe utility called checks for interacting with these additional constraints.

There are two methods provided by the checks utility:

  • has -- returns a boolean letting you know if a particular check exists on the schema.
  • find -- returns the full definition of a check, which can be useful for generating output.
ts
import { z, ZodString } from 'zod';
import { Fixture, Generator } from 'zod-fixture';

const EmailGenerator = Generator({
	schema: ZodString,
	filter: ({ transform, def }) =>
		transform.utils.checks(def.checks).has('email'),
	output: () => 'john.malkovich@gmail.com',
});

const StringGenerator = Generator({
	schema: ZodString,
	output: ({ transform, def }) => {
		let min = transform.utils.checks(def.checks).find('min')?.value;
		/**
		 *     kind: "min";
		 *     value: number;
		 *     message?: string | undefined; // a custom error message
		 */

		let max = transform.utils.checks(def.checks).find('max')?.value;
		/**
		 *     kind: "max";
		 *     value: number;
		 *     message?: string | undefined; // a custom error message
		 */

		const length = transform.utils.checks(def.checks).find('length');
		/**
		 *     kind: "length";
		 *     value: number;
		 *     message?: string | undefined; // a custom error message
		 */

		if (length) {
			min = length.value;
			max = length.value;
		}

		return transform.utils.random.string({ min, max });
	},
});

const personSchema = z.object({
	name: z.string().max(10),
	email: z.string().email(),
});

const fixture = new Fixture({ seed: 38 }).extend([
	EmailGenerator,
	StringGenerator,
]);
const person = fixture.fromSchema(personSchema);
ts
{
	email: 'john.malkovich@gmail.com',
	name: 'yxyzyskryq',
},
Filter by Key

Matching keys of an object is another common pattern and a bit tricky if you don't give it enough thought. Every generator is called with a context and that context includes a path. The path is an array of keys that got us to this value. Generally speaking, you will only want the last key in the path for matching things like "name", "email", "age", etc in a deeply nested object.

ts
import { z, ZodString } from 'zod';
import { Fixture, Generator } from 'zod-fixture';

const NameGenerator = Generator({
	schema: ZodString,
	filter: ({ context }) => context.path.at(-1) === 'name',
	output: () => 'John Doe',
});

const personSchema = z.object({
	name: z.string(), // this matches ['name']
	email: z.string().email(),
	relatives: z
		.object({
			name: z.string(), // this will match as well ['relatives', 'name']
			email: z.string().email(),
		})
		.array(),
});

const fixture = new Fixture({ seed: 7 }).extend(NameGenerator);
const person = fixture.fromSchema(personSchema);
ts
{
	email: 'rando@email.com',
	name: 'John Doe',
	relatives: [
		{
			email: 'rando@email.com',
			name: 'John Doe',
		},
		{
			email: 'rando@email.com',
			name: 'John Doe',
		},
		{
			email: 'rando@email.com',
			name: 'John Doe',
		},
	],
}

Output

Output is a function that generates the fixture for any matches. zod-fixture provides a randomization utility for creating data, in addition to all of the defaults (including the seed).

For example, in the example below we create our own totalVisitsGenerator to return more realastic numbers using the random utilities.

ts
const totalVisitsGenerator = Generator({
	schema: ZodNumber,
	filter: ({ context }) => context.path.at(-1) === 'totalVisits',
	/**
	 * The `context` provides a path to the current field
	 *
	 * {
	 *   totalVisits: ...,
	 *   nested: {
	 *     totalVisits: ...,
	 *   }
	 * }
	 *
	 * Would match twice with the following paths:
	 *   ['totalVisits']
	 *   ['nested', 'totalVisits']
	 */

	// returns a more realistic number of visits.
	output: ({ transform }) => transform.utils.random.int({ min: 0, max: 25 }),
});

FAQ

I have a custom type that I need to support. How do I do that?

zod-fixture was built with this in mind. Simply define your custom type using zod's z.custom and pass the resulting schema to your custom generator.

ts
import { z } from 'zod';
import { Fixture, Generator } from 'zod-fixture';

// Your custom type
const pxSchema = z.custom<`${number}px`>((val) => {
	return /^\d+px$/.test(val as string);
});

// Your custom generator
const PixelGenerator = Generator({
	schema: pxSchema,
	output: () => '100px',
});

// Example
const resolutionSchema = z.object({
	width: pxSchema,
	height: pxSchema,
});

const fixture = new Fixture().extend([PixelGenerator]);
const resolution = fixture.fromSchema(resolutionSchema);
ts
{
	width: '100px',
	height: '100px',
}

z.instanceof isn't returning what I expected. What gives?

z.instanceof is one of the few schemas that doesn't have first party support in zod. It's technically a z.custom under the hood, which means the only way to match is for you to create a custom generator and pass an instance of it as your schema.

ts
import { z } from 'zod';
import { Fixture, Generator } from 'zod-fixture';

class ExampleClass {
	id: number;
	constructor() {
		this.id = ExampleClass.uuid++;
	}
	static uuid = 1;
}

// Schema from instanceof (remember, this is just a z.custom)
const exampleSchema = z.instanceof(ExampleClass);

// Your custom generator
const ExampleGenerator = Generator({
	schema: exampleSchema,
	output: () => new ExampleClass(),
});

// Example
const listSchema = z.object({
	examples: exampleSchema.array(),
});

const fixture = new Fixture().extend(ExampleGenerator);
const result = fixture.fromSchema(listSchema);
ts
{
	examples: [
		{
			id: 1,
		},
		{
			id: 2,
		},
		{
			id: 3,
		},
	],
}

Do you support faker/chance/falso?

The short answer, not yet. We plan to build out pre-defined generators for popular mocking libraries but are currently prioritizing reliability and ease of use. If you'd like to help us build out this functionality, feel free to open a pull request 😀

API

Fixture

INFO

Fixture is a Transformer that comes prepackaged with generators for each of the first party types that Zod provides. For most cases, this is all you wil need, and offers a fast and easy way to create fixtures. For building a custom Transformer refer to the Advanced documentation.

Config

We provide sane defaults for the random utilities used by our generators, but these can easily be customized.

ts
interface Defaults {
	seed?: number;
	array: {
		min: number;
		max: number;
	};
	map: {
		min: number;
		max: number;
	};
	set: {
		min: number;
		max: number;
	};
	int: {
		min: number;
		max: number;
	};
	float: {
		min: number;
		max: number;
	};
	bigint: {
		min: bigint;
		max: bigint;
	};
	date: {
		min: number;
		max: number;
	};
	string: {
		min: number;
		max: number;
		characterSet: string;
	};
	recursion: {
		min: number;
		max: number;
	};
}
Seed (optional)

A seed can be provided to produce the same results every time.

ts
const fixture = new Fixture({ seed: number });

Advanced Topics

Create Your Own Transformer

Instead of using one of the opinionated Fixtures, you can extend the unopinionated Transformer and register the desired generators.

ts
import { ConstrainedTransformer, UnconstrainedTransformer } from 'zod-fixture';

/**
 * Constrained defaults
 *
 * {
 *   array: {
 *     min: 3,
 *     max: 3,
 *   },
 *   // ...
 *   string: {
 *     min: 15,
 *     max: 15,
 *     characterSet: 'abcdefghijklmnopqrstuvwxyz-',
 *   }
 * }
 */
new ConstrainedTransformer().extend([
	/* insert your generators here */
]);

/**
 * Less constrained. Better for mocking APIs.
 */
new UnconstrainedTransformer().extend([
	/* insert your generators here */
]);

Migration Guide

v1 to v2

The v2 version is a total rewrite of v1. Thanks for all the help @THEtheChad 🤝

Why a rewrite?

v1 was flexible and allowed that multiple validation libraries could be supported in the future. But, this made things more complex and I don't think we intended to add more libraries than zod.

v2 is a full-on zod version. This benefits you because we make more use of zod's schema while creating fixtures. For example, when you want to create a custom generator (previously a customization) you can also access zod's schema definition.

Fixture Generation with 1:1 Zod Parity

Breaking changes

createFixture

createFixture still exists, but it could be that it generated its output with a slightly different output. It still is compatible (even more compatible) with zod's schema. For example, the changes to a string output:

BEFORE:

street-a088e991-896e-458c-bbbd-7045cd880879

AFTER:

fbmiabahyvsy-vm

createFixture uses a pre-configured Fixture instance, which cannot be customized anymore. To create a custom fixture in v2, you need to create your own Fixture instance, for more info see the docs.

Customization

Customization is renamed to Generator.

BEFORE:

ts
const addressCustomization: Customization = {
	condition: ({ type, propertName }) =>
		type === 'object' && propertName === 'address',
	generator: () => {
		return {
			street: 'My Street',
			city: 'My City',
			state: 'My State',
		};
	},
};

AFTER:

ts
const addressGenerator = Generator({
	schema: ZodObject,
	filter: ({ context }) => context.path.at(-1) === 'address',
	output: () => ({
		street: 'My Street',
		city: 'My City',
		state: 'My State',
	}),
});
Configuring the fixture

To add custom generators to the fixture, you need to create your own fixture instance and extend it with your own generators.

BEFORE:

ts
const person = createFixture(PersonSchema, {
	customizations: [addressCustomization],
});

AFTER:

ts
const fixture = new Fixture().extend([addressGenerator]);
const person = fixture.fromSchema(personSchema);

Contributing

Getting started with GitHub Codespaces

To get started, create a codespace for this repository by clicking this 👇

Open in GitHub Codespaces

A codespace will open in a web-based version of Visual Studio Code. The dev container is fully configured with software needed for this project.

Note: Dev containers is an open spec which is supported by GitHub Codespaces and other tools.

StackBlitz

Open in StackBlitz

Blog posts

Credits

This package is inspired on AutoFixture.