Mocking Zod Schema Models with Factories
Overview
Zod schema factories are much like car manufacturing factories as in they take a spec and produce a product.
Factories are leveraged in development & unit testing to quickly generate mock data mirroring the shape of any Zod schema (spec).
Like cars & trucks, single schema models can be produced with many different options.
Where a car could have many options like self driving, all wheel drive, or an enhanced stereo system,
Zod schema could have many options like isDisplayed (true or false), has an image, has children, and is nullable.
Factories supercharge development by allowing us to quickly mock any and all states of a Zod schema.
Why We Use Factories
To properly test a unit of code you'll need test data. Testing with a single set of static data is considered "Lab Data" or "Happy Path" testing and is not an acceptable approach to testing enterprise level software. To properly test, you need to test all potential scenarios and ensure your test data's shape is in parity with that in production.
How Factories Work
To better visualize how a factory works, let's Imagine that we have a component that requires a set of specific props and accepts a few optional props as well.
It's props are defined in a Zod schema like so:
{
title: z.string(),
isDisabled: z.boolean(),
children: z.custom<ReactNode>().optional(),
href: z.string().optional(),
}
To implement a basic test to ensure this component renders without error is now as easy as this:
test('renders when all required props are present', () => {
/*-------------------------------------------------------------------
| Arrange
-------------------------------------------------------------------*/
const props = new FormButtonFactory.mock()
/*-------------------------------------------------------------------
| Act
-------------------------------------------------------------------*/
render(<FormButton {...props}/>)
/*-------------------------------------------------------------------
| Assert
-------------------------------------------------------------------*/
expect(screen.getByRole('button')).toBeInTheDocument()
});
Pretty simple right? This generates mock data with randomized results. To take control over how your schema model is mocked, you'll want to apply states.
See the Factory states section to learn more
Factory Creation
Create Mocked Data For Testing and Development With Zod Factories!
The following will outline how to start generating mocked data models from an existing Zod Schema via Factories.
Step 1: Establish A Factory Name
To name your Factory, simply take the name of your Schema and replace the word "Schema" with "Factory".
For example, if your Zod Schema is named "TransformedCarSchema.ts" your Factory name will be "TransformedCarFactory.ts"
Step 2: Create a Factory
Below is an example of a basic Factory. To make it your own:
- Copy this code snippet to a new file.
- Update the imports with your desired Zod Schema and inferred type.
import Factory from '../../../../test/src/zest/Factory';
import { TransformedCarSchema, TTransformedCar } from '@schwab/schema/transformed/TransformedCarSchema';
export class TransformedCarFactory extends Factory<TTransformedCar> {
constructor() {
super(TransformedCarSchema);
}
}
Step 3: Save Your Factory
Save your Factory file to the /packages/schema/src/factories/ directory and then mirror the subsequent path of the Zod Schema it mocks.
For example: Notice the TransformedCarSchema from above is located at:
/packages/schema/src/transformed/TransformedCarSchema.ts
So in this case, the Zod Schema Factory will be saved to:
packages/schema/src/**factories**/transformed/TransformedCarFactory.ts
Factory Methods
Start mocking model with .mock(), .mockMany(), and more!
Once you have defined your factory, you can start creating and debugging mocked models by leveraging chainable factory utility methods.
See all available methods and examples
Control How Data Is Mocked
Need more control over how your schema is mocked?
Learn how to add "Factory States" to quickly customize your mocked data here
Factory Methods
truetrueconsole.console()
.console()
Console log a mocked model from anywhere within the chain.
At times, we will want to take a peek at the data generated by a factory.
const data = new TransformedCarFactory().sedan().autobot().mock();
console.log(data);
However, taking this approach can be cumbersome and only consoles the end result.. To keep things simple, use the chainable .console() method instead.
const data = new TransformedCarFactory().sedan().autobot().console().mock();
Result: console.warn
Factory Mocked Schema Preview: { ... }
The factory will return the mocked model as expected. The only difference being that we will also see the generated model consoled for review.
**Note:**This method must be called before .mock() or .mockMay() has been called.
Consoling multiple states
Comparing 2 or more mocked states is as easy as chaining multiple .console() methods.
Only the states that appear prior to a .console() call will be applied.
This allows us to chain multiple .console()'s throughout our chain to compare results before and after states are applied.
jsRDark.console()const data = new TransformedCarFactory().sedan().console().autobot().console().blue().console().mock();
Any states that appear after the .console() method will not be applied to the logged model.
Note: The .console() method is intended for debugging only and should be removed once your finished viewing your model.
.getLocales()
Retrieves an array of Factory supported languages.
Need to test that your assertions are true even when localized characters are received? This method can help.
In this example, we are setting up a test suite that will iterate through each supported locale for testing.
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
describe.each(new TransformedCarFactory().getLocales())('My TestSuite Description', (locale: string): void => { ... }
This couples nicely with the chainable .locale() method enabling us to dynamically pass in current locale.
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
describe.each(new TransformedCarFactory().getLocales())('My TestSuite Description', (locale: string): void => {
test(`${local} input is received', () => {
const input = new TransformedCarFactory().locale(locale).mock();
...
})
}
.locale()
Tells the Factory to generate localized schema mocks.
Mock schema in a specific supported locale by chaining the .locale() method to an instantiated factory.
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
const english = new TransformedCarFactory().locale('zh\_CN').mock();
const chinese = new TransformedCarFactory().locale('zh\_CN').mock();mock.mock()
.mock()
Tells the Factory to generates a mocked schema model.
Once you have defined your factory, you can create a mocked model by simply calling the mock() method on your instantiated factory.
Let's take a look at using the mock method to generate a model without any specific states applied.
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
const data = new TransformedCarFactory().mock();mock-many.mockMany()
.mockMany()
Tells the Factory to g****enerates multiple mocked schema models.
Mock multiple schema Models with Zod Factories using the mockMany() method.
The mockMany method received the a argument reflecting the number of models you would like mocked. In the example below, we are mocking 3 transformed cars.
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
const data = new TransformedCarFactory().mockMany(3);nullable.nullable()
.nullable()
Generates null values for all .nullable() schema properties.
This chainable method tells the Factory to generate a NULL value for all schema properties marked .nullable().
export const ExampleSchema = z.object({
name: z.string(),
age: z.number().nullable(),
})
When our example schema above is mocked, the generated model will resemble something like this:
const data = new TransformedCarFactory().mock();
// Result
{
name: Megatron,
age: 438
}
When .nullable() is chained, the factory will generate:
const data = new TransformedCarFactory().nullable().mock();
// Result
{
name: Megatron,
age: null
}
.nullish()
Generates undefined values for properties that are marked .optional() or .nullish() and NULL values for all properties marked .nullable().
Example Schema
const ExampleSchema = z.object({
name: z.string(),
age: z.number().optional(),
color: z.string().nullable(),
length: z.number().nullish(),
})
When our example schema above is mocked, the generated model will resemble something like this:
const data = new TransformedCarFactory().mock();
// Result
{
name: Megatron,
age: 438,
color: "chrome",
length: "14'"
}
When .nullish() is chained, the factory will generate:
const data = new TransformedCarFactory().nullish().mock();
// Result
{
name: Megatron,
age: undefined,
color: null,
length: undefined,
}
.optional()
Generates undefined values for all .optional() schema properties.
This chainable method tells the Factory to generate undefined values for all schema properties marked .optional().
Example Schema
export const ExampleSchema = z.object({
name: z.string(),
age: z.number().optional(),
})
When our example schema above is mocked, the generated model will resemble something like this:
const data = new TransformedCarFactory().mock();
// Result
{
name: Megatron,
age: 438
}
When .optional() is chained, the factory will generate:
const data = new TransformedCarFactory().optional().mock();
// Result
{
name: Megatron,
age: undefined
}
.toSchema()
Tells the Factory to return a Zod schema reflecting the current factory state.
When mocking a schema model, factories first apply all chained states to their base schema. We can get this modified schema at any time by calling the .toSchema() method.
Example usage
Within a Model's schema, it is common to see references to related model schema. For example:
export const ExampleSchema = z.object({
name: z.string(),
image: ImageSchema,
link: LinkSchema
});
Looking at the ExampleSchema above, imagine we want to set the related ImageSchema's isBackgroundImage property to true.
You may feel inclined to take this approach:
/**
* Sets the image properties ImageSchema.isBackgroundImage property state to true.
* @returns {this}
*/
withBackgroundImage = (): this => {
this.state((schema) => {
return schema.extend({ image: ImageSchema.extend({ isBackgroundImage: z.literal(true) });
});
return this;
};
However this is not ideal as this state belongs to the ImageFactory and should only be defined there.
We can instead DRY things up by calling the ImageFactory's state .toSchema() like so:
/**
* Sets the image properties ImageSchema.isBackgroundImage property state to true.
* @returns {this}
*/
withBackgroundImage = (): this => {
this.state((schema) => {
return schema.extend({ image: ImageSchemaFactory().asBackground().toSchema() });
});
return this;
};
This also protects us from any unexpected ImageSchema updates that could alter how background images are defined.
Factory States
What is a Factory State?
Zod Factories mock schema by generating random values that conform to a Zod Schema's type definitions.
Factory states allow us to define discrete modifications to apply to mocked Zod Schema model.
In other words, by implementing a Factory States we can take control over how our Zod Schema is mocked.
Creating a Factory State
Imagine you have a Zod Schema called TransformedCarSchema that looks like this:
import {z} from 'zod';
export const TransformedCarSchema = z.object({
make: z.string(),
model: z.string(),
color: z.string(),
doors: z.literal(2).or(z.literal(4)),
team: z.literal('Decepticon').or(z.literal('Autobot')),
})
If you look closely, you'll notice that there are a 4 potential states this Zod Schema supports:
1. { doors: 2, team: 'Autobot' }
2. { doors: 4, team: 'Autobot' }
3. { doors: 2, team: 'Decepticon' }
4. { doors: 4, team: 'Decepticon' }
Now, lets say we want to mock a transformed car with 2 doors. We can add a Factory State to help us accomplish this like so:
export class TransformedCarFactory extends Factory<TTransformedCar> {
constructor() {
super(TransformedCarSchema);
}
/**
* Sets the "doors" property state to 2
* @returns {this}
*/
coupe(): this {
this.state((schema): this => {
return schema.extend({ doors: z.literal(2) });
});
return this;
}
}
We can now generate a transformed car with 2 doors by calling:
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
const data = new TransformedCarFactory().coupe().mock();
We will also want to mock 4 doors too right? So lets add a "sedan" state to handle that like so:
export class TransformedCarFactory extends Factory<TTransformedCar> {
constructor() {
super(TransformedCarSchema);
}
/**
* @description Sets the "doors" property state to 2
* @returns {this}
*/
coupe = (): this => {
this.state((schema): this => {
return schema.extend({ doors: z.literal(2) });
});
return this;
}
/**
* @description Sets the "doors" property state to 4
* @returns {this}
*/
sedan = (): this => {
this.state((schema): this => {
return schema.extend({ doors: z.literal(4) });
});
return this;
}
}
We can now generate a transformed car with 4 doors by calling:
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
const data = new TransformedCarFactory().sedan().mock();
This is nice to have and much easier than editing a mocked object after creation. Especially in the case where you are required to mock multiple transformed cars.
However, there are 2 more "team" states we haven't added. lets to that now:
export class TransformedCarFactory extends Factory<TTransformedCar> {
constructor() {
super(TransformedCarSchema);
}
/**
* @description Sets the "doors" property state to 2
* @returns {this}
*/
coupe = (): this => {
this.state((schema): this => {
return schema.extend({ doors: z.literal(2) });
});
return this;
}
/**
* @description Sets the "doors" property state to 4
* @returns {this}
*/
sedan = (): this => {
this.state((schema): this => {
return schema.extend({ doors: z.literal(4) });
});
return this;
}
/**
* @description Sets the "team" property state to "Autobot"
* @returns {this}
*/
autobot = (): this => {
this.state((schema): this => {
return schema.extend({ doors: z.literal('Autobot') });
});
return this;
}
/**
* @description Sets the "doors" property state to "Decepticon"
* @returns {this}
*/
decepticon = (): this => {
this.state((schema): this => {
return schema.extend({ doors: z.literal('Decepticon') });
});
return this;
}
}
This is great! Our Factory now has all its "States" defined. Now let's review how we can chain Factory States.
Chaining Factory States
We now have a all 4 States (permutations) defined in our Factory.
These states can be appended to an instantiated Factory instructing it to generate a mocked Zod Schema model with specific permutations.
Let's try this out by generating a transformed Autobot car with 4 doors by chaining our Factory States like so:
import TransformedCarFactory from '@schwab/schema/factories/transformed/TransformedCarSchema';
const data = new TransformedCarFactory().autobot().sedan().mock();
Pretty simple right?!?
So when we define discrete modifications representing each potential data state our Zod Schema permits, you and your fellow developers will gain the ability to mock all model permutations effortlessly in a single line of code!
Thanks for reading & Happy Mocking!
Please contact @kyle-bluff_schwab for further assistance.