Skip to content

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:

text
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:

js
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:

  • sequence
  • fake
  • seed
  • sails

sequence()

sequence() is the default uniqueness tool.

js
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.

js
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:

js
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:

js
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.

js
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:

js
.trait('publisher', { isPublisher: true })

Function traits

Function traits receive the current built value.

js
.trait('published', (issue) => ({
  status: 'published',
  publishedAt: String(Date.now())
}))

Sounding merges the returned patch into the current value, so return only the fields the trait changes. Use the current value when the trait needs to derive something:

js
.trait('published', (issue) => ({
  status: 'published',
  slug: `${issue.slug}-published`
}))

Building vs creating

Factories support two different jobs:

  • build for a value that is not persisted
  • create for 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)
  • .withOnly(overrides)
  • .value()

Repeated .with() calls merge override objects. Use .withOnly() when you intentionally want to use only the next overrides.

That means this works:

js
const publisher = await create('user').trait('publisher')
const freeIssue = await create('issue', {
  author: publisher.id
})
  .trait('published')
  .trait('free')

And this also works:

js
const preview = await build('user')
  .trait('publisher')
  .with({ email: '[email protected]' })

On the top-level world engine

Top-level world.build() returns an immediate value, so pass traits through options:

js
const preview = world.build(
  'user',
  {},
  {
    traits: ['publisher']
  }
)

Top-level world.create() is awaitable and fluent, so this works when you want to persist a record with traits:

js
const publisher = await world
  .create('user')
  .trait('publisher')
  .with({ email: '[email protected]' })

The previous options form is still supported:

js
const publisher = await world.create('user', {}, { traits: ['publisher'] })

Because the builder is thenable, this simpler form still creates the default record:

js
const reader = await world.create('user')

Building or creating many records

The world engine also provides:

  • world.buildMany(name, count, overrides, options)
  • world.createMany(name, count, overrides, options)

Example:

js
const previews = await world.buildMany('user', 3)
const subscribers = await world.createMany(
  'user',
  2,
  {},
  {
    traits: ['subscriber']
  }
)

Real example

Here is a practical factory set:

js
// 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:

js
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

js
module.exports = defineFactory('user', ({ sequence }) => ({
  email: sequence((n) => `user-${n}@example.com`)
}))

Function export using the loader API

js
module.exports = ({ factory }) =>
  factory('user', ({ sequence }) => ({
    email: sequence((n) => `user-${n}@example.com`)
  }))

Multiple definitions in one file

js
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.

All open source projects are released under the MIT License.