test-fixture-factory
Version:
A minimal library for creating and managing test fixtures using Vitest, enabling structured, repeatable, and efficient testing processes.
349 lines (241 loc) • 9.71 kB
Markdown
# test-fixture-factory
`test-fixture-factory` helps you create **typed, ergonomic test fixtures** for **Vitest** using a fluent factory API. Define attributes and defaults once, declare what can be read from the test context, and get clean fixtures with automatic teardown.
* ✅ **First-class TypeScript**: schema-driven, end-to-end inference
* ✅ **Explicit context reads**: `.from()` / `.maybeFrom()` link fields to fixtures on the test context
* ✅ **Lifecycle control**: auto-destroy by default, opt-out via env or per-fixture
* ✅ **Great DX**: actionable errors (with factory names and missing fields)
> Works best with [Vitest Test Contexts](https://vitest.dev/guide/test-context.html). It can also be used outside Vitest via `factory.build(...)` for ad-hoc creation.
## 📚 Documentation
- **[Migration Guide](./MIGRATION.md)** - Upgrading from v1? Step-by-step migration instructions
- **[Changelog](./CHANGELOG.md)** - Complete version history and breaking changes
## Installation
```bash
npm i -D test-fixture-factory
```
**Requirements**
* Node.js 24+
* TypeScript 5+
* Vitest (or Playwright + fixture layer)
## Quickstart
```typescript
import { createFactory } from 'test-fixture-factory'
// 1) Define a factory with a schema
const companyFactory = createFactory('Company')
.withSchema((f) => ({
name: f.type<string>(),
}))
.withValue(async ({ name }) => {
const company = await prisma.company.create({ data: { name } })
return {
value: company,
destroy: () => prisma.company.delete({ where: { id: company.id } }),
}
})
// 2) Use it in Vitest via fixtures
export const { useValue: useCompany, useCreateValue: useCreateCompany } = companyFactory
```
```typescript
// example.test.ts
import { test as anyTest, expect } from 'vitest'
import { useCompany } from './factories/company.js'
const test = anyTest.extend({
company: useCompany({ name: 'Acme' }),
})
test('creates data tied to a company', async ({ company }) => {
expect(company).toEqual({ id: expect.any(Number), name: 'Acme' })
})
```
## Core Concepts
### Factory
Built via `createFactory(name)` + `.withSchema()` + `.withValue()`
### Schema Fields
Declared with `f.type<T>()` and refined with:
* `.optional()` — mark the field optional
* `.default(value | () => value)` — supply a default; makes field optional for **input**
* `.from('fixture' | ['a','b'], (ctx) => T)` — read **required** value(s) from test context (see overloads below)
* `.maybeFrom('fixture' | ['a','b'], (ctx) => T | undefined)` — read **optional** value(s) from context
### Fixtures
Vitest fixtures returned by `.useValue(...)` or `.useCreateValue(...)`
### Teardown
Provide `destroy()` in `.withValue()` to auto-cleanup after each test
## How Values Are Resolved
Given a schema, the attributes passed to `.withValue()` are resolved in this order (later wins):
1. **Defaults** from `.default(...)`
2. **Context values** from `.from(...)` / `.maybeFrom(...)`
3. **Preset attributes** from `.useValue(preset)` or `.useCreateValue(preset)`
4. **Call-time attributes** passed to the `create()` function (only with `.useCreateValue()`)
Undefined keys are removed; later sources win.
> If a field is **required** and resolves to `undefined`, you'll get an `UndefinedFieldError` telling you which field was missing and which fixture(s) could have provided it.
## API Reference
### Factory Builder
#### `createFactory(name: string)` → `FactoryBuilder`
Creates a new factory builder. The `name` appears in error messages.
#### `.withContext<Context>()`
Declare the shape of the test context (fixtures) that fields can read from.
```typescript
const userFactory = createFactory('User')
.withContext<{ company: Company }>()
```
#### `.withSchema(schemaFn)`
Define fields using a builder `f`.
```typescript
.withSchema((f) => ({
companyId: f
.type<number>()
.from('company', ({ company }) => company.id),
name: f.type<string>(),
email: f.type<string>().default('default@email.com'),
}))
```
**Field builder methods & overloads**
```typescript
// Required field
f.type<string>()
// Optional field
f.type<string>().optional()
// Field with default value
f.type<string>().default('hello world')
// Field with calculated default value
f.type<number>().default(() => Math.random())
// Read from context (with transform):
// .withContext<{ user: { name: string } }>
f.type<string>().from('user', (ctx) => ctx.user.name)
// Shorthand when the type already matches:
// .withContext<{ name: string }>
f.type<string>().from('name')
// Optional read from context (may return undefined):
// .withContext<{ user?: { name: string } }>
f.type<string>().maybeFrom('user', (ctx) => ctx.user?.name)
// Similar shorthand for possibly undefined values:
// .withContext<{ name?: string }>
f.type<string>().maybeFrom('name')
```
#### `.withValue(factoryFn)`
`factoryFn` receives the fully resolved attributes and returns `{ value, destroy? }`.
```typescript
.withValue(async (attrs) => ({ value: await createInDb(attrs) }))
```
#### `.build(attrs?, context?)`
Create a value **outside of Vitest**. Useful for scripts or setup code.
```typescript
const { value, destroy } = await userFactory
.withContext<{ company: Company }>()
.withSchema(/* ... */)
.withValue(/* ... */)
.build({ name: 'Ada' }, { company })
```
### Vitest Integration
#### `.useValue(presetAttrs?, options?)`
Return a Vitest fixture that yields **one instance**.
```typescript
const test = anyTest.extend({
user: userFactory.useValue({ name: 'Max' }),
})
```
#### `.useCreateValue(presetAttrs?, options?)`
Return a Vitest fixture that yields a **creator function** for many instances.
```typescript
const test = anyTest.extend({
createUser: userFactory.useCreateValue({ name: 'Default' }),
})
test('batch', async ({ createUser }) => {
const a = await createUser({ email: 'a@ex.com' }) // merges with preset
const b = await createUser({ name: 'Bob' })
// ...
})
```
**Options**
```typescript
{ shouldDestroy?: boolean } // default true unless TFF_SKIP_DESTROY is truthy
```
## Advanced Usage
### InferFixtureValue
Use `InferFixtureValue` to extract the type of a fixture for use in helper functions:
```typescript
import { test as anyTest } from 'vitest'
import { InferFixtureValue } from 'test-fixture-factory'
import { useCompany } from './factories/company.js'
import { useCreateUser } from './factories/user.js'
// Helper function that accepts typed fixtures
const createTestUsers = async (
company: InferFixtureValue<typeof useCompany>,
createUser: InferFixtureValue<typeof useCreateUser>,
) => {
const alice = await createUser({
name: 'Alice',
email: 'alice@example.com',
companyId: company.id
})
const bob = await createUser({
name: 'Bob',
email: 'bob@example.com',
companyId: company.id
})
return { alice, bob }
}
const test = anyTest.extend({
company: useCompany({ name: 'Test Corp' }),
createUser: useCreateUser(),
})
test('tests user interactions', async ({ company, createUser }) => {
const { alice, bob } = await createTestUsers(company, createUser)
// Test alice and bob interactions...
})
test('tests user permissions', async ({ company, createUser }) => {
const { alice, bob } = await createTestUsers(company, createUser)
// Test permission scenarios...
})
```
This pattern is useful for:
- Creating reusable test data setup functions
- Maintaining type safety across test helpers
- Reducing duplication in test setup code
### Environment Variables
Disable auto-destroy globally while developing:
```bash
TFF_SKIP_DESTROY=1 vitest
```
## Best Practices
### Vitest Integration
* Always destructure fixtures in test signatures: `test('', ({ user }) => { ... })`
* You can mix `useValue` and `useCreateValue` in the same `test.extend({ ... })`
* Cleanups run in **reverse definition order**, which plays well with FK constraints
### Factory Design
* Keep factories focused on a single entity/model
* Use `.from()` to express dependencies between fixtures
* Provide sensible defaults for optional fields
* Always include a `destroy` function when creating database records
### Type Safety
* Use `InferFixtureValue` when passing fixtures to helper functions
* Let TypeScript infer as much as possible - avoid manual type annotations
* Use `.withContext<T>()` to declare available fixtures upfront
## Error Handling
All missing-field errors throw `UndefinedFieldError` with a helpful message that includes the factory name:
```
[User] 1 required field(s) have undefined values:
- companyId: must be provided as an attribute or via the test context (company)
```
Detectable via `err instanceof UndefinedFieldError`.
## FAQ
**Q: What's the difference between `.from` and `.maybeFrom`?**
`.from` expects a value to be resolvable (via context or attribute override). `.maybeFrom` allows the context read to produce `undefined`; if nothing overrides it, you'll still get an error (because the field is required unless you `.optional()` it).
**Q: Can I read multiple fixtures for one field?**
Yes — pass an array: `.from(['a','b'], ({ a, b }) => combine(a,b))`.
**Q: Can `default(() => ...)` read the test context?**
No. Defaults are pure and receive **no** arguments. If you need context, use `.from(...)` / `.maybeFrom(...)`.
**Q: Playwright?**
You can wire factories into Playwright's `test.extend` similarly to Vitest; the fixtures you declare become available on the test context.
**Q: How do I handle circular dependencies?**
Use `.maybeFrom()` to make dependencies optional, then provide values explicitly when needed.
## License
MIT