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.