First-Time User Tester
SkillDev toolsValidate the first-time user experience including onboarding flows, empty states, tutorial completion, progressive disclosure, and initial setup wizards
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the First-Time User Tester skill
What this skill tells your AI
The instructions your AI receives, as published by pramoddutta/qaskills in seed-skills/first-time-user-tester/SKILL.md and read by ahel’s review.
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:
tests/
ftue/
clean-state/
fresh-browser.spec.ts
no-data-state.spec.ts
first-visit-detection.spec.ts
onboarding/
wizard-flow.spec.ts
step-completion.spec.ts
skip-dismiss.spec.ts
resume-incomplete.spec.ts
empty-states/
dashboard-empty.spec.ts
list-empty.spec.ts
search-no-results.spec.ts
tutorials/
tooltip-tour.spec.ts
guided-walkthrough.spec.ts
video-tutorial.spec.ts
progressive-disclosure/
feature-gates.spec.ts
advanced-options.spec.ts
contextual-help.spec.ts
permissions/
notification-prompt.spec.ts
location-prompt.spec.ts
camera-prompt.spec.ts
returning-user/
onboarding-suppression.spec.ts
session-restoration.spec.ts
fixtures/
ftue.fixture.ts
clean-context.fixture.ts
helpers/
state-cleaner.ts
onboarding-tracker.ts
permission-handler.ts
pages/
onboarding.page.ts
dashboard.page.ts
welcome.page.ts
playwright.config.ts
Setting Up the FTUE Test Infrastructure
Clean State Manager
The foundation of FTUE testing is guaranteeing that every test starts with a completely clean browser state:
import { BrowserContext, Page } from '@playwright/test';
export class CleanStateManager {
private readonly context: BrowserContext;
constructor(context: BrowserContext) {
this.context = context;
}
async ensureCleanState(): Promise<void> {
// Clear all cookies
await this.context.clearCookies();
// Clear all storage via a temporary page
const page = await this.context.newPage();
await page.goto('about:blank');
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.close();
}
async clearStorageForDomain(page: Page, domain: string): Promise<void> {
await page.goto(`${domain}/`);
await page.evaluate(async () => {
// Clear localStorage and sessionStorage
localStorage.clear();
sessionStorage.clear();
// Clear all IndexedDB databases
const databases = await indexedDB.databases();
for (const db of databases) {
if (db.name) {
indexedDB.deleteDatabase(db.name);
}
}
// Clear Cache API
const cacheNames = await caches.keys();
for (const name of cacheNames) {
await caches.delete(name);
}
});
}
async verifyCleanState(page: Page): Promise<boolean> {
return page.evaluate(() => {
const hasLocalStorage = localStorage.length > 0;
const hasSessionStorage = sessionStorage.length > 0;
return !hasLocalStorage && !hasSessionStorage;
});
}
}
Onboarding Tracker
Track onboarding progress and state transitions during tests:
import { Page } from '@playwright/test';
interface OnboardingStep {
name: string;
completed: boolean;
skipped: boolean;
timestamp: number;
}
export class OnboardingTracker {
private steps: OnboardingStep[] = [];
private readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async startTracking(): Promise<void> {
// Listen for onboarding-related events
await this.page.exposeFunction(
'__onboardingStepCompleted',
(stepName: string) => {
this.steps.push({
name: stepName,
completed: true,
skipped: false,
timestamp: Date.now(),
});
}
);
await this.page.exposeFunction(
'__onboardingStepSkipped',
(stepName: string) => {
this.steps.push({
name: stepName,
completed: false,
skipped: true,
timestamp: Date.now(),
});
}
);
// Inject listeners for common onboarding events
await this.page.addInitScript(() => {
window.addEventListener('onboarding-step-complete', (e: any) => {
(window as any).__onboardingStepCompleted(e.detail?.step || 'unknown');
});
window.addEventListener('onboarding-step-skip', (e: any) => {
(window as any).__onboardingStepSkipped(e.detail?.step || 'unknown');
});
});
}
getSteps(): OnboardingStep[] {
return [...this.steps];
}
getCompletedSteps(): OnboardingStep[] {
return this.steps.filter((s) => s.completed);
}
getSkippedSteps(): OnboardingStep[] {
return this.steps.filter((s) => s.skipped);
}
isStepCompleted(stepName: string): boolean {
return this.steps.some((s) => s.name === stepName && s.completed);
}
clear(): void {
this.steps = [];
}
}
Custom Test Fixture
import { test as base, expect, BrowserContext } from '@playwright/test';
import { CleanStateManager } from '../helpers/state-cleaner';
import { OnboardingTracker } from '../helpers/onboarding-tracker';
interface FTUEFixtures {
cleanState: CleanStateManager;
onboardingTracker: OnboardingTracker;
freshContext: BrowserContext;
freshPage: () => Promise<import('@playwright/test').Page>;
}
export const test = base.extend<FTUEFixtures>({
cleanState: async ({ context }, use) => {
const manager = new CleanStateManager(context);
await manager.ensureCleanState();
await use(manager);
},
onboardingTracker: async ({ page }, use) => {
const tracker = new OnboardingTracker(page);
await tracker.startTracking();
await use(tracker);
tracker.clear();
},
freshContext: async ({ browser }, use) => {
// Create a brand-new context with no state
const context = await browser.newContext({
storageState: undefined,
permissions: [],
});
await use(context);
await context.close();
},
freshPage: async ({ freshContext }, use) => {
const createPage = async () => {
const page = await freshContext.newPage();
return page;
};
await use(createPage);
},
});
export { expect };
Clean State Testing
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 = await freshPage();
await page.goto('/');
// Should show the welcome/onboarding screen, not the main app
await expect(
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 = await freshPage();
await page.goto('/');
const cookies = await page.context().cookies();
// Only expect cookies set by the app during this visit, not from prior sessions
const priorSessionCookies = cookies.filter(
(c) => c.name.includes('session') || c.name.includes('token')
);
expect(priorSessionCookies).toHaveLength(0);
const storageIsClean = await page.evaluate(() => {
return localStorage.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 = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Verify the app detected this as a new user
const isNewUser = await page.evaluate(() => {
// Check common patterns for first-time user detection
return (
localStorage.getItem('hasVisited') === null ||
localStorage.getItem('onboardingComplete') === null
);
});
expect(isNewUser).toBe(true);
});
test('authenticated new user sees onboarding after signup', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/signup');
// Complete signup flow
await 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 dashboard
await expect(
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 = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Step 1: Welcome
await expect(page.getByTestId('onboarding-step-1')).toBeVisible();
await expect(page.getByText(/welcome/i)).toBeVisible();
await page.getByRole('button', { name: /next|continue|get started/i }).click();
// Step 2: Profile setup
await expect(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: Preferences
await expect(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)
await expect(page.getByTestId('onboarding-step-4')).toBeVisible();
await page.getByRole('button', { name: /finish|complete|done/i }).click();
// Should now be on the main dashboard
await expect(page.getByTestId('dashboard')).toBeVisible({ timeout: 10000 });
// Onboarding should not reappear on refresh
await page.reload();
await expect(page.getByTestId('dashboard')).toBeVisible();
await expect(page.getByTestId('onboarding-step-1')).not.toBeVisible();
});
test('skip button is available on every skippable step', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Navigate through steps checking for skip button
const 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 forward
expect(hasSkip || hasNext).toBe(true);
if (hasNext) {
await nextButton.click();
} else if (hasSkip) {
await skipButton.click();
}
}
}
});
test('skipping onboarding leads to functional app', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Skip the entire onboarding
const skipAllButton = page.getByRole('button', { name: /skip|dismiss|later/i });
while (await skipAllButton.isVisible().catch(() => false)) {
await skipAllButton.click();
await new Promise((r) => setTimeout(r, 500));
}
// App should be functional even without completing onboarding
await expect(
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 = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Complete step 1
await page.getByRole('button', { name: /next|continue|get started/i }).click();
// Complete step 2
await page.getByLabel('Display Name').fill('Test User');
await page.getByRole('button', { name: /next|continue/i }).click();
// Navigate away before completing onboarding
await page.goto('/dashboard');
// Come back -- should resume where we left off
await page.goto('/');
// Should show step 3, not step 1
const 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 state
expect(showsStep3 || showsStep1).toBe(true);
});
test('back button works during onboarding', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Move forward two steps
await 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 back
const 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 preserved
await expect(page.getByTestId('onboarding-step-2')).toBeVisible();
await expect(page.getByLabel('Display Name')).toHaveValue('Test User');
}
});
test('progress indicator reflects current step', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/');
await page.waitForLoadState('networkidle');
// Check progress indicator
const progressIndicator = page.getByTestId('onboarding-progress').or(
page.getByRole('progressbar')
);
if (await progressIndicator.isVisible().catch(() => false)) {
// Step through and verify progress updates
await page.getByRole('button', { name: /next|continue|get started/i }).click();
// Progress should have advanced
const 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 = await freshPage();
// Navigate past onboarding to reach the dashboard
await page.goto('/dashboard');
// If redirected to onboarding, skip it
const 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 area
const emptyState = page
.getByTestId('empty-state')
.or(page.getByText(/no .* yet|get started|create your first/i));
await expect(emptyState).toBeVisible();
// Empty state should have a call-to-action
const cta = page.getByRole('button', { name: /create|add|get started/i }).or(
page.getByRole('link', { name: /create|add|get started/i })
);
await expect(cta).toBeVisible();
});
test('project list shows empty state with create button', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/projects');
const emptyState = page.getByText(/no projects|create your first project/i);
await expect(emptyState).toBeVisible();
// The create button should be prominent
const createButton = page.getByRole('button', { name: /create project/i }).or(
page.getByRole('link', { name: /create project/i })
);
await expect(createButton).toBeVisible();
});
test('search with no results shows helpful message', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/search');
// Perform a search that should return no results for a new user
const 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 space
const noResults = page.getByText(
/no results|nothing found|no matches|try different/i
);
await expect(noResults).toBeVisible();
});
test('notification center shows empty state when no notifications', async ({
freshPage,
}) => {
const page = await freshPage();
await page.goto('/notifications');
const emptyState = page.getByText(
/no notifications|all caught up|nothing new/i
);
await expect(emptyState).toBeVisible();
});
test('empty state CTA actually works', async ({ freshPage }) => {
const page = await freshPage();
await page.goto('/tasks');
// Find and click the empty state CTA
const 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 flow
await expect(
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 = await freshPage();
await page.goto('/tasks');
// Empty state should not be just a visual element -- it should be accessible
const emptyStateRegion = page.getByTestId('empty-state').or(
page.locator('[role="status"]')
);
if (await emptyStateRegion.isVisible().catch(() => false)) {
// Should have descriptive text, not just an image
const text = await emptyStateRegion.textContent();
expect(text?.trim().length).toBeGreaterThan(10);
// If there is an illustration, it should have alt text
const 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 = await freshPage();
await page.goto('/dashboard');
// Skip onboarding to reach the dashboard where the tooltip tour starts
const skipButton = page.getByRole('button', { name: /skip/i });
if (await skipButton.isVisible().catch(() => false)) {
await skipButton.click();
}
// Tooltip tour should start automatically or after a trigger
const tooltip = page
.getByTestId('tour-tooltip')
.or(page.locator('[data-tour-step]').first());
if (await tooltip.isVisible({ timeout: 5000 }).catch(() => false)) {
// Track visited elements
const visitedElements: string[] = [];
let maxSteps = 20; // Safety limit
while (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 highlighted
const targetSelector = await currentTooltip
.getAttribute('data-target')
.catch(() => null);
if (targetSelector) {
visitedElements.push(targetSelector);
}
// Tooltip should have descriptive text
const tooltipText = await currentTooltip.textContent();
expect(tooltipText?.trim().length).toBeGreaterThan(5);
// Click next
const nextBtn = page.getByRole('button', { name: /next|got it|continue/i });
if (await nextBtn.isVisible().catch(() => false)) {
await nextBtn.click();
await new Promise((r) => setTimeout(r, 500));
} else {
break;
}
}
// Should have visited multiple elements
expect(visitedElements.length).toBeGreaterThan(0);
}
});
test('tooltip tour can be dismissed at any step', async ({ freshPage }) => {
const page = await freshPage();
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());
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 224
- Forks
- 27
- Last commit
- Aug 2026
Advanced
- Catalog kind
- skill
- Gateway key
first-time-user-tester- Source
- github.com/pramoddutta/qaskills