| name | playwright-tests |
| description | Write Playwright E2E tests for Stencil web components. Use when creating tests, writing e2e tests, or testing components with functional tests, screenshot tests, and accessibility tests. |
Component Testing with Playwright
Setup
import { test } from '../../test-utils/fixtures';
import { expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
The test fixture automatically wraps content passed to page.setContent() with the correct CSS
stylesheet link.
Test File Structure
Every component test file has three test.describe blocks with clear separation of concerns:
test.describe('six-{component} behavior', () => {
test('should be visible and show text when open', ...);
test('should be hidden when closed', ...);
test('should skip disabled in tab navigation', ...);
test('should emit events (focus, change, blur)', ...);
test('should not change state when disabled', ...);
test('should {keyboard behavior per APG}', ...);
});
test.describe('six-{component} screenshots', () => {
const states = [
{ name: 'default', props: '' },
{ name: 'disabled', props: 'disabled' },
];
states.forEach(({ name, props }) => {
test(`should match screenshot for ${name}`, ...);
});
});
test.describe('six-{component} accessibility', () => {
test('should have correct ARIA attributes', ...);
test('should have aria-hidden="true" when closed', ...);
test('should have aria-hidden="false" when open', ...);
test('should have no a11y violations for default', ...);
test('should have no a11y violations for disabled', ...);
});
Separation of Concerns
IMPORTANT: Keep functional tests and accessibility tests separate.
- Functional tests - What sighted users see/experience
- Assertions:
toBeVisible(), toBeHidden(), toContainText()
- Accessibility tests - What screen reader users experience
- Assertions:
toHaveAttribute('aria-hidden', 'true'), toHaveRole(), axe-core
Why separate?
- A failure clearly indicates whether it's a visual bug or an accessibility bug
- Ensures both aspects are actually tested (correct ARIA doesn't mean it's visually working, and
vice versa)
- Makes tests more maintainable and easier to understand
What to Test
VERY IMPORTANT: Never use page.waitForTimeout() in tests.
Research Phase (before writing tests)
Functional Tests
Every interactive component needs:
Avoid redundant tests: Don't write functional tests that only check visibility/appearance and
implement screenshot tests for those states instead. Screenshots already verify visual correctness.
Functional tests should focus on behavior that screenshots can't capture (error handling, state
transitions, event emission, etc.).
Example: Testing visibility by visible/hidden state:
test('should show and hide via open prop', async ({ page }) => {
await page.setContent('<six-alert>Alert message</six-alert>');
const alert = page.locator('six-alert');
await expect(page.getByRole('alert')).toBeHidden();
await alert.evaluate((el: HTMLElement) => el.setAttribute('open', ''));
await expect(page.getByRole('alert')).toBeVisible();
await alert.evaluate((el: HTMLElement) => el.removeAttribute('open'));
await expect(page.getByRole('alert')).toBeHidden();
});
Example: Testing visibility by opacity (for popover-based components):
await expect(page.locator('six-dropdown [part="panel"]')).toHaveCSS('opacity', '0');
await expect(page.locator('six-dropdown [part="panel"]')).toHaveCSS('opacity', '1');
Example: Testing visibility by height
await expect(page.locator('.details__body')).toHaveCSS('height', '0px');
expect(
await page.locator('.details__body').evaluate((el) => parseFloat(getComputedStyle(el).height))
).toBeGreaterThan(0);
ARIA Authoring Practices Guide (APG)
Consult the ARIA APG for each component. Read the full
pattern page, including:
- Description - Required ARIA roles, states, and properties
- Keyboard Interaction - All required keyboard behaviors
- Examples - Linked at the bottom of each pattern page; study these for correct implementation
Rule: Test ALL keyboard interactions in ONE test using a fixture with disabled items. This tests
real-world scenarios and ensures disabled items are skipped.
Screenshot Tests
Use for: Visual features functional tests can't verify (slot positioning, styling, states, focus
rings)
Don't use for: Behavior, events, accessibility
States to capture:
IMPORTANT: Always validate screenshots visually. After generating or updating snapshots, open
the PNG files and verify:
Focusable components (require focus/hover/focus-visible screenshots):
- Form controls: six-button, six-checkbox, six-input, six-radio, six-range, six-switch,
six-textarea
- Pickers: six-datepicker, six-timepicker, six-select, six-language-switcher, six-item-picker,
six-rating
- Other interactive: six-search-field, six-tab-group
Accessibility Tests
ARIA attribute tests (what screen readers need):
axe-core tests (WCAG compliance):
How to Test (Patterns)
Selectors: locator vs getByRole
- Click/interact -
page.locator('six-checkbox').click()
- Assert state -
expect(page.getByRole('checkbox')).toBeChecked()
- Assert role -
expect(page.getByRole('alert')).toHaveRole('alert')
Why: locator('six-component') clicks the host element (real user behavior). getByRole()
queries the accessibility tree (verifies a11y).
Prefer getByRole() over [part="..."] when the element has a role and is visible. Only use
part selectors when getByRole won't work.
const input = page.getByRole('textbox');
await expect(input).toHaveAttribute('type', 'password');
const input = page.locator('six-input [part="input"]');
Role Assertions: toHaveRole
Use toHaveRole() instead of toHaveAttribute('role', ...):
await expect(page.getByRole('alert')).toHaveRole('alert');
await expect(page.locator('[part="base"]')).toHaveAttribute('role', 'alert');
Disabling Animations
The test fixture disables animations by default for faster, more reliable tests. However,
after-events (like six-dialog-after-show, six-dropdown-after-hide) only fire when animations
complete.
Use { disableAnimations: false } when testing after-events:
test('should emit show/hide events', async ({ page }) => {
await page.setContent(
`<six-dialog label="Test Dialog">Content</six-dialog>`,
{ disableAnimations: false }
);
const showSpy = await page.spyOnEvent('six-dialog-show');
const afterShowSpy = await page.spyOnEvent('six-dialog-after-show');
await page.locator('six-dialog').evaluate((el: HTMLSixDialogElement) => el.show());
expect(showSpy).toHaveReceivedEvent();
await expect.poll(() => afterShowSpy.length).toBe(1);
});
Events: spyOnEvent
Test focus, change, and blur events in one realistic flow:
test('should emit events (focus, change, blur)', async ({ page }) => {
await page.setContent(`
<six-checkbox>Checkbox</six-checkbox>
<six-button>Other</six-button>
`);
const focusSpy = await page.spyOnEvent('six-checkbox-focus');
const changeSpy = await page.spyOnEvent('six-checkbox-change');
const blurSpy = await page.spyOnEvent('six-checkbox-blur');
const standardFocus = await page.spyOnEvent('focus');
const standardChange = await page.spyOnEvent('change');
const standardBlur = await page.spyOnEvent('blur');
await page.locator('six-checkbox').click();
expect(focusSpy).toHaveReceivedEvent();
expect(standardFocus).toHaveReceivedEvent();
expect(changeSpy).toHaveReceivedEvent();
expect(standardChange).toHaveReceivedEvent();
await page.locator('six-button').click();
expect(blurSpy).toHaveReceivedEvent();
expect(standardBlur).toHaveReceivedEvent();
});
Matchers: toHaveReceivedEvent(), toHaveReceivedEventTimes(n),
toHaveReceivedEventDetail(detail)
Async Events: expect.poll
For events that fire asynchronously (e.g., via setTimeout), use expect.poll() to wait:
test('should submit form on Enter key', async ({ page }) => {
await page.setContent(`
<form id="test-form">
<six-input></six-input>
</form>
`);
const submitSpy = await page.spyOnEvent('submit');
await page.getByRole('textbox').click();
await page.keyboard.press('Enter');
await expect.poll(() => submitSpy.length).toBeGreaterThan(0);
});
Setting Web Component Properties: evaluate
Use evaluate() to set custom element properties (no Playwright API exists for this):
test('should handle null value gracefully', async ({ page }) => {
await page.setContent('<six-input></six-input>');
await page.locator('six-input').evaluate((el: HTMLElement & { value: string | null }) => {
el.value = null;
});
await expect(page.getByRole('textbox')).toHaveValue('');
});
Disabled State: force click
test('should not change state when disabled', async ({ page }) => {
await page.setContent('<six-checkbox disabled>Disabled</six-checkbox>');
await expect(page.getByRole('checkbox')).toBeDisabled();
await page.locator('six-checkbox').click({ force: true });
await expect(page.getByRole('checkbox')).not.toBeChecked();
});
Keyboard Typing: focus first
When using page.keyboard.type(), you must focus the element first:
test('should not allow editing when disabled', async ({ page }) => {
await page.setContent('<six-input disabled value="initial"></six-input>');
const input = page.getByRole('textbox');
await expect(input).toBeDisabled();
await input.focus();
await page.keyboard.type('new text');
await expect(input).toHaveValue('initial');
});
Why? page.keyboard.type() sends keystrokes to whatever element has focus. Unlike
locator.fill() which auto-focuses, keyboard.type() requires explicit focus.
When to use:
fill() - For setting input values (auto-focuses, clears, types)
keyboard.type() - For testing keyboard rejection (disabled/readonly inputs)
Screenshots: state loop
test.describe('six-checkbox screenshots', () => {
const states = [
{ name: 'unchecked', props: '' },
{ name: 'checked', props: 'checked' },
{ name: 'disabled', props: 'disabled' },
{ name: 'disabled-checked', props: 'disabled checked' },
];
states.forEach(({ name, props }) => {
test(`should match screenshot for ${name}`, async ({ page }) => {
await page.setContent(`<six-checkbox ${props}>Label</six-checkbox>`);
await expect(page.locator('.playwright-test-container')).toHaveScreenshot(
`checkbox-${name}.png`
);
});
});
});
Commands:
cd libraries/ui-library && npx playwright test six-button --update-snapshots
cd libraries/ui-library && npx playwright test six-button
Accessibility: ARIA attributes
Accessibility: axe-core
test('should have no a11y violations', async ({ page }) => {
await page.setContent('<six-button type="primary">Button</six-button>');
const results = await new AxeBuilder({ page }).include('six-button').analyze();
expect(results.violations).toEqual([]);
});
Documenting exceptions:
if (type === 'link') {
builder = builder.disableRules(['color-contrast']);
}
Documenting Component Issues
When tests reveal component bugs or questionable behavior, document them with TODO comments:
test('should have role attribute', async ({ page }) => {
await page.setContent('<six-avatar></six-avatar>');
await expect(page.locator('[part="base"]')).toHaveAttribute('role', 'image');
});
test('should be focusable via tab', async ({ page }) => {
});
When to add TODOs:
- Invalid ARIA roles or attributes (document the correct value)
- Color contrast issues (include the actual vs required ratio)
- Questionable focusability (presentational elements shouldn't be in tab order)
- Missing focus styles (if focusable, must have visible focus indicator)
- Any axe rule you disable (explain why)
- When in doubt, add a TODO instead of trying to fix the test!