Skip to content

Polymer Testing — Testing Web Components with @open-wc/testing

DodaTech Updated 2026-06-28 5 min read

In this tutorial, you will learn about Polymer Testing. We cover key concepts, practical examples, and best practices to help you master this topic.

Testing ensures component reliability across browsers and prevents regressions. @open-wc/testing provides web-component-specific testing utilities built on Web Test Runner.

What You'll Learn

  • Setting up @open-wc/testing
  • Testing LitElement components
  • Querying shadow DOM
  • Testing events and async rendering
  • Snapshot Testing

Why It Matters

Web Components encapsulate DOM and styles, making testing straightforward — set properties, query shadow DOM, and assert on rendered output.

Real-World Use

A component library with 500+ tests running in CI, covering every property combination, user interaction, and edge case.

Testing Architecture

flowchart TD
    A[Testing] --> B[Setup]
    A --> C[Unit Tests]
    A --> D[Integration Tests]
    B --> E[@web/test-runner]
    B --> F[@open-wc/testing]
    C --> G[Property Tests]
    C --> H[Event Tests]
    C --> I[Render Tests]
    D --> J[Component Composition]
    D --> K[User Interaction]
    style A fill:#e6f3ff,stroke:#4a90d9,stroke-width:2px

Basic Test Setup

// web-test-runner.config.mjs
import { playwrightLauncher } from '@web/test-runner-playwright';

export default {
  files: 'test/**/*.test.js',
  nodeResolve: true,
  browsers: [playwrightLauncher({ product: 'chromium' })],
  testFramework: { config: { ui: 'bdd', timeout: '10000' } }
};
// test/counter.test.js
import { html, fixture, expect } from '@open-wc/testing';
import '../src/counter.js';

describe('Counter', () => {
  it('renders with default value', async () => {
    const el = await fixture(html`<my-counter></my-counter>`);
    const count = el.shadowRoot.querySelector('.count');
    expect(count.textContent).to.equal('0');
  });

  it('increments on button click', async () => {
    const el = await fixture(html`<my-counter></my-counter>`);
    const button = el.shadowRoot.querySelector('#inc');
    button.click();
    await el.updateComplete;
    expect(el.count).to.equal(1);
  });

  it('accepts initial value', async () => {
    const el = await fixture(html`<my-counter value="10"></my-counter>`);
    expect(el.value).to.equal(10);
  });
});

Expected output: Tests run in a real browser via Web Test Runner. fixture() mounts the component. shadowRoot.querySelector accesses internal DOM.

Testing Properties

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

describe('UserCard', () => {
  it('renders user name', async () => {
    const el = await fixture(html`<user-card name="Alice" email="alice@test.com"></user-card>`);
    const name = el.shadowRoot.querySelector('.name');
    expect(name.textContent).to.include('Alice');
  });

  it('reflects name attribute', async () => {
    const el = await fixture(html`<user-card name="Bob"></user-card>`);
    expect(el.getAttribute('name')).to.equal('Bob');
  });

  it('renders fallback when no name', async () => {
    const el = await fixture(html`<user-card></user-card>`);
    const avatar = el.shadowRoot.querySelector('.avatar');
    expect(avatar.textContent).to.equal('?');
  });

  it('reacts to property change', async () => {
    const el = await fixture(html`<user-card name="Charlie"></user-card>`);
    el.name = 'Diana';
    await el.updateComplete;
    const name = el.shadowRoot.querySelector('.name');
    expect(name.textContent).to.include('Diana');
  });
});

Expected output: Property combinations tested declaratively. await updateComplete waits for re-render after property changes.

Testing Events

import { html, fixture, expect, oneEvent } from '@open-wc/testing';
import '../src/dropdown.js';

describe('Dropdown', () => {
  it('dispatches select event', async () => {
    const el = await fixture(html`<my-dropdown .options=${['A', 'B']}></my-dropdown>`);
    el.open = true;
    await el.updateComplete;

    const option = el.shadowRoot.querySelector('li');
    const listener = oneEvent(el, 'select');
    option.click();
    const { detail } = await listener;

    expect(detail.value).to.equal('A');
  });

  it('does not dispatch when closed', async () => {
    const el = await fixture(html`<my-dropdown></my-dropdown>`);
    let fired = false;
    el.addEventListener('select', () => fired = true);
    const option = el.shadowRoot.querySelector('li');
    if (option) option.click();
    await el.updateComplete;
    expect(fired).to.be.false;
  });
});

Expected output: oneEvent returns a promise resolving on the next event. Tests verify event payload and timing.

Testing Async Behavior

import { html, fixture, expect, waitUntil } from '@open-wc/testing';
import '../src/search-panel.js';

describe('SearchPanel', () => {
  it('shows loading state', async () => {
    const el = await fixture(html`<search-panel query="test"></search-panel>`);
    expect(el.loading).to.be.true;
    const loading = el.shadowRoot.querySelector('.loading');
    expect(loading).to.exist;
  });

  it('renders results after fetch', async () => {
    const el = await fixture(html`<search-panel query="test"></search-panel>`);
    await waitUntil(() => !el.loading, 'Search never completed', { timeout: 3000 });
    const items = el.shadowRoot.querySelectorAll('li');
    expect(items.length).to.be.greaterThan(0);
  });

  it('debounces rapid queries', async () => {
    const el = await fixture(html`<search-panel .debounceMs=${500}></search-panel>`);
    el.query = 'a';
    await new Promise(r => setTimeout(r, 100));
    el.query = 'ab';
    await new Promise(r => setTimeout(r, 100));
    el.query = 'abc';
    await waitUntil(() => !el.loading, 'Debounced search', { timeout: 2000 });
    expect(el.query).to.equal('abc');
  });
});

Expected output: waitUntil polls until condition is met. Async behavior (fetch, debounce) is tested with real timers.

Styling and Theme Tests

import { html, fixture, expect } from '@open-wc/testing';
import '../src/themed-button.js';

describe('ThemedButton', () => {
  beforeEach(() => {
    document.documentElement.style.setProperty('--primary-color', '#ff0000');
  });

  afterEach(() => {
    document.documentElement.style.removeProperty('--primary-color');
  });

  it('uses theme primary color', async () => {
    const el = await fixture(html`<themed-button>Click</themed-button>`);
    const button = el.shadowRoot.querySelector('button');
    const bg = getComputedStyle(button).getPropertyValue('background-color');
    expect(bg).to.not.be.empty;
  });

  it('applies variant styles', async () => {
    const el = await fixture(html`<themed-button variant="primary"></themed-button>`);
    const button = el.shadowRoot.querySelector('button');
    const classes = Array.from(button.classList);
    expect(classes).to.include('primary');
  });
});

Expected output: Document-level CSS custom properties are set in beforeEach. getComputedStyle reads inherited theme values.

Snapshot Testing

import { html, fixture, expect } from '@open-wc/testing';
import '../src/status-badge.js';

describe('StatusBadge', () => {
  it('matches snapshot for success', async () => {
    const el = await fixture(html`<status-badge status="success">Verified</status-badge>`);
    expect(el).shadowDom.to.equal(`
      <span class="badge success">
        Verified
      </span>
    `);
  });

  it('matches snapshot with custom attribute', async () => {
    const el = await fixture(html`<status-badge status="warning" size="lg"></status-badge>`);
    expect(el).shadowDom.to.equalSnapshot();
  });
});

Expected output: shadowDom.to.equal compares precise DOM. to.equalSnapshot stores/compares snapshot files.

Common Mistakes

  1. Not awaiting fixture - fixture() returns a promise. Always await.

  2. Forgetting updateComplete - Wait for re-render after property change.

  3. Using setTimeout instead of waitUntil - waitUntil handles async timing.

  4. Testing implementation details - Test behavior, not internal methods.

  5. Not cleaning up global state - Reset CSS variables, event listeners, timers.

Practice Questions

  1. How do you set up @open-wc/testing with LitElement?
  2. How do you query elements inside a component's shadow DOM?
  3. How do you test custom event dispatch and handling?
  4. How do you test async component behavior?
  5. How do you test theme-affected component styles?

Challenge: Build a test suite for a data table component covering: empty state, row rendering, sorting, filtering, selection, keyboard navigation, async data loading, custom event dispatch, and theme Compliance.

FAQ

How do I test components that depend on API calls?

Mock the API with sinon or MSW (Mock Service Worker). Use waitUntil for loading states.

Can I run tests in CI?

Yes. @web/test-runner supports headless browsers for CI environments.

How do I test keyboard interactions?

Dispatch keyboard events: el.shadowRoot.querySelector('button').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })).

What is the difference between fixture and litFixture?

fixture is a convenience wrapper. litFixture is the LitElement-specific version.

Mini Project

Build a test suite for a todo app with: add/remove/edit todo items, toggle completion, filter active/done, localStorage persistence, keyboard shortcuts, and theme switching — with full async and event test coverage.

What's Next

Testing ensures quality. Learn how Polymer Building bundles and optimizes components for production.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro