Skip to main content

Why Atomic Testing?

The problem​

Testing a UI built on third-party component libraries — Material UI, Radix, PrimeVue — is hard to keep maintainable. Each component's markup is an implementation detail, so tests that reach into it break every time the component changes or the library upgrades. And each framework and test runner has its own interaction API, so the same intent (fill this field, click submit) is written differently in React, Vue, Angular, and Playwright, with nothing shared between them.

The solution​

Atomic Testing gives you one consistent way to interact with components across every testing environment. You describe the components you care about once, as a ScenePart, and drive them through component drivers that expose semantic actions (click(), setValue(), getText()) instead of DOM manipulation. The same scene and the same test body then run under React, Vue, Angular, Storybook play functions, and Playwright — because only the Interactor underneath changes, not your test.

Three principles guide the design:

  • Reusability — a standard, reusable way to interact with any component.
  • Composability — compose small drivers into larger ones to model complex components and flows.
  • Adaptability — the same test logic runs across DOM tests (React, Vue, Angular, vanilla JS), Storybook, and end-to-end (Playwright).

The rest of this page makes the case concretely: the scenarios where that portability pays off, the trade-offs over time, and — just as important — when not to reach for Atomic Testing.

What it looks like​

There's some setup: you declare the parts of a scene once.

const loginScene = {
email: { locator: byDataTestId('email'), driver: TextFieldDriver },
password: { locator: byDataTestId('password'), driver: TextFieldDriver },
submit: { locator: byDataTestId('submit'), driver: ButtonDriver },
error: { locator: byDataTestId('error'), driver: HTMLElementDriver },
} satisfies ScenePart;

After that, tests read as intent, and the same body works in every environment:

await testEngine.parts.email.setValue('user@example.com');
await testEngine.parts.password.setValue('secure123');
await testEngine.parts.submit.click();

expect(await testEngine.parts.error.isVisible()).toBe(false);

// The same test runs under React DOM, Vue DOM, and Playwright E2E —
// and transfers unchanged when you migrate frameworks.

Where it pays off​

Framework migration​

Without a shared layer, switching frameworks means rewriting every test, because each framework's API is called directly.

To be clear: the pain below isn't React Testing Library, Vue Test Utils, or Playwright — each is a solid tool for its own environment. For DOM testing, Atomic Testing is a sibling of RTL, not a wrapper around it: both build on the same @testing-library/dom / @testing-library/user-event libraries (see Atomic Testing vs RTL for the full breakdown). The pain is calling each framework's API directly, per framework, with no shared layer.

// React Testing Library
const emailInput = screen.getByLabelText(/email/i);
const submitButton = screen.getByRole('button', { name: /submit/i });
fireEvent.change(emailInput, { target: { value: 'user@example.com' } });
fireEvent.click(submitButton);

// Vue Test Utils (different API)
const wrapper = mount(LoginForm);
await wrapper.find('[data-testid="email"]').setValue('user@example.com');
await wrapper.find('[data-testid="submit"]').trigger('click');

// Playwright (different again)
await page.locator('[data-testid="email"]').fill('user@example.com');
await page.locator('[data-testid="submit"]').click();

Changing framework means rewriting every test.

Component library upgrades​

A design-system major upgrade often changes the markup and class names your tests asserted on. With drivers, those details live in the driver package, so an upgrade is an import change rather than a sweep across hundreds of tests.

// Before a MUI major upgrade:
expect(button).toHaveClass('MuiButton-containedPrimary');

// After: the classes changed, and every test asserting them breaks.
expect(button).toHaveClass('MuiButton-contained MuiButton-colorPrimary');

One test body, fast and thorough​

The same test logic can run as a fast jsdom test during development and as a full browser test before deploy — you write it once and choose the engine.

async function validatePaymentFlow(testEngine) {
await testEngine.parts.creditCard.setValue('4111111111111111');
await testEngine.parts.expiryDate.setValue('12/25');
await testEngine.parts.cvv.setValue('123');
await testEngine.parts.submit.click();
expect(await testEngine.parts.successMessage.isVisible()).toBe(true);
}

// Fast DOM test during development:
await validatePaymentFlow(createTestEngine(<PaymentForm />, paymentScene));

// Same function, real browser before deploy:
await validatePaymentFlow(createTestEngine(page, paymentScene));

The trade-off over time​

The up-front cost is the scene definition and learning the driver API. The payoff grows as the suite does. Once a scene has a multi-field form, you can compose a driver method for it — the same pattern LoginFormDriver.login() uses in the tutorial — so tests stay declarative even as flows grow:

import { ComponentDriver } from '@atomic-testing/core';

// A driver method you write once — not a built-in API:
class SignupFormDriver extends ComponentDriver<typeof parts> {
async fillAndSubmit(value: SignupValue): Promise<void> {
await this.parts.email.setValue(value.email);
await this.parts.password.setValue(value.password);
await this.parts.confirmPassword.setValue(value.confirmPassword);
await this.parts.agreeToTerms.setSelected(value.agreeToTerms);
await this.parts.submit.click();
}
}

// The test stays a single declarative call:
await testEngine.parts.form.fillAndSubmit({
email: 'user@example.com',
password: 'secure123',
confirmPassword: 'secure123',
agreeToTerms: true,
});

The same amount of behavior driven through raw framework queries takes a query-and-fire block per field, repeated in every test that touches the form.

SituationTraditional per-framework testingAtomic Testing
Framework migrationRewrite the testsChange the engine creation
Library major upgradeUpdate markup assertions across the suiteUpdate the driver package version
DOM + E2E coverageDuplicate the logic per runnerShare one test body across runners
Refactoring internalsTests break on markup changesTests assert behavior, not markup
OnboardingLearn DOM and E2E patterns separatelyOne driver API across environments

When not to use Atomic Testing​

The benefits scale with maintenance and reuse, so the payoff is smaller when those are absent:

  • Prototype or throwaway code you won't maintain.
  • A single simple element — testing one button directly is fine.
  • No component library and no reuse — if there's nothing to share a driver across, the abstraction earns less.
  • A team not ready to adopt a shared pattern — drivers need buy-in to pay off.

Getting started​

If the trade-offs land on the "worth it" side, you don't have to wire anything up by hand. create atomic-testing detects your framework, runner, and design system and writes a runnable example test for you — see the Quick Start for the one-liner and exactly what it generates.

Adopt it gradually: use it for new tests first, then convert the tests that break most often during refactors and library upgrades. You don't need to migrate an existing suite to benefit.

Ready to start?

Build your first test and see the pattern firsthand.

Start Tutorial →

Still have questions?

The FAQ covers common concerns and comparisons.

Read FAQ →