Skip to content

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.

Popular Testing Tools

Tool Type Best For
Vitest Unit / integration Fast local tests with happy-dom or jsdom
Jest Unit / integration Teams already using the Jest ecosystem
@web/test-runner Browser unit tests Real browser behavior for Shadow DOM
Playwright E2E / component Real browser interaction and visual checks
axe-core Accessibility Automated a11y rule checks
Testing Library DOM interaction Testing from the user's perspective

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.