Scenarios
If a factory describes one record shape, a scenario composes those records into a named business situation.
Scenario names usually describe the setup they provide:
issue-accesspublisher-editorbilling-upgradeteam-invite
The output of a scenario is the world your trial will read from.
If you want the broader concept model around worlds and actors, read Worlds.
Where scenarios live
By default, Sounding looks for scenarios under:
tests/scenarios/That default comes from sounding.world.scenarios. If your app needs a different location, you can override it in config/sounding.js.
Sounding loads scenario files recursively and understands:
.js.cjs.mjs
The simplest scenario
import { defineScenario } from 'sounding'
export default defineScenario('issue-access', async ({ create }) => {
const publisher = await create('user').trait('publisher')
const subscriber = await create('user').trait('subscriber')
const gatedIssue = await create('issue', {
author: publisher.id
}).trait('published')
return {
users: { publisher, subscriber },
issues: { gatedIssue }
}
})That return value is the resolved world.
A good scenario return shape should give the trial readable handles like:
current.users.publishercurrent.users.subscribercurrent.issues.gatedIssue
Avoid vague keys like:
user1record2data
What the definition receives
When a scenario definition is a function, Sounding calls it with a small helper object:
buildcreatedefineFactorydefineScenariosailssequenceseedcontext
build()
build(name, overrides) resolves a factory value without persisting it.
create()
create(name, overrides) resolves a factory and persists it when a matching Sails model identity exists.
If there is no matching model, Sounding returns the built object.
defineFactory() and defineScenario()
These advanced helpers let a scenario file register additional world definitions when it truly needs to.
Most apps should prefer static top-level definitions and only reach for this when the file is intentionally composing related world definitions together.
sails
The real Sails runtime.
This is useful when a scenario needs to call a real helper, inspect config, or intentionally create part of its state through application behavior instead of plain model creation.
sequence
The same deterministic sequence helper factories use.
This is handy when a scenario needs one more unique value without falling back to Date.now() or Math.random().
seed
The current world seed.
If you set one through sails.sounding.world.seed(value), Sounding exposes it here for deterministic scenario behavior.
context
The optional input passed to world.use(name, context).
That lets a trial parameterize the scenario without inventing a second scenario for a small but meaningful variation.
const current = await world.use('team-invite', {
inviterRole: 'owner'
})Then the scenario can read:
export default defineScenario('team-invite', async ({ context, create }) => {
const inviter = await create('user').trait(context.inviterRole)
return { users: { inviter } }
})Scenario-local builders
Inside a scenario, build() and create() return thenable builders with a small fluent API:
.trait(name).traits(names).with(overrides).value()
That means all of these work:
const publisher = await create('user').trait('publisher')
const unlockedReader = await create('user')
.traits(['subscriber', 'active'])
.with({ email: '[email protected]' })
const preview = await build('issue')
.trait('published')
.with({ title: 'Preview only' })This fluent builder exists on the scenario-local helpers. It does not exist on top-level world.create() or world.build().
Loading a scenario in a trial
Trials usually load a scenario through the world engine:
import { test } from 'sounding'
test('subscriber can read a members-only issue', async ({ world, expect }) => {
const current = await world.use('issue-access')
expect(current.users.subscriber).toBeDefined()
expect(current.issues.gatedIssue).toBeDefined()
})After use(), the resolved world is also available on:
world.currentThis is mainly useful when a helper needs to inspect the current resolved world after the initial load.
Scenario helpers
Sometimes a scenario needs more than direct factory creation.
For example:
- a real signup helper that creates a user, team, and membership together
- a billing bootstrap helper that calls application code on purpose
- a small helper that keeps a scenario readable without becoming a reusable factory
In those cases:
- factories own primitive record shapes
- scenarios own business situations
- scenario-local helpers keep a scenario readable when the setup is more than one record
When a helper only exists to support scenario composition, keep it in a clearly named world-helper area near the scenarios.
One useful layout is:
tests/
world-helpers/
create-user-with-team.js
scenarios/
publisher-editor.js
issue-access.jsKeep these helpers outside tests/scenarios/ itself. Sounding recursively loads JavaScript files under the configured scenario directory, so plain helper modules should live nearby, not inside the auto-loaded tree.
Supported export shapes
Sounding's world loader understands a few shapes today.
A direct definition
import { defineScenario } from 'sounding'
export default defineScenario('issue-access', async ({ create }) => {
// ...
})A function export
If a file exports a function, Sounding calls it with a loader API containing:
sailsworlddefineFactorydefineScenariofactoryscenarioregisterFactoryregisterScenario
That means this is also valid:
module.exports = ({ scenario }) =>
scenario('issue-access', async ({ create }) => {
// ...
})An array
module.exports = [
defineScenario('issue-access', async () => ({})),
defineScenario('publisher-editor', async () => ({}))
]An object group
module.exports = {
scenarios: [
defineScenario('issue-access', async () => ({})),
defineScenario('publisher-editor', async () => ({}))
]
}Object groups can also include factories.
What makes a good scenario
A strong scenario:
- is named after a business situation
- returns readable handles
- uses traits and overrides before inventing one-off setup
- stays small enough to trust
- works across multiple related trials
A weak scenario:
- tries to model the whole app
- returns generic names
- duplicates factory work
- creates data the trial never uses
- exists only because one trial needed one more field value
When to create a new scenario
Create a new scenario when the trial needs a new named business situation.
Prefer a trait, override, or small scenario helper when:
- the actors are the same
- the relationships are basically the same
- only one or two fields change
- the difference does not need a new scenario name
Factories answer "what is one of these records like?"
Scenarios answer "what situation is this trial living inside?"