Skip to main content

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-library utilities. This is used for unit/integration tests.
  • ReactInteractor from @atomic-testing/react-core and the versioned React adapters – extensions of DOMInteractor that wrap interactions in React's act() helper so state updates are flushed correctly when testing React 18 or later.
  • VueInteractor from @atomic-testing/vue-3 – an extension of DOMInteractor that calls Vue's nextTick() 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, -21 and -22 adapters) – an extension of DOMInteractor that awaits the app's ApplicationRef.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 DOMInteractor for driving component drivers inside a real-browser Storybook: play functions and stories running under @storybook/addon-vitest. Unlike the React/Vue/Angular interactors, it has no framework act()/nextTick() to hook into, so it settles after every interaction with a macrotask plus two animation frames instead, and dispatches through Storybook's instrumented userEvent so interactions show up in the Interactions panel.
  • PlaywrightInteractor – drives a Playwright Page object 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 a PartLocator chain to the CSS selector string your environment actually queries with.
  • interactorUtil.interactorWaitUtil β€” backs waitUntilComponentState; probes an Interactor method on an interval until a WaitForOption.condition is met or the timeout elapses.
  • timingUtil.wait / timingUtil.waitUntil β€” the lower-level polling primitive interactorWaitUtil itself is built on, if you need a bespoke probe.
  • dateUtil β€” the shared HTML date/time/datetime-local input validation policy (assertValidHtmlDateInputValue), so enterText rejects malformed values consistently across every interactor.
  • ElementNotFoundError and defaultWaitForOption β€” 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.