Writing reliable tests in TypeScript can be a challenging task. It often involves complex tasks like mocking modules, stubbing API responses, and spying on functions.
However, by using factory functions, we can achieve consistency, readability, and type safety in our TypeScript and React tests.
In this article, I'll demonstrate how I use a simple creator function to generate factories and speed up the test writing and storybook creation processes.
Previous Approach: Lots of ad-hoc stuff
In the past, I relied on building numerous small factory functions for usage in Storybook or component tests. While this approach allowed me to build typed objects for tests and Storybook stories, it also introduced a considerable amount of code to review and maintain.
With each new test scenario, I found myself defining new functions and types, which increased the complexity and made it harder to encourage consistency across our codebase.
Looking for a Solution: npm packages
In search of a solution, I explored various TypeScript libraries that could handle factory logic. One such library was fishery, which is really well designed.
However, I realized that it offered more functionality than I needed for my use case. So I decided to create my own custom factory function that suited our specific requirements.
Introducing makeFactory
: the factory creator
This factory creator function, called makeFactory
, returns a factory function, such as createUser
, which can be invoked to generate new objects.
It accepts a default object argument that can be overridden, providing flexibility for different scenarios. For instance, we can explicitly define the object's type using a generic with makeFactory<IUser>({ name: 'Jane', age: 40 })
.
Here's the implementation of the makeFactory function:
import merge from "lodash/merge";
import cloneDeep from "lodash/cloneDeep";
export function makeFactory<T>(
defaultArgs: T
): (args?: RecursivePartial<T>) => T {
return (args = {}) => merge({}, cloneDeep(defaultArgs), args);
}
By utilizing this pattern, we can create a function like createUser
that generates an object with the IUser
type:
const createUser = makeFactory<IUser>({
name: "George Rosin",
type: "customer",
age: 25,
});
And when called, it looks something like this:
const USER_ADMIN = createUser({ type: 'admin' })
// { name: 'George Rosin', type: 'admin', age: 25 }
The makeFactory
function's argument defines the default properties assigned to the object returned from createUser
. Any properties passed to the createUser
function will override the default properties, allowing for easy definition of different data scenarios for tests.
Usage in Storybook: speed things up!
This factory function pattern not only simplifies object creation but also enhances readability. In the context of Storybook stories, we can define different scenarios with ease. Here's a quick example:
// UserLabel.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import UserLabel, { IUserLabelProps } from "./UserLabel";
import createUser from "./createUser";
const meta: Meta<typeof UserLabel> = {
title: "UserLabel",
component: UserLabel,
};
export default meta;
type Story = StoryObj<typeof UserLabel>;
export const Admin: Story = {
args: {
user: createUser({ type: "admin" }),
},
};
export const User: Story = {
args: {
user: createUser({ type: "customer" }),
},
};
By utilizing the factory function createUser
, we can easily define different scenarios while maintaining readability in our Storybook stories.
Usage in Component Tests: faster tests!
The power of factory functions extends to React component tests as well. Just like in the Storybook stories, we can explicitly define the differences between scenarios. Here's an example:
// UserLabel.spec.tsx
import { render, screen } from "@testing-library/react";
import UserLabel, { IUserLabelProps } from "./UserLabel";
describe("<UserLabel />", () => {
it("renders an 'admin' label", () => {
const USER_ADMIN = createUser({ type: "admin" });
render(<UserLabel user={USER_ADMIN} />);
expect(screen.getByTestId("UserLabel")).toContain("George John - Admin");
});
it("renders a 'customer' label", () => {
const USER_CUSTOMER = createUser({ type: "customer" });
render(<UserLabel user={USER_CUSTOMER} />);
expect(screen.getByTestId("UserLabel")).toContain("George John - Customer");
});
});
With this approach, even when dealing with complex API models or types, testing becomes much simpler and more efficient.
Going further: nested functions
When dealing with complex interface types with nested objects, it can also be useful to use factories inside of other factories. Here's an example of that pattern:
interface IAddress {
addressLine1: string;
state: string;
city: string;
postalCode: string;
}
interface IBusiness {
id: string;
name: string;
address: IAddress;
}
const createAddress = makeFactory<IAddress>({
addressLine1: '123 Broadway',
state: 'NY',
city: 'Chester',
postalCode: '10920'
});
const createBusiness = makeFactory<IBusiness>({
id: '123',
name: 'Pizza Inc.',
address: createAddress()
});
While the above example is a bit simple, the modularity of the functions creates a reusability that can be really helpful.
Additionally, because of lodash
's merge
function, we can pass in partial values and override nested object properties, like this:
const BUSINESS_PA = createBusiness({ address: { state: 'PA' }})
/**
* {
* id: '123',
* name: 'Pizza Inc.',
* address: {
* addressLine1: '123 Broadway',
* state: 'PA',
* city: 'Chester',
* postalCode: '10920'
* }
* }
*/
In conclusion: make a factory 🏭
By leveraging factory functions, we can create reliable tests with consistency, readability, and type safety in TypeScript and React. The makeFactory
pattern provides a flexible and maintainable solution for generating typed objects that will speed up your developer workflow for writing tests and Storybook stories.
Bonus: Use ChatGPT to generate default values
Here is a prompt I have found useful for generating realistic default values depending on the property name:
🤖 For the following code, create a typed object for each interface with typed values that are realistic based on the property names.
interface IAddress {
addressLine1: string;
state: string;
city: string;
postalCode: string;
}
interface IBusiness {
id: string;
name: string;
address: IAddress;
}
This returned the following answer from ChatGPT:
Sure! Here are typed objects for each interface with realistic property values:
const address: IAddress = {
addressLine1: "123 Main Street",
state: "California",
city: "Los Angeles",
postalCode: "90001",
};
const business: IBusiness = {
id: "123456789",
name: "ABC Company",
address: address,
};
🤯 Not bad!
Top comments (0)