← Back to DevBytes

Testing SWC Components: From Unit to E2E Tests

Testing SWC Components: From Unit to E2E Tests

Stencil Web Components (SWC) have become a popular way to build framework-agnostic, reusable UI components that work across React, Vue, Angular, or plain HTML. But building components is only half the battle—verifying they behave correctly across browsers, frameworks, and edge cases is what separates a hobby project from a production-grade design system. This tutorial walks you through the full testing pyramid for Stencil components, from fast unit tests to realistic end-to-end (E2E) scenarios.

What Is SWC Component Testing?

Stencil ships with a first-class testing toolkit built on top of Jest, jsdom, and Puppeteer. It provides three primary testing primitives:

Because Stencil compiles to standard Custom Elements, you can also test the resulting output with any browser-based runner (Playwright, Cypress, WebdriverIO). However, Stencil's native tooling is the most ergonomic starting point.

Why Testing SWC Components Matters

Web components live in a unique niche. They are consumed by teams you may never meet, embedded inside frameworks you don't control, and rendered in browsers with subtly different implementations of Shadow DOM, Custom Elements, and CSS scoping. A robust test suite gives you:

Setting Up the Testing Environment

Stencil scaffolds a testing setup automatically when you create a project with npm init stencil. If you're adding tests to an existing project, ensure your stencil.config.ts includes the testing flags:

import { Config } from '@stencil/core';

export const config: Config = {
  namespace: 'my-components',
  outputTargets: [
    { type: 'dist' },
    { type: 'www', serviceWorker: null },
  ],
  testing: {
    browserHeadless: true,
    browserArgs: ['--no-sandbox', '--disable-setuid-sandbox'],
    testPathIgnorePatterns: ['/node_modules/', '/dist/'],
  },
};

Install the peer dependencies if they aren't already present:

npm install --save-dev @stencil/core jest puppeteer

Stencil exposes its test commands through the CLI:

npx stencil test --spec        # run unit/spec tests
npx stencil test --e2e         # run end-to-end tests
npx stencil test --spec --e2e  # run both
npx stencil test --watch       # watch mode

By convention, spec tests live next to the component as *.spec.ts, and E2E tests live as *.e2e.ts.

Unit Testing with newSpecPage

Spec tests run in jsdom, which is fast but doesn't fully implement Shadow DOM or layout. They're ideal for testing logic, rendering output, prop handling, and event emission.

Let's start with a simple component under test:

// src/components/my-button/my-button.tsx
import { Component, Prop, h, EventEmitter, Event } from '@stencil/core';

@Component({
  tag: 'my-button',
  styleUrl: 'my-button.css',
  shadow: true,
})
export class MyButton {
  @Prop() label = 'Click me';
  @Prop() disabled = false;
  @Prop() variant: 'primary' | 'secondary' = 'primary';

  @Event() buttonClick: EventEmitter<{ label: string }>;

  private handleClick() {
    if (this.disabled) return;
    this.buttonClick.emit({ label: this.label });
  }

  render() {
    return (
      <button
        class={`btn btn--${this.variant}`}
        disabled={this.disabled}
        onClick={() => this.handleClick()}
      >
        {this.label}
      </button>
    );
  }
}

Writing Your First Spec Test

// src/components/my-button/my-button.spec.tsx
import { newSpecPage } from '@stencil/core/testing';
import { MyButton } from './my-button';

describe('my-button', () => {
  it('renders with default props', async () => {
    const page = await newSpecPage({
      components: [MyButton],
      html: `<my-button></my-button>`,
    });

    expect(page.root).toEqualHtml(`
      <my-button>
        <mock:shadow-root>
          <button class="btn btn--primary">
            Click me
          </button>
        </mock:shadow-root>
      </my-button>
    `);
  });

  it('applies the variant class', async () => {
    const page = await newSpecPage({
      components: [MyButton],
      html: `<my-button variant="secondary" label="Save"></my-button>`,
    });

    const button = page.root.shadowRoot.querySelector('button');
    expect(button.className).toContain('btn--secondary');
    expect(button.textContent).toBe('Save');
  });

  it('emits buttonClick when clicked', async () => {
    const page = await newSpecPage({
      components: [MyButton],
      html: `<my-button label="Submit"></my-button>`,
    });

    const spy = jest.fn();
    page.root.addEventListener('buttonClick', spy);

    const button = page.root.shadowRoot.querySelector('button');
    button.click();

    expect(spy).toHaveBeenCalledTimes(1);
    expect(spy.mock.calls[0][0].detail).toEqual({ label: 'Submit' });
  });

  it('does not emit when disabled', async () => {
    const page = await newSpecPage({
      components: [MyButton],
      html: `<my-button disabled="true"></my-button>`,
    });

    const spy = jest.fn();
    page.root.addEventListener('buttonClick', spy);

    page.root.shadowRoot.querySelector('button').click();
    expect(spy).not.toHaveBeenCalled();
  });
});

Notice the <mock:shadow-root> tag in the snapshot matcher—Stencil uses this placeholder because jsdom doesn't render true Shadow DOM. This lets you assert on shadow content declaratively.

Testing Lifecycle and State Changes

Spec pages also let you mutate props and trigger re-renders. Use page.waitForChanges() after mutating a component to let Stencil's async rendering pipeline flush.

// src/components/my-counter/my-counter.spec.tsx
import { newSpecPage } from '@stencil/core/testing';
import { MyCounter } from './my-counter';

describe('my-counter', () => {
  it('increments when the increment method is called', async () => {
    const page = await newSpecPage({
      components: [MyCounter],
      html: `<my-counter></my-counter>`,
    });

    const counter = page.rootInstance as MyCounter;
    expect(counter.count).toBe(0);

    counter.increment();
    await page.waitForChanges();

    expect(counter.count).toBe(1);
    expect(page.root.shadowRoot.textContent).toContain('Count: 1');
  });
});

Testing Components with Slots

Slots are a common source of bugs. Verify that projected content lands in the right place:

// src/components/my-card/my-card.spec.tsx
import { newSpecPage } from '@stencil/core/testing';
import { MyCard } from './my-card';

describe('my-card', () => {
  it('projects slotted content', async () => {
    const page = await newSpecPage({
      components: [MyCard],
      html: `
        <my-card>
          <span slot="title">Hello</span>
          <p>Body content</p>
        </my-card>
      `,
    });

    const titleSlot = page.root.querySelector('[slot="title"]');
    expect(titleSlot.textContent).toBe('Hello');
    expect(page.root.querySelector('p').textContent).toBe('Body content');
  });
});

Integration Testing Multiple Components

Spec pages accept an array of components, so you can compose them and test interactions between parents and children:

// src/components/my-form/my-form.spec.tsx
import { newSpecPage } from '@stencil/core/testing';
import { MyForm } from './my-form';
import { MyButton } from '../my-button/my-button';
import { MyInput } from '../my-input/my-input';

describe('my-form', () => {
  it('disables submit until input has a value', async () => {
    const page = await newSpecPage({
      components: [MyForm, MyButton, MyInput],
      html: `<my-form></my-form>`,
    });

    const submit = page.root.querySelector('my-button');
    expect(submit.getAttribute('disabled')).toBe('true');

    const input = page.root.querySelector('my-input');
    input.value = 'someone@example.com';
    input.dispatchEvent(new Event('input'));
    await page.waitForChanges();

    expect(submit.getAttribute('disabled')).toBeNull();
  });
});

End-to-End Testing with newE2EPage

Spec tests are fast, but jsdom can't faithfully reproduce real Shadow DOM, CSS encapsulation, browser events, or layout. For these concerns, Stencil provides newE2EPage, which spins up a headless Chromium instance via Puppeteer and loads your component from the compiled dev server.

Writing an E2E Test

// src/components/my-button/my-button.e2e.ts
import { newE2EPage } from '@stencil/core/testing';

describe('my-button (e2e)', () => {
  it('renders and responds to a real click', async () => {
    const page = await newE2EPage({
      html: `<my-button label="Go"></my-button>`,
    });

    const button = await page.find('my-button >>> button');
    expect(button).toHaveClass('btn--primary');

    const spy = await page.spyOnEvent('buttonClick');
    await button.click();

    expect(spy).toHaveReceivedEventTimes(1);
    expect(spy).toHaveReceivedEventDetail({ label: 'Go' });
  });

  it('reflects prop changes to the attribute', async () => {
    const page = await newE2EPage({
      html: `<my-button></my-button>`,
    });

    const el = await page.find('my-button');
    el.setProperty('variant', 'secondary');
    await page.waitForChanges();

    const button = await page.find('my-button >>> button');
    expect(button).toHaveClass('btn--secondary');
  });
});

The >>> selector pierces Shadow DOM, mirroring Puppeteer's deep selector syntax. This is essential for asserting on internal markup that isn't exposed to the light DOM.

Testing Visual and CSS Behavior

Because E2E tests run in a real browser, you can assert on computed styles:

// src/components/my-button/my-button.visual.e2e.ts
import { newE2EPage } from '@stencil/core/testing';

describe('my-button styles', () => {
  it('uses the primary background color', async () => {
    const page = await newE2EPage({
      html: `<my-button></my-button>`,
    });

    const button = await page.find('my-button >>> button');
    const bg = await button.getComputedStyle('background-color');
    expect(bg).toBe('rgb(0, 112, 224)');
  });

  it('hides focus outline only on mouse interaction', async () => {
    const page = await newE2EPage({
      html: `<my-button></my-button>`,
    });

    await page.keyboard.press('Tab');
    await page.waitForChanges();

    const button = await page.find('my-button >>> button');
    const outline = await button.getComputedStyle('outline-style');
    expect(outline).not.toBe('none');
  });
});

Testing Keyboard and Accessibility

E2E tests are the right place to verify keyboard interactions and ARIA contracts:

// src/components/my-modal/my-modal.a11y.e2e.ts
import { newE2EPage } from '@stencil/core/testing';

describe('my-modal accessibility', () => {
  it('traps focus and closes on Escape', async () => {
    const page = await newE2EPage({
      html: `
        <my-modal>
          <input id="first" />
          <input id="second" />
        </my-modal>
      `,
    });

    const modal = await page.find('my-modal');
    await modal.callMethod('open');
    await page.waitForChanges();

    const first = await page.find('#first');
    expect(await first.isFocused()).toBe(true);

    await page.keyboard.press('Tab');
    const second = await page.find('#second');
    expect(await second.isFocused()).toBe(true);

    await page.keyboard.press('Escape');
    await page.waitForChanges();

    expect(await modal.getProperty('isOpen')).toBe(false);
  });
});

Testing Across Frameworks

One of the main selling points of SWC components is framework agnosticism. To prove that your component works in React, Vue, or Angular, write a small integration harness in your E2E suite:

// e2e/react-integration.e2e.ts
import { newE2EPage } from '@stencil/core/testing';
import path from 'path';

describe('my-button in React', () => {
  it('binds to React state', async () => {
    const page = await newE2EPage({
      url: `file://${path.resolve(__dirname, 'fixtures/react.html')}`,
    });

    const button = await page.find('my-button >>> button');
    await button.click();

    const counter = await page.find('#react-counter');
    expect((await counter.getProperty('textContent')).trim()).toBe('1');
  });
});

The fixture HTML loads your component bundle and a minimal React mount script. This catches issues like React passing props as attributes instead of properties, or event names being mangled by synthetic event systems.

Best Practices

Choose the Right Test Level

Test the Public API, Not the Internals

Avoid asserting on private methods or internal state names. Instead, drive the component through its public surface: props, methods decorated with @Method(), events, and slotted content. This keeps tests resilient to refactors.

Keep Snapshots Focused

toEqualHtml() snapshots are powerful but brittle. Snapshot only the meaningful parts of the output, and prefer explicit assertions for behavior. If a snapshot fails, ask whether the change is intentional before blindly updating it.

Isolate Async Behavior

Stencil's rendering is asynchronous. Always call await page.waitForChanges() after mutating props or dispatching events, both in spec and E2E tests. Forgetting this is the most common cause of flaky tests.

Mock External Dependencies

If your component fetches data or reads from window, inject those dependencies through props or methods so tests can substitute them. Avoid hitting real networks in unit tests:

// src/components/my-profile/my-profile.spec.tsx
import { newSpecPage } from '@stencil/core/testing';
import { MyProfile } from './my-profile';

describe('my-profile', () => {
  it('renders fetched user data', async () => {
    const fakeFetch = jest.fn().mockResolvedValue({
      json: () => ({ name: 'Ada Lovelace' }),
    });
    jest.spyOn(globalThis as any, 'fetch').mockImplementation(fakeFetch);

    const page = await newSpecPage({
      components: [MyProfile],
      html: `<my-profile user-id="42"></my-profile>`,
    });

    await page.waitForChanges();

    expect(page.root.shadowRoot.textContent).toContain('Ada Lovelace');
    expect(fakeFetch).toHaveBeenCalledWith('/api/users/42');
  });
});

Run Tests in CI with Headless Flags

In CI environments, configure Puppeteer to run without a sandbox and with deterministic flags. Add these to stencil.config.ts:

testing: {
  browserHeadless: 'new',
  browserArgs: [
    '--no-sandbox',
    '--disable-setuid-sandbox',
    '--disable-dev-shm-usage',
    '--disable-gpu',
  ],
}

Coverage and Thresholds

Enable coverage reporting to spot untested branches, especially around prop validation and conditional rendering:

testing: {
  coverage: true,
  coverageDirectory: './coverage',
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 85,
      lines: 85,
      statements: 85,
    },
  },
}

Common Pitfalls

Conclusion

Testing Stencil Web Components effectively means embracing the testing pyramid: lean heavily on fast spec tests for logic and rendering, supplement them with E2E tests for the things only a real browser can verify, and add framework integration tests where consumption patterns warrant it. By testing through the public API, isolating async rendering with waitForChanges, mocking external dependencies, and reserving browser-based assertions for Shadow DOM, CSS, and accessibility concerns, you'll build a suite that's both fast and trustworthy. The result is a component library you can evolve with confidence—knowing that whether it's consumed in plain HTML, React, or Vue, your tests have already proven it works.

— Ad —

Google AdSense will appear here after approval

← Back to all articles