Factories
Factories are the primitive data layer in Sounding.
If a scenario is a named business situation, a factory is the record shape that situation is built from.
Factories answer questions like:
- what does a default user look like?
- what should change when that user is a publisher?
- how do we make a default issue, subscription, or invoice?
Factories are not the place to describe the whole product situation. They should stay narrow and reusable.
If you want the composition layer above factories, read Scenarios.
Where factories live
By default, Sounding looks for factories under:
tests/factories/That default comes from the Sounding world config. If your app needs a different location, you can override sounding.world.factories in config/sounding.js.
Sounding loads factory files recursively and understands:
.js.cjs.mjs
The simplest factory
The most direct form is a single factory definition:
import { defineFactory } from 'sounding'
export default defineFactory('user', ({ sequence }) => ({
fullName: 'Test User',
email: sequence('user-email', (n) => `user-${n}@example.com`),
emailStatus: 'verified'
}))The factory name matters.
If the name matches a Sails model identity like user, issue, or subscription, then world.create('user') will persist through that model.
If it does not match a model identity, Sounding can still build the value, but create() will just return the built object.
What the definition receives
When a factory definition is a function, Sounding calls it with a small helper object:
sequencefakeseedsails
sequence()
sequence() is the default uniqueness tool.
email: sequence('user-email', (n) => `user-${n}@example.com`)
slug: sequence('issue-slug', (n) => `issue-${n}`)If you omit the name, Sounding uses a default sequence.
email: sequence((n) => `user-${n}@example.com`)This is usually better than scattering Date.now() and Math.random() helpers across test files.
fake
Sounding ships a deliberately small fake-data surface today:
fake.person.fullName()fake.internet.email()fake.lorem.words(count)fake.lorem.sentence(count)
Example:
defineFactory('user', ({ fake, sequence }) => ({
fullName: fake.person.fullName(),
email: sequence('user-email', (n) => `user-${n}@example.com`)
}))The fake-data API is intentionally small. The main value comes from:
- sane defaults
- deterministic uniqueness
- meaningful traits
seed
Sounding's world engine also exposes a current seed value to factory and scenario definitions.
You can set it manually:
sails.sounding.world.seed('demo-a')Then factory definitions can read that value through the seed helper.
sails
The real Sails runtime is also available.
That means a factory definition can read app helpers or configuration when it truly needs to, although most factories should stay simple and data-shaped.
Traits
Traits add named variants to a factory.
import { defineFactory } from 'sounding'
export default defineFactory('user', ({ fake, sequence }) => ({
fullName: fake.person.fullName(),
email: sequence('user-email', (n) => `user-${n}@example.com`),
emailStatus: 'verified',
isPublisher: false
}))
.trait('publisher', { isPublisher: true })
.trait('unverified', { emailStatus: 'unverified' })A trait patch can be:
- an object
- a function
Object traits
Object traits merge into the built value:
.trait('publisher', { isPublisher: true })Function traits
Function traits receive the current built value.
.trait('published', (issue) => ({
...issue,
status: 'published',
publishedAt: String(Date.now())
}))Function traits replace the current value with whatever they return. So if you do not merge the base record, you can accidentally wipe out required fields.
Dangerous:
.trait('published', () => ({
status: 'published'
}))Safer:
.trait('published', (issue) => ({
...issue,
status: 'published'
}))Building vs creating
Factories support two different jobs:
buildfor a value that is not persistedcreatefor a record that should be persisted when a matching model exists
Inside a scenario
Inside a scenario, build() and create() return a thenable builder with a small fluent API:
.trait(name).traits(names).with(overrides).value()
That means this works:
const publisher = await create('user').trait('publisher')
const freeIssue = await create('issue', {
author: publisher.id
})
.trait('published')
.trait('free')And this also works:
const preview = await build('user')
.trait('publisher')
.with({ email: '[email protected]' })On the top-level world engine
The top-level world engine has the same concepts, but not the same fluent shape.
Use:
const preview = world.build(
'user',
{},
{
traits: ['publisher']
}
)
const publisher = await world.create(
'user',
{},
{
traits: ['publisher']
}
)Not:
await world.create('user').trait('publisher')That chaining style is for the scenario-local build() and create() helpers, not the top-level world.build() and world.create() methods.
Building or creating many records
The world engine also provides:
world.buildMany(name, count, overrides, options)world.createMany(name, count, overrides, options)
Example:
const previews = await world.buildMany('user', 3)
const subscribers = await world.createMany(
'user',
2,
{},
{
traits: ['subscriber']
}
)Real example
Here is a practical factory set:
// tests/factories/user.js
const { defineFactory } = require('sounding')
module.exports = defineFactory('user', ({ fake, sequence }) => ({
fullName: fake.person.fullName(),
email: sequence('user-email', (n) => `user-${n}@example.com`),
password: 'secret123',
tosAcceptedByIp: '127.0.0.1',
emailStatus: 'verified',
isPublisher: false
}))
.trait('publisher', { isPublisher: true })
.trait('unverified', { emailStatus: 'unverified' })
// tests/factories/issue.js
module.exports = defineFactory('issue', ({ sequence }) => ({
slug: sequence('issue-slug', (n) => `issue-${n}`),
title: sequence('issue-title', (n) => `Issue ${n}`),
category: 'deep-dive',
excerpt: 'Preview text',
content: '<p>Full issue content</p>',
status: 'draft',
readingTime: 3,
isFree: false
}))
.trait('published', (issue) => ({
...issue,
status: 'published',
publishedAt: String(Date.now())
}))
.trait('free', { isFree: true, freeUntil: null })Then a scenario can stay focused on the situation:
const { defineScenario } = require('sounding')
module.exports = defineScenario('issue-access', async ({ create }) => {
const publisher = await create('user').trait('publisher')
const subscriber = await create('user')
const freeIssue = await create('issue', { author: publisher.id })
.trait('published')
.trait('free')
const gatedIssue = await create('issue', { author: publisher.id }).trait(
'published'
)
return {
users: { publisher, subscriber },
issues: { freeIssue, gatedIssue }
}
})File export shapes Sounding understands
Sounding's world loader accepts a few factory export shapes.
Single definition export
module.exports = defineFactory('user', ({ sequence }) => ({
email: sequence((n) => `user-${n}@example.com`)
}))Function export using the loader API
module.exports = ({ factory }) =>
factory('user', ({ sequence }) => ({
email: sequence((n) => `user-${n}@example.com`)
}))Multiple definitions in one file
module.exports = {
factories: [
defineFactory('user', ({ sequence }) => ({
email: sequence((n) => `user-${n}@example.com`)
})),
defineFactory('issue', ({ sequence }) => ({
slug: sequence((n) => `issue-${n}`)
}))
]
}Practical guidance
A good factory:
- models one record shape well
- uses
sequence()for deterministic uniqueness - uses traits for meaningful variants
- stays small enough to inspect
A bad factory:
- tries to describe the whole business situation
- hard-codes too many unrelated relationships
- duplicates scenario-level meaning
- becomes a dumping ground for random setup
The simplest rule is:
If setup is repeating across files, add a factory. If the setup is describing a business situation, add or reuse a scenario.