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 fisheryBasic 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 databaseIntegration 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.comSequences 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 shapessequenceprovides an auto-incrementing integer for unique field values.build()returns a plain object;.create()calls theonCreatecallback 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