Skip to content

Lit Testing

Testing a Lit component means rendering it into a real (or headless) browser DOM and asserting on its Shadow DOM output and behavior. This lesson covers the standard tooling and patterns.

Testing in a Real Browser Environment

Because Lit components rely on genuine browser APIs (Custom Elements, Shadow DOM), they need to run in an actual browser environment to be tested meaningfully — a Node.js-only DOM simulation like jsdom has historically had gaps in Custom Elements/Shadow DOM support. The Modern Web team's @web/test-runner, paired with @open-wc/testing helpers, is the standard, actively maintained tooling for this, running tests in real (optionally headless) browsers via Playwright or Puppeteer.

The core testing pattern is: render a fresh instance of your component into the test DOM using a fixture() helper, query into its Shadow DOM to assert on rendered output, and simulate user interaction to test behavior.

import { fixture, html, expect } from '@open-wc/testing';
import './greeting-card.js';

describe('greeting-card', () => {
  it('renders a greeting with the given name', async () => {
    const el = await fixture(html`<greeting-card name="Ada"></greeting-card>`);
    const p = el.shadowRoot.querySelector('p');
    expect(p.textContent).to.include('Hello, Ada!');
  });
});

fixture() renders the element into the live test DOM and automatically waits for its first update to complete before the test continues.

Common Testing Patterns

const el = await fixture(html`<my-el></my-el>`);
await el.updateComplete;                      // wait after a later property change
el.shadowRoot.querySelector('.foo');           // query rendered output
el.shadowRoot.querySelector('button').click(); // simulate a click
expect(el).to.be.accessible();                 // aXe-powered accessibility assertion
  • fixture() automatically cleans up the rendered element after each test, avoiding test-to-test DOM pollution.
  • Always await el.updateComplete after changing a property mid-test, before asserting on the resulting DOM.
  • @open-wc/testing's expect(el).to.be.accessible() runs an automated accessibility audit (via axe-core) directly in your test suite.
  • Simulate real user events (.click(), dispatching keydown) rather than calling a component's internal handler methods directly, to test the actual user-facing behavior.

Lit Testing Cheat Sheet

The core testing vocabulary and helpers.

Tool/Pattern Purpose
@web/test-runner Runs tests in real (optionally headless) browsers
@open-wc/testing Testing helpers: fixture(), expect(), accessibility assertions
fixture(html`...`) Renders a component into the test DOM, waits for first update
el.updateComplete Promise resolving after a pending update finishes
el.shadowRoot.querySelector(...) Queries the component's rendered Shadow DOM
expect(el).to.be.accessible() Automated accessibility check via axe-core

Testing Dispatched Custom Events

To test that a component dispatches an expected custom event, attach a listener before triggering the interaction, then assert on the captured event's detail once the interaction completes.

it('dispatches item-selected with the correct id', async () => {
  const el = await fixture(html`<item-list .items=${[{ id: '1', name: 'A' }]}></item-list>`);

  const eventPromise = new Promise((resolve) => {
    el.addEventListener('item-selected', resolve, { once: true });
  });

  el.shadowRoot.querySelector('li').click();
  const event = await eventPromise;

  expect(event.detail.id).to.equal('1');
});

Wrapping the listener in a Promise lets the test await the event cleanly rather than using arbitrary timeouts.

Testing Slotted Content and Accessibility

Slotted content can be tested by rendering a fixture with real light DOM children and asserting on the resulting rendered layout; automated accessibility testing catches missing labels, incorrect ARIA usage, and insufficient color contrast without manual screen reader testing for every single change.

it('projects slotted content', async () => {
  const el = await fixture(html`<fancy-card><p>Body text</p></fancy-card>`);
  const slot = el.shadowRoot.querySelector('slot');
  const [assigned] = slot.assignedElements();
  expect(assigned.textContent).to.equal('Body text');
});

it('has no accessibility violations', async () => {
  const el = await fixture(html`<fancy-card><p>Body text</p></fancy-card>`);
  await expect(el).to.be.accessible();
});

Common Mistakes

  • Testing Lit components with a Node.js-only DOM simulation lacking full Custom Elements/Shadow DOM support, leading to unreliable results.
  • Forgetting to await el.updateComplete after a mid-test property change, then asserting on stale DOM before the update actually applied.
  • Calling a component's private handler methods directly in tests instead of simulating real user events, testing implementation rather than behavior.
  • Skipping automated accessibility checks entirely, missing an easy, low-effort way to catch common a11y regressions in CI.

Key Takeaways

  • Lit components should be tested in a real (or headless) browser environment, typically via @web/test-runner.
  • @open-wc/testing's fixture() helper renders a component and waits for its first update automatically.
  • Test dispatched custom events by attaching a listener before the interaction and asserting on the captured detail.
  • Automated accessibility assertions (to.be.accessible()) are a low-effort, high-value addition to a Lit test suite.

Pro Tip

Add expect(el).to.be.accessible() to your test template/boilerplate for every new component from day one — making automated accessibility checking the default, rather than an afterthought, catches a meaningful share of a11y regressions before they ever reach code review.