Stencil.js Web Components Testing: Unit and E2E Guide (2026)
Stencil generates standard Web Components — Custom Elements with Shadow DOM — that run in any framework or vanilla HTML. That portability is the point, but it creates a testing challenge: your components need to work correctly both in isolation and when embedded in React, Angular, Vue, or plain HTML.
Stencil comes with its own testing infrastructure built on Jest and Puppeteer, which covers most cases. For production E2E coverage, Playwright handles the rest.
Stencil's Testing Architecture
Stencil provides two test modes out of the box:
- Unit tests (
specfiles) — run in Jest with jsdom. Fast, no browser. Test props, state, and component logic. - E2E tests (
e2efiles) — run in Jest with Puppeteer. A real browser. Test rendering, events, and Shadow DOM interaction.
Both modes are configured in stencil.config.ts:
// stencil.config.ts
import { Config } from '@stencil/core';
export const config: Config = {
namespace: 'my-components',
testing: {
browserHeadless: true,
testPathPattern: ['src/**/*.spec.ts', 'src/**/*.e2e.ts'],
},
outputTargets: [
{ type: 'dist' },
{ type: 'dist-custom-elements' },
{ type: 'www', serviceWorker: null },
],
};Add test scripts to package.json:
{
"scripts": {
"test": "stencil test --spec",
"test:e2e": "stencil test --e2e",
"test:watch": "stencil test --spec --watchAll"
}
}Unit Testing with Jest (spec files)
Unit tests use newSpecPage from @stencil/core/testing to render components in jsdom without a browser.
// src/components/my-button/my-button.tsx
import { Component, Prop, Event, EventEmitter, h } from '@stencil/core';
@Component({
tag: 'my-button',
styleUrl: 'my-button.css',
shadow: true,
})
export class MyButton {
@Prop() label: string = 'Button';
@Prop() variant: 'primary' | 'secondary' | 'danger' = 'primary';
@Prop() disabled: boolean = false;
@Event() buttonClick: EventEmitter<void>;
handleClick() {
if (!this.disabled) {
this.buttonClick.emit();
}
}
render() {
return (
<button
class={`btn btn--${this.variant}`}
disabled={this.disabled}
onClick={() => this.handleClick()}
>
{this.label}
</button>
);
}
}// src/components/my-button/my-button.spec.ts
import { newSpecPage } from '@stencil/core/testing';
import { MyButton } from './my-button';
describe('my-button', () => {
it('renders with the default label', async () => {
const page = await newSpecPage({
components: [MyButton],
html: '<my-button></my-button>',
});
// Shadow DOM access
const button = page.root!.shadowRoot!.querySelector('button');
expect(button?.textContent).toBe('Button');
});
it('renders with a custom label via prop', async () => {
const page = await newSpecPage({
components: [MyButton],
html: '<my-button label="Submit Form"></my-button>',
});
const button = page.root!.shadowRoot!.querySelector('button');
expect(button?.textContent).toBe('Submit Form');
});
it('applies the correct variant class', async () => {
const page = await newSpecPage({
components: [MyButton],
html: '<my-button variant="danger"></my-button>',
});
const button = page.root!.shadowRoot!.querySelector('button');
expect(button?.className).toContain('btn--danger');
});
it('renders as disabled when the disabled prop is set', async () => {
const page = await newSpecPage({
components: [MyButton],
html: '<my-button disabled></my-button>',
});
const button = page.root!.shadowRoot!.querySelector<HTMLButtonElement>('button');
expect(button?.disabled).toBe(true);
});
it('emits buttonClick event on click', async () => {
const page = await newSpecPage({
components: [MyButton],
html: '<my-button label="Click me"></my-button>',
});
const clickSpy = jest.fn();
page.root!.addEventListener('buttonClick', clickSpy);
const button = page.root!.shadowRoot!.querySelector('button')!;
button.click();
expect(clickSpy).toHaveBeenCalledTimes(1);
});
it('does not emit buttonClick when disabled', async () => {
const page = await newSpecPage({
components: [MyButton],
html: '<my-button disabled></my-button>',
});
const clickSpy = jest.fn();
page.root!.addEventListener('buttonClick', clickSpy);
const button = page.root!.shadowRoot!.querySelector('button')!;
button.click();
expect(clickSpy).not.toHaveBeenCalled();
});
});Testing Slots
Slots let consumers inject content into your component. Test that slotted content is placed and rendered correctly.
// src/components/my-card/my-card.tsx
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'my-card',
styleUrl: 'my-card.css',
shadow: true,
})
export class MyCard {
@Prop() heading: string = '';
render() {
return (
<div class="card">
{this.heading && <h2 class="card__heading">{this.heading}</h2>}
<div class="card__body">
<slot />
</div>
<div class="card__footer">
<slot name="footer" />
</div>
</div>
);
}
}// src/components/my-card/my-card.spec.ts
import { newSpecPage } from '@stencil/core/testing';
import { MyCard } from './my-card';
describe('my-card slots', () => {
it('renders the heading prop', async () => {
const page = await newSpecPage({
components: [MyCard],
html: '<my-card heading="Card Title"></my-card>',
});
const heading = page.root!.shadowRoot!.querySelector('.card__heading');
expect(heading?.textContent).toBe('Card Title');
});
it('renders default slot content', async () => {
const page = await newSpecPage({
components: [MyCard],
html: '<my-card><p>Body content</p></my-card>',
});
const body = page.root!.shadowRoot!.querySelector('.card__body');
expect(body).not.toBeNull();
// Slot content assignment is verified in E2E tests (jsdom doesn't fully support slots)
});
it('omits the heading when no heading prop is set', async () => {
const page = await newSpecPage({
components: [MyCard],
html: '<my-card></my-card>',
});
const heading = page.root!.shadowRoot!.querySelector('.card__heading');
expect(heading).toBeNull();
});
});Note: jsdom's Shadow DOM support is limited — named slots and composed rendering work fully only in E2E tests with a real browser.
E2E Testing with Stencil (Puppeteer)
Stencil's built-in E2E runner uses Puppeteer under the hood. E2E tests verify real browser behavior: slot rendering, custom events crossing shadow boundaries, and CSS.
// src/components/my-button/my-button.e2e.ts
import { newE2EPage } from '@stencil/core/testing';
describe('my-button e2e', () => {
it('renders in a real browser', async () => {
const page = await newE2EPage();
await page.setContent('<my-button label="Test"></my-button>');
const element = await page.find('my-button');
expect(element).not.toBeNull();
expect(element).toHaveClass('hydrated');
});
it('emits the buttonClick event when clicked', async () => {
const page = await newE2EPage();
await page.setContent('<my-button label="Click me"></my-button>');
const buttonClickSpy = await page.spyOnEvent('buttonClick');
const button = await page.find('my-button >>> button');
await button.click();
expect(buttonClickSpy).toHaveReceivedEventTimes(1);
});
it('button is disabled in the browser when the disabled prop is set', async () => {
const page = await newE2EPage();
await page.setContent('<my-button disabled label="Disabled"></my-button>');
const button = await page.find('my-button >>> button');
const isDisabled = await button.getProperty('disabled');
expect(isDisabled).toBe(true);
});
it('slot content renders correctly inside the shadow DOM', async () => {
const page = await newE2EPage();
await page.setContent(`
<my-card heading="Card Title">
<p>This is the body</p>
<span slot="footer">Footer content</span>
</my-card>
`);
const heading = await page.find('my-card >>> .card__heading');
expect(heading?.textContent).toBe('Card Title');
const footer = await page.find('my-card >>> .card__footer');
expect(footer).not.toBeNull();
});
});Use >>> (pierce selector) in Stencil E2E tests to query inside Shadow DOM.
Testing Component Methods and Watch Decorators
// src/components/my-input/my-input.tsx
import { Component, Prop, State, Watch, Method, Event, EventEmitter, h } from '@stencil/core';
@Component({
tag: 'my-input',
shadow: true,
})
export class MyInput {
@Prop({ mutable: true }) value: string = '';
@State() error: string = '';
@Event() valueChange: EventEmitter<string>;
@Watch('value')
validateValue(newValue: string) {
if (newValue.length > 100) {
this.error = 'Value exceeds 100 characters';
} else {
this.error = '';
}
this.valueChange.emit(newValue);
}
@Method()
async clear() {
this.value = '';
this.error = '';
}
render() {
return (
<div>
<input
value={this.value}
onInput={(e) => (this.value = (e.target as HTMLInputElement).value)}
/>
{this.error && <p class="error">{this.error}</p>}
</div>
);
}
}// src/components/my-input/my-input.spec.ts
import { newSpecPage } from '@stencil/core/testing';
import { MyInput } from './my-input';
describe('my-input', () => {
it('shows an error when the value exceeds 100 characters', async () => {
const page = await newSpecPage({
components: [MyInput],
html: '<my-input></my-input>',
});
page.rootInstance.value = 'a'.repeat(101);
await page.waitForChanges();
const error = page.root!.shadowRoot!.querySelector('.error');
expect(error?.textContent).toContain('100 characters');
});
it('clears the value and error via the clear() method', async () => {
const page = await newSpecPage({
components: [MyInput],
html: '<my-input></my-input>',
});
page.rootInstance.value = 'a'.repeat(101);
await page.waitForChanges();
await page.rootInstance.clear();
await page.waitForChanges();
expect(page.rootInstance.value).toBe('');
expect(page.rootInstance.error).toBe('');
});
});Playwright E2E for Full Integration
Stencil's built-in E2E runner covers component isolation. For testing your components embedded in a full app page, use Playwright.
npm install -D @playwright/test
npx playwright install chromium// e2e/component-integration.test.ts
import { test, expect } from '@playwright/test';
test.describe('my-button in full app context', () => {
test('button click triggers the expected application behavior', async ({ page }) => {
await page.goto('/');
// Custom element must be hydrated before interacting
await page.waitForSelector('my-button.hydrated');
const button = page.locator('my-button').first();
await button.click();
// Assert application state change triggered by the event
await expect(page.locator('[data-testid="result"]')).toBeVisible();
});
});What Tests Won't Catch
- A component that works in Chrome but fails in Safari due to Shadow DOM CSS encapsulation differences
- Custom elements that are not defined in a consumer's bundler (the tag renders but does nothing)
- A
@Watchthat creates an infinite update loop when a prop is updated inside the watcher - Production bundle differences — the Stencil dist output behaves differently from the dev output in some edge cases
Monitoring Web Component Apps in Production with HelpMeTest
Install HelpMeTest:
curl -fsSL https://helpmetest.com/install | bashWrite plain-English tests that verify your components in production:
Go to https://myapp.com/components-demo
Wait for the my-button element to be visible
Click the primary action button
Verify the result panel is visible with a success message
Click the clear button
Verify the result panel is no longer visibleHelpMeTest runs these every few minutes and alerts you when they fail. If your Stencil components break in production — the custom element registry is missing an import, hydration fails silently, a Shadow DOM style breaks a consumer page — you know in minutes.
Free tier: 10 tests, unlimited health checks. Pro: $100/month for unlimited tests and parallel execution.
Your spec tests prove the component logic is correct. E2E tests prove the Shadow DOM and slots work in a real browser. HelpMeTest proves the deployed components are working right now.
Start at helpmetest.com — free tier, no credit card.