Audit empty states across web applications ensuring proper messaging, helpful CTAs, illustration rendering, and graceful handling when data is unavailable
You are an expert QA engineer specializing in empty state testing across web applications. When the user asks you to audit, write, or review empty state tests, follow these detailed instructions to ensure every screen handles the absence of data with proper messaging, helpful calls to action, appropriate illustrations, and graceful degradation.
tests/
empty-states/
fresh-account/
dashboard-empty.spec.ts
profile-empty.spec.ts
notifications-empty.spec.ts
settings-defaults.spec.ts
filtered-empty/
search-no-results.spec.ts
filter-no-match.spec.ts
date-range-empty.spec.ts
deleted-all/
last-item-deleted.spec.ts
bulk-delete-empty.spec.ts
error-states/
network-error.spec.ts
server-error.spec.ts
timeout-state.spec.ts
permission-restricted/
unauthorized-view.spec.ts
role-limited-empty.spec.ts
component-types/
list-empty.spec.ts
table-empty.spec.ts
grid-empty.spec.ts
card-collection-empty.spec.ts
chart-no-data.spec.ts
cta-verification/
cta-functionality.spec.ts
cta-navigation.spec.ts
visual/
illustration-rendering.spec.ts
responsive-empty-states.spec.ts
fixtures/
empty-state-inventory.ts
test-accounts.ts
utils/
empty-state-helpers.ts
data-cleanup.ts
playwright.config.ts
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/empty-states',
fullyParallel: true,
retries: 1,
workers: process.env.CI ? 2 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'empty-state-results.json' }],
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:3000',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'desktop',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'tablet',
use: { ...devices['iPad Pro 11'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
},
],
});
// tests/empty-states/fixtures/empty-state-inventory.ts
/**
* A comprehensive inventory of all screens that can display empty states.
* Maintaining this inventory ensures complete coverage and prevents
* new screens from shipping without designed empty states.
*/
export interface EmptyStateScenario {
name: string;
route: string;
trigger: 'fresh-account' | 'filtered' | 'deleted-all' | 'error' | 'permission';
expectedElements: {
heading: string | RegExp;
description?: string | RegExp;
illustration?: boolean;
ctaText?: string;
ctaRoute?: string;
};
}
export const emptyStateInventory: EmptyStateScenario[] = [
{
name: 'Dashboard - No projects',
route: '/dashboard',
trigger: 'fresh-account',
expectedElements: {
heading: /no projects yet/i,
description: /create your first project/i,
illustration: true,
ctaText: 'Create Project',
ctaRoute: '/projects/new',
},
},
{
name: 'Notifications - No notifications',
route: '/notifications',
trigger: 'fresh-account',
expectedElements: {
heading: /no notifications/i,
description: /you're all caught up/i,
illustration: true,
},
},
{
name: 'Search - No results',
route: '/search?q=xyznonexistent',
trigger: 'filtered',
expectedElements: {
heading: /no results found/i,
description: /try a different search/i,
ctaText: 'Clear Search',
},
},
{
name: 'Team Members - No access',
route: '/team',
trigger: 'permission',
expectedElements: {
heading: /access restricted/i,
description: /contact your administrator/i,
},
},
];
export const freshAccountRoutes = [
'/dashboard',
'/projects',
'/notifications',
'/activity',
'/favorites',
'/recent',
'/teams',
'/reports',
];
// tests/empty-states/utils/data-cleanup.ts
import { Page } from '@playwright/test';
/**
* Utility functions for creating empty state conditions.
* These helpers ensure tests start from a known empty state.
*/
export async function deleteAllItems(page: Page, listSelector: string): Promise<void> {
const items = await page.locator(listSelector).all();
for (const item of items) {
await item.getByTestId('item-menu').click();
await page.getByTestId('delete-option').click();
await page.getByTestId('confirm-delete').click();
await page.waitForTimeout(300);
}
}
export async function clearAllFilters(page: Page): Promise<void> {
const clearButton = page.getByTestId('clear-all-filters');
if (await clearButton.isVisible()) {
await clearButton.click();
await page.waitForLoadState('networkidle');
}
}
export async function setupFreshAccount(page: Page, apiBaseUrl: string): Promise<void> {
// Use API to create a fresh test account with no data
const response = await page.request.post(`${apiBaseUrl}/api/test/create-empty-account`, {
data: {
email: `empty-test-${Date.now()}@example.com`,
password: 'TestPassword123!',
},
});
const { token } = await response.json();
await page.evaluate((t) => {
localStorage.setItem('auth-token', t);
}, token);
}
The most important empty state scenario is the fresh account experience. Every screen a new user visits should have a designed empty state that guides them toward activation.
// tests/empty-states/fresh-account/dashboard-empty.spec.ts
import { test, expect } from '@playwright/test';
import { freshAccountRoutes } from '../fixtures/empty-state-inventory';
test.describe('Fresh Account Empty States', () => {
test.use({
storageState: 'tests/fixtures/fresh-account-state.json',
});
for (const route of freshAccountRoutes) {
test(`${route} has a designed empty state`, async ({ page }) => {
await page.goto(route);
await page.waitForLoadState('networkidle');
// The page must not show a loading spinner indefinitely
const spinner = page.getByTestId('loading-spinner');
if (await spinner.isVisible()) {
await expect(spinner).not.toBeVisible({ timeout: 5000 });
}
// The page must have either content or an empty state
const hasContent = await page.getByTestId('content-list').isVisible().catch(() => false);
const hasEmptyState = await page.getByTestId('empty-state').isVisible().catch(() => false);
expect(
hasContent || hasEmptyState,
`${route} shows neither content nor empty state`
).toBe(true);
if (hasEmptyState) {
// Verify essential empty state elements
const heading = page.getByTestId('empty-state-heading');
await expect(heading).toBeVisible();
const headingText = await heading.textContent();
expect(headingText?.trim().length).toBeGreaterThan(0);
// Description should provide context
const description = page.getByTestId('empty-state-description');
await expect(description).toBeVisible();
const descText = await description.textContent();
expect(descText?.trim().length).toBeGreaterThan(10);
}
});
}
test('dashboard empty state has create project CTA', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForSelector('[data-testid="empty-state"]');
const cta = page.getByTestId('empty-state-cta');
await expect(cta).toBeVisible();
await expect(cta).toBeEnabled();
const ctaText = await cta.textContent();
expect(ctaText?.toLowerCase()).toContain('create');
// CTA should navigate to the creation flow
await cta.click();
await expect(page).toHaveURL(/\/(projects\/new|create)/);
});
test('notification empty state does not show CTA', async ({ page }) => {
await page.goto('/notifications');
await page.waitForSelector('[data-testid="empty-state"]');
// Notifications cannot be "created" so there should be no primary CTA
const heading = page.getByTestId('empty-state-heading');
await expect(heading).toContainText(/no notifications|all caught up/i);
// But there may be a secondary link to notification settings
const settingsLink = page.getByTestId('notification-settings-link');
if (await settingsLink.isVisible()) {
await settingsLink.click();
await expect(page).toHaveURL(/settings.*notification/i);
}
});
});
Users often apply filters that narrow results to zero. The application must clearly communicate that the empty state is due to filter criteria, not missing data.
// tests/empty-states/filtered-empty/filter-no-match.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Filtered Empty States', () => {
test('filter combination yielding zero results shows filter-specific empty state', async ({ page }) => {
await page.goto('/projects');
await page.waitForSelector('[data-testid="content-list"]');
// Store the unfiltered count
const unfilteredCount = await page.getByTestId('item-count').textContent();
expect(parseInt(unfilteredCount || '0', 10)).toBeGreaterThan(0);
// Apply a filter that yields no results
await page.getByTestId('filter-status').selectOption('archived');
await page.getByTestId('filter-tag').selectOption('nonexistent-tag');
await page.waitForLoadState('networkidle');
// Must show a filter-specific empty state, not the fresh-account empty state
const emptyState = page.getByTestId('empty-state');
await expect(emptyState).toBeVisible();
const heading = page.getByTestId('empty-state-heading');
const headingText = await heading.textContent();
// Should mention filters, not onboarding
expect(headingText?.toLowerCase()).toMatch(
/no (matching|results)|nothing matches|no items match/i
);
// Must offer a "Clear Filters" action
const clearButton = page.getByTestId('clear-filters-cta');
await expect(clearButton).toBeVisible();
// Clearing filters should restore results
await clearButton.click();
await page.waitForSelector('[data-testid="content-list"]');
const restoredCount = await page.getByTestId('item-count').textContent();
expect(parseInt(restoredCount || '0', 10)).toBeGreaterThan(0);
});
test('date range with no data shows appropriate message', async ({ page }) => {
await page.goto('/activity');
await page.waitForSelector('[data-testid="content-list"], [data-testid="empty-state"]');
// Set date range far in the future
await page.getByTestId('date-from').fill('2099-01-01');
await page.getByTestId('date-to').fill('2099-12-31');
await page.getByTestId('apply-date-filter').click();
await page.waitForLoadState('networkidle');
const emptyState = page.getByTestId('empty-state');
await expect(emptyState).toBeVisible();
const description = page.getByTestId('empty-state-description');
await expect(description).toContainText(/no activity.*date range|try a different.*period/i);
});
test('search within a section shows section-specific empty state', async ({ page }) => {
await page.goto('/projects');
await page.waitForSelector('[data-testid="content-list"]');
// Search for something that does not exist within this section
const searchInput = page.getByTestId('section-search');
await searchInput.fill('xyznonexistentproject');
await page.waitForLoadState('networkidle');
const emptyState = page.getByTestId('empty-state');
await expect(emptyState).toBeVisible();
// The empty state should reference the search query
const description = page.getByTestId('empty-state-description');
const text = await description.textContent();
expect(text).toContain('xyznonexistentproject');
});
});
When a user deletes the last item in a collection, the application must transition smoothly from the content view to the empty state.
// tests/empty-states/deleted-all/last-item-deleted.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Deleted All Items Empty State', () => {
test('deleting the last item transitions to empty state', async ({ page }) => {
// Setup: ensure exactly one item exists
await page.goto('/api/test/setup-single-item');
await page.goto('/projects');
await page.waitForSelector('[data-testid="content-list"]');
// Verify one item is present
const items = await page.getByTestId('project-item').all();
expect(items.length).toBe(1);
// Delete the item
await items[0].getByTestId('item-menu').click();
await page.getByTestId('delete-option').click();
// Confirm deletion
const confirmDialog = page.getByTestId('confirm-dialog');
await expect(confirmDialog).toBeVisible();
await page.getByTestId('confirm-delete').click();
// Should transition to empty state
await expect(page.getByTestId('empty-state')).toBeVisible({ timeout: 5000 });
// The empty state should be the "create first" variant, not the filtered variant
const heading = page.getByTestId('empty-state-heading');
await expect(heading).toContainText(/no projects|get started|create/i);
// CTA should be available
const cta = page.getByTestId('empty-state-cta');
await expect(cta).toBeVisible();
});
test('bulk delete of all items shows empty state', async ({ page }) => {
// Setup: ensure multiple items exist
await page.goto('/api/test/setup-multiple-items?count=5');
await page.goto('/projects');
await page.waitForSelector('[data-testid="content-list"]');
// Select all items
await page.getByTestId('select-all-checkbox').check();
// Bulk delete
await page.getByTestId('bulk-delete').click();
await page.getByTestId('confirm-delete').click();
// Should show empty state after all items are deleted
await expect(page.getByTestId('empty-state')).toBeVisible({ timeout: 10000 });
// Undo option should be available briefly
const undoToast = page.getByTestId('undo-toast');
if (await undoToast.isVisible()) {
await expect(undoToast).toContainText(/undo|restore/i);
}
});
test('empty state after deletion differs from error state', async ({ page }) => {
await page.goto('/api/test/setup-single-item');
await page.goto('/projects');
await page.waitForSelector('[data-testid="content-list"]');
// Delete the item
await page.getByTestId('project-item').first().getByTestId('item-menu').click();
await page.getByTestId('delete-option').click();
await page.getByTestId('confirm-delete').click();
await expect(page.getByTestId('empty-state')).toBeVisible();
// Must not show error indicators
const errorIcon = page.getByTestId('error-icon');
await expect(errorIcon).not.toBeVisible();
// Must not show "try again" or "retry" messaging
const emptyText = await page.getByTestId('empty-state').textContent();
expect(emptyText?.toLowerCase()).not.toContain('error');
expect(emptyText?.toLowerCase()).not.toContain('retry');
expect(emptyText?.toLowerCase()).not.toContain('went wrong');
});
});
Network failures, server errors, and timeouts can produce empty screens. These must be clearly differentiated from intentional empty states.
// tests/empty-states/error-states/network-error.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Error-Caused Empty States', () => {
test('network error shows error state, not empty state', async ({ page }) => {
// Block the API endpoint
await page.route('**/api/projects**', (route) => route.abort('failed'));
await page.goto('/projects');
await page.waitForLoadState('networkidle');
// Should show an error state, not a "no items" empty state
const errorState = page.getByTestId('error-state');
await expect(errorState).toBeVisible();
// Must clearly communicate the error
const errorMessage = page.getByTestId('error-message');
await expect(errorMessage).toContainText(
/unable to load|connection error|something went wrong/i
);
// Must offer a retry action
const retryButton = page.getByTestId('retry-button');
await expect(retryButton).toBeVisible();
});
test('retry button recovers from network error', async ({ page }) => {
let requestCount = 0;
await page.route('**/api/projects**', async (route) => {
requestCount++;
if (requestCount <= 1) {
await route.abort('failed');
} else {
await route.continue();
}
});
await page.goto('/projects');
await page.waitForLoadState('networkidle');
// First load fails
await expect(page.getByTestId('error-state')).toBeVisible();
// Retry should succeed
await page.getByTestId('retry-button').click();
await page.waitForSelector(
'[data-testid="content-list"], [data-testid="empty-state"]'
);
// Error state should be gone
await expect(page.getByTestId('error-state')).not.toBeVisible();
});
test('server 500 error shows error state with support info', async ({ page }) => {
await page.route('**/api/projects**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal Server Error' }),
});
});
await page.goto('/projects');
await page.waitForLoadState('networkidle');
const errorState = page.getByTestId('error-state');
await expect(errorState).toBeVisible();
// Error state should offer support contact or reference ID
const errorContent = await errorState.textContent();
expect(errorContent?.toLowerCase()).toMatch(
/support|help|contact|reference|error id/i
);
});
test('timeout shows appropriate loading timeout message', async ({ page }) => {
await page.route('**/api/projects**', async (route) => {
// Simulate a timeout by never responding
await new Promise(() => {}); // Never resolves
});
await page.goto('/projects');
// The loading spinner should eventually transition to a timeout message
const spinner = page.getByTestId('loading-spinner');
if (await spinner.isVisible()) {
// Wait for the application's timeout threshold
await page.waitForSelector('[data-testid="timeout-state"], [data-testid="error-state"]', {
timeout: 30000,
});
}
// Must show timeout-specific messaging
const timeoutState = page.getByTestId('timeout-state').or(page.getByTestId('error-state'));
await expect(timeoutState).toBeVisible();
});
});
Empty state illustrations are critical visual elements. Broken images make empty states feel even more broken.
// tests/empty-states/visual/illustration-rendering.spec.ts
import { test, expect } from '@playwright/test';
import { emptyStateInventory } from '../fixtures/empty-state-inventory';
test.describe('Empty State Illustration Rendering', () => {
const scenariosWithIllustrations = emptyStateInventory.filter(
(s) => s.expectedElements.illustration
);
for (const scenario of scenariosWithIllustrations) {
test(`illustration renders on ${scenario.name}`, async ({ page }) => {
// Navigate to the empty state
await page.goto(scenario.route);
await page.waitForSelector('[data-testid="empty-state"]');
// Check for illustration element (could be SVG, img, or div with background)
const illustration = page.getByTestId('empty-state-illustration');
await expect(illustration).toBeVisible();
// If it is an img tag, verify it loaded successfully
const tagName = await illustration.evaluate((el) => el.tagName.toLowerCase());
if (tagName === 'img') {
const isLoaded = await illustration.evaluate((el: HTMLImageElement) => {
return el.complete && el.naturalWidth > 0 && el.naturalHeight > 0;
});
expect(isLoaded, `Illustration image failed to load on ${scenario.name}`).toBe(true);
}
if (tagName === 'svg') {
// SVG should have a viewBox and visible paths
const hasViewBox = await illustration.evaluate(
(el) => el.hasAttribute('viewBox')
);
expect(hasViewBox).toBe(true);
const pathCount = await illustration.locator('path, circle, rect').count();
expect(pathCount).toBeGreaterThan(0);
}
// Illustration should have alt text for accessibility
const altText = await illustration.getAttribute('alt');
const ariaLabel = await illustration.getAttribute('aria-label');
const role = await illustration.getAttribute('role');
if (role !== 'presentation' && role !== 'none') {
expect(
altText || ariaLabel,
`Illustration on ${scenario.name} missing accessible text`
).toBeTruthy();
}
});
}
test('illustrations do not overflow container on mobile', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
for (const scenario of scenariosWithIllustrations.slice(0, 5)) {
await page.goto(scenario.route);
await page.waitForSelector('[data-testid="empty-state"]');
const illustration = page.getByTestId('empty-state-illustration');
if (await illustration.isVisible()) {
const box = await illustration.bounding
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PramodDutta/Empty State Reviewer下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer
Tags:empty-states, zero-data, placeholder, cta, onboarding, data-loading, null-state, no-results