Interactor
An Interactor provides the low level operations used by component drivers to manipulate and query the UI. Drivers delegate every action such as clicking, entering text or reading an attribute to an interactor. By swapping the interactor implementation, the same driver code works in different environments like unit tests running in JSDOM or endβtoβend tests with Playwright.
Available interactorsβ
The project ships with several interactors:
- DOMInteractor β runs against a DOM environment using
@testing-libraryutilities. This is used for unit/integration tests. - ReactInteractor from @atomic-testing/react-core and the versioned
React adapters β extensions of
DOMInteractorthat wrap interactions in React'sact()helper so state updates are flushed correctly when testing React 18 or later. - VueInteractor from @atomic-testing/vue-3 β an extension of
DOMInteractorthat calls Vue'snextTick()after every interaction so reactive state settles before the next assertion. - AngularInteractor from @atomic-testing/angular-core (used by the
versioned
@atomic-testing/angular-20,-21and-22adapters) β an extension ofDOMInteractorthat awaits the app'sApplicationRef.whenStable()after every interaction, so change detection has settled before the next assertion. This works under both zone.js and zoneless change detection. - StorybookInteractor from @atomic-testing/storybook β an extension of
DOMInteractorfor driving component drivers inside a real-browser Storybook:playfunctions and stories running under@storybook/addon-vitest. Unlike the React/Vue/Angular interactors, it has no frameworkact()/nextTick()to hook into, so it settles after every interaction with a macrotask plus two animation frames instead, and dispatches through Storybook's instrumenteduserEventso interactions show up in the Interactions panel. - PlaywrightInteractor β drives a Playwright
Pageobject to execute tests in a real browser.
Building an interactorβ
To build your own interactor, implement the Interactor interface from the core
package. Most custom interactors extend an existing one and override only the
behaviour that differs. The snippet below logs every click before delegating to
DOMInteractor:
import { ClickOption, PartLocator } from '@atomic-testing/core';
import { DOMInteractor } from '@atomic-testing/dom-core';
export class LoggingInteractor extends DOMInteractor {
async click(locator: PartLocator, option?: Partial<ClickOption>): Promise<void> {
console.log('clicking', await this.innerHTML(locator));
await super.click(locator, option);
}
}
None of the framework createTestEngine factories β @atomic-testing/react-18/
react-19, @atomic-testing/vue-3, @atomic-testing/angular-20/-21/-22,
@atomic-testing/playwright, or @atomic-testing/dom-core β accept a custom
interactor; each one constructs its own hardcoded Interactor subclass
internally (see e.g. packages/dom-core/src/createTestEngine.ts). To use a
custom interactor, construct TestEngine (packages/core/src/TestEngine.ts)
directly instead of calling createTestEngine. For a DOM-only interactor, that
means reproducing what @atomic-testing/dom-core's createTestEngine does
internally, with your interactor swapped in for DOMInteractor:
import { TestEngine } from '@atomic-testing/core';
import { LoggingInteractor } from './LoggingInteractor';
// Same shape as @atomic-testing/dom-core's own createTestEngine, with
// LoggingInteractor swapped in for DOMInteractor. `element` is whatever root
// node your component was already rendered into.
const testEngine = new TestEngine([], new LoggingInteractor(element), { parts: partDefinitions });
The cleanup callback (TestEngine's 4th constructor argument) is optional and
defaults to a no-op if omitted. If your custom interactor needs React's
act() or Vue's nextTick() wrapping too, extend ReactInteractor /
VueInteractor instead of DOMInteractor, and mirror the locator/mount/cleanup
logic from that framework's own createTestEngine
(packages/react-core/src/createTestEngine.ts,
packages/vue-3/src/createTestEngine.ts).
The public toolkitβ
@atomic-testing/core exports the same building blocks every shipped interactor
is written with:
locatorUtil.toCssSelector(locator, interactor)β resolves aPartLocatorchain to the CSS selector string your environment actually queries with.interactorUtil.interactorWaitUtilβ backswaitUntilComponentState; probes anInteractormethod on an interval until aWaitForOption.conditionis met or the timeout elapses.timingUtil.wait/timingUtil.waitUntilβ the lower-level polling primitiveinteractorWaitUtilitself is built on, if you need a bespoke probe.dateUtilβ the shared HTML date/time/datetime-local input validation policy (assertValidHtmlDateInputValue), soenterTextrejects malformed values consistently across every interactor.ElementNotFoundErroranddefaultWaitForOptionβ the shared error class and default timeout/condition every interactor's option types build on.
The ElementNotFoundError-vs-auto-wait conventionβ
Every Interactor method that targets a missing element must resolve the same
way regardless of environment: a read returns the type's empty/falsy value
(undefined, '', false) immediately, and a mutation throws
ElementNotFoundError immediately β never your environment's own native
auto-wait timeout. DOMInteractor gets this for free (a jsdom query either
finds an element or doesn't, synchronously). A browser-driving adapter has to
work for it: PlaywrightInteractor resolves every read through a private
firstMatch helper (an immediate count() === 0 check, never Playwright's
default actionability wait) and every mutation through runMutation (run the
action; if it fails AND the element genuinely doesn't exist, rethrow as
ElementNotFoundError; otherwise propagate the original error so a real,
temporarily-not-actionable element still gets Playwright's auto-wait). Skipping
this β calling your driver's native locator API directly instead of routing
through an equivalent helper β is the single most common way a new adapter
technically type-checks but disagrees with every other interactor on missing-element
behavior.
jsdom vs. real-browser caveatsβ
A conformant interactor still can't fake real layout: jsdom performs no layout
engine, so getBoundingRect on a jsdom-backed interactor returns a zero rect
rather than the CSS-computed box a browser gives you, and getStyleValue only
sees inline styles, never cascade from a stylesheet rule. Gate assertions that
depend on real geometry behind the test harness's hasLayout flag (see
packages/internal-interactor-conformance/src/conformanceSuites.ts for the
pattern) rather than assuming every interactor's numbers are comparable.
PlaywrightInteractor: a from-scratch exampleβ
DOMInteractor and its subclasses share almost all of their implementation, so
they're a poor template for an adapter with no DOM to inherit from β
PlaywrightInteractor (packages/playwright/src/PlaywrightInteractor.ts)
implements Interactor bare, driving a Page object instead, and is the
better model to study for a genuinely new environment (a native app driver, a
different browser-automation library). Notice the shape: every primitive
resolves a PartLocator via locatorUtil.toCssSelector, then reads go through
firstMatch and mutations go through runMutation (both above) to uphold the
error convention, and layout-dependent methods return real Playwright geometry
with no jsdom fallback needed.
Validating your interactor: the conformance suiteβ
@atomic-testing/internal-interactor-conformance exports a runnable test-compatibility-kit
(defineConformanceSuite) that asserts the full Interactor contract β every
capability facet, the null-vs-empty and throw-vs-auto-wait conventions above,
and hasLayout-gated geometry β against a factory that produces your
interactor over a shared, framework-agnostic fixture DOM. It's how
DOMInteractor and PlaywrightInteractor themselves stay provably in sync;
wire your own interactor factory into it the same way
package-tests/internal-interactor-conformance-test does for the two shipped
adapters before trusting a new one in real tests.