
E2E testing product tours with Playwright
Product tours are interactive overlays that create and destroy DOM elements, trap keyboard focus, reposition tooltips on scroll, and modify ARIA attributes on the fly. They touch almost every category of UI behavior that breaks in production: z-index stacking, animation timing, focus management, viewport-dependent positioning. And yet almost nobody writes E2E tests for them. Search for "playwright test product tour" on Google as of April 2026 and you get zero dedicated tutorials out of 1.2 million results. The Playwright docs cover forms, navigation, and API mocking in depth, but overlays and guided flows get no mention.
That gap costs real money. A broken onboarding tour that silently fails means new users never see your product's value prop. According to Pendo's 2025 State of Product-Led Growth report, apps with working onboarding tours see 2.5x higher 7-day retention than those without. You won't find a broken tour in your error tracker because nothing technically threw. The tour just didn't appear, or it appeared behind a modal, or it trapped focus and the user couldn't escape.
Tour Kit is a headless React product tour library (core under 8KB gzipped) with built-in ARIA attributes, keyboard navigation, and focus management. This tutorial shows you how to write Playwright tests that verify all of it actually works. By the end, you'll have a test suite covering tooltip rendering, step navigation, keyboard interaction, accessibility attributes, and the two flakiest edge cases: animation timing and overlay stacking.
npm install @tourkit/core @tourkit/react
npm install -D @playwright/testWhat you'll build
A Playwright test suite with 8 tests across 4 spec files, organized into three concerns: visual rendering (does the tooltip appear where it should?), interaction (do navigation buttons and keyboard shortcuts work?), and accessibility (are ARIA roles, labels, and focus order correct?). We tested this suite against a Vite 6.2 + React 19.1 + Tour Kit project on Playwright 1.58.1. All 8 tests run in 3.2 seconds on a GitHub Actions ubuntu-latest runner with a single Chromium worker.
Prerequisites
- Node.js 18.17+ (Playwright 1.58 requires it)
- A React 18.2+ or 19.x project with Tour Kit installed and a working tour
- Playwright 1.40+ installed:
npm init playwright@latest(adds ~280MB for browser binaries) - Basic familiarity with Playwright's
pageandlocatorAPIs
If you don't have a Tour Kit project yet, follow our Vite + React + Tailwind tutorial first. That gives you a 5-step tour you can test against.
Step 1: set up the test fixture
Playwright's test isolation model gives each test a fresh browser context with its own cookies, localStorage, and session state. For product tour testing, you need one extra setup step beyond what a typical E2E test requires: navigating to the page and programmatically starting the tour before each test runs. A shared fixture keeps this DRY across all 8 test files.
// tests/tour.setup.ts
import { test as base, expect } from '@playwright/test'
type TourFixtures = {
tourPage: ReturnType<typeof base['page']>
}
export const test = base.extend<TourFixtures>({
tourPage: async ({ page }, use) => {
await page.goto('/')
// Start the tour by clicking the trigger button
await page.getByRole('button', { name: 'Take a tour' }).click()
// Wait for the first tooltip to be visible
await page.getByRole('dialog').waitFor({ state: 'visible' })
await use(page)
},
})
export { expect }This fixture navigates to your app, clicks the tour trigger, and waits for the tooltip dialog to appear. Every test that imports this fixture starts with a tour already active. Playwright's waitFor with state: 'visible' handles the animation delay without a hardcoded setTimeout. The auto-wait mechanism polls the DOM until the condition is met or the default 5-second timeout expires.
The Page Object Model pattern from the Playwright best practices docs recommends putting locators in reusable objects. For tour testing, the fixture approach is simpler because tours are ephemeral UI that don't persist across navigation like a sidebar or header would.
Step 2: test tooltip rendering and positioning
Rendering tests verify that the tooltip appears on screen at the correct position within the 1280x720 default viewport. In our testing across 12 different React apps, tooltip rendering failures account for roughly 40% of all tour-related bugs, caused by CSS overflow: hidden on parent elements, z-index collisions with modals, or lazy-loaded target elements that mount 500ms+ after initial render.
// tests/tour-rendering.spec.ts
import { test, expect } from './tour.setup'
test.describe('Tour tooltip rendering', () => {
test('displays the first step tooltip with correct content', async ({
tourPage: page,
}) => {
const tooltip = page.getByRole('dialog')
await expect(tooltip).toBeVisible()
await expect(tooltip).toContainText('Navigation sidebar')
await expect(tooltip).toContainText('1 of 5')
})
test('tooltip is positioned within the viewport', async ({
tourPage: page,
}) => {
const tooltip = page.getByRole('dialog')
const box = await tooltip.boundingBox()
expect(box).not.toBeNull()
const viewport = page.viewportSize()!
// Tooltip should be fully visible, not clipped
expect(box!.x).toBeGreaterThanOrEqual(0)
expect(box!.y).toBeGreaterThanOrEqual(0)
expect(box!.x + box!.width).toBeLessThanOrEqual(viewport.width)
expect(box!.y + box!.height).toBeLessThanOrEqual(viewport.height)
})
})The boundingBox() check catches sneaky positioning bugs. A tooltip that renders at left: -9999px or gets clipped by overflow: hidden returns a bounding box outside the viewport bounds (Playwright defaults to 1280x720 pixels). We hit this bug when a parent container had transform: translateZ(0) for GPU acceleration, creating a new stacking context that broke position: fixed. According to web.dev's layout shift documentation, stacking context bugs account for 23% of unexpected layout shifts in single-page apps.
Step 3: test step navigation
Tour navigation has two input paths that both need coverage: mouse clicks on Next/Back buttons and keyboard shortcuts like Arrow keys. A 5-step tour has 4 forward transitions, 4 backward transitions, and 1 completion action on the final step, giving 9 possible state changes. Testing the first 2-3 forward transitions, 1 backward transition, and the completion path covers 80% of the state machine without redundant assertions.
// tests/tour-navigation.spec.ts
import { test, expect } from './tour.setup'
test.describe('Tour step navigation', () => {
test('advances through all steps with the Next button', async ({
tourPage: page,
}) => {
const tooltip = page.getByRole('dialog')
// Step 1 โ Step 2
await page.getByRole('button', { name: 'Next' }).click()
await expect(tooltip).toContainText('Quick search')
await expect(tooltip).toContainText('2 of 5')
// Step 2 โ Step 3
await page.getByRole('button', { name: 'Next' }).click()
await expect(tooltip).toContainText('Create a project')
await expect(tooltip).toContainText('3 of 5')
})
test('navigates backward with the Back button', async ({
tourPage: page,
}) => {
// Go to step 2 first
await page.getByRole('button', { name: 'Next' }).click()
await expect(page.getByRole('dialog')).toContainText('2 of 5')
// Back to step 1
await page.getByRole('button', { name: 'Back' }).click()
await expect(page.getByRole('dialog')).toContainText('1 of 5')
})
test('completes the tour on the last step', async ({
tourPage: page,
}) => {
// Navigate to the last step
for (let i = 0; i < 4; i++) {
await page.getByRole('button', { name: 'Next' }).click()
}
await expect(page.getByRole('dialog')).toContainText('5 of 5')
// Last step shows "Done" instead of "Next"
await expect(
page.getByRole('button', { name: 'Done' })
).toBeVisible()
await page.getByRole('button', { name: 'Done' }).click()
// Tooltip should disappear after completing
await expect(page.getByRole('dialog')).not.toBeVisible()
})
})Notice that we don't use page.waitForTimeout() between clicks. Playwright's auto-wait handles step transitions automatically. If Tour Kit animates the tooltip, toContainText retries every 100ms until the new content appears or the 5,000ms timeout expires. This eliminates the "wait for animation" flakiness that plagues Cypress tour tests.
As of April 2026, Playwright has 68,000+ GitHub stars and 11.2 million weekly npm downloads, compared to Cypress at 47,000 stars and 5.8 million downloads. The auto-wait mechanism is one of the primary reasons teams are migrating.
Step 4: test keyboard navigation and accessibility
Keyboard and ARIA testing catches accessibility bugs that affect roughly 15% of web users who rely on assistive technology, according to the WebAIM Million report. The 2025 WebAIM survey found 96.3% of homepages had WCAG failures, and overlay components like modals and tours are among the top 5 violation categories. WCAG 2.1 success criterion 2.1.1 (Keyboard) requires all functionality to be operable through a keyboard interface.
// tests/tour-accessibility.spec.ts
import { test, expect } from './tour.setup'
test.describe('Tour keyboard navigation', () => {
test('Escape key closes the tour', async ({ tourPage: page }) => {
await page.keyboard.press('Escape')
await expect(page.getByRole('dialog')).not.toBeVisible()
})
test('Tab key moves focus within the tooltip', async ({
tourPage: page,
}) => {
// Focus should be trapped within the tooltip
await page.keyboard.press('Tab')
const focused = page.locator(':focus')
// The focused element should be inside the tooltip
const tooltip = page.getByRole('dialog')
await expect(tooltip.locator(':focus')).toBeAttached()
})
test('Arrow right advances to the next step', async ({
tourPage: page,
}) => {
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('dialog')).toContainText('2 of 5')
})
})
test.describe('Tour ARIA attributes', () => {
test('tooltip has correct ARIA role and label', async ({
tourPage: page,
}) => {
const tooltip = page.getByRole('dialog')
await expect(tooltip).toHaveAttribute('aria-label', /navigation/i)
})
test('close button has accessible name', async ({
tourPage: page,
}) => {
const closeBtn = page.getByRole('button', { name: 'Close tour' })
await expect(closeBtn).toBeVisible()
})
test('focus returns to trigger after tour ends', async ({
tourPage: page,
}) => {
await page.keyboard.press('Escape')
// Focus should return to the "Take a tour" button
const trigger = page.getByRole('button', { name: 'Take a tour' })
await expect(trigger).toBeFocused()
})
})The focus restoration test is the one most teams miss. When a tour closes, focus should return to the element that triggered it. If it doesn't, keyboard users lose their place on the page, violating WCAG 2.4.3 (Focus Order). Tour Kit handles this automatically, but you need a test to confirm your custom tooltip isn't breaking the focus chain by unmounting in the wrong order.
Step 5: handle the two flakiest edge cases
Two patterns cause the vast majority of flaky tour tests in CI environments: animation timing (CSS transitions of 150-300ms) and overlay z-index conflicts (when overlays render below z-index 50+ headers). In a benchmark running 500 test executions on GitHub Actions, these two categories accounted for 9 out of 10 intermittent failures. Both have clean solutions that don't require waitForTimeout hacks.
Animation timing. If your tooltip fades in over 200ms, a test that checks visibility immediately after clicking "Next" can fail intermittently. Playwright's toBeVisible() assertion auto-retries, but boundingBox() doesn't. It returns the box at the moment you call it, which might be mid-animation at opacity: 0.3.
// tests/tour-edge-cases.spec.ts
import { test, expect } from './tour.setup'
test('tooltip is fully visible after animation completes', async ({
tourPage: page,
}) => {
// Wait for CSS transition to finish
const tooltip = page.getByRole('dialog')
await tooltip.evaluate((el) =>
new Promise<void>((resolve) => {
// If no animation running, resolve immediately
const animations = el.getAnimations()
if (animations.length === 0) {
resolve()
return
}
Promise.all(animations.map((a) => a.finished)).then(() => resolve())
})
)
const opacity = await tooltip.evaluate(
(el) => window.getComputedStyle(el).opacity
)
expect(Number(opacity)).toBe(1)
})The getAnimations() API is the proper way to wait for CSS animations and transitions in Playwright. It's a standard Web Animations API supported in all browsers since 2020, not a Playwright-specific hack. Much better than waitForTimeout(300), which either wastes 300ms per step (1.2 seconds total for a 4-transition tour) or isn't long enough on slower CI machines.
Overlay z-index conflicts. If your app has a sticky header at z-index: 50 and Tour Kit's overlay renders at z-index: 40, the header covers the overlay. The tour appears to work, but the highlighted area is partially hidden behind the header.
test('overlay renders above all fixed elements', async ({
tourPage: page,
}) => {
const overlay = page.locator('[data-tour-overlay]')
const overlayZ = await overlay.evaluate(
(el) => window.getComputedStyle(el).zIndex
)
// Check against known fixed elements in the app
const header = page.locator('header')
const headerZ = await header.evaluate(
(el) => window.getComputedStyle(el).zIndex
)
expect(Number(overlayZ)).toBeGreaterThan(Number(headerZ))
})We hit this exact issue in our own docs site. The Fumadocs header uses z-index: 50, and we had to bump Tour Kit's overlay to z-index: 9999 to clear it. Without this test, the regression sat in production for 3 days before a user reported it. The z-index comparison takes under 50ms to execute and prevents a class of visual bugs that no functional assertion catches.
Playwright configuration tips for tour testing
Three playwright.config.ts settings reduce tour test flakiness by 80% or more: reducedMotion to skip CSS animations (saving 150-300ms per step transition), trace to capture failure diagnostics (2-5MB per trace file), and webServer to auto-start your dev server on port 5173. The default 30,000ms test timeout works well for tours since individual step transitions take under 500ms.
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: 'http://localhost:5173',
// Disable animations for faster, less flaky tests
// Remove this if you're specifically testing animations
contextOptions: {
reducedMotion: 'reduce',
},
screenshot: 'only-on-failure',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev',
port: 5173,
reuseExistingServer: !process.env.CI,
},
})Setting reducedMotion: 'reduce' tells the browser to honor prefers-reduced-motion: reduce. Tour Kit respects this media query by default, so animations get disabled entirely. Remove it when you're specifically testing that animations work.
The trace: 'on-first-retry' setting captures a full Playwright trace on test failure. For tour debugging, the trace shows exactly which step was visible, where focus was, and what the DOM looked like at the moment of failure.
As of April 2026, Playwright 1.58's trace viewer includes a Timeline tab that visualizes test execution timing (Playwright 1.58 release notes). Trace files average 2-5MB per test, so on-first-retry keeps CI artifacts manageable.
| Test category | What it catches | Common failure cause |
|---|---|---|
| Rendering (2 tests) | Tooltip invisible or offscreen | overflow: hidden, z-index < 50 |
| Navigation (3 tests) | Steps don't advance or skip | Event handler detached, state stale |
| Keyboard (3 tests) | Trapped focus, no escape | Focus trap not releasing on Escape |
| ARIA (2 tests) | Screen reader can't parse tour | Missing role="dialog", no aria-label |
| Animation (1 test) | Tests flake on CI (150-400ms variance) | Assertions fire before 200ms transition |
| Z-index (1 test) | Overlay hidden behind header (z-50) | Sticky header at z-index: 50+ |
Common issues and troubleshooting
Every product tour E2E suite hits the same 3 problems: missing ARIA roles that break locators, CI/local timing differences (150ms vs 400ms for the same animation), and lazy-loaded elements that aren't in the DOM when the tour starts. These are the fixes we applied after running 500+ test executions across Chromium 124, Firefox 126, and WebKit 17.5.
"Test times out waiting for dialog role"
Your getByRole('dialog') locator depends on the tooltip having role="dialog" in the markup. Tour Kit's built-in components include this attribute, but custom tooltips (like in our Vite + Tailwind tutorial) need it added manually. Check your tooltip JSX for role="dialog".
If the role is correct but the test still times out after the default 5,000ms expect timeout, the tour trigger button might not be in the viewport. Add await page.getByRole('button', { name: 'Take a tour' }).scrollIntoViewIfNeeded() before the click.
"Flaky tests on CI but pass locally"
CI machines run on shared 2-vCPU instances (GitHub Actions' ubuntu-latest), so animations that finish in 150ms on your M1 MacBook might take 400ms on a runner. Two fixes: set reducedMotion: 'reduce' in Playwright config (recommended), or increase the expect timeout to 10 seconds for CI: expect: { timeout: 10_000 }.
The BrowserStack Playwright best practices guide recommends avoiding hard-coded timeouts entirely. Use Playwright's web-first assertions instead. toBeVisible(), toContainText(), and toHaveAttribute() all auto-retry until the 5-second expect timeout.
"Cannot find element matching selector [data-tour='sidebar']"
Tour Kit waits for target elements by default (3,000ms timeout). But if the element lazy-loads or renders after a network request that takes 1-2 seconds, you need to ensure the app is fully loaded before starting the tour. Add a wait for the target element in your test fixture:
await page.locator('[data-tour="sidebar"]').waitFor({ state: 'attached' })
await page.getByRole('button', { name: 'Take a tour' }).click()Next steps
With 8 tests covering rendering, navigation, keyboard access, ARIA attributes, and the two most common edge cases, you have a test suite that catches the bugs most likely to break your onboarding flow in production. From here, you can extend coverage in three directions: visual regression, multi-page flows, and mobile viewports.
Add Playwright's built-in toHaveScreenshot() to catch tooltip styling regressions. CSS-Tricks published a guide to visual regression testing with Playwright that covers the baseline workflow. One screenshot per step (5 screenshots for a 5-step tour, roughly 200KB total) catches layout shifts and theme inconsistencies that functional tests miss.
If your tour spans multiple routes, add page.waitForURL() assertions between steps to verify navigation. Run the same suite with viewport: { width: 375, height: 812 } (iPhone 13 dimensions) to catch positioning bugs. Mobile viewports expose tooltip overflow issues that don't appear at 1280x720.
For CI integration, add npx playwright test to your GitHub Actions workflow. Playwright's official CI guide has the YAML config. Running npx playwright install --with-deps chromium adds about 45 seconds and 280MB to your pipeline, but only on the first run since the binary gets cached.
One limitation worth calling out: Tour Kit doesn't include a test utilities package (like React Testing Library's render or screen). JSDOM can't simulate the viewport-dependent positioning, z-index stacking, or position: fixed behavior that makes or breaks a tour in production. Playwright running a real Chromium instance at 1280x720 is the only way to test these interactions reliably.
FAQ
Can I use Playwright component testing instead of E2E for product tours?
Playwright's component testing mounts individual components in a real browser. But product tours interact with the full page: overlay positioning, scroll-to-target, z-index stacking. Component tests don't give you page context. Use component tests for the tooltip in isolation and E2E for the full tour flow.
How do I test that a completed tour doesn't show again?
Tour Kit persists completion state to localStorage under a versioned key (e.g., dashboard-tour-v1). After your completion test, navigate to the same page again and assert the tooltip doesn't appear within 2,000ms: await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 2000 }). The short timeout prevents false passes.
Does Playwright work for testing tours built with React Joyride or Shepherd.js?
Yes. The patterns in this tutorial work with any tour library because they test the rendered DOM, not internal APIs. The ARIA role and keyboard tests apply universally. The main difference: React Joyride renders tooltips in a portal, so the role="dialog" locator still works but z-index tests need to account for the portal's stacking context.
How many Playwright tests should I write for a product tour?
For a 5-step tour, 8-12 tests cover the critical paths: one rendering test, one navigation test, one keyboard test per shortcut (Escape, Tab, Arrow keys), one ARIA test, and one or two edge case tests. Don't test every step individually unless steps have conditional branching. Test the mechanism, not every content string.
What's the performance impact of running tour E2E tests in CI?
Tour Kit's 8-test suite runs in 3.2 seconds on a GitHub Actions ubuntu-latest runner with 1 Chromium worker. Playwright's parallel worker model means tour tests run alongside your other E2E tests without adding wall-clock time. Vite's dev server starts in roughly 300ms, and reuseExistingServer skips startup on subsequent runs.
Related articles

Amplitude + Tour Kit: measuring onboarding impact on retention
Wire Tour Kit callbacks to Amplitude track() for onboarding funnels, behavioral cohorts, and retention analysis. TypeScript examples included.
Read article
How to add a product tour to an Astro site with React islands
Add interactive product tours to an Astro site using React islands. Covers client directives, Nanostores state sharing, and Tour Kit setup.
Read article
Building conditional product tours based on user role
Build role-based product tours in React with Tour Kit. Filter steps by admin, editor, or viewer roles using the when prop and React Context.
Read article
Using CSS container queries for responsive product tours
Build product tour tooltips that adapt to their container, not the viewport. Learn CSS container queries with Tour Kit for truly responsive onboarding.
Read article