Skip to main content

Build Component Driver

Component drivers encapsulate the logic for interacting with a UI component. While many drivers are provided out of the box, you can build your own for custom widgets.

Basic structure​

A driver extends ComponentDriver and implements any interfaces that describe its capabilities. The constructor receives a locator, an interactor and optional configuration.

SimpleButtonDriver.ts
import { ComponentDriver, IClickableDriver } from '@atomic-testing/core';

export class SimpleButtonDriver extends ComponentDriver implements IClickableDriver {
click(): Promise<void> {
return this.interactor.click(this.locator);
}

get driverName(): string {
return 'SimpleButtonDriver';
}
}

Capability interfaces​

IClickableDriver above is one of several small, single-method capability interfaces exported from @atomic-testing/core (packages/core/src/drivers/driverTypes.ts). Implement whichever ones describe what your component can actually do, so tests can assert against the interface rather than a component-specific method name:

  • IFormFieldDriver<T> β€” a readable value (getValue()); the base every form field capability builds on.
  • IInputDriver<T> β€” a readable and writable value (setValue()), for text fields, selects, and similar inputs.
  • IToggleDriver β€” a binary selected/unselected state (isSelected()/setSelected()), for checkboxes, radios, and switches.
  • IDisableableDriver, IReadonlyableDriver, IRequirableDriver, IValidatableDriver β€” the disabled/readonly/required/error state checks shared across form controls.
  • IClickableDriver, IMouseInteractableDriver β€” click and hover.

A driver typically implements several of these together (e.g. a text field is IInputDriver<string> & IDisableableDriver & IValidatableDriver). Each interface maps to a driver.interactor.* call, so implementing one is usually a one-line delegation, as click() is above.

Portal hooks​

Some components render outside the DOM subtree of the driver that owns them β€” a dialog or menu portalled to the end of <body>. ComponentDriver exposes two static hooks, overriddenParentLocator() and overrideLocatorRelativePosition(), for re-rooting a driver's locator in that case. See Testing portals & overlays for the full recipe and the jsdom Popover-API caveat to plan around.

Why use drivers?​

Drivers abstract away the DOM details of complex components. Instead of manipulating HTML in tests, you interact with high level methods (next(), setValue(), etc.). Drivers can expose child parts so you can compose them into larger units and reuse them across tests.

For example, the signup form in the example project is implemented with a CredentialFormDriver that wraps four MUI text fields and a navigation component. Tests only call setValue() and next() on this driver, keeping the assertions focused on behavior rather than DOM markup.

By composing multiple drivers you can model entire workflows with minimal test code. For the wider question of how many drivers a page should decompose into β€” and how to avoid a single god driver β€” see Decomposing driver trees.

Example: composing a login form driver​

The snippet below defines a LoginFormDriver that combines two TextFieldDrivers and a ButtonDriver. It exposes a high level login() helper so tests no longer deal with individual inputs.

import { TextFieldDriver, ButtonDriver } from '@atomic-testing/component-driver-mui-v6';
import {
ComponentDriver,
Interactor,
IComponentDriverOption,
IInputDriver,
PartLocator,
ScenePart,
byDataTestId,
} from '@atomic-testing/core';

const parts = {
username: { locator: byDataTestId('username'), driver: TextFieldDriver },
password: { locator: byDataTestId('password'), driver: TextFieldDriver },
submit: { locator: byDataTestId('submit'), driver: ButtonDriver },
} satisfies ScenePart;

export interface LoginCredential {
username: string;
password: string;
}

export class LoginFormDriver extends ComponentDriver<typeof parts> implements IInputDriver<LoginCredential> {
constructor(locator: PartLocator, interactor: Interactor, option?: Partial<IComponentDriverOption>) {
super(locator, interactor, { ...option, parts });
}

async getValue(): Promise<LoginCredential> {
return {
username: (await this.parts.username.getValue()) ?? '',
password: (await this.parts.password.getValue()) ?? '',
};
}

async setValue(value: LoginCredential): Promise<boolean> {
await this.parts.username.setValue(value.username);
await this.parts.password.setValue(value.password);
return true;
}

async login(value: LoginCredential): Promise<void> {
await this.setValue(value);
await this.parts.submit.click();
}

get driverName(): string {
return 'LoginFormDriver';
}
}

By wrapping the lower level drivers, the login test becomes very small:

await testEngine.parts.form.login({ username: 'alice', password: 's3cr3t' });

Environment agnostic​

Drivers rely on the Interactor abstraction, so the same driver can run in unit tests with JSDOM or in browser tests using Playwright (and other environments via a custom interactor). Tests remain identical across environments.

Pro tip

Encapsulating interactions in drivers keeps tests declarative. When the login flow changes, update the driver once and reuse it for both unit and end‑to‑end scenarios.