Markdown & source details
Supplemental guide. Source snapshot: 8ed7f99e2c44d3093123dd0796d17416d4d541ad. Reading source: 8ed7f99e2c44d3093123dd0796d17416d4d541ad. Check the guide's compatibility requirements against your installed packages.
On this page
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.
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
- Vitest fixtures
- Testing Library queries
- Resource lifetimes and failed mounts
- Cypress component setup
- Playwright gallery setup
- Views, Applications and resource scopes
- Cleanup, readiness and runner behavior
- Tested versions
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, add:
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:
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:
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:
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 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.
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.
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.
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
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:
// 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:
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.
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:
<!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:
// 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):
// 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:
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.
// 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);
// 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:
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:
<!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:
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#
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#
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:
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#
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
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
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
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
and repository specs
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:
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:
// cypress/support/mnjs-test-hooks.js
import { setupCypress } from '@mnjs/test/cypress';
setupCypress();
// 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:
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:
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 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
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, Vitest fixture guide and Playwright component guide.