Skip to content

Unit Testing

This lesson explains how to unit test Web Components in isolation, verify public APIs and lifecycle behavior, and write reliable tests for attributes, properties, Shadow DOM, events, and slots.

What Is Unit Testing for Web Components?

Unit testing for Web Components means verifying one custom element in isolation. You test its public API, rendering behavior, lifecycle callbacks, and emitted events without loading the full application or running end-to-end browser flows.

Good unit tests focus on what consumers depend on: attributes, properties, slots, custom events, accessibility roles, and cleanup when the element is removed from the DOM.

Concept Description
Unit Test Tests one component in isolation
Public API Attributes, properties, events, and slots
Lifecycle connectedCallback, disconnectedCallback
Shadow DOM Internal rendering behind an encapsulation boundary
Common Tools Vitest, Jest, Web Test Runner, Playwright component tests
Goal Prevent regressions in reusable custom elements

Basic Unit Test Example

// counter-badge.js
class CounterBadge extends HTMLElement {
  connectedCallback() {
    this.textContent = this.getAttribute("count") || "0";
  }
}

customElements.define("counter-badge", CounterBadge);
// counter-badge.test.js
import "./counter-badge.js";
import { describe, it, expect, beforeEach, afterEach } from "vitest";

describe("counter-badge", () => {
  let element;

  beforeEach(() => {
    element = document.createElement("counter-badge");
    document.body.appendChild(element);
  });

  afterEach(() => {
    element.remove();
  });

  it("renders default count", () => {
    expect(element.textContent).toBe("0");
  });

  it("renders count from attribute", () => {
    element.setAttribute("count", "5");
    element.connectedCallback();
    expect(element.textContent).toBe("5");
  });
});

This pattern registers the element, mounts it in a real DOM environment, asserts behavior, and removes it after each test.

Test Environment Setup

Web Components need a DOM. Most projects use Vitest with happy-dom or jsdom, or run tests in a real browser with @web/test-runner.

// vitest.config.js
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "happy-dom",
    setupFiles: ["./test/setup.js"]
  }
});
// test/setup.js
import { beforeEach, afterEach } from "vitest";

beforeEach(() => {
  document.body.innerHTML = "";
});

afterEach(() => {
  document.body.innerHTML = "";
});
Tool Environment Best For
Vitest + happy-dom Simulated DOM Fast local unit tests
Jest + jsdom Simulated DOM Existing Jest projects
@web/test-runner Real browser Accurate Shadow DOM behavior
Playwright Real browser Component and integration tests

Testing Lifecycle Callbacks

class LoggerPanel extends HTMLElement {
  connectedCallback() {
    this.log = [];
    this.log.push("connected");
  }

  disconnectedCallback() {
    this.log.push("disconnected");
  }
}

customElements.define("logger-panel", LoggerPanel);
it("runs lifecycle callbacks", () => {
  const panel = document.createElement("logger-panel");
  document.body.appendChild(panel);

  expect(panel.log).toEqual(["connected"]);

  panel.remove();
  expect(panel.log).toEqual(["connected", "disconnected"]);
});

Lifecycle tests confirm setup runs when the element enters the DOM and cleanup runs when it leaves. This is essential for timers, observers, and event listeners.

Testing Attributes and Properties

class StatusBadge extends HTMLElement {
  static get observedAttributes() {
    return ["status"];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    if (name === "status") {
      this.textContent = newValue;
    }
  }
}

customElements.define("status-badge", StatusBadge);
it("updates text when status attribute changes", async () => {
  const badge = document.createElement("status-badge");
  document.body.appendChild(badge);

  badge.setAttribute("status", "active");
  await Promise.resolve();

  expect(badge.textContent).toBe("active");
});

it("syncs property and attribute when designed to do so", () => {
  const badge = document.createElement("status-badge");
  badge.status = "pending";
  badge.setAttribute("status", badge.status);

  expect(badge.getAttribute("status")).toBe("pending");
});

Test both attributes and JavaScript properties when your component exposes both. Consumers may use either depending on the framework.

Testing Shadow DOM

class AppButton extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.shadowRoot.innerHTML = `
      <button type="button">
        <slot></slot>
      </button>
    `;
  }
}

customElements.define("app-button", AppButton);
it("renders internal button in shadow root", () => {
  const button = document.createElement("app-button");
  button.textContent = "Save";
  document.body.appendChild(button);

  const internalButton =
    button.shadowRoot.querySelector("button");

  expect(internalButton).not.toBeNull();
  expect(internalButton.textContent.trim()).toBe("Save");
});

With open Shadow DOM, use element.shadowRoot to query internal nodes. Prefer testing user-visible outcomes and public behavior over private implementation details when possible.

Testing Custom Events

class SearchInput extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<input type="search" />`;
    this.querySelector("input").addEventListener("input", (event) => {
      this.dispatchEvent(
        new CustomEvent("search-change", {
          detail: { value: event.target.value },
          bubbles: true,
          composed: true
        })
      );
    });
  }
}

customElements.define("search-input", SearchInput);
it("emits search-change with input value", () => {
  const input = document.createElement("search-input");
  document.body.appendChild(input);

  const handler = vi.fn();
  input.addEventListener("search-change", handler);

  const field = input.querySelector("input");
  field.value = "astro";
  field.dispatchEvent(new Event("input", { bubbles: true }));

  expect(handler).toHaveBeenCalledTimes(1);
  expect(handler.mock.calls[0][0].detail.value).toBe("astro");
});

Custom event tests should verify event name, payload, bubbling, and whether the event crosses the Shadow DOM boundary with composed: true.

Testing Slots

<info-card>
  <h2 slot="title">Billing</h2>
  <p>Your plan renews tomorrow.</p>
</info-card>
it("projects slotted content into the component", () => {
  document.body.innerHTML = `
    <info-card>
      <h2 slot="title">Billing</h2>
      <p>Your plan renews tomorrow.</p>
    </info-card>
  `;

  const card = document.querySelector("info-card");
  const title = card.querySelector('[slot="title"]');

  expect(title.textContent).toBe("Billing");
});

Slot tests confirm that consumers can pass markup and that named slots render in the correct places inside the component.

Testing Cleanup and Memory Leaks

class IntervalTicker extends HTMLElement {
  connectedCallback() {
    this.timer = setInterval(() => {
      this.tickCount = (this.tickCount || 0) + 1;
    }, 100);
  }

  disconnectedCallback() {
    clearInterval(this.timer);
  }
}
it("clears interval on disconnect", () => {
  vi.useFakeTimers();

  const ticker = document.createElement("interval-ticker");
  document.body.appendChild(ticker);

  vi.advanceTimersByTime(300);
  expect(ticker.tickCount).toBeGreaterThan(0);

  ticker.remove();
  const countAfterRemove = ticker.tickCount;

  vi.advanceTimersByTime(300);
  expect(ticker.tickCount).toBe(countAfterRemove);

  vi.useRealTimers();
});

Always test cleanup for timers, observers, listeners, and network subscriptions. Leaks often appear only after many mount and unmount cycles.

What to Unit Test

Area Example Assertion
Rendering Default markup appears after connect
Attributes variant="danger" updates classes
Properties element.open = true shows panel
Events Click emits item-selected
Slots Named slot content is projected
Accessibility Button has correct role and label
Cleanup Listeners removed on disconnect

Unit Tests vs Integration Tests

Type Scope Example
Unit Test One custom element <app-button> emits click event
Integration Test Multiple elements together Form composed of several custom inputs
End-to-End Test Full app in browser User completes checkout flow

Unit tests should stay fast and focused. Use integration or E2E tests for flows that depend on routing, APIs, or multiple components interacting in a real page.

When to Write Unit Tests

  • You maintain a shared component library used by multiple teams.
  • The component exposes a public API with attributes and events.
  • Lifecycle cleanup is easy to break during refactors.
  • Shadow DOM rendering has conditional branches.
  • Accessibility behavior must remain stable over time.
  • You want fast feedback before running slower browser tests.

Common Unit Testing Use Cases

  • Buttons, badges, alerts, and other design system primitives.
  • Form controls with validation and value change events.
  • Tabs, accordions, and disclosure widgets with state changes.
  • Data display components that map attributes to rendered output.
  • Event-emitting inputs used by React, Vue, or Angular apps.
  • Components with timers, observers, or async setup logic.

Unit Testing Best Practices

  • Test the public API, not private implementation details.
  • Mount and unmount elements in every test to mimic real usage.
  • Reset document.body between tests.
  • Use fake timers when testing intervals and debounced behavior.
  • Prefer real DOM environments when Shadow DOM accuracy matters.
  • Assert custom event names, detail payloads, and bubbling behavior.
  • Keep tests small, readable, and focused on one behavior each.

Common Unit Testing Mistakes

  • Testing only static HTML without connecting the element to the DOM.
  • Forgetting to remove elements and leaking listeners between tests.
  • Reaching into Shadow DOM for every assertion instead of outcomes.
  • Not awaiting microtasks after attribute or property updates.
  • Mocking so much behavior that the test no longer reflects reality.
  • Skipping cleanup tests for timers and observers.
  • Writing giant tests that cover too many behaviors at once.

Key Takeaways

  • Unit tests verify one Web Component's public behavior in isolation.
  • Use a DOM test environment such as Vitest with happy-dom or a real browser runner.
  • Test lifecycle, attributes, properties, Shadow DOM, events, and slots.
  • Always verify cleanup when the element is removed from the DOM.
  • Fast, focused unit tests protect reusable custom elements from regressions.

Pro Tip

Write tests the way consumers use your component: create the element, append it to document.body, interact through attributes and events, then remove it. That keeps tests realistic and catches lifecycle bugs early.