<!-- Supplemental documentation: base revision 8ed7f99e2c44d3093123dd0796d17416d4d541ad; original source SHA-256 5100ac0281b20fed2d3856ca69ce8a0b11a4aa6c098fea7517b332192a80ef73; reading source revision 8ed7f99e2c44d3093123dd0796d17416d4d541ad; publication edits /docs/publication.json. -->

# Testing helper reference

Consumer reference for `@mnjs/test` **0.2.0** with Marionette **5.x**.
For installation and a first complete test, start with the [package README](/docs/packages/test.md).
Mounting requires a DOM; importing the neutral entry and creating a cleanup scope do not.
Repository-example links are pinned to this guide's reading source revision.
Use the guide shipped with your installed helper version for its contract.

Choose the part that matches your test:

- [Mocha with the core](#mocha-use-the-core)
- [Vitest fixtures](#vitest-own-mounts-per-test)
- [Testing Library queries](#testing-library-query-an-existing-mount)
- [Resource lifetimes and failed mounts](#resource-lifetimes-and-failed-mounts)
- [Cypress component setup](#cypress-register-cymount-once)
- [Playwright gallery setup](#playwright-mount-named-browser-stories)
- [Views, Applications and resource scopes](#shared-api)
- [Cleanup, readiness and runner behavior](#lifecycle-semantics)
- [Tested versions](#tested-with)

## Mocha: use the core

The root `@mnjs/test` entry is runner-independent. With Mocha, use its ordinary
async tests and your own assertions; no helper adapter or hook registration is
needed. After [installing the helper and Marionette](/docs/packages/test.md#install), add:

```sh
npm install --save-dev --save-exact mocha@12.0.3 jsdom@30.1.1
```

Create `test/dom.mjs`. Node must load this **before** Marionette or the helper can
capture a DOM environment:

```js
import { JSDOM } from 'jsdom';

const dom = new JSDOM('<!doctype html><html><body></body></html>', {
  url: 'https://example.test/'
});
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.HTMLElement = dom.window.HTMLElement;
```

Create `test/core.test.mjs`:

```js
import assert from 'node:assert/strict';
import { it } from 'mocha';
import { Application, View } from 'marionette';
import { mountApplication } from '@mnjs/test';

const Greeting = Application.extend({
  async prepareStart() { return { name: 'Ada' }; },
  onStart(_app, _options, profile) {
    this.showView(new View({ template: () => `Hello ${profile.name}` }));
  }
});

it('shows prepared output', async function() {
  const mounted = mountApplication(region => new Greeting({ region }));
  try {
    assert.equal(await mounted.ready, true);
    assert.equal(mounted.el.textContent, 'Hello Ada');
  } finally {
    await mounted.cleanup();
  }
});
```

Run from the project directory, with Node preloading the DOM before Mocha loads
the test's static imports:

```sh
node --import ./test/dom.mjs ./node_modules/mocha/bin/mocha.js test/core.test.mjs
```

The async test returns its Promise to Mocha. `ready` covers initial Application
startup, not later work; cleanup is awaited even when readiness or an assertion
fails. For multiple tests, a Mocha `afterEach` can instead return or await cleanup
of the resources owned by that test.

## Vitest: own mounts per test

Use the [README setup](/docs/packages/test.md#write-a-test-with-vitest) for Vitest 5 with JSDOM,
or configure Vitest Browser Mode. Each test receives its own `mounts`
fixture and awaited teardown, including concurrent tests and failing assertions.

```js
import { expect } from 'vitest';
import { test } from '@mnjs/test/vitest';
import { View } from 'marionette';
import { withQueries } from '@mnjs/test/testing-library';

const Greeting = View.extend({ template: () => '<h1>Hello</h1>' });
test('renders a greeting', ({ mounts }) => {
  const mounted = withQueries(mounts.mountView(new Greeting()));
  expect(mounted.getByRole('heading').textContent).toBe('Hello');
}); // The fixture awaits teardown, including when the test fails.
```

## Testing Library: query an existing mount

Install `@testing-library/dom` alongside the helper, then import `withQueries` from
`@mnjs/test/testing-library`. It decorates an existing View or Application mount
in place and returns that same handle with standard `getBy*`, `getAllBy*`,
`queryBy*`, `queryAllBy*`, `findBy*` and `findAllBy*` methods. Role/name, label,
missing/multiple-result errors and asynchronous polling follow DOM Testing Library.
This is an ergonomic wrapper around `within`, not another runner or lifecycle.

```js
import { expect } from 'vitest';
import { test } from '@mnjs/test/vitest';
import { withQueries } from '@mnjs/test/testing-library';
import { Application, View } from 'marionette';

const Editor = View.extend({ template: () => '<label>Name <input></label>' });
const Feature = Application.extend({ onStart() { this.showView(new Editor()); } });
test('queries prepared output', async ({ mounts }) => {
  const mounted = withQueries(mounts.mountApplication(region => new Feature({ region })));
  await expect(mounted.ready).resolves.toBe(true);
  expect(await mounted.findByLabelText('Name')).toBe(mounted.el.querySelector('input'));
});
```

Queries bind to the stable `mounted.container`, so `region.show()` replacement and
`replaceElement` keep working without rebinding. If you supply a borrowed container,
queries include all its descendants, including preexisting siblings. The live `el` getter, concrete
`view`/`app` types, initial `ready` Promise and `cleanup` function are preserved.
The decorator never starts an Application, awaits readiness or registers cleanup;
your existing runner fixture or test scope retains ownership. Always await or
handle `ready` on Application mounts: `findBy*` waits for queried DOM, not global
application idleness or unrelated asynchronous work.

`withQueries(mounted, { baseElement })` binds queries to that borrowed HTMLElement,
for example a portal root. It does not move the mount or change cleanup ownership;
after cleanup, unrelated content in that query root remains queryable. Detached
mounts and elements from another document work without global `screen` or registry.
Query semantics still come from DOM Testing Library and the host DOM; native label
associations may require an attached host. Detached examples use `aria-label`.
Decorating again returns the same handle and replaces its query methods with ones
bound to the newly selected root; omitting `baseElement` restores the container.
Previously destructured query functions keep their original root.

In Cypress, use `cy.mount(...).then(withQueries)` to decorate the yielded handle,
or call it inside `.then(mounted => ...)` when you need `baseElement`. Component
tests can stay in Cypress. Bound `getBy*` calls are synchronous; they do not acquire
Cypress retry semantics. `findBy*` uses Testing Library polling, and a returned
Promise is awaited by Cypress's `.then` command. Existing Cypress teardown still
owns the mount. See the [repository Cypress lifecycle example](https://github.com/marionettejs/marionette/blob/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples/cypress/component/lifecycle.cy.js).

Import `userEvent` from `@testing-library/user-event` separately for interactions;
install/configure `@testing-library/jest-dom` separately if your runner supports
its matchers. Neither is required by this entrypoint. Existing `within(container)`
code continues to work; the [repository form suite](https://github.com/marionettejs/marionette/blob/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples/form.spec.js)
demonstrates the decorator with per-test ownership.

## Cypress: register cy.mount once

Install Cypress and `@cypress/mount-utils` alongside the helper. Register commands
explicitly in the component support file:

```js
// cypress/support/component.js
import { mount, mountApplication, setupCypress } from '@mnjs/test/cypress';

setupCypress();
Cypress.Commands.add('mount', mount);
Cypress.Commands.add('mountApplication', mountApplication);
```

The component HTML needs `<div data-cy-root></div>`. The configuration below uses
Cypress's custom Vite dev-server function without a UI-framework preset.

### Cypress configuration

For the setup below, install the custom dev server and Vite alongside Cypress:

```sh
npm install --save-dev --save-exact cypress@16.1.1 @cypress/mount-utils@5.0.0 @cypress/vite-dev-server@8.0.0 vite@8.0.16
```

Use `cypress.config.mjs` so the configuration works in either a CommonJS or ESM
application. Cypress's normal installation also needs its browser binary and OS
display dependencies.

```js
import { defineConfig } from 'cypress';
import { devServer } from '@cypress/vite-dev-server';

export default defineConfig({
  component: {
    devServer(config) { return devServer({ ...config, viteConfig: {} }); },
    specPattern: 'cypress/component/**/*.cy.js',
    supportFile: 'cypress/support/component.js',
    indexHtmlFile: 'cypress/support/component-index.html'
  }
});
```

Create `cypress/support/component-index.html`:

```html
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Component tests</title></head>
<body><div data-cy-root></div></body></html>
```

Keep the command registration above in `cypress/support/component.js`. Put this
first spec in `cypress/component/greeting.cy.js`, then run
`npx cypress run --component --config-file cypress.config.mjs`:

```js
// cypress/component/greeting.cy.js, with the support file above
import { View } from 'marionette';

const Greeting = View.extend({ template: () => '<h1>Hello</h1>' });
it('replaces the previous mount', () => {
  let previous;
  cy.mount(() => new Greeting()).then(mounted => { previous = mounted; });
  cy.mount(() => new Greeting()).then(mounted => {
    expect(previous.view.isDestroyed()).to.equal(true);
    expect(mounted.el.parentElement).to.equal(mounted.container);
  });
  cy.get('h1').should('have.text', 'Hello');
});
```

Choose Application startup explicitly with `start: true` (await readiness) or
`start: false` (start it yourself):

```js
// cypress/component/shell.cy.js, with the support file above
import { Application, View } from 'marionette';

it('shows a shell without starting feature work', () => {
  cy.mountApplication(region => new Application({ region }), { start: false })
    .then(({ app }) => {
      app.showView(new View({ template: () => '<h1>Shell</h1>' }));
    });
  cy.get('h1').should('have.text', 'Shell');
});
```

For TypeScript, add the command declarations to your component support types:

```ts
import { mount, mountApplication } from '@mnjs/test/cypress';
declare global {
  namespace Cypress {
    interface Chainable {
      mount: typeof mount;
      mountApplication: typeof mountApplication;
    }
  }
}
```

## Playwright: mount named browser stories

Use Playwright 1.63's built-in `mount` fixture with a browser gallery served by your
dev server. Stories create Views in the browser; tests use Playwright Locators.

```js
// gallery/gallery.js, served by your dev server from a page with <div id="root"></div>
import { View } from 'marionette';
import { createGallery } from '@mnjs/test/playwright';

const Editor = View.extend({ template: () => '<h1>Editor</h1><input aria-label="Draft">' });
const gallery = createGallery({
  'editor/Default'(props, mounts) {
    const mounted = mounts.mountView(new Editor());
    return { update(next) { mounted.el.querySelector('h1').textContent = next.title; } };
  },
}, { container: document.querySelector('#root') });
Object.assign(window, gallery);
```

```js
// playwright/editor.spec.js; baseURL points to the gallery page
import { test, expect } from '@playwright/test';

test.afterEach(async ({ page }) => {
  await page.evaluate(async () => { await window.unmount?.(); });
});
test('updates preserve the draft', async ({ mount }) => {
  const editor = await mount('editor/Default');
  await editor.getByRole('textbox', { name: 'Draft' }).fill('Keep me');
  await editor.update({ title: 'Rename' });
  await expect(editor.getByRole('heading')).toHaveText('Rename');
  await expect(editor.getByRole('textbox', { name: 'Draft' })).toHaveValue('Keep me');
  await editor.unmount();
  await expect(editor).toBeEmpty();
});
```

Return the gallery's `unmount()` Promise from `afterEach` to await teardown.

### Playwright configuration

Install the runner and dev server, then install Chromium through Playwright's
normal browser setup:

```sh
npm install --save-dev --save-exact @playwright/test@1.63.0 vite@8.0.16
npx playwright install chromium
```

Save the story module above as `gallery/gallery.js` and create
`gallery/index.html`:

```html
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Gallery</title></head>
<body><div id="root"></div><script type="module" src="./gallery.js"></script></body></html>
```

Use `playwright.config.mjs`:

```js
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './playwright',
  use: { baseURL: 'http://127.0.0.1:4187/gallery/index.html' },
  projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
  webServer: {
    command: 'npx vite --host 127.0.0.1 --port 4187 --strictPort',
    url: 'http://127.0.0.1:4187/gallery/index.html',
    reuseExistingServer: false
  }
});
```

Save the test above as `playwright/editor.spec.js` and run `npx playwright test`.
Stop any existing server on port 4187 first so the test uses this gallery. If your
project already supplies a dev server or browser configuration, adapt those two
settings while retaining the awaited `afterEach` cleanup.

## Shared API

Use the neutral entry with any DOM-capable test runner. Always await cleanup.

### Mount a View

```js
import { View } from 'marionette';
import { mountView } from '@mnjs/test';

const Greeting = View.extend({ template: () => '<h1>Hello</h1>' });
const mounted = mountView(new Greeting());
try {
  console.log(mounted.view.isAttached(), mounted.el.textContent);
} finally {
  await mounted.cleanup();
}
```

`mountView(viewOrFactory, options?)` transfers a live, unowned View to a new Region. The
handle exposes the original `view`, `region`, `container`, and `slot`. Its live `el`
getter follows `region.currentView`, including replacements, and is `undefined`
when empty. Handle identities are readonly in TypeScript; the actual View and
Region retain their public methods. Use `mounted.region.show(nextView)` to replace
the root. Cleanup destroys the current View and its owned children. Calling
`region.detachView()` transfers that View back to you to destroy or adopt elsewhere.

| Mount option | Behavior |
| --- | --- |
| `container` | Borrow this Element; append a slot and preserve the host and siblings. Takes precedence over `attachTo`. |
| `attachTo` | Append a helper-created host to this Element; `null` keeps it detached. Defaults to the chosen document's body. |
| `document` | Fallback Document, after `container.ownerDocument` and `attachTo.ownerDocument`, before the global document. |
| `regionClass` | Defaults to Marionette's Region. Pass `runtime.Region` for an isolated `createMarionette()` runtime. |
| `replaceElement` | Uses the Region class default when omitted (`false` for the standard Region). Set `true` to put each View root directly inside the container, including later replacements. |
| `showOptions` | View-only options for the initial `Region.show`, including `replaceElement`. |

`container` is the host; `slot` is the original Region placeholder, temporarily
unparented during replacement. Scope DOM queries to `container`. Ownership is
captured at mount time; later changes to options or handle properties cannot
redirect cleanup. Borrowed data, channels, providers and destination elements
remain borrowed.

### Mount an Application

```js
import { Application, View } from 'marionette';
import { mountApplication } from '@mnjs/test';

const Feature = Application.extend({
  async prepareStart() {
    return { name: 'Ada' };
  },
  onStart(app, options, profile) {
    this.showView(new View({ template: () => profile.name }));
  },
});
const mounted = mountApplication(region => new Feature({ region }));
try {
  if (!await mounted.ready) throw new Error('Startup was cancelled');
  console.log(mounted.app.getView(), mounted.el);
} finally {
  await mounted.cleanup();
}
```

The factory must return a **fresh, unstarted Application**. The helper passes its
Region to the factory and initial `start()`; `startOptions` forwards other startup
options. The returned handle adds `app` and `ready` to the mount fields above.

By default the helper starts the Application. `ready` is the actual initial start
Promise: `true` for success, `false` for cancellation, rejection for failure.
Always await or handle `mounted.ready`, including inside a gallery story, so startup
failures reach the test that owns the mount. Cleanup does not consume startup errors.
Pass `start: false` to skip startup and leave it to the caller. In this mode,
`ready` resolves `false` to indicate a skipped start, not cancellation; it is not
a signal for later startup. When starting manually, await the Promise returned
by `app.start()`. `mounted.ready` stays tied to the initial helper-managed
attempt; it does not change after a manual start or restart. Await
`app.restart()` explicitly. Cleanup begins in the calling turn and cancels
pending preparation before destroying the Application and helper Region. You can
also call `app.stop()` while preparation is pending.

To test an existing shell without starting its feature work:

```js
import { Application, View } from 'marionette';
import { mountApplication } from '@mnjs/test';

const mounted = mountApplication(region => new Application({ region }), {
  start: false, replaceElement: true,
});
try {
  mounted.app.showView(new View({ template: () => '<h1>Shell</h1>' }));
  console.log(mounted.container.firstElementChild === mounted.app.getView().el);
  await mounted.app.start(); // The test chooses when startup happens.
} finally {
  await mounted.cleanup();
}
```

Readiness covers preparation, including children that preparation explicitly awaits.
It does not await asynchronous notification hooks, later saves or every future task.
Observe those operations' own completion boundaries. Coordinate cancellation of
required child startup explicitly; a preparation signal is not a lifetime signal
after startup succeeds.

### Own a test's resources

```js
import { View } from 'marionette';
import { createTestScope, mountView } from '@mnjs/test';

const scope = createTestScope();
const controller = new AbortController();
scope.defer(() => controller.abort());
const mounted = scope.own(mountView(new View({ template: () => '<p>Owned</p>' })));
try {
  console.log(mounted.el.textContent);
} finally {
  await scope.cleanup();
}
```

`own(resource)` registers `resource.cleanup()` and returns the same resource.
`defer(callback)` registers a disposer. Returned values are ignored and promises
are awaited, one callback at a time in reverse registration order. Cleanup returns
the same Promise on every call. Registration after cleanup begins throws; create
a fresh scope for the next test. A disposer must not await its own scope's cleanup.

Always await or return cleanup so failures reach your runner. An
`AggregateError.errors` array preserves encountered failures in execution order
while other registered disposers run. Helper-owned wrapper messages summarize
failures for runner reporters; user-created aggregate errors retain their own
message and nested errors. Application and Region destruction form one
dependent operation: an Application teardown failure skips Region destruction;
helper-owned fixture DOM is still removed. Partial framework operations are not retried.

Synchronous failures after Region acquisition throw `MountError`; await its
`cleanup` Promise and inspect `cause` for the original error. Cleanup releases
registered resources and known ownership without destroying a failed caller View
or repairing an unknown partial constructor. The [failure contract](#resource-lifetimes-and-failed-mounts)
describes these boundaries. Async startup failure rejects `ready`; cleanup of the
returned handle remains your responsibility.
Register your own listeners, subscriptions and servers, and restore runner mocks
and timers at their chosen lifetime. There is no universal leak detector.

Use `createMarionette()` for independent class/provider and Radio families. Pass
that runtime's `Region` when mounting its Views, and register cleanup for the Radio
instance your test owns. The [repository runtime isolation example](https://github.com/marionettejs/marionette/blob/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples/runtime.spec.js)
shows this with ordinary `mounts.scope.defer(...)` callbacks and public channel APIs.

## Lifecycle semantics

### Runner contexts

`mounts.mountRegion`, `mounts.mountView` and `mounts.mountApplication` accept the neutral helper options
and return the same handles. Application mounting starts by default; use
`{ start: false }` for manual startup. `replaceElement` defaults to `true` in runner
contexts so the View is a direct child of its container; pass `false` if a slot is
intentional. Register project disposers with `mounts.scope.defer(...)` or
`mounts.scope.own(...)`. `mounts.cleanup()` closes this context and is idempotent. It returns the same
Promise, including a cached rejection. Catching a cleanup rejection inside a test
does not make the fixture's later teardown succeed: it reports that same failure.
Use an independently owned neutral scope or an expected-failure runner test when
asserting teardown errors.

Context teardown initiates **all owned mount cleanup immediately**, then awaits
those results before cleaning the project scope. This aborts pending Application
preparation before a slow project disposer can delay it. Project disposers remain
LIFO; failures are retained in `AggregateError.errors`. Closed contexts reject new
mounts. Code that acquires resources outside these mount functions must register
or release them itself.

The [repository form suite](https://github.com/marionettejs/marionette/blob/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples/form.spec.js)
uses this fixture for validation, focus, failed saves, retry and cancellation in
JSDOM and Chromium with Testing Library. It needs no per-file mount tracking or
`beforeEach`/`afterEach` ownership boilerplate.

### Cypress queue and startup

`mount(viewOrFactory, options?)` returns a Cypress chain yielding a `MountedView`.
A factory runs **inside the queue after prior cleanup finishes**. It receives a
fresh mount-local scope, also exposed on its handle: register local services
there and return a fresh View. Test-wide fixtures use `testScope()` instead.
Factories are synchronous; put asynchronous Cypress setup in earlier commands.
Pass a View instance or `() => new MyView()`; a View constructor alone is not a factory.
The adapter uses `[data-cy-root]`, destroys the previous mount before replacement,
and awaits cleanup before and after each test. `setupCypress()` is idempotent and
component-only. Importing this entry registers no commands or hooks.
After-test cleanup also removes the final mounted UI in Cypress open mode. This
adapter prioritizes awaited resource release over leaving that UI available for inspection.

`start: true` waits for initial readiness; cancellation rejects the command and
startup failure preserves its error. `start: false` never calls `app.start()`;
use the public Application API when the test chooses to start. Its factory receives
`(region, scope, fixtures)` so project setup can stay at the support-wrapper boundary.
Queued `unmount()` explicitly awaits the same teardown. Optional `log: false`
suppresses the mount log. Runner mount options accept `regionClass`,
`replaceElement`, and initial View `showOptions` or Application `startOptions`;
the runner chooses the container.
Mounting and queued cleanup use Cypress's configured `defaultCommandTimeout`.
Set that option in the project or test configuration when readiness or disposal
needs more time; later query timeouts do not extend an earlier mount command.

The chain yields a handle, not a DOM query subject. Use `cy.get(...)` or a project
wrapper ending in `.get('#root')` when a DOM subject is required. The integration
does not choose application startup, global providers or CSS for that wrapper.

### Playwright stories and updates

Each story receives serializable props and a context with the same owned mount
methods as the Vitest fixture. Stories own browser providers, callbacks and CSS.
A story can await setup; acquire resources through the context before waiting so
`unmount()` can cancel pending Application startup. Return an `update(props)`
callback for state-preserving updates; returning nothing means updates are unsupported.
With TypeScript, `Story<{ title: string }>` and the resulting gallery preserve the
required props. Runtime data from the browser boundary still belongs to the story
to validate. Direct gallery calls forward absent props as `undefined`; declare an
optional or defaulted story parameter when omission is supported. Playwright's
fixture also passes omitted props as `undefined`.

`gallery.mount({ story, props? })` mounts a new story or updates the current one;
`gallery.unmount()` starts teardown without waiting for unfinished setup/update.
Unknown story ids and missing update callbacks reject with the story name. Setup
failure cleans acquired resources; combined setup/cleanup failure reports both.
An async setup or update that finishes after unmount rejects. User async work still
needs its own cancellation: cleanup cannot stop arbitrary promises or roll back
external effects. Starting a replacement invalidates the previous story's update
handle immediately, then waits for prior disposal before its factory runs.

Playwright 1.63 navigates on each fixture `mount` call and uses `window.mount` for
`component.update`. Before calling the fixture's `mount()` again in the same test,
await the previous handle's `component.unmount()` so its story's disposers run
before navigation. Use `component.update(props)` when the existing story should
remain mounted. The fixture does not invoke `window.unmount` at test teardown;
return that Promise from the `afterEach` above, which cleans up the final page. The
[repository gallery](https://github.com/marionettejs/marionette/blob/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples/gallery/gallery.js)
and [repository specs](https://github.com/marionettejs/marionette/tree/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples/playwright)
include a prepared Application and native keyboard submission.

## Resource lifetimes and failed mounts

A neutral `mountRegion(options)` creates and owns a Region and fixture DOM. Its
handle has `region`, live `el`, `container`, `slot`, `scope`, and asynchronous
`cleanup()`. `region.show(view)` transfers each displayed View to the Region;
replacement destroys its previous View. Cleanup destroys the Region and its last
View, removes helper DOM, and preserves a supplied container and borrowed siblings.
A View detached through `region.detachView()` transfers back to the caller.

`mountView(viewOrFactory, options)` and `mountApplication(factory, options)` expose
the same mount-local `scope`. The View factory receives `(scope)`; the Application
factory receives `(region, scope)`. That scope is available before factory setup,
and it is the same object as `mounted.scope`. Call `scope.defer(dispose)` for a
resource's teardown or `scope.own(resource)` for an object with a `cleanup()` or
`destroy()` method. Cleanup takes precedence when both exist; the method keeps
its receiver and its returned Promise is awaited.
It is a nested owner: local resources finish in reverse registration order before
the outer View/Application, Region, and helper DOM are released. Both
`mounted.cleanup()` and `mounted.scope.cleanup()` perform this entire teardown;
repeated calls return the same Promise, including its rejection.

### Cypress fixture and mount lifetimes

`testScope()` synchronously returns the current test's fixture owner. It throws
before the first test, between tests, and once automatic teardown begins. It is
available in test bodies and per-test hooks while that test is active:

```js
import { testScope, mountApplication, unmount } from '@mnjs/test/cypress';
import { Radio } from '@mnjs/radio';

beforeEach(() => {
  const fixtures = testScope();
  fixtures.reply(Radio, 'session', 'current', () => ({ user: 'Ada' }));
});

it('owns services inside a mounted shell', () => {
  mountApplication((region, scope, fixtures) => {
    // scope is mount-local; fixtures survives this mount being replaced.
    return new Shell({ region });
  }, { start: false }).then(mounted => {
    mounted.app.showView(new ShellView());
    const service = createDialer(mounted.app);
    mounted.scope.own(service);
  });
  unmount(); // Waits for the dialer, then shell and Region. Session replies remain.
});
```

`Shell`, `ShellView`, and `createDialer` stand for the project's own components.
The View factory receives `(scope, fixtures)` and the Application factory receives
`(region, scope, fixtures)` in Cypress. Handles also expose `testScope`, the same
fixture owner returned by `testScope()`. Use the mount-local owner for services
that must be disposed on replacement; do not put them in the fixture owner.

The hierarchy is deliberate, rather than one flat list: the previous mount is
fully cleaned before the next factory runs. At test teardown, mount-local
resources, shell, Region and helper DOM finish before test-wide fixtures. Fixture
registration order relative to mounts does not change that nesting. Explicit
`unmount()` disposes only the current mount and leaves test fixtures alive. The
adapter attempts fixture cleanup even when a mounted disposer fails and preserves
both failures. A new test receives a fresh fixture scope.

### Migrating factory scopes from 0.1

In 0.1 the scope passed to Cypress mount factories was cleaned after the mounted
shell. In 0.2 it is mount-local and cleans up before that shell. Keep shell-owned
services there; move fixtures that must survive replacement to `testScope()`.
The accessor is synchronous, so fixture setup in `beforeEach` needs no `.then`.

### Support-file hook order

Register the helper's hooks before other support modules register root hooks, so
owned resources finish before WebSocket or coverage teardown. With static ESM
imports, put setup in a side-effect module and import it first:

```js
// cypress/support/mnjs-test-hooks.js
import { setupCypress } from '@mnjs/test/cypress';
setupCypress();
```

```js
// cypress/support/component.js
import './mnjs-test-hooks.js';
import '@cypress/code-coverage/support';
import './websockets.js';
// Register the project's mount commands here.
```

Moving a call above static imports does not make it run before those modules.
`afterEach` hooks nested inside `describe` run before the helper's root teardown
and may use `testScope()`. Later root hooks, including top-level spec hooks,
cannot acquire fixtures after that teardown starts.

### E2E resource ownership

`setupCypress()` is for component testing. E2E tests can own fixtures with the
runner-neutral scope and ordinary hooks:

```js
import { createTestScope } from '@mnjs/test/scope';
let resources;
beforeEach(() => { resources = createTestScope(); });
afterEach(() => resources?.cleanup());
// Register each test's fixtures with resources.defer(...) or resources.own(...).
```

### Borrowed container contents

Cleanup removes helper-owned fixture DOM and preserves foreign nodes in a
borrowed container. If a test appends a node outside its mount, register its
removal explicitly, for example `scope.defer(() => node.remove())`.

### Pending startup exception

Cleanup first calls `app.stop()` while the helper's initial startup is pending.
This interrupts readiness before an asynchronous disposer can wait indefinitely
for cancelled work. Marionette's public `stop()` also releases prepared/displayed
UI, so that UI may be destroyed **before** local service cleanup in this one case.
The Application object and Region are destroyed afterward. No cancellation-only
core API or private state is used. If a service requires its shell UI to remain
alive through cleanup, mount with `{ start: false }` or finish startup before
acquiring that service. This hook tracks only the helper's initial start, not
later manual starts/restarts; cancel that later work explicitly before a disposer
waits for its completion. A throwing core stop/destroy callback aborts its dependent
teardown group; the helper reports it and removes its own DOM without retrying
that core operation.

### Scoped Radio replies

`scope.reply(channel, requestName, valueOrHandler, context?)` accepts an explicitly
supplied channel. `scope.reply(Radio, channelName, requestName, valueOrHandler,
context?)` keeps string-based channel names and uses the supplied Radio instance.
There is no global Radio import. Function receivers and arguments follow Radio's
normal context behavior; omitted/falsy contexts use the channel.

Every registration has its own callback identity. Cleanup filters by request name,
that callback, and its effective context. A later unrelated replacement remains
installed, even if it uses the same original function or context. Overwriting an
older reply follows Radio's existing behavior; cleanup does not restore that
older handler. Neither channel-wide `stopReplying()` nor `Radio.reset()` is used.
Registration after cleanup begins is rejected before changing any reply.

`@mnjs/test/scope` exports `createTestScope` and its types without importing
Marionette, a DOM or a runner. The package's Marionette peer still applies to its
mounting exports. Mount-utils remains an optional package peer required when using
`@mnjs/test/cypress`, rather than a dependency of the scope entry.

### Failed acquisition

In the neutral helpers, after Region construction, synchronous factory/show/start failures throw
`MountError`. Its `cause` is the original failure; its `cleanup` Promise finishes
registered resource cleanup and safe owned teardown. Await it before proceeding:

```js
import { mountApplication, MountError } from '@mnjs/test';

try {
  const mounted = mountApplication((region, scope) => createFeature(region, scope));
  // Keep mounted for the test's awaited teardown.
} catch (error) {
  if (error instanceof MountError) { await error.cleanup; throw error.cause; }
  throw error;
}
```

If cleanup also fails, that Promise rejects with an `AggregateError` containing
both the mount failure and cleanup failure. The Promise is observable and must
not be ignored. Runner contexts take responsibility for failed acquisitions, rethrow the
original cause, and await their pending cleanup at teardown. Cypress and the
gallery await that teardown before rejecting setup and preserve combined failures.

A directly supplied caller View is not destroyed when its show fails: if this
Region adopted it, cleanup uses public `detachView()` before destroying the
Region. If detachment fails, destruction of that caller View is not attempted.
A factory View is destroyed only if this Region adopted it; rejection because it
is already managed elsewhere leaves that other owner intact. A returned
Application is owned and can be destroyed after synchronous start failure.

A throwing Region constructor has not returned an instance the helper can safely
destroy; only helper DOM is removed. A factory that throws before returning an
Application can still have its registered resources and known Region released,
but its unknown partial Application is not repaired or destroyed automatically.
Register resources as they are acquired. Failures inside core lifecycle callbacks
still abort those core operations; these helpers do not promise rollback or
repair of partially initialized caller objects.

## Tested with

The table records checks of the published `@mnjs/test` 0.1.0 release.

The 5.0.0 version in this historical table is not the current installation pin.
Use the [installation instructions](/docs/packages/test.md#install) for Marionette 5.0.1 and
the separately versioned `@mnjs/test` 0.2.0.

| Entry / runner | Version and checks |
| --- | --- |
| `@mnjs/test` | Marionette 5.0.0; ESM/CommonJS, DOM-free imports and lifecycle tests. |
| `@mnjs/test/vitest` | Vitest 5.0.3; ESM fixture, JSDOM and Chromium form suite. |
| `@mnjs/test/playwright` | Playwright 1.63.0; browser gallery with its built-in fixture and ESM/CommonJS browser helper. |
| `@mnjs/test/cypress` | Cypress 16.1.1 / Electron and mount-utils 5.0.0; component lifecycle and expected-failure cleanup probes. |
| `@mnjs/test/testing-library` | DOM Testing Library 10.4.1; ESM/CommonJS and types, scoped queries, replacement, readiness and existing runner cleanup. |

Optional peers support Vitest `>=4.1.0`, DOM Testing Library `>=8.7.0`,
mount-utils `>=4` and Cypress `>=14`. The tested minimum versions are
Vitest 4.1.0, DOM Testing Library 8.7.0 and mount-utils 4.0.0, installed with
strict peer resolution and no overrides. Checks cover ESM and CommonJS
runtime/type consumers, the README examples, scope-only imports without
Marionette, and JSDOM lifecycle and ownership behavior.

The Cypress adapter's queue and hook behavior was checked in JSDOM with these
minimum versions and with the current versions listed above. These JSDOM checks
do not run Cypress or its bundled Electron.

Vitest 4.1 is the builder-fixture API boundary; DOM Testing Library 8.7 is the
generic query declaration boundary. Mount-utils keeps the existing v4 floor.
The ranges have no upper caps, allowing future majors to be tried without a
manifest update. Future majors are not pre-certified by this policy. Marionette
remains `^5.0.0` because the mounting lifecycle and declarations target v5.

Cypress/mount-utils, Vitest and DOM Testing Library are optional peers. Import the adapter you use;
the neutral entry and browser gallery do not require these runners. Other
dependency versions and integrations have not been checked.

The [repository examples](https://github.com/marionettejs/marionette/tree/8ed7f99e2c44d3093123dd0796d17416d4d541ad/packages/test/examples)
include larger form, runtime-isolation and browser projects at the same source
revision as this guide. Use the manifest and lockfile from that example checkout
for its dependencies. The guide installed with `@mnjs/test` remains the reference for
that installed helper version.

Runner conventions follow the [Cypress mount guide](https://docs.cypress.io/api/commands/mount),
[Vitest fixture guide](https://vitest.dev/guide/test-context) and
[Playwright component guide](https://playwright.dev/docs/test-components).


[Canonical source](/docs/markdown/packages/test/guide.md) · [Source identity](/docs/supplemental-manifest.json)
