Lit Element Testing Guide: @web/test-runner and open-wc (2026)
Lit builds on the Web Components standard — LitElement extends HTMLElement directly, uses the browser's native Custom Elements and Shadow DOM APIs, and adds a reactive property system and tagged template literal rendering. The result is small, fast web components that work anywhere.
Testing Lit components requires real browser APIs. jsdom doesn't implement Shadow DOM well enough for a reliable test suite, so the Lit community settled on @web/test-runner — a test runner that executes in a real headless browser (Chromium by default) via Playwright or Puppeteer.
Setup: @web/test-runner
Install the dependencies:
npm install -D @web/test-runner @web/test-runner-playwright @open-wc/testing litCreate web-test-runner.config.mjs:
// web-test-runner.config.mjs
import { playwrightLauncher } from '@web/test-runner-playwright';
export default {
files: 'src/**/*.test.ts',
nodeResolve: true,
browsers: [playwrightLauncher({ product: 'chromium' })],
testFramework: {
config: {
timeout: 5000,
},
},
};Add test scripts to package.json:
{
"scripts": {
"test": "wtr",
"test:watch": "wtr --watch",
"test:coverage": "wtr --coverage"
}
}@web/test-runner uses the browser's native ES module support — your Lit components run exactly as they will in production, not in a shimmed Node.js environment.
Your First Lit Component
// src/components/my-counter.ts
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
@customElement('my-counter')
export class MyCounter extends LitElement {
static styles = css`
:host {
display: block;
font-family: sans-serif;
}
.count {
font-size: 2rem;
font-weight: bold;
}
button {
padding: 0.5rem 1rem;
margin: 0 0.25rem;
cursor: pointer;
}
`;
@property({ type: Number }) initialValue = 0;
@property({ type: Number }) step = 1;
@state() private _count = 0;
connectedCallback() {
super.connectedCallback();
this._count = this.initialValue;
}
increment() {
this._count += this.step;
this.dispatchEvent(
new CustomEvent('count-changed', {
detail: { count: this._count },
bubbles: true,
composed: true,
})
);
}
decrement() {
this._count -= this.step;
this.dispatchEvent(
new CustomEvent('count-changed', {
detail: { count: this._count },
bubbles: true,
composed: true,
})
);
}
reset() {
this._count = this.initialValue;
}
render() {
return html`
<div>
<button @click=${this.decrement}>-</button>
<span class="count" data-testid="count">${this._count}</span>
<button @click=${this.increment}>+</button>
<button @click=${this.reset}>Reset</button>
</div>
`;
}
}Testing with open-wc Helpers
@open-wc/testing provides fixture, expect (Chai-based), and helpers for rendering and querying Lit components.
// src/components/my-counter.test.ts
import { fixture, html, expect } from '@open-wc/testing';
import { MyCounter } from './my-counter';
// Ensure the element is registered
customElements.define('my-counter', MyCounter);
describe('my-counter', () => {
it('renders with initial value of 0 by default', async () => {
const el = await fixture<MyCounter>(html`<my-counter></my-counter>`);
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('0');
});
it('renders with the provided initial value', async () => {
const el = await fixture<MyCounter>(html`<my-counter .initialValue=${10}></my-counter>`);
await el.updateComplete;
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('10');
});
it('increments the count by the step value', async () => {
const el = await fixture<MyCounter>(html`<my-counter .step=${5}></my-counter>`);
await el.updateComplete;
el.increment();
await el.updateComplete;
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('5');
});
it('decrements the count correctly', async () => {
const el = await fixture<MyCounter>(html`<my-counter .initialValue=${10}></my-counter>`);
await el.updateComplete;
el.decrement();
await el.updateComplete;
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('9');
});
it('resets to the initial value', async () => {
const el = await fixture<MyCounter>(html`<my-counter .initialValue=${5}></my-counter>`);
await el.updateComplete;
el.increment();
el.increment();
await el.updateComplete;
el.reset();
await el.updateComplete;
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('5');
});
it('emits count-changed event on increment', async () => {
const el = await fixture<MyCounter>(html`<my-counter></my-counter>`);
await el.updateComplete;
let eventDetail: { count: number } | undefined;
el.addEventListener('count-changed', (e) => {
eventDetail = (e as CustomEvent).detail;
});
el.increment();
await el.updateComplete;
expect(eventDetail).to.exist;
expect(eventDetail!.count).to.equal(1);
});
});Always await el.updateComplete after changing a property or calling a method that triggers re-render. Lit batches updates asynchronously — without the await, your assertions run before the DOM updates.
Testing Button Clicks via Shadow DOM
Calling methods directly is the cleanest approach for unit tests. For click testing through the Shadow DOM:
// src/components/my-counter.test.ts (continued)
describe('button interactions', () => {
it('clicking + button increments the counter', async () => {
const el = await fixture<MyCounter>(html`<my-counter></my-counter>`);
await el.updateComplete;
// Query inside Shadow DOM
const buttons = el.shadowRoot!.querySelectorAll('button');
const incrementButton = buttons[1]; // +
incrementButton.click();
await el.updateComplete;
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('1');
});
it('clicking Reset button restores initial value', async () => {
const el = await fixture<MyCounter>(html`<my-counter .initialValue=${3}></my-counter>`);
await el.updateComplete;
el.increment();
el.increment();
await el.updateComplete;
const resetButton = el.shadowRoot!.querySelectorAll('button')[2];
resetButton.click();
await el.updateComplete;
const count = el.shadowRoot!.querySelector('[data-testid="count"]');
expect(count?.textContent?.trim()).to.equal('3');
});
});Testing a11y with @web/test-runner
@open-wc/testing integrates with axe-core for accessibility testing:
// src/components/my-counter.test.ts (continued)
import { fixture, html, expect, isAccessible } from '@open-wc/testing';
describe('accessibility', () => {
it('passes accessibility checks', async () => {
const el = await fixture<MyCounter>(html`<my-counter></my-counter>`);
await expect(el).to.be.accessible();
});
});The accessible() matcher runs axe-core against the rendered Shadow DOM. It catches missing ARIA labels, low-contrast text, and other common a11y violations automatically.
Testing Reactive Properties
Lit's @property decorator triggers re-renders when the attribute or property changes. Test both attribute and property update paths.
// src/components/my-badge.ts
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
type BadgeVariant = 'info' | 'success' | 'warning' | 'error';
@customElement('my-badge')
export class MyBadge extends LitElement {
static styles = css`
:host { display: inline-block; }
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; }
.badge--info { background: #dbeafe; color: #1d4ed8; }
.badge--success { background: #dcfce7; color: #166534; }
.badge--warning { background: #fef9c3; color: #854d0e; }
.badge--error { background: #fee2e2; color: #991b1b; }
`;
@property({ type: String }) variant: BadgeVariant = 'info';
@property({ type: String }) label = '';
render() {
return html`
<span class="badge badge--${this.variant}" role="status">
${this.label}
</span>
`;
}
}// src/components/my-badge.test.ts
import { fixture, html, expect } from '@open-wc/testing';
import { MyBadge } from './my-badge';
describe('my-badge', () => {
it('applies the correct class for each variant', async () => {
for (const variant of ['info', 'success', 'warning', 'error'] as const) {
const el = await fixture<MyBadge>(html`<my-badge .variant=${variant} label="Test"></my-badge>`);
await el.updateComplete;
const span = el.shadowRoot!.querySelector('.badge');
expect(span?.className).to.include(`badge--${variant}`);
}
});
it('updates when the variant property changes', async () => {
const el = await fixture<MyBadge>(html`<my-badge variant="info" label="Test"></my-badge>`);
await el.updateComplete;
el.variant = 'error';
await el.updateComplete;
const span = el.shadowRoot!.querySelector('.badge');
expect(span?.className).to.include('badge--error');
expect(span?.className).not.to.include('badge--info');
});
it('renders the label text', async () => {
const el = await fixture<MyBadge>(html`<my-badge label="New"></my-badge>`);
const span = el.shadowRoot!.querySelector('.badge');
expect(span?.textContent?.trim()).to.equal('New');
});
it('has the correct ARIA role', async () => {
const el = await fixture<MyBadge>(html`<my-badge label="Status"></my-badge>`);
const span = el.shadowRoot!.querySelector('[role="status"]');
expect(span).to.exist;
});
});Playwright E2E for Lit Components in a Full App
@web/test-runner tests Lit components in isolation in a real browser. Playwright tests them embedded in your actual application pages.
npm install -D @playwright/test
npx playwright install chromium// e2e/counter-widget.test.ts
import { test, expect } from '@playwright/test';
test.describe('my-counter widget on the dashboard', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/dashboard');
// Wait for custom element to be defined and rendered
await page.waitForSelector('my-counter:defined');
});
test('counter starts at the configured initial value', async ({ page }) => {
const count = page.locator('my-counter').first().locator('pierce/[data-testid="count"]');
await expect(count).toHaveText('0');
});
test('clicking + increments the counter', async ({ page }) => {
const counter = page.locator('my-counter').first();
// Pierce shadow DOM with >>
const incrementBtn = counter.locator('pierce/button >> text=+');
await incrementBtn.click();
const count = counter.locator('pierce/[data-testid="count"]');
await expect(count).toHaveText('1');
});
test('count-changed event updates other parts of the application', async ({ page }) => {
// If the app listens to count-changed and updates a global state display:
await page.locator('my-counter').first().locator('pierce/button >> text=+').click();
await expect(page.locator('[data-testid="global-count-display"]')).toHaveText('Total: 1');
});
});Use pierce/ in Playwright selectors to penetrate Shadow DOM boundaries.
What Tests Won't Catch
- A Lit component that renders correctly in isolation but breaks when two instances share the same
adoptedStyleSheetsin a polyfilled environment - Custom element registration conflicts when two versions of the same component are bundled by different consumers
- Memory leaks from event listeners added in
connectedCallbacknot cleaned up indisconnectedCallback - A CSS custom property (design token) that works in Chrome but is unsupported in an older browser your users still run
- Production CDN serving a stale version of the component JavaScript after a deploy
Monitoring Lit Component Apps in Production with HelpMeTest
Install HelpMeTest:
curl -fsSL https://helpmetest.com/install | bashWrite plain-English tests that run against your live application:
Go to https://myapp.com/dashboard
Wait for the counter widget to be visible
Click the increment button three times
Verify the counter shows 3
Click the reset button
Verify the counter shows 0
Verify the page has no console errorsHelpMeTest runs these every few minutes and alerts you when they fail. If your Lit components break in production — custom element registration fails, Shadow DOM CSS breaks, a property update stops triggering re-renders — you know immediately, not from a user complaint.
Free tier: 10 tests, unlimited health checks. Pro: $100/month for unlimited tests and parallel execution.
Your @web/test-runner tests prove the components work in a real browser. Playwright proves they work in your full application. HelpMeTest proves the deployed application is working right now.
Start at helpmetest.com — free tier, no credit card.