Test Data Factories in JavaScript: Patterns, Libraries, and TypeScript

Test Data Factories in JavaScript: Patterns, Libraries, and TypeScript

Test data factories eliminate the boilerplate of constructing test objects. Instead of manually building up every field of every object in every test, you define a factory once — with sensible defaults — and tests only specify what matters.

This guide covers factory patterns in JavaScript from scratch, then looks at dedicated libraries for larger projects.

The Factory Pattern

At its simplest, a factory is a function with defaults that accepts overrides:

function createUser(overrides = {}) {
  return {
    id: crypto.randomUUID(),
    email: 'user@example.com',
    name: 'Test User',
    role: 'user',
    isActive: true,
    createdAt: new Date(),
    ...overrides,
  };
}

A test for role-based access only specifies role:

test('admin can access user management', () => {
  const admin = createUser({ role: 'admin' });
  expect(hasAccess(admin, '/admin/users')).toBe(true);
});

test('regular user cannot access user management', () => {
  const user = createUser(); // role defaults to 'user'
  expect(hasAccess(user, '/admin/users')).toBe(false);
});

Everything else is irrelevant and gets a default. The test communicates intent directly.

Adding Faker for Realistic Values

Hardcoded defaults like 'user@example.com' cause collisions in parallel tests and database unique constraint violations. Use Faker.js for uniqueness:

const { faker } = require('@faker-js/faker');

function createUser(overrides = {}) {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    role: 'user',
    isActive: true,
    createdAt: faker.date.past(),
    ...overrides,
  };
}

Now every createUser() call produces different values — no collisions.

Building vs. Persisting

Tests that don't need a database are faster. Separate build (in-memory) from create (persisted):

const { faker } = require('@faker-js/faker');
const db = require('./db');

const UserFactory = {
  build(overrides = {}) {
    return {
      id: faker.string.uuid(),
      email: faker.internet.email(),
      name: faker.person.fullName(),
      role: 'user',
      isActive: true,
      createdAt: new Date(),
      ...overrides,
    };
  },

  async create(overrides = {}) {
    const data = this.build(overrides);
    return db('users').insert(data).returning('*').then(rows => rows[0]);
  },

  async createMany(n, overrides = {}) {
    return Promise.all(
      Array.from({ length: n }, () => this.create(overrides))
    );
  },
};

module.exports = UserFactory;

Usage:

// Unit test — no DB
const user = UserFactory.build({ role: 'admin' });

// Integration test — writes to DB
const user = await UserFactory.create({ role: 'admin' });

// Create 10 users
const users = await UserFactory.createMany(10);

Traits

Traits are named configurations for common states:

const UserFactory = {
  build(overrides = {}) {
    return {
      id: faker.string.uuid(),
      email: faker.internet.email(),
      name: faker.person.fullName(),
      role: 'user',
      isActive: true,
      emailVerified: true,
      subscriptionTier: 'free',
      ...overrides,
    };
  },

  traits: {
    admin: {
      role: 'admin',
      isStaff: true,
    },
    inactive: {
      isActive: false,
      deactivatedAt: new Date(),
    },
    pro: {
      subscriptionTier: 'pro',
      subscriptionExpiresAt: faker.date.future(),
    },
    unverified: {
      emailVerified: false,
      verificationToken: faker.string.uuid(),
    },
  },

  with(...traitNames) {
    const traitOverrides = traitNames.reduce(
      (acc, name) => ({ ...acc, ...this.traits[name] }),
      {}
    );
    return (overrides = {}) => this.build({ ...traitOverrides, ...overrides });
  },
};

// Usage
const admin = UserFactory.with('admin')();
const proUser = UserFactory.with('pro')();
const inactiveAdmin = UserFactory.with('inactive', 'admin')();
const customAdmin = UserFactory.with('admin')({ name: 'Alice' });

Factories for related objects should accept parent objects or create them automatically:

const OrderFactory = {
  async create(overrides = {}) {
    // Auto-create user if not provided
    const user = overrides.userId
      ? { id: overrides.userId }
      : await UserFactory.create();

    const data = {
      id: faker.string.uuid(),
      userId: user.id,
      status: 'pending',
      total: faker.number.float({ min: 10, max: 500, precision: 0.01 }),
      createdAt: new Date(),
      ...overrides,
    };

    const [order] = await db('orders').insert(data).returning('*');
    return order;
  },
};

// Creates order + user automatically
const order = await OrderFactory.create();

// Reuse an existing user
const user = await UserFactory.create();
const order1 = await OrderFactory.create({ userId: user.id });
const order2 = await OrderFactory.create({ userId: user.id });

TypeScript Factories

TypeScript adds type safety to factories:

import { faker } from '@faker-js/faker';

type UserRole = 'admin' | 'editor' | 'user';

interface User {
  id: string;
  email: string;
  name: string;
  role: UserRole;
  isActive: boolean;
  createdAt: Date;
}

function createUser(overrides: Partial<User> = {}): User {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    role: 'user',
    isActive: true,
    createdAt: new Date(),
    ...overrides,
  };
}

// TypeScript catches bad overrides at compile time
const user = createUser({ role: 'superadmin' }); // Error: not assignable to UserRole

Generic Factory Builder

type FactoryFn<T> = (overrides?: Partial<T>) => T;

function defineFactory<T>(defaults: () => T): FactoryFn<T> {
  return (overrides = {}) => ({ ...defaults(), ...overrides });
}

const createUser = defineFactory<User>(() => ({
  id: faker.string.uuid(),
  email: faker.internet.email(),
  name: faker.person.fullName(),
  role: 'user' as UserRole,
  isActive: true,
  createdAt: new Date(),
}));

const createProduct = defineFactory<Product>(() => ({
  id: faker.string.uuid(),
  name: faker.commerce.productName(),
  price: parseFloat(faker.commerce.price()),
  stock: faker.number.int({ min: 0, max: 1000 }),
}));

Fishery: TypeScript-First Factories

For TypeScript projects with complex models, Fishery provides a more structured factory API:

npm install --save-dev fishery
import { Factory } from 'fishery';
import { faker } from '@faker-js/faker';
import { User, UserRole } from '../src/types';
import { db } from '../src/db';

const userFactory = Factory.define<User>(({ sequence, params }) => ({
  id: faker.string.uuid(),
  email: faker.internet.email(),
  name: faker.person.fullName(),
  role: 'user' as UserRole,
  isActive: true,
  sequenceNumber: sequence, // built-in unique counter
  createdAt: new Date(),
}));

// Usage
const user = userFactory.build();
const admin = userFactory.build({ role: 'admin' });
const users = userFactory.buildList(5);

Fishery with Async (DB persistence)

const userFactory = Factory.define<User, UserTransient, UserDB>(
  ({ onCreate }) => {
    // onCreate runs when .create() is called
    onCreate(async (user) => {
      const [saved] = await db('users').insert(user).returning('*');
      return saved;
    });

    return {
      id: faker.string.uuid(),
      email: faker.internet.email(),
      name: faker.person.fullName(),
      role: 'user' as UserRole,
      isActive: true,
      createdAt: new Date(),
    };
  }
);

// DB write
const user = await userFactory.create({ role: 'admin' });
const users = await userFactory.createList(5);

Fishery Traits

const userFactory = Factory.define<User>(({ transientParams }) => {
  return {
    id: faker.string.uuid(),
    email: faker.internet.email(),
    name: faker.person.fullName(),
    role: 'user' as UserRole,
    isActive: true,
    createdAt: new Date(),
  };
});

// Define trait as a separate factory
const adminFactory = userFactory.params({ role: 'admin', isStaff: true });
const inactiveFactory = userFactory.params({ isActive: false });

// Usage
const admin = adminFactory.build();
const inactiveAdmin = adminFactory.params({ isActive: false }).build();

Rosie: Another JavaScript Factory Library

npm install --save-dev rosie
const { Factory } = require('rosie');
const { faker } = require('@faker-js/faker');

Factory.define('user')
  .attr('id', () => faker.string.uuid())
  .attr('email', () => faker.internet.email())
  .attr('name', () => faker.person.fullName())
  .attr('role', 'user')
  .attr('isActive', true)
  .option('admin', false)
  .after((user, options) => {
    if (options.admin) {
      user.role = 'admin';
    }
    return user;
  });

// Usage
const user = Factory.build('user');
const admin = Factory.build('user', {}, { admin: true });
const users = Factory.buildList('user', 5);

Organizing Factories

Keep factories in a dedicated directory:

src/
tests/
  factories/
    index.js          // re-exports everything
    user.factory.js
    order.factory.js
    product.factory.js
  integration/
    orders.test.js
  unit/
    pricing.test.js
// tests/factories/index.js
const { UserFactory } = require('./user.factory');
const { OrderFactory } = require('./order.factory');
const { ProductFactory } = require('./product.factory');

module.exports = { UserFactory, OrderFactory, ProductFactory };

// In tests:
const { UserFactory, OrderFactory } = require('../factories');

Cleanup Strategies

Transaction Rollback (Jest)

// tests/helpers/db.js
const db = require('../../src/db');

let trx;

beforeEach(async () => {
  trx = await db.transaction();
  db.transacting = trx;
});

afterEach(async () => {
  await trx.rollback();
});

Truncate After Suite

afterAll(async () => {
  await db.raw(
    'TRUNCATE TABLE order_items, orders, products, users RESTART IDENTITY CASCADE'
  );
  await db.destroy();
});

Common Pitfalls

Mutating Factory Output

// Dangerous — mutating the returned object can affect other tests
const user = UserFactory.build();
user.role = 'admin'; // Don't do this

// Right — use the factory for the correct variant
const admin = UserFactory.build({ role: 'admin' });

Sharing Persisted Objects Between Tests

// Dangerous — Test B sees mutations from Test A
let user;
beforeAll(async () => {
  user = await UserFactory.create();
});

test('A', async () => { user.name = 'Changed'; await user.save(); });
test('B', async () => { expect(user.name).toBe('Test User'); }); // fails

Fix: use beforeEach or transaction rollback.

Factories That Are Too Clever

// Overcomplicated — hard to understand and debug
function createOrderWithUser(traitNames = []) {
  const base = traitNames.reduce(
    (acc, t) => deepMerge(acc, traits[t]),
    defaultOrder()
  );
  return applyPostProcessors(base, traitNames);
}

// Simple is better — explicit is better than magic
const order = createOrder({ status: 'cancelled', userId: user.id });

Summary

JavaScript test data factories follow a simple progression:

  1. Start with plain factory functionscreateUser(overrides) gets you 80% of the way
  2. Add Faker.js for unique, realistic values
  3. Separate build from create for unit vs. integration test speed
  4. Add traits for named states (admin, inactive, expired)
  5. Adopt Fishery or Rosie when you need TypeScript types, complex relationships, or team conventions

The factory pattern is the highest-leverage testing investment you can make. One well-written factory eliminates setup boilerplate from dozens of tests.

Read more