DEV Community

Cover image for Make a Factory: Creating Tests and Stories with Factory Functions in TypeScript and React
Matt Unhjem
Matt Unhjem

Posted on

Make a Factory: Creating Tests and Stories with Factory Functions in TypeScript and React

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);
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

And when called, it looks something like this:

const USER_ADMIN = createUser({ type: 'admin' })
// { name: 'George Rosin', type: 'admin', age: 25 }
Enter fullscreen mode Exit fullscreen mode

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" }),
  },
};
Enter fullscreen mode Exit fullscreen mode

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");
  });
});
Enter fullscreen mode Exit fullscreen mode

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()
});
Enter fullscreen mode Exit fullscreen mode

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' 
 *   }
 * }
 */
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
};
Enter fullscreen mode Exit fullscreen mode

🤯 Not bad!

Top comments (0)