Factories

Generate realistic, repeatable test data with a single line of code using defineFactory.

Before you begin: Factories work best alongside setupTestDb. Make sure you have read the Getting Started guide before continuing.

Introduction

Almost every test that touches a database needs some data to work with. Without a factory, you end up repeating the same Model.create({...}) calls, with all their required fields, in every single test. When your schema changes, you have to update those calls everywhere.

// Without factories, every test that needs a user looks like this:
test('user can create a post', async () => {
  const user = await User.create({
    name: 'Test User',
    email: 'test_' + crypto.randomUUID() + '@example.com',
    password: 'secret123',
    role: 'user',
    emailVerifiedAt: new Date(),
    createdAt: new Date()
  })

  // ... rest of test
})

// Repeat that block in 20 tests and you have 20 places to update
// when you add a required column to the users table.

A factory centralises the default data for a model in one place. Tests ask the factory for an object and only specify the fields that are actually relevant to the scenario being tested:

// With a factory, every test is one line:
test('user can create a post', async () => {
  const user = await UserFactory.create()
  // ... rest of test
})

defineFactory is the only function you need. It returns a factory object with four methods, make, makeMany, create, and createMany, plus a state method for creating named variants.

Defining a Factory

Call defineFactory with two arguments:

  • A defaults function that returns a fresh object with default values every time it is called. Using a function (rather than a plain object) ensures that random values like crypto.randomUUID() are unique for each generated record.
  • Optionally, the model class to use when persisting records. Without a model, only make and makeMany are available.
tests/factories/user_factory.ts
import { defineFactory } from '@tekir/testing'
import { User } from '~/models/user'

export const UserFactory = defineFactory<{
  name: string
  email: string
  password: string
  role: string
  emailVerifiedAt: Date | null
}>(
  // The defaults function is called fresh for every make() / create() call,
  // so random values are unique across calls.
  () => ({
    name: 'Test User',
    email: `${crypto.randomUUID()}@example.com`,
    password: 'secret123',
    role: 'user',
    emailVerifiedAt: new Date()
  }),
  // Passing the model as the second argument enables create() / createMany()
  User,
)

The type parameter on defineFactory<T> describes the shape of the generated object. This gives you full TypeScript autocompletion on the factory methods and on override objects. You can define it inline or import a type from your model:

tests/factories/post_factory.ts
import { defineFactory } from '@tekir/testing'
import { Post } from '~/models/post'

// You can inline the type if you prefer not to write a separate interface
export const PostFactory = defineFactory<{
  title: string
  body: string
  published: boolean
  userId: number
}>(
  () => ({
    title: `Post ${Math.random().toString(36).slice(2, 8)}`,
    body: 'This is the body of the post. It has enough words to pass validation.',
    published: false,
    userId: 1
  }),
  Post,
)

Generating Objects

make() and makeMany() generate plain JavaScript objects without touching the database. They are ideal for unit tests that exercise logic without any I/O:

make()

import { PostFactory } from '~/tests/factories/post_factory'

// make() returns a plain object: nothing is written to the database
const post = PostFactory.make()
console.log(post)
// {
//   title: 'Post x7k2mq',
//   body: 'This is the body of the post...',
//   published: false,
//   userId: 1,
// }

makeMany(count)

// makeMany(count) returns an array of plain objects
const posts = PostFactory.makeMany(5)
console.log(posts.length) // 5

// Each object is independently generated: random values differ across items
console.log(posts[0].title) // 'Post x7k2mq'
console.log(posts[1].title) // 'Post p3wn9a'

Because the defaults function is called separately for each item, random fields like email or title will be unique across all generated objects.

tests/unit/user.test.ts
import { test, expect } from '@tekir/testing'
import { UserFactory } from '~/tests/factories/user_factory'

test('user serialises to JSON correctly', () => {
  // make() is perfect for pure unit tests that do not touch the database
  const user = UserFactory.make()

  expect(user.name).toBe('Test User')
  expect(user.email).toContain('@example.com')
  expect(user.role).toBe('user')
})

Persisting to the Database

create() and createMany() generate an object and persist it by calling Model.create() (or Model.createMany()) on the model you passed to defineFactory. They return whatever the model method returns, typically the fully hydrated record with its auto-assigned id and any computed columns.

Always combine them with setupTestDb so the database is clean before each test:

create()

tests/unit/user.test.ts
import { test, expect } from '@tekir/testing'
import { setupTestDb } from '@tekir/testing'
import { UserFactory } from '~/tests/factories/user_factory'
import { User } from '~/models/user'

let cleanup: () => void
beforeEach(() => { cleanup = setupTestDb([User]) })
afterEach(() => cleanup())

test('create() persists a user to the database', async () => {
  const user = await UserFactory.create()

  // user is the object returned by User.create(): it has an id
  expect(user.id).toBeDefined()
  expect(user.email).toContain('@example.com')

  // Verify it was actually written
  const found = await User.find(user.id)
  expect(found).not.toBeNull()
})

createMany(count)

test('createMany() persists multiple records', async () => {
  const users = await UserFactory.createMany(3)

  expect(users).toHaveLength(3)

  // Every record has its own unique id
  const ids = users.map((u) => u.id)
  expect(new Set(ids).size).toBe(3)   // all ids are unique

  // All three are in the database
  const count = await User.query().count()
  expect(count).toBe(3)
})

If you call create() on a factory that was defined without a model, it will throw a descriptive error at runtime: Factory has no model. Pass a model as second argument to defineFactory.

Overrides

All four methods accept an optional overrides object. Any field you supply will replace the corresponding default; fields you omit will use the defaults function as normal. This is how you write a test that cares about one specific field without having to construct the entire object by hand:

// Pass an overrides object to make() or create() to change specific fields.
// Any field you do not override is filled in by the defaults function.

const adminUser = await UserFactory.create({ role: 'admin' })
expect(adminUser.role).toBe('admin')

const unverifiedUser = await UserFactory.create({ emailVerifiedAt: null })
expect(unverifiedUser.emailVerifiedAt).toBeNull()

// Override multiple fields at once
const customUser = UserFactory.make({
  name: 'Alice',
  email: '[email protected]',
  role: 'editor'
})

makeMany and createMany apply the same overrides to every generated item:

// makeMany and createMany also accept overrides,
// the same overrides apply to every generated object

const drafts = await PostFactory.createMany(5, { published: false })
drafts.forEach((p) => expect(p.published).toBe(false))

const published = await PostFactory.createMany(3, { published: true })
published.forEach((p) => expect(p.published).toBe(true))

States

A state is a named variant of a factory that has certain defaults pre-applied. States are created with factory.state(overrides), which returns a brand new factory; the original factory is never modified.

Use states when you find yourself passing the same overrides in many places. Instead of writing UserFactory.create({ role: 'admin' }) in every admin test, define an AdminFactory once:

tests/factories/user_factory.ts
import { defineFactory } from '@tekir/testing'
import { User } from '~/models/user'

const UserFactory = defineFactory<{
  name: string
  email: string
  password: string
  role: string
  emailVerifiedAt: Date | null
}>(
  () => ({
    name: 'Test User',
    email: `${crypto.randomUUID()}@example.com`,
    password: 'secret123',
    role: 'user',
    emailVerifiedAt: new Date()
  }),
  User,
)

// state() returns a NEW factory whose defaults are merged with the state overrides.
// The original UserFactory is unchanged.
export const AdminFactory = UserFactory.state({ role: 'admin' })

export const UnverifiedUserFactory = UserFactory.state({ emailVerifiedAt: null })

export const EditorFactory = UserFactory.state({ role: 'editor' })
import { AdminFactory, UnverifiedUserFactory } from '~/tests/factories/user_factory'

test('admin can delete any post', async () => {
  const admin = await AdminFactory.create()
  // admin.role === 'admin'
})

test('unverified user cannot log in', async () => {
  const user = await UnverifiedUserFactory.create()
  // user.emailVerifiedAt === null
})

// States can be further overridden at call time
const admin = await AdminFactory.create({ name: 'Super Admin' })
// admin.role === 'admin', admin.name === 'Super Admin'

When you call a method on a state factory you can still pass overrides, they are merged on top of the state's defaults, which are themselves merged on top of the base factory's defaults.

If the state overrides need to be computed dynamically (for example, they contain a fresh Date), pass a function instead of a plain object:

tests/factories/post_factory.ts
// (e.g., they contain random values).

export const PublishedPostFactory = PostFactory.state(() => ({
  published: true,
  publishedAt: new Date()
}))

Integration with Models

Because create() delegates to Model.create(), factory-created records go through the same lifecycle hooks as records created in production code. If your model hashes passwords, encrypts fields, or sets computed columns in a hook, factory records will have those transformations applied too:

// When you pass a model as the second argument to defineFactory(),
// the factory calls model.create(data) under the hood.
// This means create() goes through all the same hooks, validators,
// and computed columns as your production code does.

// For example, if your User model hashes the password in a beforeCreate hook:
export class User extends BaseModel {
  static async beforeCreate(data: any) {
    data.password = await bcrypt.hash(data.password, 10)
  }
}

// Then factory-created users will also have hashed passwords:
const user = await UserFactory.create({ password: 'secret' })
const valid = await bcrypt.compare('secret', user.password)
expect(valid).toBe(true)

Creating related records is straightforward. Create the parent first, then pass its id as an override to the child factory:

// Create related records by first creating the parent,
// then passing its id as an override to the child factory.

test('post belongs to a user', async () => {
  const user = await UserFactory.create()
  const post = await PostFactory.create({ userId: user.id })

  expect(post.userId).toBe(user.id)

  // Preload the relation and verify
  const loaded = await Post.query().where('id', post.id).preload('author').first()
  expect(loaded.author.name).toBe(user.name)
})

Organizing Factories

Keep all factory files in a tests/factories/ directory, one file per model. A barrel index.ts re-exports everything so you only need one import per test file:

project root
tests/
└── factories/
    ├── index.ts                # Re-exports all factories for convenience
    ├── user_factory.ts
    ├── post_factory.ts
    └── comment_factory.ts
tests/factories/index.ts
export { UserFactory, AdminFactory, UnverifiedUserFactory } from './user_factory'
export { PostFactory, PublishedPostFactory } from './post_factory'
export { CommentFactory } from './comment_factory'
// In any test file, import exactly what you need:
import { UserFactory, PostFactory, PublishedPostFactory } from '~/tests/factories'

Full Example

The following shows a complete factory setup with states and the integration test file that uses it. Notice how descriptive and concise the tests become when the data setup is handled by factories:

tests/factories/user_factory.ts
// tests/factories/user_factory.ts
import { defineFactory } from '@tekir/testing'
import { User } from '~/models/user'

const UserFactory = defineFactory<{
  name: string
  email: string
  password: string
  role: string
  emailVerifiedAt: Date | null
}>(
  () => ({
    name: 'Test User',
    email: `${crypto.randomUUID()}@example.com`,
    password: 'secret123',
    role: 'user',
    emailVerifiedAt: new Date()
  }),
  User,
)

export { UserFactory }
export const AdminFactory = UserFactory.state({ role: 'admin' })
export const UnverifiedUserFactory = UserFactory.state({ emailVerifiedAt: null })

Each test describes a precise scenario and overrides only the fields that make that scenario unique. The rest (valid email addresses, hashed passwords, unique IDs) are handled by the factory. When the schema changes, you update one file: the factory.