02 · Component Testing¶
Full E2E tests are slow and only exercise a component through whatever paths
the rest of the app happens to expose. Playwright's component testing runners
(@playwright/experimental-ct-react, -vue, -svelte) mount a single
component in a real browser, in isolation, letting you test its props and
states directly. Note: component testing is JS/TS-only — this module covers
it because Python E2E suites in a full-stack team frequently sit alongside a
JS/TS component-test layer, and understanding the split matters for Level 4
architecture decisions even when you personally write the Python side.
Why component tests exist alongside E2E, not instead of it¶
Unit test: pure function, no DOM at all — fastest, narrowest
Component test: one component, real browser DOM, isolated from the app —
fast, catches rendering/interaction bugs directly
E2E test: the whole app, real backend or a realistic fake —
slowest, but the only tier that catches integration bugs
between systems (routing, auth, real API contracts)
A component test can't tell you if a component wired into the real page works — only that it works given the exact props you passed it. That's a feature: it isolates the failure to the component itself when the assertion fails, rather than to "somewhere in the checkout flow."
Setup (React example)¶
// playwright-ct.config.ts
import { defineConfig, devices } from "@playwright/experimental-ct-react";
export default defineConfig({
testDir: "./src",
snapshotDir: "./__snapshots__",
use: { trace: "on-first-retry" },
projects: [{ name: "chromium", use: devices["Desktop Chrome"] }],
});
A basic component test¶
// Button.ct.tsx
import { test, expect } from "@playwright/experimental-ct-react";
import { Button } from "./Button";
test("renders label and fires onClick", async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button label="Save" onClick={() => (clicked = true)} />
);
await expect(component).toHaveText("Save");
await component.click();
expect(clicked).toBe(true);
});
# mount() renders the component into a real, isolated browser
# page — not jsdom, an actual Chromium instance — so real CSS,
# real event dispatch, and real layout all apply, unlike a
# pure unit test with a simulated DOM
Testing every visual state of a component directly¶
test("disabled button does not fire onClick", async ({ mount }) => {
let clicked = false;
const component = await mount(
<Button label="Save" disabled onClick={() => (clicked = true)} />
);
await component.click({ force: true }); // bypass actionability to prove it's truly inert
expect(clicked).toBe(false);
});
test("loading state shows a spinner instead of the label", async ({ mount }) => {
const component = await mount(<Button label="Save" loading />);
await expect(component.getByRole("status")).toBeVisible();
await expect(component).not.toContainText("Save");
});
# hitting every prop combination (disabled, loading, error)
# directly is far cheaper here than finding an E2E scenario
# that happens to put the real app into each of those states —
# some states (a specific error prop) might not even be
# reachable through the live app at all
Mocking props that would otherwise require a real backend¶
test("data table renders rows from props, no API involved", async ({ mount }) => {
const rows = [
{ id: 1, name: "Widget", price: 9.99 },
{ id: 2, name: "Gadget", price: 19.99 },
];
const component = await mount(<DataTable rows={rows} />);
await expect(component.getByRole("row")).toHaveCount(3); // 2 + header
await expect(component.getByText("$19.99")).toBeVisible();
});
When to still write the E2E test anyway¶
Skip E2E, keep only component test:
- pure presentational components (a Badge, a Tooltip, a Card)
- components whose only inputs are props, no routing/context/API
Keep both:
- a form component: component test covers validation states,
E2E covers "this form's submit actually persists to the backend
and the resulting page reflects it"
- anything wired into routing or global app state
Full worked example: form component test suite¶
// LoginForm.ct.tsx
import { test, expect } from "@playwright/experimental-ct-react";
import { LoginForm } from "./LoginForm";
test("shows validation error for invalid email", async ({ mount }) => {
const component = await mount(<LoginForm onSubmit={() => {}} />);
await component.getByLabel("Email").fill("not-an-email");
await component.getByRole("button", { name: "Sign in" }).click();
await expect(component.getByText("Enter a valid email")).toBeVisible();
});
test("calls onSubmit with form values when valid", async ({ mount }) => {
let submitted: any = null;
const component = await mount(
<LoginForm onSubmit={(values) => (submitted = values)} />
);
await component.getByLabel("Email").fill("user@example.com");
await component.getByLabel("Password").fill("hunter2");
await component.getByRole("button", { name: "Sign in" }).click();
expect(submitted).toEqual({ email: "user@example.com", password: "hunter2" });
});
How It Actually Works¶
mount() isn't a JavaScript simulation like jsdom — it works by starting a
tiny purpose-built dev server (a Vite build under the hood for the React/
Vue/Svelte CT runners) that compiles your component and a small test
harness page around it, then has Playwright navigate a real Chromium page
to that served harness via ordinary Page.navigate, exactly like navigating
to any other URL. The component handle returned from mount() is a
regular Locator scoped to the mounted root element — every method on it
(.click(), .getByRole(), to_be_visible) goes through the identical CDP
actionability-check and accessibility-tree pipeline covered throughout this
site, which is exactly why component.click({force: true}) behaves
identically to a page-level forced click: it's the same underlying
mechanism, just scoped to a component mounted in isolation rather than a
full app route.
This is also why component tests catch real rendering/CSS/event bugs that a
jsdom-based unit test structurally cannot: jsdom never runs an actual layout
or paint engine, so it can't observe genuine CSS cascade effects, real
getBoundingClientRect() values, or true browser event dispatch order —
whereas a Playwright component test's mount() produces a real DOM inside a
real rendering engine, subject to the exact same actionability and
accessibility-tree machinery as a full E2E page, just with the surrounding
application (routing, global state, real API calls) deliberately absent.
Exercise¶
- Scaffold
@playwright/experimental-ct-react(or the Vue/Svelte equivalent your stack uses) in a sample project and mount a single simple component. - Write component tests covering at least three prop-driven states of one component (default, disabled, loading, or error) without touching a real backend.
- Write a test that passes a mock
onSubmit/onChangecallback as a prop and asserts it was called with the expected arguments. - Pick one component currently only covered by an E2E test and write a component test for its validation logic; note in a comment which parts of the original E2E test remain necessary anyway (e.g. real persistence) and which are now redundant.