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-eventlibraries (see Atomic Testing vs RTL for the full breakdown). The pain is calling each framework's API directly, per framework, with no shared layer.
- Per-framework (no shared layer)
- Atomic Testing
// 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.
// Define once
const loginScene = {
email: { locator: byDataTestId('email'), driver: TextFieldDriver },
submit: { locator: byDataTestId('submit'), driver: ButtonDriver },
};
// Only the engine creation differs per environment:
const reactEngine = createTestEngine(<LoginForm />, loginScene);
const vueEngine = createTestEngine(LoginFormVue, loginScene);
const e2eEngine = createTestEngine(page, loginScene);
// The test body is identical everywhere:
await testEngine.parts.email.setValue('user@example.com');
await testEngine.parts.submit.click();
Changing framework means changing the engine creation. The test logic is untouched.
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.
- Asserting on markup
- Using drivers
// 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');
// Upgrading MUI v6 → v7 drivers is one import:
import { ButtonDriver } from '@atomic-testing/component-driver-mui-v7';
// Test logic is unchanged:
await testEngine.parts.submit.click();
expect(await testEngine.parts.submit.isDisabled()).toBe(false);
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.
| Situation | Traditional per-framework testing | Atomic Testing |
|---|---|---|
| Framework migration | Rewrite the tests | Change the engine creation |
| Library major upgrade | Update markup assertions across the suite | Update the driver package version |
| DOM + E2E coverage | Duplicate the logic per runner | Share one test body across runners |
| Refactoring internals | Tests break on markup changes | Tests assert behavior, not markup |
| Onboarding | Learn DOM and E2E patterns separately | One 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 →