Skip to main content

Step-by-Step Tutorial

This tutorial builds one complete, runnable Atomic Testing test from scratch using the canonical stack — @atomic-testing/react-19 + @atomic-testing/component-driver-html + Jest — the same stack introduced in Quick Start. Component drivers work the same way in isolated unit tests or full browser tests, so everything you see here applies to both.

Once you have a green test, the tutorial shows how to wrap a component library like Material UI, compose drivers for reuse, and reuse the same scene in a Playwright E2E test. An optional section at the end walks through the full example-mui-signup-form app if you want to see a larger, real-world codebase.

1. Install dependencies

Fast path

create atomic-testing installs these packages and writes the Jest config for you in one command. This tutorial does it by hand so you understand each piece — reach for the CLI when you want the setup done automatically.

pnpm add -D @atomic-testing/core @atomic-testing/react-19 @atomic-testing/component-driver-html

# react-19's peer dependencies — npm/yarn don't install peers automatically like pnpm can
pnpm add -D react react-dom @testing-library/dom @testing-library/react @testing-library/user-event

pnpm add -D jest jest-environment-jsdom @swc/jest @swc/core @types/jest

(Already have a runner configured? Skip to step 2. See Configure your test runner for the Vitest equivalent.)

2. Configure Jest

jest.config.cjs
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', { jsc: { transform: { react: { runtime: 'automatic' } } } }],
},
testMatch: ['**/*.test.@(ts|tsx|js|jsx)'],
// ReactInteractor wraps every interaction in React's act(). Without this flag,
// React 18+ logs "not wrapped in act(...)" warnings even though it is.
globals: {
IS_REACT_ACT_ENVIRONMENT: true,
},
};
CommonJS config

The .cjs extension keeps this module.exports config as CommonJS even when your package.json has "type": "module" (common with a Vite-scaffolded project), sidestepping the rename trap. This is why create atomic-testing emits jest.config.cjs.

3. Write the component under test

LoginForm.tsx
import { useState } from 'react';

export function LoginForm({ onSubmit }: { onSubmit: (username: string) => void }) {
const [username, setUsername] = useState('');

return (
<form
data-testid='login-form'
onSubmit={e => {
e.preventDefault();
onSubmit(username);
}}>
<input data-testid='username' value={username} onChange={e => setUsername(e.target.value)} />
<button data-testid='submit' disabled={username.length === 0}>
Log in
</button>
</form>
);
}

4. Declare a ScenePart

A ScenePart maps each data-testid to a locator and the driver that knows how to interact with it:

LoginForm.scene.ts
import { HTMLButtonDriver, HTMLTextInputDriver } from '@atomic-testing/component-driver-html';
import { byDataTestId, ScenePart } from '@atomic-testing/core';

export const loginFormScene = {
username: { locator: byDataTestId('username'), driver: HTMLTextInputDriver },
submit: { locator: byDataTestId('submit'), driver: HTMLButtonDriver },
} satisfies ScenePart;

5. Render, interact, assert, clean up

LoginForm.test.tsx
import { TestEngine } from '@atomic-testing/core';
import { createTestEngine } from '@atomic-testing/react-19';

import { LoginForm } from './LoginForm';
import { loginFormScene } from './LoginForm.scene';

describe('LoginForm', () => {
let engine: TestEngine<typeof loginFormScene>;
let onSubmit: jest.Mock;

beforeEach(() => {
onSubmit = jest.fn();
engine = createTestEngine(<LoginForm onSubmit={onSubmit} />, loginFormScene);
});

afterEach(async () => {
await engine.cleanUp();
});

it('enables submit once a username is entered', async () => {
expect(await engine.parts.submit.isDisabled()).toBe(true);

await engine.parts.username.setValue('ada');
expect(await engine.parts.submit.isDisabled()).toBe(false);

await engine.parts.submit.click();
expect(onSubmit).toHaveBeenCalledWith('ada');
});
});

6. Run it

npx jest

You should see one passing test. That's the whole loop: install → configure → render → interact → assert → clean up.

7. Compose a driver for reuse

For larger forms it's convenient to wrap the individual inputs in a driver of your own, exposing a high-level action instead of making tests poke at each field. Below is a driver for the LoginForm above, combining HTMLTextInputDriver and HTMLButtonDriver:

import { HTMLButtonDriver, HTMLTextInputDriver } from '@atomic-testing/component-driver-html';
import {
ComponentDriver,
Interactor,
IComponentDriverOption,
IInputDriver,
PartLocator,
ScenePart,
byDataTestId,
} from '@atomic-testing/core';

const parts = {
username: { locator: byDataTestId('username'), driver: HTMLTextInputDriver },
submit: { locator: byDataTestId('submit'), driver: HTMLButtonDriver },
} satisfies ScenePart;

export interface LoginCredential {
username: 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()) ?? '' };
}

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

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

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

Use it in place of the raw scene parts:

LoginForm.test.tsx (with a composed driver)
const parts = {
form: { locator: byDataTestId('login-form'), driver: LoginFormDriver },
} satisfies ScenePart;

const engine = createTestEngine(<LoginForm onSubmit={onSubmit} />, parts);
await engine.parts.form.login({ username: 'ada' });

With the login() helper, tests stay declarative and don't depend on the form's markup. If the implementation changes, update only LoginFormDriver.

8. Same scene, now in Playwright (E2E)

Because drivers depend only on the Interactor abstraction, the exact same loginFormScene from step 4 works against a real browser page — you just create the engine with @atomic-testing/playwright's createTestEngine(page, ...) instead of React's. As covered in the Playwright notes on Quick Start, this requires an actual page served by your app (Playwright drives a real browser, not jsdom) — configure webServer/baseURL in playwright.config.ts to point at it:

login.e2e.spec.ts
import { TestEngine } from '@atomic-testing/core';
import { createTestEngine } from '@atomic-testing/playwright';
import { test, expect } from '@playwright/test';

import { loginFormScene } from './LoginForm.scene';

test.describe('LoginForm', () => {
let engine: TestEngine<typeof loginFormScene>;

test.beforeEach(async ({ page }) => {
await page.goto('/login'); // a route your app actually serves
engine = createTestEngine(page, loginFormScene);
});

test.afterEach(async () => {
await engine.cleanUp();
});

test('enables submit once a username is entered', async () => {
expect(await engine.parts.submit.isDisabled()).toBe(true);
await engine.parts.username.setValue('ada');
expect(await engine.parts.submit.isDisabled()).toBe(false);
});
});

Testing a component library (e.g. MUI)

Component libraries like Material UI often need a context provider — a ThemeProvider, a router, etc. — to render correctly. createTestEngine has no knowledge of your app's providers, so wrap the component yourself before passing it in.

The example-mui-signup-form app does exactly this with a small local helper. It is not a package export — you write the equivalent for your own app:

testUtil.tsx (example-local helper, not a package export)
import { createTestEngine } from '@atomic-testing/react-19';
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider } from '@mui/material/styles';
import { ReactNode } from 'react';

import { theme } from './theme';

const ComponentWrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);

export const createTestEngineForComponent: typeof createTestEngine = (node, partDefinitions, option) =>
createTestEngine(<ComponentWrapper>{node}</ComponentWrapper>, partDefinitions, option);

Once you have that wrapper, use it exactly like createTestEngine, and reach for a component-library driver package instead of the HTML drivers — for example @atomic-testing/component-driver-mui-v7 (see the Package Guide for which MUI version matches your app):

import { ButtonDriver, TextFieldDriver } from '@atomic-testing/component-driver-mui-v7';
import { byDataTestId, ScenePart } from '@atomic-testing/core';

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

const engine = createTestEngineForComponent(<CredentialForm />, parts);

Optional: explore the full example app

The rest of this section tours example-mui-signup-form, a larger multi-step signup form in the monorepo's examples folder, built with the createTestEngineForComponent wrapper from the previous section. You may also run it live in Codesandbox without installing anything locally.

Install and run it

From the repository root:

pnpm install
cd examples/example-mui-signup-form
pnpm install
pnpm dev

Open http://localhost:5173 to interact with the multi-step form.

Run its tests

pnpm test:dom # Unit tests for each form step
pnpm test:e2e:chrome # e2e/success.spec.ts in Chrome (add --ui to watch it)

Explore Storybook (optional)

Some components contain Storybook stories with interaction tests:

pnpm storybook

To drive those stories with the same component drivers used here — inside Storybook's play functions and under @storybook/addon-vitest — see Testing in Storybook.

Write an end-to-end test

The example's e2e/success.spec.ts reuses the same drivers as its unit tests — this is the pattern from step 8 above, applied to the full signup flow:

import { createTestEngine } from '@atomic-testing/playwright';
import { expect, test } from '@playwright/test';

import { getGoodCredentialMock } from './__mocks__/signup';
import { parts } from './signupScenePart';

// Shortened end-to-end test using Playwright

test('user can sign up', async ({ page }) => {
await page.goto('/');
const engine = createTestEngine(page, parts);

await engine.parts.credentialStep.setValue(getGoodCredentialMock());
await engine.parts.credentialStep.next();
await expect(await engine.parts.shippingStep.isVisible()).toBe(true);
});

What to try next

  • Core Concepts — the vocabulary behind ScenePart, Locator, and TestEngine
  • Best Practices — patterns for keeping tests portable and maintainable
  • Package Guide — which driver and framework packages to install for your stack
  • Build Component Driver — write a driver for a custom or unsupported component
  • API Reference — the full list of locators, drivers, and interactor methods
Morty Proxy This is a proxified and sanitized view of the page, visit original site.