Skip to content

Factory

@modularityjs/factory is the test-data factory primitive: defineFactory with deterministic sequences, traits, deep-partial overrides, and an app-supplied persistence hook. Zero deps, no DI, no Module — like exception and retry, it sits on the infrastructure allowlist and is importable from anywhere without module wiring. That includes production code: the same factories drive vitest specs and database seeders.

Defining a factory

The definition builds the whole object and receives a context with a 1-based sequence that increments per built object:

typescript
import { defineFactory } from '@modularityjs/factory';

interface User {
  email: string;
  name: string;
  role: 'user' | 'admin';
  createdAt: Date;
}

const userFactory = defineFactory<User>(
  ({ sequence }) => ({
    email: `user-${sequence}@example.com`,
    name: `User ${sequence}`,
    role: 'user',
    createdAt: new Date('2026-01-01T00:00:00Z'),
  }),
  {
    traits: {
      admin: () => ({ role: 'admin' }),
    },
  },
);

const user = userFactory.build(); // sequence 1
const three = userFactory.buildMany(3); // sequences 2–4

No faker (or any randomness) is bundled — the definition function is the seam. Deterministic sequence-derived values keep tests reproducible by default; an app that wants realistic noise calls its own faker inside the definition.

Traits

with(...traits) returns a derived factory with the named traits applied on top of the definition (definition → traits in argument order → overrides). Derived factories share the root sequence counter, so uniqueness invariants (email above) hold across variants:

typescript
const admin = userFactory.with('admin').build(); // sequence 5 — same counter
admin.role; // 'admin'
admin.email; // 'user-5@example.com' — still unique vs. plain builds

Unknown trait names throw a ValidationException.

Overrides

build/buildMany accept a deep-partial override — an object, or a function receiving the same { sequence } context:

typescript
const named = userFactory.build({ name: 'Ada' });
const scoped = userFactory.build(({ sequence }) => ({
  email: `user-${sequence}@corp.test`,
}));

Merge rules:

  • Plain objects merge recursively — override only the nested field you care about.
  • Arrays, Dates, and class instances replace wholesale — an existing entity swapped in as an association is not merged field-by-field.
  • undefined override values are skipped — spread conditionals don't erase definition values.

Persistence: withCreate

The factory has no ORM coupling. The app supplies the persistence hook via withCreate, unlocking create/createMany:

typescript
const persistedUsers = userFactory.withCreate((user) =>
  repository.save(repository.create(user)),
);

const saved = await persistedUsers.create(); // build → hook
const many = await persistedUsers.createMany(10); // sequential, not Promise.all

createMany runs sequentially by design — deterministic sequence and insert order. Calling create without a hook attached throws a ValidationException.

Test isolation

reset() resets one factory's sequence to 0 (the next build is 1); resetAllFactories() resets every factory created via defineFactory — a one-line beforeEach:

typescript
import { resetAllFactories } from '@modularityjs/factory';
import { beforeEach } from 'vitest';

beforeEach(() => {
  resetAllFactories();
});

Consumption lanes

In a vitest spec

typescript
import { resetAllFactories } from '@modularityjs/factory';
import { beforeEach, describe, expect, it } from 'vitest';

import { userFactory } from '../factories/user.factory.js';

beforeEach(() => resetAllFactories());

describe('UserService', () => {
  it('rejects a duplicate email', async () => {
    const existing = userFactory.build();
    const service = new UserService(fakeStoreWith([existing]));

    await expect(
      service.register(userFactory.build({ email: existing.email })),
    ).rejects.toThrow();
  });
});

In a database seeder

Factories are a zero-dep primitive precisely so production seeders can use them. If they lived in @modularityjs/testing — a devDependency — a seeder importing them would drag test tooling into the production dependency graph and fail in a pruned production install. Because factory is a runtime-safe primitive with no module wiring, a Seeder contributed to DatabaseSeedersPool can build its rows from the same factory the specs use:

typescript
import { Seeder } from '@modularityjs/database';
import { Inject, Injectable } from '@modularityjs/di';
import { DataSource } from 'typeorm';

import { UserEntity } from '../modules/user/index.js';
import { userFactory } from '../factories/user.factory.js';

@Injectable()
export class DemoUsersSeeder extends Seeder {
  readonly name = 'demo-users';

  constructor(@Inject(DataSource) private readonly dataSource: DataSource) {
    super();
  }

  async run(): Promise<void> {
    const repository = this.dataSource.getRepository(UserEntity);
    const users = userFactory.withCreate((user) =>
      repository.upsert(user, ['email']),
    );
    await users.createMany(25); // upsert on email — idempotent re-runs
  }
}

Seeders must be idempotent (run() is re-runnable by design) — the deterministic sequence-derived email plus an upsert keyed on it makes repeated seeding a no-op.