Worlds and actors
If a trial is the behavior being proved, a world is the business situation that trial lives inside.
If test() is the outer shape of Sounding, worlds are the inner shape.
A world is the named, deterministic business state that a trial lives inside.
That means a world is not just a bag of fixtures and it is not just raw seed data. A world is a product situation:
- a guest trying to open a gated issue
- a subscriber with active access
- a publisher editing a draft
- a reader requesting a magic link
That distinction matters. Sounding should help you test product situations, not just database rows.
The four building blocks
There are four closely related concepts here:
Factory
A factory describes how to build or create one kind of record.
Factories answer questions like:
- what does a default user look like?
- how do we make a default issue?
- what changes when the user is a publisher?
Scenario
A scenario composes factories into a business situation.
Scenarios answer questions like:
- what does issue access look like?
- what does a publisher editor session need?
- what should exist before a billing upgrade flow?
Actor
An actor is the role a trial operates through inside a world.
Examples:
guestsubscriberpublisherunlockedReaderteamOwner
Actors matter because most product behavior is role-sensitive.
World
A world is the resolved state returned by a scenario.
It includes:
- the actors
- the records they need
- the relationships between those records
- the business situation the trial actually cares about
So the hierarchy looks like this:
- factories build records
- scenarios compose records
- actors give those records meaning
- the resulting world is what the trial uses
Why worlds matter
Without worlds, tests usually drift toward one of two bad outcomes:
- giant setup blocks that hide the point of the trial
- tiny fake fixtures that are too thin to trust
Worlds give Sounding a better middle path:
- realistic enough to trust
- named clearly enough to understand
- reusable without feeling abstract
The test stays focused on behavior because the world already carries the setup burden.
Factories are the primitive layer
A good factory should describe one thing well and stay boring.
import { defineFactory } from 'sounding'
export default defineFactory('user', ({ fake, sequence }) => ({
fullName: fake.person.fullName(),
email: sequence((n) => `user${n}@example.com`),
emailStatus: 'verified'
}))
.trait('publisher', { isPublisher: true })
.trait('subscriber', { subscriptionStatus: 'active' })The important thing here is not fake data generation by itself. It is the traits.
Traits let the test vocabulary start sounding like the product:
publishersubscriberexpiredfoundingSupporter
That is how the data layer starts to feel intentional.
Scenarios are where meaning appears
A scenario is where separate records become a named business situation.
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 freeIssue = await create('issue', {
author: publisher.id,
isFree: true
})
const gatedIssue = await create('issue', {
author: publisher.id,
isFree: false
})
return {
users: { publisher, subscriber },
issues: { freeIssue, gatedIssue }
}
})The return value is the world.
It should be something a test can read without opening the scenario first.
What makes a good scenario name
Scenario names should sound like a product conversation, not a seed script.
Good names:
issue-accesspublisher-editormagic-link-signinbilling-upgradereader-dashboard
Weak names:
users-and-issuesseed-1default-datatest-case-a
The rule is simple:
If the name does not tell you what business situation exists, it is probably not a good scenario name.
A world should read like the product
Once you load a world, the test should feel like it is operating inside the product, not inside setup code.
import { test } from 'sounding'
test('subscriber can read a members-only issue', async ({ sails, expect }) => {
const current = await sails.sounding.world.use('issue-access')
const response = await sails.sounding.request
.as(current.users.subscriber)
.get(`/i/${current.issues.gatedIssue.slug}`)
expect(response).toHaveStatus(200)
})That reads well because the world gives the trial meaningful handles:
current.users.subscribercurrent.issues.gatedIssue
Those names do a lot of work.
Good worlds vs bad worlds
A good world:
- is named after a business situation
- returns readable handles
- avoids unnecessary data
- works across multiple trial types
- stays stable enough to be trusted
A bad world:
- tries to model the whole app at once
- leaks low-level implementation detail into every test
- returns vague keys like
user1anditem2 - creates too much data just because it can
- is only understandable if you read 100 lines of setup first
If a scenario starts feeling like a miniature production snapshot, it is too big.
Use the same world across multiple trial types
One of the best things about Sounding worlds is that the same scenario can support different layers of testing.
Request-level trial
test('guest sees the paywall state', async ({ sails, visit, expect }) => {
const current = await sails.sounding.world.use('issue-access')
const page = await visit(`/i/${current.issues.gatedIssue.slug}`)
expect(page).toHaveProp('hasSubscription', false)
})Browser-capable trial
test(
'subscriber can read the issue in full',
{ browser: true },
async ({ sails, login, page, expect }) => {
const current = await sails.sounding.world.use('issue-access')
await login.as('subscriber', page)
await page.goto(`/i/${current.issues.gatedIssue.slug}`)
await expect(
page.getByText(current.issues.gatedIssue.premiumDetail)
).toBeVisible()
}
)Mail trial
test('reader gets a magic link email', async ({ sails, auth, expect }) => {
await sails.sounding.world.use('magic-link-signin')
await auth.requestMagicLink('[email protected]')
const email = sails.sounding.mailbox.latest()
expect(email.ctaUrl).toContain('/magic-link/')
})That is the payoff: one world, many useful trial surfaces.
When to create a new scenario
Create a new scenario when the trial needs a new named business situation.
A good scenario earns its existence by making many related trials easier to read. Create one when:
- the business situation is meaningfully different
- the actors or permissions are different
- the data relationships change in a real way
- reusing another world would make the trial harder to understand
Do not create a new scenario just because one trial needs a tiny data variation. In those cases, prefer:
- a trait
- a small override
- a small helper inside the scenario
The relationship between trials, worlds, and actors
A useful way to hold these ideas together is:
- the trial states the behavior being proved
- the world provides the business situation
- the actor provides the role operating inside that situation
- the trial context provides the runtime and tools
That is the grammar Sounding is trying to make elegant.
A simple naming pattern that works well
A practical pattern for most codebases is:
noun-capabilityrole-situationfeature-state
Examples:
issue-accesspublisher-editorreader-dashboardbilling-upgradeteam-invite
It keeps the suite calm and predictable.
The main idea to hold onto
If you remember only one thing, let it be this:
A world is not there to make tests shorter. It is there to make them more truthful.
The goal is not clever setup reuse. The goal is a test that reads like a real product situation and still stays easy to trust.