Validate the first-time user experience including onboarding flows, empty states, tutorial completion, progressive disclosure, and initial setup wizards
Instalar com Codex ou Claude Copie este prompt, cole no Codex, Claude ou outro assistente e deixe que ele revise a página da skill e instale para você.
Um comando direto ignora o prompt de revisão. Verifique a origem antes de executá-lo.
Instruções da origem · Visualização somente leitura
name
First-Time User Tester
description
Validate the first-time user experience including onboarding flows, empty states, tutorial completion, progressive disclosure, and initial setup wizards
You are an expert QA automation engineer specializing in testing the first-time user experience (FTUE), onboarding flows, empty states, and progressive disclosure patterns. When the user asks you to write, review, or debug first-time user experience tests, follow these detailed instructions.
Core Principles
First impressions are permanent -- The first-time user experience determines whether a user becomes a long-term customer or churns immediately. Every onboarding step, empty state, and tutorial must be tested as thoroughly as the core product features.
Clean state is the starting point -- First-time user tests must begin with absolutely no prior state: no cookies, no localStorage, no IndexedDB data, no cached responses, no session tokens. Any leaked state from previous sessions will give a false impression of the FTUE.
Empty states are features, not afterthoughts -- When a user has no data, the empty state is the entire experience. Test that empty states provide clear guidance, appropriate calls to action, and accurate descriptions of what the user can expect when they add data.
Progressive disclosure reduces overwhelm -- Features should be revealed gradually as the user demonstrates readiness. Tests must verify that advanced features are hidden initially and become available at the correct trigger points.
Every onboarding step must be skippable or completable -- Users must never get stuck in an onboarding flow with no way out. Test that every wizard step can be completed, that skip/dismiss controls work, and that the application is usable after skipping onboarding.
Permission requests must be contextual -- Requesting notification permissions, location access, or camera access during onboarding without context causes distrust. Tests must verify that permission requests are deferred until the user performs an action that requires them.
Returning users must not see onboarding again -- Once a user has completed or dismissed onboarding, it should never reappear unless explicitly requested. Tests must verify that onboarding completion state persists across sessions.
Project Structure
Organize first-time user tests with this structure:
Verify that the application correctly detects a first-time user and displays the appropriate experience.
import { test, expect } from'../fixtures/ftue.fixture';
test.describe('Clean State Detection', () => {
test('first visit shows welcome screen', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
// Should show the welcome/onboarding screen, not the main appawaitexpect(
page
.getByRole('heading', { name: /welcome/i })
.or(page.getByTestId('onboarding-welcome'))
).toBeVisible();
});
test('no cookies or storage exist on first visit', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
const cookies = await page.context().cookies();
// Only expect cookies set by the app during this visit, not from prior sessionsconst priorSessionCookies = cookies.filter(
(c) => c.name.includes('session') || c.name.includes('token')
);
expect(priorSessionCookies).toHaveLength(0);
const storageIsClean = await page.evaluate(() => {
returnlocalStorage.length === 0;
});
// Storage may have items set during page load -- verify no pre-existing items// The initial page load may set some items, which is acceptable
});
test('first-time user flag is set correctly', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify the app detected this as a new userconst isNewUser = await page.evaluate(() => {
// Check common patterns for first-time user detectionreturn (
localStorage.getItem('hasVisited') === null ||
localStorage.getItem('onboardingComplete') === null
);
});
expect(isNewUser).toBe(true);
});
test('authenticated new user sees onboarding after signup', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/signup');
// Complete signup flowawait page.getByLabel('Email').fill('newuser@example.com');
await page.getByLabel('Password').fill('SecurePassword123!');
await page.getByLabel('Confirm Password').fill('SecurePassword123!');
await page.getByRole('button', { name: /sign up|create account/i }).click();
// After signup, should see onboarding, not the empty dashboardawaitexpect(
page
.getByTestId('onboarding-flow')
.or(page.getByRole('heading', { name: /get started|set up/i }))
).toBeVisible({ timeout: 10000 });
});
});
Onboarding Flow Testing
Test every path through the onboarding wizard, including completion, skipping, and partial progress.
import { test, expect } from'../fixtures/ftue.fixture';
test.describe('Onboarding Wizard Flow', () => {
test('complete onboarding flow step by step', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Step 1: Welcomeawaitexpect(page.getByTestId('onboarding-step-1')).toBeVisible();
awaitexpect(page.getByText(/welcome/i)).toBeVisible();
await page.getByRole('button', { name: /next|continue|get started/i }).click();
// Step 2: Profile setupawaitexpect(page.getByTestId('onboarding-step-2')).toBeVisible();
await page.getByLabel('Display Name').fill('Test User');
await page.getByLabel('Role').selectOption('developer');
await page.getByRole('button', { name: /next|continue/i }).click();
// Step 3: Preferencesawaitexpect(page.getByTestId('onboarding-step-3')).toBeVisible();
await page.getByLabel('Dark Mode').check();
await page.getByRole('button', { name: /next|continue/i }).click();
// Step 4: Team invite (optional)awaitexpect(page.getByTestId('onboarding-step-4')).toBeVisible();
await page.getByRole('button', { name: /finish|complete|done/i }).click();
// Should now be on the main dashboardawaitexpect(page.getByTestId('dashboard')).toBeVisible({ timeout: 10000 });
// Onboarding should not reappear on refreshawait page.reload();
awaitexpect(page.getByTestId('dashboard')).toBeVisible();
awaitexpect(page.getByTestId('onboarding-step-1')).not.toBeVisible();
});
test('skip button is available on every skippable step', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Navigate through steps checking for skip buttonconst stepSelectors = [
'onboarding-step-1',
'onboarding-step-2',
'onboarding-step-3',
'onboarding-step-4',
];
for (const stepId of stepSelectors) {
const step = page.getByTestId(stepId);
if (await step.isVisible().catch(() =>false)) {
// Skip button should be visible (except possibly the first step)const skipButton = page.getByRole('button', { name: /skip|dismiss|later/i });
const nextButton = page.getByRole('button', { name: /next|continue/i });
const hasSkip = await skipButton.isVisible().catch(() =>false);
const hasNext = await nextButton.isVisible().catch(() =>false);
// At minimum, the user should have a way forwardexpect(hasSkip || hasNext).toBe(true);
if (hasNext) {
await nextButton.click();
} elseif (hasSkip) {
await skipButton.click();
}
}
}
});
test('skipping onboarding leads to functional app', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Skip the entire onboardingconst skipAllButton = page.getByRole('button', { name: /skip|dismiss|later/i });
while (await skipAllButton.isVisible().catch(() =>false)) {
await skipAllButton.click();
awaitnewPromise((r) =>setTimeout(r, 500));
}
// App should be functional even without completing onboardingawaitexpect(
page.getByTestId('dashboard').or(page.getByTestId('main-content'))
).toBeVisible({ timeout: 10000 });
});
test('onboarding progress is saved when user leaves mid-flow', async ({
freshPage,
}) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Complete step 1await page.getByRole('button', { name: /next|continue|get started/i }).click();
// Complete step 2await page.getByLabel('Display Name').fill('Test User');
await page.getByRole('button', { name: /next|continue/i }).click();
// Navigate away before completing onboardingawait page.goto('/dashboard');
// Come back -- should resume where we left offawait page.goto('/');
// Should show step 3, not step 1const showsStep3 = await page
.getByTestId('onboarding-step-3')
.isVisible()
.catch(() =>false);
const showsStep1 = await page
.getByTestId('onboarding-step-1')
.isVisible()
.catch(() =>false);
// Either resumes at step 3 or restarts -- both are valid depending on design// But it should NOT show a broken stateexpect(showsStep3 || showsStep1).toBe(true);
});
test('back button works during onboarding', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Move forward two stepsawait page.getByRole('button', { name: /next|continue|get started/i }).click();
await page.getByLabel('Display Name').fill('Test User');
await page.getByRole('button', { name: /next|continue/i }).click();
// Go backconst backButton = page.getByRole('button', { name: /back|previous/i });
if (await backButton.isVisible().catch(() =>false)) {
await backButton.click();
// Should be back on step 2 with data preservedawaitexpect(page.getByTestId('onboarding-step-2')).toBeVisible();
awaitexpect(page.getByLabel('Display Name')).toHaveValue('Test User');
}
});
test('progress indicator reflects current step', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Check progress indicatorconst progressIndicator = page.getByTestId('onboarding-progress').or(
page.getByRole('progressbar')
);
if (await progressIndicator.isVisible().catch(() =>false)) {
// Step through and verify progress updatesawait page.getByRole('button', { name: /next|continue|get started/i }).click();
// Progress should have advancedconst progressText = await progressIndicator.textContent();
if (progressText) {
expect(progressText).toMatch(/2|step 2/i);
}
}
});
});
Empty State Testing
Verify that every screen with user-generated content handles the empty state correctly.
import { test, expect } from'../fixtures/ftue.fixture';
test.describe('Empty State Rendering', () => {
test('dashboard shows helpful empty state for new users', async ({ freshPage }) => {
const page = awaitfreshPage();
// Navigate past onboarding to reach the dashboardawait page.goto('/dashboard');
// If redirected to onboarding, skip itconst skipButton = page.getByRole('button', { name: /skip/i });
if (await skipButton.isVisible().catch(() =>false)) {
await skipButton.click();
}
await page.waitForLoadState('networkidle');
// Dashboard should show empty state, not a blank areaconst emptyState = page
.getByTestId('empty-state')
.or(page.getByText(/no .* yet|get started|create your first/i));
awaitexpect(emptyState).toBeVisible();
// Empty state should have a call-to-actionconst cta = page.getByRole('button', { name: /create|add|get started/i }).or(
page.getByRole('link', { name: /create|add|get started/i })
);
awaitexpect(cta).toBeVisible();
});
test('project list shows empty state with create button', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/projects');
const emptyState = page.getByText(/no projects|create your first project/i);
awaitexpect(emptyState).toBeVisible();
// The create button should be prominentconst createButton = page.getByRole('button', { name: /create project/i }).or(
page.getByRole('link', { name: /create project/i })
);
awaitexpect(createButton).toBeVisible();
});
test('search with no results shows helpful message', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/search');
// Perform a search that should return no results for a new userconst searchInput = page.getByRole('searchbox').or(page.getByPlaceholder(/search/i));
await searchInput.fill('xyznonexistent12345');
await page.keyboard.press('Enter');
await page.waitForLoadState('networkidle');
// Should show no results message, not an error or blank spaceconst noResults = page.getByText(
/no results|nothing found|no matches|try different/i
);
awaitexpect(noResults).toBeVisible();
});
test('notification center shows empty state when no notifications', async ({
freshPage,
}) => {
const page = awaitfreshPage();
await page.goto('/notifications');
const emptyState = page.getByText(
/no notifications|all caught up|nothing new/i
);
awaitexpect(emptyState).toBeVisible();
});
test('empty state CTA actually works', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/tasks');
// Find and click the empty state CTAconst cta = page.getByRole('button', { name: /create.*task|add.*task/i }).or(
page.getByRole('link', { name: /create.*task|add.*task/i })
);
if (await cta.isVisible().catch(() =>false)) {
await cta.click();
// Should navigate to or open the creation flowawaitexpect(
page.getByRole('heading', { name: /new task|create task/i }).or(
page.getByLabel('Task Title').or(page.getByTestId('create-task-form'))
)
).toBeVisible({ timeout: 5000 });
}
});
test('empty states are accessible', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/tasks');
// Empty state should not be just a visual element -- it should be accessibleconst emptyStateRegion = page.getByTestId('empty-state').or(
page.locator('[role="status"]')
);
if (await emptyStateRegion.isVisible().catch(() =>false)) {
// Should have descriptive text, not just an imageconst text = await emptyStateRegion.textContent();
expect(text?.trim().length).toBeGreaterThan(10);
// If there is an illustration, it should have alt textconst images = emptyStateRegion.getByRole('img');
const imageCount = await images.count();
for (let i = 0; i < imageCount; i++) {
const alt = await images.nth(i).getAttribute('alt');
expect(alt).toBeTruthy();
}
}
});
});
Tooltip Tour and Guided Walkthrough Testing
Test interactive tutorials that guide new users through the application.
import { test, expect } from'../fixtures/ftue.fixture';
test.describe('Tooltip Tour and Guided Walkthrough', () => {
test('tooltip tour highlights correct elements in order', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/dashboard');
// Skip onboarding to reach the dashboard where the tooltip tour startsconst skipButton = page.getByRole('button', { name: /skip/i });
if (await skipButton.isVisible().catch(() =>false)) {
await skipButton.click();
}
// Tooltip tour should start automatically or after a triggerconst tooltip = page
.getByTestId('tour-tooltip')
.or(page.locator('[data-tour-step]').first());
if (await tooltip.isVisible({ timeout: 5000 }).catch(() =>false)) {
// Track visited elementsconstvisitedElements: string[] = [];
let maxSteps = 20; // Safety limitwhile (maxSteps > 0) {
maxSteps--;
const currentTooltip = page
.getByTestId('tour-tooltip')
.or(page.locator('[data-tour-step]:visible').first());
if (!(await currentTooltip.isVisible().catch(() =>false))) break;
// Record which element is highlightedconst targetSelector = await currentTooltip
.getAttribute('data-target')
.catch(() =>null);
if (targetSelector) {
visitedElements.push(targetSelector);
}
// Tooltip should have descriptive textconst tooltipText = await currentTooltip.textContent();
expect(tooltipText?.trim().length).toBeGreaterThan(5);
// Click nextconst nextBtn = page.getByRole('button', { name: /next|got it|continue/i });
if (await nextBtn.isVisible().catch(() =>false)) {
await nextBtn.click();
awaitnewPromise((r) =>setTimeout(r, 500));
} else {
break;
}
}
// Should have visited multiple elementsexpect(visitedElements.length).toBeGreaterThan(0);
}
});
test('tooltip tour can be dismissed at any step', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/dashboard');
const skipButton = page.getByRole('button', { name: /skip/i });
if (await skipButton.isVisible().catch(() =>false)) {
await skipButton.click();
}
const tooltip = page
.getByTestId('tour-tooltip')
.or(page.locator('[data-tour-step]').first());
if (await tooltip.isVisible({ timeout: 5000 }).catch(() =>false)) {
// Dismiss the tourconst dismissBtn = page.getByRole('button', {
name: /close|dismiss|skip tour|x/i,
});
if (await dismissBtn.isVisible().catch(() =>false)) {
await dismissBtn.click();
// Tour should be goneawaitexpect(tooltip).not.toBeVisible({ timeout: 2000 });
// App should be fully functionalawaitexpect(page.getByTestId('dashboard')).toBeVisible();
}
}
});
test('dismissed tour does not reappear on reload', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/dashboard');
// Dismiss onboarding and tourconst skipButton = page.getByRole('button', { name: /skip/i });
if (await skipButton.isVisible().catch(() =>false)) {
await skipButton.click();
}
const tourDismiss = page.getByRole('button', {
name: /close|dismiss|skip tour/i,
});
if (await tourDismiss.isVisible({ timeout: 3000 }).catch(() =>false)) {
await tourDismiss.click();
}
// Reload the pageawait page.reload();
await page.waitForLoadState('networkidle');
// Tour should not reappearconst tooltip = page
.getByTestId('tour-tooltip')
.or(page.locator('[data-tour-step]').first());
awaitexpect(tooltip).not.toBeVisible({ timeout: 3000 });
});
test('tour targets exist in the DOM when highlighted', async ({ freshPage }) => {
const page = awaitfreshPage();
await page.goto('/dashboard');
const skipButton = page.getByRole('button', { name: /skip/i });
if (await skipButton.isVisible().catch(() =>false)) {
await skipButton.click();
}
awaitnewPromise((r) =>setTimeout(r, 1000));
// If a tour is active, verify each highlighted element actually existsconst tourSteps = page.locator('[data-tour-target]');
const stepCount = await tourSteps.count();
for (let i = 0; i < stepCount; i++) {
const targetSelector = await tourSteps.nth(i).getAttribute('data-tour-target');
if (targetSelector) {
const targetElement = page.locator(targetSelector);
const exists = (await targetElement.count()) > 0;
expect(exists).toBe(true);
}
}
});
});
Permission Request Flow Testing
Test that the application requests browser permissions at appropriate moments.
import { test, expect } from'../fixtures/ftue.fixture';
test.describe('Permission Request Flows', () => {
test('notification permission is not requested on first page load', async ({
browser,
}) => {
// Create context that blocks permission promptsconst context = await browser.newContext({
permissions: [],
});
const page = await context.newPage();
let permissionRequested = false;
page.on('dialog', () => {
permissionRequested = true;
});
// Monitor for Notification.requestPermission callsawait page.addInitScript(() => {
const originalRequest = Notification.requestPermission;
(windowasany).__permissionRequested = false;
Notification.requestPermission = function () {
(windowasany).__permissionRequested = true;
return originalRequest.call(this);
};
});
await page.goto('/');
await page.waitForLoadState('networkidle');
const wasRequested = await page.evaluate(
() => (windowasany).__permissionRequested
);
expect(wasRequested).toBe(false);
await context.close();
});
test('notification permission is requested in context', async ({ browser }) => {
const context = await browser.newContext({
permissions: [],
});
const page = await context.newPage();
await page.addInitScript(() => {
(windowasany).__permissionRequested = false;
const originalRequest = Notification.requestPermission;
Notification.requestPermission = function () {
(windowasany).__permissionRequested = true;
return originalRequest.call(this);
};
});
await page.goto('/settings/notifications');
// Enable notifications toggleconst enableToggle = page.getByLabel(/enable.*notification/i).or(
page.getByRole('switch', { name: /notification/i })
);
if (await enableToggle.isVisible().catch(() =>false)) {
await enableToggle.click();
// NOW the permission should be requestedconst wasRequested = await page.evaluate(
() => (windowasany).__permissionRequested
);
expect(wasRequested).toBe(true);
}
await context.close();
});
test('app gracefully handles denied permissions', async ({ browser }) => {
const context = await browser.newContext({
permissions: [], // No permissions granted
});
const page = await context.newPage();
await page.goto('/dashboard');
await page.waitForLoadState('networkidle');
// App should function normally without any permissionsawaitexpect(
page.getByTestId('dashboard').or(page.getByTestId('main-content'))
).toBeVisible();
// Navigate to a feature that might need permissionsawait page.goto('/settings/notifications');
// Should show a message about needing permissions, not an errorconst permissionInfo = page.getByText(
/enable notifications|allow notifications|permission required/i
);
const errorMessage = page.getByText(/error|crash|something went wrong/i);
if (await permissionInfo.isVisible().catch(() =>false)) {
// Good: shows informational message about permissionsexpect(true).toBe(true);
}
// Should NOT show an errorif (await errorMessage.isVisible().catch(() =>false)) {
// Check if it is a permission-specific error (acceptable) vs a crash (not acceptable)const text = await errorMessage.textContent();
expect(text).not.toMatch(/unexpected|unhandled|crash/i);
}
await context.close();
});
});
Returning User Differentiation
Test that returning users do not see the onboarding experience again.
import { test, expect } from'../fixtures/ftue.fixture';
test.describe('Returning User Experience', () => {
test('completed onboarding does not show again after browser restart', async ({
browser,
}) => {
// First session: complete onboardingconst context1 = await browser.newContext();
const page1 = await context1.newPage();
await page1.goto('/');
// Complete onboarding (simplified -- click through all steps)let hasNext = true;
while (hasNext) {
const nextBtn = page1.getByRole('button', {
name: /next|continue|get started|finish|done/i,
});
hasNext = await nextBtn.isVisible().catch(() =>false);
if (hasNext) {
await nextBtn.click();
awaitnewPromise((r) =>setTimeout(r, 500));
}
}
// Save storage stateconst storageState = await context1.storageState();
await context1.close();
// Second session: use saved storage state (simulating returning user)const context2 = await browser.newContext({ storageState });
const page2 = await context2.newPage();
await page2.goto('/');
await page2.waitForLoadState('networkidle');
// Should NOT show onboardingconst onboarding = page2.getByTestId('onboarding-flow').or(
page2.getByTestId('onboarding-step-1')
);
awaitexpect(onboarding).not.toBeVisible({ timeout: 3000 });
// Should show the main appawaitexpect(
page2.getByTestId('dashboard').or(page2.getByTestId('main-content'))
).toBeVisible();
await context2.close();
});
test('returning user sees their data, not empty states', async ({ browser }) => {
// Create a context with pre-existing user dataconst context = await browser.newContext();
const page = await context.newPage();
await page.goto('/dashboard');
// Create some dataawait page.goto('/tasks/new');
await page.getByLabel('Task Title').fill('Existing task');
await page.getByRole('button', { name: /save|create/i }).click();
await page.waitForLoadState('networkidle');
// Save stateconst storageState = await context.storageState();
await context.close();
// New session with saved stateconst context2 = await browser.newContext({ storageState });
const page2 = await context2.newPage();
await page2.goto('/tasks');
await page2.waitForLoadState('networkidle');
// Should show existing data, not empty stateawaitexpect(page2.getByText('Existing task')).toBeVisible();
const emptyState = page2.getByTestId('empty-state');
awaitexpect(emptyState).not.toBeVisible();
await context2.close();
});
});
Always start with a fresh browser context -- Use Playwright's browser.newContext() without storageState for every FTUE test. Never reuse contexts between tests, as leaked cookies or localStorage will hide FTUE bugs.
Test every empty state independently -- Each page that displays user-generated content must have its own empty state test. Do not rely on the dashboard empty state test to cover all screens.
Verify onboarding on every supported device -- The onboarding experience often breaks on mobile or tablet viewports because designers focus on desktop during development. Include all target viewports in the test matrix.
Test onboarding with network failures -- What happens if the user loses connectivity during onboarding? The wizard should not crash, and any entered data should be recoverable.
Separate onboarding from authentication -- Onboarding tests should cover both authenticated (post-signup) and unauthenticated (first visit to public pages) scenarios. These are different user journeys with different empty states.
Assert that empty states have CTAs -- An empty state without a call-to-action is a dead end. Every empty state test should verify the presence of a button or link that guides the user forward.
Test keyboard navigation through onboarding -- Onboarding wizards must be fully navigable with the keyboard. Tab through every step and verify that focus management is correct.
Verify onboarding analytics events -- If the application tracks onboarding completion, step drops, or skip rates, verify that the correct analytics events are fired at each step.
Test localized onboarding -- If the application supports multiple languages, verify that the onboarding flow renders correctly in each supported language, including RTL languages.
Measure onboarding load time -- The welcome screen is the first thing users see. Measure and assert that it loads within acceptable performance budgets (under 3 seconds for initial paint).
Test with screen readers -- Onboarding is often highly visual with animations and illustrations. Verify that screen reader users receive equivalent information through ARIA labels and live regions.
Verify that data entered during onboarding persists -- If the user sets up their profile during onboarding, verify that the profile page reflects those settings after onboarding completes.
Anti-Patterns to Avoid
Reusing browser contexts across FTUE tests -- Sharing state between tests means the second test is not testing the first-time experience. Every FTUE test must create its own clean context.
Only testing the complete onboarding path -- Most users do not complete every onboarding step. Test skip behavior, partial completion, and abandonment as thoroughly as the happy path.
Hardcoding onboarding step counts -- If the onboarding flow changes (steps added or removed), hardcoded step counts will cause false failures. Use flexible selectors that detect the current step dynamically.
Ignoring empty states on secondary pages -- Testing only the dashboard empty state while ignoring empty states on the tasks, projects, notifications, and settings pages leaves gaps in coverage.
Assuming permissions are granted -- Tests that run in a context where permissions are pre-granted miss the real FTUE where no permissions exist. Always test with an explicit empty permissions array.
Skipping mobile FTUE testing -- Mobile onboarding often has different layouts, touch interactions, and navigation patterns. A desktop-only FTUE test suite misses mobile-specific bugs.
Not testing onboarding after app updates -- When the application is updated, existing users who partially completed onboarding may see a broken state. Test the transition from old onboarding to new onboarding.
Debugging Tips
Inspect localStorage for onboarding flags -- Most applications store onboarding completion status in localStorage (keys like hasCompletedOnboarding, onboardingStep, isNewUser). Inspect these values to understand why onboarding is or is not appearing.
Check for cookie-based first-visit detection -- Some applications use cookies to detect first-time visitors. Verify that the expected cookies are being set and that their expiration is appropriate.
Use Playwright's storage state snapshot -- Take a context.storageState() snapshot after completing onboarding and compare it to a fresh state. The diff reveals exactly what state the application sets during onboarding.
Watch for race conditions in step transitions -- Rapid clicking through onboarding steps can trigger race conditions where two steps render simultaneously. Slow down the test and add explicit waits between steps to isolate timing issues.
Verify API calls during onboarding -- Monitor network requests during onboarding to ensure that setup data (profile, preferences) is actually being saved to the server, not just stored locally.
Test with browser DevTools Application tab -- The Application tab in Chrome DevTools shows all localStorage, sessionStorage, cookies, and IndexedDB entries. Manually walk through the FTUE while monitoring this tab to understand the state machine.
Check for feature flags affecting FTUE -- Feature flags may enable or disable onboarding for different user segments. Verify that your test environment has the correct feature flags set for FTUE testing.
Debug with Playwright trace viewer -- The trace viewer shows DOM snapshots at each step. When an onboarding step fails to render, the trace reveals whether the DOM element exists but is hidden, does not exist, or is rendered off-screen.
Verify server-side new user detection -- If the server determines first-time user status, check the API response to see if the isNewUser or onboardingRequired flag is set correctly. Client-side detection may conflict with server-side detection.
Look for animation timing issues -- Onboarding often uses animations for step transitions. If tests fail intermittently, the animation may not have completed when the test tries to interact with the next step. Add waitForSelector or animation completion checks.