DEV Community

Cover image for I made FactoryJS - a TypeScript version of factory_bot
ktmouk
ktmouk

Posted on

I made FactoryJS - a TypeScript version of factory_bot

Recently, I've been developing a web app in TypeScript and struggled with writing clean tests. This article introduces factory-js and how to use this library.


What is factory_bot?

factory_bot is a popular Ruby library for generating dummy models for tests.
Here is a simple example demonstrating how to use factory_bot:

def bio(user)
  "I'm #{user.name}"
end

# 1. Define the user factory
factory :user do
  name "john"
  email { "#{name}@example.com" }
end

describe "#bio" do
  it "returns bio" do
    # 2. build the user model with defined factory.
    user = build(:user, name: "james")

    # 3. Write tests using the generated model.
    expect(bio(user)).to eq("I'm james")
  end
end
Enter fullscreen mode Exit fullscreen mode

factory_bot has many advantages:

  • Time-saving: Writing multiple lines of code just to create models is tedious. factory_bot can create models with almost a single line of code.

  • Maintainability: Once a factory is defined, it can be reused across multiple tests. For instance, if you have to add new required columns to the user table, you just need to add new attributes to the user factory.

  • Readability: factory_bot offers useful features like traits and dependent attributes, enhancing the readability of tests.

In fact, I use factory_bot in many of my projects, and it has become an indispensable library for writing highly maintainable tests.


What about the TypeScript world?

These days, you can use well-crafted ORM libraries such as Prisma, Drizzle, MikroORM, etc.

However, what about a testing library similar to factory_bot for TypeScript?

I have recently been developing an API for a hobby project in TypeScript and was looking for a library like factory_bot.

Requirements

Ideally, I was looking for a factory library that met the following requirements.

  • Provide as much of the same features as factory_bot as possible (e.g. traits) while ensuring type safety.
  • Support multiple ORM libraries (e.g. Prisma, Drizzle).
  • Capable of creating models with complex relations: e.g. N:1, N:M, STI, self-relationships.
  • Supports type inference from objects, eliminating the need to define types manually.

After an extensive search, I could not find a factory library that met these requirements, so I decided to create my own.

Is it really possible?

First, I looked into what APIs the popular ORMs have, and found that the APIs differ from ORM to ORM.

For example, Prisma returns a plain object, while TypeORM returns an instance of the class. Additionally, Prisma supports connects, which allows related models to be created simultaneously—a feature that DrizzleORM does not have.

These differences were enough to make my brain explode 🤯. I was wondering which ORM to design factory-js based on.

Ultimately, I realized that an ORM essentially acts as a wrapper for an SQL builder and is based on the CREATE TABLE DDL. A relational database (RDB) table basically consists of two elements: columns and foreign keys (although there is also a primary key, it is not crucial for factory-js).

Consequently, I added two features to factory-js: props and vars. props corresponds to columns and vars corresponds foreign keys.


API design

As shown in the following picture, the factory-js API is almost identical to the factory_bot API and is intuitive.

Image description

props

props is a property of the object, corresponding to the columns of the table.

const userFactory = await factory
  .define({
    props: {
      firstName: () => faker.person.firstName(),
      lastName: () => faker.person.lastName()
    },
    vars: {},
  })

const user = userFactory.build();
console.log(user) // { firstName: "...", lastName: "..." }
Enter fullscreen mode Exit fullscreen mode

Each property value is wrapped in a function so that random generator libraries such as faker can be used.

It is also possible to override default property values in the method chain. This is useful when tests need to use fixed values rather than randomly generated values.

it("returns firstName", async () => {
  const user = userFactory
    .props({ firstName: () => "John" })
    .build()

  expect(user.firstName).toBe("John")
})
Enter fullscreen mode Exit fullscreen mode

vars

This is to define variable and similar to factory_bot's transient. In contrast to props, variables are not added as properties to the built object.

Unfortunately, unlike factory_bot, variables cannot be used in the .define method but are usable in the method chain after the factory has been defined.

const userFactory = await factory
  .define({
    props: {
      // Set a temporary value so that TypeScript can infer types
      name: () => "",
    },
    vars: {
      title: () => "Mr.",
    },
  })
  .props({
    // Then, use a variable to set the actual value.
    name: async ({ vars }) => `${await vars.title} John`,
  });

const user = await userFactory.build();
console.log(user); // { name: 'Mr. John' }
Enter fullscreen mode Exit fullscreen mode

This is due to a limitation of TypeScript: factory_bot does not have a type, but factory-js does, and factory-js infers types based on a object passed to .define. TypeScript tends to fail to infer the type if variables can be used in .define, because vars and props can have circular dependencies.

create

Factory-js also has .create method, which is similar to factory_bot. In contrast to .build, this method inserts records into the database, so you can write tests that require stored records.

To use create, you need to pass a function as the second argument that insert a record into the database with the ORM you are using.

const userFactory = await factory.define(
  {
    props: {
      firstName: () => "John",
      lastName: () => "Doe",
    },
    vars: {},
  },
  // This implementation depends on the ORM.
  async (props) => await db.user.create({ data: props }),
);

// Returns a saved object.
await userFactory.create();
Enter fullscreen mode Exit fullscreen mode

Examples

As explained in the previous section, we can define factories that have associations using only props and vars features. For example, the following factories have 1:1 relationships. A profile model belongs to a single user model.

const userFactory = factory.define(
  {
    props: {
      id: () => faker.number.int(),
      role: () => 'admin',
    },
    vars: {},
  },
  async (props) => await db.user.create({ data: props })
)

const profileFactory = factory.define(
  {
    props: {
      id: () => faker.number.int(),
      userId: () => faker.number.int(),
      firstName: () => faker.person.firstName(),
      lastName: () => faker.person.lastName()
    },
    vars: {
      user: async () => await userFactory.create(),
    },
  },
  async (props) => await db.profile.create({ data: props })
).props({
  userId: async ({ vars }) => (await vars.user).id
});
Enter fullscreen mode Exit fullscreen mode

In the example above, vars is used to create the associated user model. The process is as follows:

Image description

Let's compare with CREATE TABLE DDL. Where props corresponds to columns and vars to foreign keys.

Image description

Is vars really required?

In fact, the use of vars is not mandatory in the previous example. It is also possible to define a profile factory without the use of vars, as shown below:

const profileFactory = factory.define(
  {
    props: {
      id: () => faker.number.int(),
      // create user model and set its id.
      userId: async () => await userFactory.create().id,
      firstName: () => faker.person.firstName(),
      lastName: () => faker.person.lastName()
    },
    vars: {},
  },
  async (props) => await db.profile.create({ data: props })
)
Enter fullscreen mode Exit fullscreen mode

However, RDB tables sometimes have multiple foreign keys, for example, the following user table has composite primary key: firstName and lastName. In this case, we have to use vars to refer to the same user.

const userFactory = factory.define(
  {
    props: {
      id: () => faker.number.int(),
      firstName: () => faker.person.firstName(),
      lastName: () => faker.person.lastName()
    },
    vars: {},
  },
  async (props) => await db.user.create({ data: props })
)

// BAD
const profileFactory = factory.define(
  {
    props: {
      id: () => faker.number.int(),
      // ❌ The user relationship cannot be created correctly
      //    because firstName and lastName
      //    refer to different users.
      firstName: () => (await userFactory.create()).firstName,
      lastName: () => (await userFactory.create()).lastName,
    },
    vars: {},
  },
  async (props) => await db.profile.create({ data: props })
)

// GOOD
const profileFactory = factory.define(
  {
    props: {
      id: () => faker.number.int(),
      firstName: () => '',
      lastName: () => ''
    },
    vars: {
      user: async () => await userFactory.create(),
    },
  },
  async (props) => await db.profile.create({ data: props })
).props({
  // ⭕ This works correctly because
  //    these columns refer to the same user.
  firstName: async ({ vars }) => (await vars.user).firstName,
  lastName: async ({ vars }) => (await vars.user).lastName,
});
Enter fullscreen mode Exit fullscreen mode

Since it is tedious to use vars and props depending on whether it is a single or multiple foreign key, it is generally recommended to use vars.

How does it work inside?

Factory-js uses two simple tricks internally to implement vars and props: the Proxy and a simple memo function.

Proxy enable you to intercept the property access and customize returns values. Proxy is also used in Vue3.

The memo function is a small closure for caching calculated values so that vars returns the same value no matter how many times it is called.

export const memo = <V>(func: (...args: any) => V) => {
  let evaluated = false;
  let value: V | undefined = undefined;

  return (...args: A) => {
    // If this is the first call,
    // Execute the `func` and stores the result to `value`.
    if (!evaluated) value = func(...args);
    evaluated = true;
    return value;
  };
};
Enter fullscreen mode Exit fullscreen mode

As you can see from bundlephobia, factory-js is currently a very small package because it contains very simple logic.

Image description


Other features

Other than vars and props, factory-js has more powerful features that inspired by factory_bot.

trait

I think the most useful feature of factory_bot is trait, and factory-js also supports trait.

trait enable you to manage multiple props, vars with a key and apply them with .use.

const userFactory = await factory
  .define({
    props: {
      role: () => "guest",
      isAdmin: () => false,
    },
    vars: {},
  })
  .traits({
    admin: {
      props: {
        role: () => "admin",
        isAdmin: () => true
      }
    }
  })

const user = await userFactory.use((t) => t.admin).build();
console.log(user) // { role: "admin", isAdmin: "true" } 
Enter fullscreen mode Exit fullscreen mode

dependent props / vars

This is the most powerful feature of factory-js. Thanks to Proxy, you can define properties that depend on other properties.

The following example compute the fullName value from firstName and lastName.

const user = await factory
  .define({
    props: {
      firstName: () => "John",
      lastName: () => "Doe",
      fullName: () => "",
    },
    vars: {},
  })
  .props({
    fullName: async ({ props }) =>
      `${await props.firstName} ${await props.lastName}`,
  });

const user = await userFactory
  // dependent props works correctly
  // even if the prop value is changed later.
  .props({ firstName: () => 'Tom' })
  .build();

// { firstName: 'Tom', lastName: 'Doe', fullName: 'Tom Doe' }
console.log(user); 
Enter fullscreen mode Exit fullscreen mode

Are there really cases where this functionality is needed? In fact, I have experienced cases where I want to use dependent props to avoid validation errors.

For example, suppose an event log model has startTime and endTime columns and this model has a validation that checks if startTime is before endTime. In this case, you can use this feature to set endTime to one second before startTime to avoid validation errors.


Prisma Plugin

As new features are added to the product, the database tables grow daily. I didn't want to define factories myself each time a new table was added.

So I created a prisma plugin for factory-js. The plugin generates factories based on the prisma schema file.

Image description

Factory-js only has a prisma plugin at the moment, but I think it would be possible to implement plugins for other ORMs such as DrizzleORM.


Conclusion

So far I'm very happy with factory-js and am able to write clean tests with it. If you are interested in factory-js, you can try factory-js as it is open source.

GitHub logo factory-js / factory-js

🏭 The ORM-agnostic object generator for testing

factory-js

🏭 The ORM-agnostic object generator for testing

coverage npm bundle size

🚀 Features

  • 🔌  ORM Agnostic - Can be used with Prisma and more!
  • 🥰  Simple API - Generates objects with a simple, chainable interface.
  • ✅  Fully Typed - Maximize the benefits of TypeScript.

📦 Install

Please refer to the section according to the ORM you want to use.

⭐️ Introduction

Factory-js is a dummy object generator for testing.
The goal is to save developers time and to make tests easier to write and read.

For example, the following code tests the function that returns whether the user is an admin.

// user.test.ts
describe("when a user is admin", () => {
  it("returns true", async () => {
    const user = await db.user.create({
      data: {
        name: "John",
        role: "ADMIN",
      }
Enter fullscreen mode Exit fullscreen mode

I hope this article will interest and help you! 💖

Top comments (0)