Skip to main content
Version: v5 (Preview)

Migration Guide

Stencil v5 removes the integrated test runner (stencil test, with Jest and Puppeteer). This guide moves its tests to Vitest.

There are two ways to migrate spec tests, and one project can use both:

  • Keep your newSpecPage() tests and run them under Vitest. This is the quickest route for a large existing suite.
  • Move to render() from @stencil/vitest. Tests written this way run in real browsers as well as Node, and it's the recommended API for new tests.

E2E tests written with newE2EPage() become browser tests.

Keeping newSpecPage() Tests​

newSpecPage() is still exported from @stencil/core/testing. The stencilSpecPage() plugin from @stencil/unplugin compiles your components so that it works under Vitest.

Install the dependencies:

npm install --save-dev vitest @stencil/unplugin @stencil/vitest

Then create a Vitest config:

vitest.config.ts
import { stencilSpecPage } from '@stencil/unplugin';
import { defineConfig } from 'vitest/config';

export default defineConfig({
plugins: [stencilSpecPage()],
test: {
globals: true,
environment: 'node',
include: ['src/**/*.spec.{ts,tsx}'],
setupFiles: ['./vitest.setup.ts'],
},
});

globals: true provides describe, it, and expect without imports, as Jest did.

The setup file registers Stencil's matchers, such as toEqualHtml() and toHaveClass(), which the old runner registered for you:

vitest.setup.ts
import '@stencil/vitest';

Your tests keep their newSpecPage() calls and imports. What does change is anything that used Jest's own API, which has a Vitest equivalent under vi:

JestVitest
jest.fn()vi.fn()
jest.spyOn()vi.spyOn()
jest.mock()vi.mock()
jest.useFakeTimers()vi.useFakeTimers()

See Vitest's guide to migrating from Jest for the full list of differences.

Moving to render()​

Follow the Quick Start to install and configure @stencil/vitest, then convert your tests.

Spec Tests​

Before:

import { newSpecPage } from '@stencil/core/testing';
import { MyComponent } from './my-component';

describe('my-component', () => {
it('renders', async () => {
const page = await newSpecPage({
components: [MyComponent],
html: '<my-component></my-component>',
});
expect(page.root).toEqualHtml(`
<my-component>
<mock:shadow-root>
<div>Hello, World!</div>
</mock:shadow-root>
</my-component>
`);
});
});

After:

import { render, describe, it, expect } from '@stencil/vitest';

import './my-component';

describe('my-component', () => {
it('renders', async () => {
const { root } = await render(<my-component />);
await expect(root).toEqualHtml(`
<my-component class="hydrated">
<mock:shadow-root>
<div>Hello, World!</div>
</mock:shadow-root>
</my-component>
`);
});
});

E2E Tests​

newE2EPage() and its Puppeteer browser are removed. The same render() tests run in a real browser when your Vitest config enables browser mode. To test whole pages instead, in several browsers, see Migrating E2E Tests to Playwright.

Before:

import { newE2EPage } from '@stencil/core/testing';

describe('my-component e2e', () => {
it('renders', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');

const element = await page.find('my-component');
expect(element).toHaveClass('hydrated');
});

it('handles click', async () => {
const page = await newE2EPage();
await page.setContent('<my-component></my-component>');

const spy = await page.spyOnEvent('myEvent');
const button = await page.find('my-component >>> button');
await button.click();

expect(spy).toHaveReceivedEvent();
});
});

After:

import { render, describe, it, expect } from '@stencil/vitest';

import './my-component';

describe('my-component e2e', () => {
it('renders', async () => {
const { root } = await render(<my-component />);
expect(root).toHaveClass('hydrated');
});

it('handles click', async () => {
const { root, spyOnEvent, waitForChanges } = await render(<my-component />);

const spy = spyOnEvent('myEvent');
const button = root.shadowRoot?.querySelector('button');
button?.click();
await waitForChanges();

expect(spy).toHaveReceivedEvent();
});
});

To test whole pages, such as routing between them, use @stencil/playwright instead.

API Mapping​

Test Setup​

Stencil Test Runner@stencil/vitest
newSpecPage({ components, html })render(<component />)
newE2EPage()render(<component />) with browser project
page.setContent(html)render(<component />)
page.waitForChanges()waitForChanges()

Element Access​

Stencil Test Runner@stencil/vitest
page.rootroot
page.find('selector')root.querySelector('selector')
page.find('component >>> shadow')root.shadowRoot?.querySelector('shadow')
page.findAll('selector')root.querySelectorAll('selector')

Events​

Stencil Test Runner@stencil/vitest
page.spyOnEvent('event')spyOnEvent('event')
spy.eventsspy.events
spy.firstEventspy.firstEvent
spy.lastEventspy.lastEvent

Matchers​

Most matchers work the same way:

MatcherNotes
toEqualHtml()Same API
toEqualLightHtml()Same API
toEqualText()Same API
toHaveClass()Same API
toHaveClasses()Same API
toHaveAttribute()Same API
toHaveReceivedEvent()Same API
toHaveReceivedEventTimes()Same API
toHaveReceivedEventDetail()Same API

Package.json Scripts​

Before​

{
"scripts": {
"test": "stencil test --spec",
"test:watch": "stencil test --spec --watchAll",
"test:e2e": "stencil test --e2e"
}
}

After​

{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
}
}

If you split your tests into projects, run one with vitest run --project browser.

Key Differences​

Components Are Imported​

render() takes no list of component classes. Each test file imports the source files of the components it uses, which registers them:

import './my-component';

A component that's rendered by another one needs importing too.

JSX-Based Rendering​

Use JSX directly instead of HTML strings (though HTML strings are still supported):

// Before
const page = await newSpecPage({
components: [MyComponent],
html: '<my-component first="John" last="Doe"></my-component>',
});

// After
const { root } = await render(<my-component first="John" last="Doe" />);

Shadow DOM Queries​

Use standard DOM APIs instead of special selectors:

// Before
const button = await page.find('my-component >>> button');

// After
const button = root.shadowRoot?.querySelector('button');

Troubleshooting​

Components Not Rendering​

An element that stays empty and never gets the hydrated class isn't registered. Check that the test file imports that component's source file.

Waiting for Updates​

Always await waitForChanges() after modifying component state:

root.value = 'new value';
await waitForChanges();