Skip to content
All notes

3 min read

Test data that tells the truth

Fixtures lie by omission. mongoose-test-factory reads your schema and generates data that respects it. How it decides what to generate, and the three modes I reach for every day.

Every Mongoose project I have joined had a fixtures/ folder, and every one of those folders was quietly wrong. A field was added to the schema and not to the JSON. An enum gained a value nobody tested. A required flag appeared and half the fixtures would no longer save, so someone marked the test skip and moved on.

Fixtures are a snapshot of what the schema looked like the day they were written. The schema is what the code believes today. mongoose-test-factory exists to close that gap: it reads the schema and generates data that is valid against it, right now.

#Start from the schema

The package is a plugin. Apply it, wrap the model, and the model gets a factory().

user.model.tstypescript
import mongoose, { Schema } from 'mongoose'
import mongooseTestFactory, { withFactory } from 'mongoose-test-factory'

const userSchema = new Schema({
  name: { type: String, required: true },
  email: { type: String, required: true, unique: true },
  age: { type: Number, min: 18, max: 120 },
  isActive: { type: Boolean, default: true },
})

userSchema.plugin(mongooseTestFactory)
export const User = withFactory(mongoose.model('User', userSchema))
typescript
const user = User.factory().build()
// { name: 'John Doe', email: 'john.doe@example.com', age: 28, isActive: true }

Nothing was configured. The plugin walked the schema, saw a required string called name, a unique string called email, a number bounded between 18 and 120, and generated accordingly. Change the schema and the next run changes with it.

#How it decides what to generate

Three layers, in order of priority.

  1. An explicit factoryType on the field wins. Forty-odd types are built in: email, phone, price, slug, uuid, birthdate, tags, and so on.
  2. The field name. userEmail, contactEmail and email all look like emails. price, cost and amount look like money. createdAt looks like a timestamp.
  3. The type and its validators. A Number with min and max stays inside them. A String with an enum picks from it. required is always honoured.

When the name is ambiguous, say so in the schema:

typescript
const productSchema = new Schema({
  name: { type: String, factoryType: 'title' },
  vendor: { type: String, factoryType: 'company' },
  price: { type: Number, factoryType: 'price' },
  isActive: { type: Boolean, factoryType: 'active' }, // true about 80% of the time
  website: { type: String, factoryType: 'url' },
})

#Three modes, three kinds of test

MethodReturnsHits the databaseUse it for
build()plain objectsnounit tests, request bodies
make()Mongoose instancesnovirtuals, methods, hooks
create()saved documentsyesintegration tests
typescript
const body = User.factory().build() // fast, no connection needed
const doc = User.factory().make() // an instance, not persisted
const saved = await User.factory(50).create() // fifty real rows

Most of my unit tests never open a connection. build() is fast enough that I stopped caching test data entirely.

#Overrides and relations

The generated value is a starting point. Whatever the test actually cares about, set explicitly.

typescript
const admin = User.factory().with({ name: 'Admin', role: 'admin' }).build()

const author = await User.factory().create()
const posts = await Post.factory(5).with({ author: author._id }).create()

That second example is the pattern I use most: one real parent, N generated children pointing at it. The factory fills in everything I did not mention, and the assertions read as intent rather than setup.

#What it will not do

It will not invent your business rules. If an order's total must equal the sum of its lines, the schema does not say so, and the factory cannot know. Write that as an override, or as a small helper in your test setup that builds consistent orders. Generated data should be valid against the schema and honest about the rest; pretending it understands the domain would be another kind of lie.

Did this land?

Conversation

Building something like this?

Tell me what you are working on. I reply within a day.

Write to me