Testing Web Components
This lesson explains how to test Web Components at every level, from fast unit tests to browser-based integration and accessibility checks, so you can ship reliable custom elements with confidence.
Why Testing Web Components Matters
Web Components are often shared across teams, frameworks, and
products. A bug in one custom element can break many applications.
Testing protects your public API, lifecycle behavior, accessibility,
and event contracts before consumers depend on them.
Because Web Components use Custom Elements, Shadow DOM, slots, and
custom events, they need a testing strategy that goes beyond plain
HTML or framework-only component tests.
| Concept | Description |
| Custom Elements | Require registration and lifecycle testing |
| Shadow DOM | Needs encapsulated rendering checks |
| Slots | Require composition and projection tests |
| Custom Events | Must verify payload and bubbling behavior |
| Accessibility | Roles, labels, and keyboard support still matter |
| Goal | Reliable reusable UI across apps and frameworks |
Basic Testing Example
// alert-banner.js
class AlertBanner extends HTMLElement {
connectedCallback() {
const type = this.getAttribute("type") || "info";
this.innerHTML = `
<div class="alert alert-${type}" role="alert">
<slot></slot>
</div>
`;
}
}
customElements.define("alert-banner", AlertBanner);
// alert-banner.test.js
import "./alert-banner.js";
it("renders alert with default type", () => {
const banner = document.createElement("alert-banner");
banner.textContent = "Saved successfully";
document.body.appendChild(banner);
const alert = banner.querySelector('[role="alert"]');
expect(alert).not.toBeNull();
expect(alert.className).toContain("alert-info");
expect(alert.textContent).toContain("Saved successfully");
banner.remove();
});
Even a simple test verifies rendering, default attribute behavior,
accessibility markup, and cleanup after the element is removed.
Testing Pyramid for Web Components
| Level | Scope | Tools | Speed |
| Unit Tests | One custom element in isolation | Vitest, Jest, Web Test Runner | Fast |
| Integration Tests | Multiple components working together | Vitest, Web Test Runner | Medium |
| Accessibility Tests | Roles, labels, keyboard, contrast | axe-core, Playwright | Medium |
| End-to-End Tests | Full user flows in a real app | Playwright, Cypress | Slow |
Use many fast unit tests, fewer integration tests, and a small set
of E2E tests for critical flows. This balance gives confidence
without slowing down development.
What to Test in Web Components
| Area | What to Verify | Example |
| Registration | Element is defined and upgradeable | customElements.get("app-button") |
| Rendering | Default UI appears on connect | Button label renders correctly |
| Attributes | Observed attributes update UI | variant="danger" changes style |
| Properties | JS properties sync with behavior | element.open = true |
| Events | Custom events fire with correct detail | item-selected event |
| Slots | Slotted content is projected | Named title slot works |
| Lifecycle | Setup and cleanup run correctly | Timer cleared on disconnect |
| Accessibility | Roles, labels, and keyboard support | Dialog traps focus |
Testing Lifecycle Behavior
class LiveRegion extends HTMLElement {
connectedCallback() {
this.setAttribute("aria-live", "polite");
this.message = "Connected";
}
disconnectedCallback() {
this.message = "Disconnected";
}
}
customElements.define("live-region", LiveRegion);
it("handles connect and disconnect lifecycle", () => {
const region = document.createElement("live-region");
document.body.appendChild(region);
expect(region.getAttribute("aria-live")).toBe("polite");
expect(region.message).toBe("Connected");
region.remove();
expect(region.message).toBe("Disconnected");
});
Lifecycle tests are critical because many bugs appear only when
components are mounted, updated, or removed repeatedly in an app.
Testing Shadow DOM
class ToggleChip extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<button type="button" part="chip">
<slot></slot>
</button>
`;
}
}
customElements.define("toggle-chip", ToggleChip);
it("renders slotted label inside shadow root", () => {
const chip = document.createElement("toggle-chip");
chip.textContent = "Active";
document.body.appendChild(chip);
const button = chip.shadowRoot.querySelector("button");
expect(button).not.toBeNull();
expect(button.textContent.trim()).toBe("Active");
chip.remove();
});
Open Shadow DOM allows internal queries during tests. Prefer
asserting user-visible results and public behavior instead of
testing every private DOM detail.
Testing Custom Events
class OptionList extends HTMLElement {
connectedCallback() {
this.addEventListener("click", (event) => {
const item = event.target.closest("[data-value]");
if (!item) return;
this.dispatchEvent(
new CustomEvent("option-selected", {
detail: { value: item.dataset.value },
bubbles: true,
composed: true
})
);
});
}
}
it("emits option-selected when an item is clicked", () => {
const list = document.createElement("option-list");
list.innerHTML = `<button data-value="pro">Pro</button>`;
document.body.appendChild(list);
const handler = vi.fn();
list.addEventListener("option-selected", handler);
list.querySelector("button").click();
expect(handler).toHaveBeenCalledOnce();
expect(handler.mock.calls[0][0].detail.value).toBe("pro");
list.remove();
});
Event tests should verify the event name, payload, bubbling, and
whether the event crosses Shadow DOM boundaries with
composed: true.
Integration Testing Example
<signup-form>
<email-input></email-input>
<password-input></password-input>
<submit-button>Create Account</submit-button>
</signup-form>
it("submits form when child inputs are valid", () => {
document.body.innerHTML = `
<signup-form>
<email-input value="alex@example.com"></email-input>
<password-input value="secret123"></password-input>
<submit-button>Create Account</submit-button>
</signup-form>
`;
const form = document.querySelector("signup-form");
const handler = vi.fn();
form.addEventListener("form-submit", handler);
form.querySelector("submit-button").click();
expect(handler).toHaveBeenCalled();
});
Integration tests verify that multiple custom elements work together
the way consumers will compose them in real applications.
Accessibility Testing
import { axe } from "jest-axe";
it("has no accessibility violations", async () => {
const dialog = document.createElement("app-dialog");
dialog.setAttribute("open", "");
dialog.innerHTML = `<h2 slot="title">Confirm</h2>`;
document.body.appendChild(dialog);
const results = await axe(dialog);
expect(results).toHaveNoViolations();
dialog.remove();
});
Accessibility testing is not optional for custom elements. Test
roles, labels, focus order, keyboard interaction, and visible
states just as you would for native HTML controls.
Choosing a Test Environment
| Environment | Pros | Cons |
| happy-dom / jsdom | Very fast, easy CI setup | Some browser APIs differ from real browsers |
| Real browser runner | Accurate Shadow DOM and event behavior | Slower than simulated DOM environments |
| Playwright / Cypress | Best for full user flows | Slower and more expensive to maintain |
When to Invest in Web Component Testing
- You publish a shared component library for multiple teams.
- Components are consumed across React, Vue, Angular, and plain HTML.
- Public APIs include attributes, properties, slots, and events.
- Shadow DOM encapsulation affects rendering and styling behavior.
- Accessibility and keyboard support are part of the contract.
- Refactors happen often and regressions are costly.
Common Testing Use Cases
- Design system primitives such as buttons, inputs, and badges.
- Complex widgets like tabs, dialogs, and date pickers.
- Form controls with validation and custom event output.
- Embeddable widgets distributed to third-party sites.
- Micro frontend UI packages with independent release cycles.
- Components with async data loading and lifecycle cleanup.
Web Component Testing Best Practices
- Test the public API the way consumers use it.
- Mount and unmount elements in every test.
- Reset the DOM between tests to avoid leakage.
- Cover lifecycle setup and cleanup explicitly.
- Verify custom events, not only rendered markup.
- Include accessibility checks in your test suite.
- Use fast unit tests for most coverage and E2E for critical flows.
Common Testing Mistakes
- Assuming framework tests alone cover custom element behavior.
- Skipping tests because Shadow DOM feels hard to inspect.
- Not testing element removal and listener cleanup.
- Over-mocking the DOM and losing real browser behavior.
- Testing internal markup instead of user-visible outcomes.
- Ignoring accessibility until late in development.
- Relying only on slow E2E tests for basic component behavior.
Key Takeaways
- Web Components need tests for lifecycle, Shadow DOM, slots, and events.
- Use a testing pyramid: many unit tests, some integration, few E2E.
- Test the public API and user-visible behavior first.
- Accessibility testing is part of reliable component quality.
- Good tests make shared custom elements safe to reuse everywhere.
Pro Tip
Treat every custom element like a mini library with a contract.
If attributes, events, slots, and accessibility behavior are tested
consistently, your components become safe to use in any app or
framework.