Fishery: Type-Safe Test Factories for TypeScript

Fishery: Type-Safe Test Factories for TypeScript

Fishery is a TypeScript library for creating test objects using factories. It replaces hand-rolled helper functions and fills the gap left by factory-girl (which doesn't have TypeScript-first design). Every factory in Fishery is fully typed — if you change your model interface, TypeScript catches factories that produce incorrect shapes.

Installation

npm install --save-dev fishery

Basic Factory

import { Factory } from 'fishery';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'viewer';
  createdAt: Date;
}

const userFactory = Factory.define<User>(({ sequence }) => ({
  id: sequence,
  name: 'Test User',
  email: `user-${sequence}@example.com`,
  role: 'viewer',
  createdAt: new Date('2024-01-01'),
}));

sequence is an auto-incrementing integer that starts at 1 and increases for each call to build(). Use it to ensure uniqueness for email addresses, usernames, or IDs.

Building Objects

// Single object with defaults
const user = userFactory.build();
// { id: 1, name: 'Test User', email: 'user-1@example.com', role: 'viewer', ... }

// Override specific fields
const admin = userFactory.build({ role: 'admin', name: 'Alice' });

// Multiple objects
const users = userFactory.buildList(3);
// Returns array of 3 users with sequence ids 2, 3, 4

// Batch with overrides
const viewers = userFactory.buildList(2, { role: 'viewer' });

build() and buildList() return plain objects — no database interaction.

Traits

Traits are named configurations that encode common test scenarios:

const userFactory = Factory.define<User>(({ sequence, traits }) => ({
  id: sequence,
  name: 'Test User',
  email: `user-${sequence}@example.com`,
  role: 'viewer' as const,
  createdAt: new Date('2024-01-01'),
  ...traits,
}));

userFactory.withTraits({
  admin: () => ({ role: 'admin' as const }),
  recentSignup: () => ({ createdAt: new Date() }),
  withCustomEmail: (email: string) => ({ email }),
});

Wait — Fishery uses a slightly different trait API. The correct pattern:

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'viewer';
  active: boolean;
}

const userFactory = Factory.define<User>(({ sequence, transientParams }) => {
  const { isAdmin = false } = transientParams;
  return {
    id: sequence,
    name: 'Test User',
    email: `user-${sequence}@example.com`,
    role: isAdmin ? 'admin' : 'viewer',
    active: true,
  };
});

// Using transient parameters as a trait mechanism
const admin = userFactory.build({}, { transient: { isAdmin: true } });

For clean trait syntax, Fishery recommends extending the factory:

const adminUserFactory = userFactory.params({ role: 'admin' });
const inactiveUserFactory = userFactory.params({ active: false });

// Use the derived factory
const admin = adminUserFactory.build();
const inactiveAdmin = adminUserFactory.params({ active: false }).build();

.params() creates a new factory with merged overrides — it's the idiomatic "trait" in Fishery.

Nested Objects

interface Post {
  id: number;
  title: string;
  author: User;
  publishedAt: Date | null;
}

const postFactory = Factory.define<Post>(({ sequence }) => ({
  id: sequence,
  title: `Post ${sequence}`,
  author: userFactory.build(),
  publishedAt: new Date('2024-06-01'),
}));

const draftPostFactory = postFactory.params({ publishedAt: null });

// Override nested fields with explicit build
const postByAdmin = postFactory.build({
  author: userFactory.build({ role: 'admin' }),
});

Async Factories

When your factory needs to create database records or do async work:

import { Factory } from 'fishery';
import { createUser } from '../db';

const userFactory = Factory.define<User>(({ sequence }) => ({
  id: sequence,
  name: 'Test User',
  email: `user-${sequence}@example.com`,
  role: 'viewer' as const,
  createdAt: new Date(),
}));

// Override the `create` method to persist
userFactory.withDecorator(async (user) => {
  return createUser(user);
});

Actually, Fishery's async persistence uses the create callback in Factory.define:

const persistedUserFactory = Factory.define<User, string>(({ sequence, onCreate }) => {
  onCreate(async (user) => {
    // Called when you use .create() instead of .build()
    const created = await db.users.insert(user);
    return { ...user, id: created.id };
  });

  return {
    id: sequence,
    name: 'Test User',
    email: `user-${sequence}@example.com`,
    role: 'viewer' as const,
    createdAt: new Date(),
  };
});

// In tests:
const user = await persistedUserFactory.create();  // hits the database
const plainUser = persistedUserFactory.build();     // no database

Integration with Prisma

A common pattern for Next.js / Prisma projects:

import { Factory } from 'fishery';
import { prisma } from '../lib/prisma';
import type { User } from '@prisma/client';

const userFactory = Factory.define<User>(({ sequence, onCreate }) => {
  onCreate((user) =>
    prisma.user.create({
      data: {
        name: user.name,
        email: user.email,
        role: user.role,
      },
    })
  );

  return {
    id: `user-${sequence}`,
    name: 'Test User',
    email: `user-${sequence}@example.com`,
    role: 'VIEWER',
    createdAt: new Date(),
    updatedAt: new Date(),
  };
});

// In a test (e.g., Vitest + Prisma):
describe('UserService', () => {
  it('finds user by email', async () => {
    const user = await userFactory.create({ email: 'alice@example.com' });
    const found = await userService.findByEmail('alice@example.com');
    expect(found?.id).toBe(user.id);
  });
});

Sequences and Uniqueness

sequence solves the unique-email-per-test problem without random values (which make test failures harder to reproduce):

const userFactory = Factory.define<User>(({ sequence }) => ({
  id: sequence,
  email: `user-${sequence}@example.com`,
  username: `user${sequence}`,
}));

userFactory.build(); // email: user-1@example.com
userFactory.build(); // email: user-2@example.com
userFactory.build(); // email: user-3@example.com

Sequences are per-factory and reset between test files (each test file gets a fresh module scope).

Type Safety

The main advantage over factory-girl or hand-rolled helpers:

interface Order {
  id: number;
  amount: number;  // cents
  currency: 'usd' | 'eur';
  status: 'pending' | 'paid' | 'refunded';
}

const orderFactory = Factory.define<Order>(({ sequence }) => ({
  id: sequence,
  amount: 1000,
  currency: 'usd',
  status: 'pending',
}));

// TypeScript error: 'gbp' is not assignable to 'usd' | 'eur'
orderFactory.build({ currency: 'gbp' });

// TypeScript error: 'processing' is not assignable to status type
orderFactory.build({ status: 'processing' });

When you rename a field in the interface, every factory that references it breaks at compile time — not at runtime when tests happen to cover that field.

Key Points

  • Factory.define<T>() creates a fully typed factory — TypeScript catches invalid override shapes
  • sequence provides an auto-incrementing integer for unique field values
  • .build() returns a plain object; .create() calls the onCreate callback for database persistence
  • .params() creates derived factories — use these as traits for common configurations
  • Nested objects are built by calling other factories inside Factory.define
  • Works with Vitest, Jest, Mocha, and any TypeScript test runner

Read more