Practical Smoke Testing with Playwright: Code Examples and Setup

Practical Smoke Testing with Playwright: Code Examples and Setup

Playwright is a strong choice for smoke testing. It's fast, handles modern web apps well, and has built-in support for parallel execution, auth state persistence, and multiple browsers. This post walks through building a complete smoke test suite with Playwright, from project setup to CI integration.

Project Setup

Install Playwright in your project:

npm init playwright@latest

This creates the base config and example tests. For a smoke-focused setup, structure your project like this:

tests/
├── smoke/
│   ├── 01-health.spec.js
│   ├── 02-auth.spec.js
│   ├── 03-navigation.spec.js
│   ├── 04-core-features.spec.js
│   └── setup/
│       └── auth.setup.js
├── regression/      # separate suite, runs less frequently
└── .auth/           # gitignored, stores saved auth state
    └── .gitkeep

Keep smoke tests in their own directory, separate from regression tests. They'll run on a different schedule and with different configuration.

Playwright Config for Smoke Tests

// playwright.config.js
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,
  expect: { timeout: 5000 },

  // Stop after first failure in smoke suite — don't waste time
  // running all tests when something fundamental is broken
  ...(process.env.TEST_SUITE === 'smoke' ? { workers: 4, fullyParallel: true } : {}),

  reporter: [
    ['list'],
    ['html', { outputFolder: 'playwright-report', open: 'never' }],
    ['json', { outputFile: 'results.json' }],
  ],

  use: {
    baseURL: process.env.BASE_URL || 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    // Auth setup runs first
    {
      name: 'setup',
      testMatch: '**/setup/*.setup.js',
    },
    // Smoke tests run after setup, using saved auth
    {
      name: 'smoke',
      testMatch: 'tests/smoke/**/*.spec.js',
      dependencies: ['setup'],
      use: {
        ...devices['Desktop Chrome'],
        storageState: 'tests/.auth/user.json',
      },
    },
  ],
});

Auth Setup

Save auth state once, reuse it across all smoke tests:

// tests/smoke/setup/auth.setup.js
import { test as setup, expect } from '@playwright/test';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const authFile = path.join(__dirname, '../../.auth/user.json');

setup('authenticate', async ({ page }) => {
  await page.goto('/login');

  // Wait for the login form to be ready
  await expect(page.locator('[data-testid="login-form"]')).toBeVisible();

  await page.fill('[data-testid="email-input"]', process.env.SMOKE_USER_EMAIL);
  await page.fill('[data-testid="password-input"]', process.env.SMOKE_USER_PASSWORD);
  await page.click('[data-testid="login-submit"]');

  // Wait for successful login
  await page.waitForURL(/\/(dashboard|home|app)/);
  await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();

  // Save the auth state for reuse
  await page.context().storageState({ path: authFile });
});

The Health Check Test

Start with the most basic check: does the app respond at all?

// tests/smoke/01-health.spec.js
import { test, expect } from '@playwright/test';

test.describe('Health Checks', () => {
  // This test doesn't need auth — run it unauthenticated
  test.use({ storageState: { cookies: [], origins: [] } });

  test('homepage responds with 200', async ({ page }) => {
    const response = await page.goto('/');
    expect(response.status()).toBeLessThan(400);
    await expect(page).not.toHaveTitle(/Error|Not Found|500/i);
  });

  test('API health endpoint is healthy', async ({ request }) => {
    const response = await request.get('/api/health');
    expect(response.status()).toBe(200);

    const body = await response.json();
    expect(body).toMatchObject({
      status: 'ok',
    });
  });

  test('login page renders correctly', async ({ page }) => {
    // Test this without auth state
    await page.goto('/login');
    await expect(page.locator('[data-testid="email-input"]')).toBeVisible();
    await expect(page.locator('[data-testid="password-input"]')).toBeVisible();
    await expect(page.locator('[data-testid="login-submit"]')).toBeVisible();
  });
});

Authentication Smoke Tests

// tests/smoke/02-auth.spec.js
import { test, expect } from '@playwright/test';

test.describe('Authentication', () => {
  test('authenticated user sees dashboard', async ({ page }) => {
    // Auth state is pre-loaded — this should land on the dashboard
    await page.goto('/');
    await expect(page).toHaveURL(/\/(dashboard|home|app)/);
    await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
  });

  test('session persists on reload', async ({ page }) => {
    await page.goto('/dashboard');
    await page.reload();
    // After reload, should still be authenticated — not redirected to login
    await expect(page).not.toHaveURL(/\/login/);
    await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
  });

  test('invalid credentials show error', async ({ page }) => {
    // Temporarily clear auth state for this test
    await page.context().clearCookies();
    await page.evaluate(() => window.localStorage.clear());

    await page.goto('/login');
    await page.fill('[data-testid="email-input"]', 'notauser@example.com');
    await page.fill('[data-testid="password-input"]', 'wrongpassword');
    await page.click('[data-testid="login-submit"]');

    // Should show an error, not redirect to dashboard
    await expect(page.locator('[data-testid="login-error"]')).toBeVisible();
    await expect(page).toHaveURL(/\/login/);
  });
});
// tests/smoke/03-navigation.spec.js
import { test, expect } from '@playwright/test';

// Define the main sections of your app
const mainNavLinks = [
  { name: 'Dashboard', url: '/dashboard', selector: '[data-testid="nav-dashboard"]' },
  { name: 'Projects', url: '/projects', selector: '[data-testid="nav-projects"]' },
  { name: 'Reports', url: '/reports', selector: '[data-testid="nav-reports"]' },
  { name: 'Settings', url: '/settings', selector: '[data-testid="nav-settings"]' },
];

test.describe('Navigation', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/dashboard');
  });

  for (const link of mainNavLinks) {
    test(`${link.name} nav link works`, async ({ page }) => {
      await page.click(link.selector);
      await expect(page).toHaveURL(new RegExp(link.url));
      // Verify the page actually loaded something
      await expect(page.locator('main')).toBeVisible();
      await expect(page.locator('main')).not.toContainText('Error');
    });
  }

  test('back navigation works', async ({ page }) => {
    await page.click('[data-testid="nav-projects"]');
    await expect(page).toHaveURL(/\/projects/);

    await page.goBack();
    await expect(page).toHaveURL(/\/dashboard/);
  });
});

Core Feature Smoke Tests

// tests/smoke/04-core-features.spec.js
import { test, expect } from '@playwright/test';

test.describe('Core Features', () => {
  test('projects list loads', async ({ page }) => {
    await page.goto('/projects');

    // Wait for the page to fully load — not just the shell
    await expect(page.locator('[data-testid="projects-container"]')).toBeVisible();

    // Verify it's not showing an error state
    await expect(page.locator('[data-testid="error-state"]')).not.toBeVisible();
  });

  test('can open project detail', async ({ page }) => {
    await page.goto('/projects');

    // Click the first project (smoke test data always has at least one)
    await page.locator('[data-testid="project-card"]').first().click();

    // Should navigate to a project detail page
    await expect(page).toHaveURL(/\/projects\/\d+/);
    await expect(page.locator('[data-testid="project-detail"]')).toBeVisible();
  });

  test('create project form opens', async ({ page }) => {
    await page.goto('/projects');
    await page.click('[data-testid="create-project-button"]');

    // Verify the form/modal opens — don't actually submit
    await expect(page.locator('[data-testid="create-project-form"]')).toBeVisible();
    await expect(page.locator('[data-testid="project-name-input"]')).toBeVisible();
  });

  test('reports page renders', async ({ page }) => {
    await page.goto('/reports');
    await expect(page.locator('[data-testid="reports-container"]')).toBeVisible();
    // Check that charts/visualizations render (not just empty containers)
    await expect(page.locator('[data-testid="report-chart"]').first()).toBeVisible();
  });
});

Running Smoke Tests

Run the full smoke suite:

# Run smoke tests against staging
BASE_URL=https://staging.yourapp.com \
SMOKE_USER_EMAIL=smoke@example.com \
SMOKE_USER_PASSWORD=TestPass123 \
npx playwright <span class="hljs-built_in">test tests/smoke/ --project=smoke

<span class="hljs-comment"># Run with HTML report
npx playwright <span class="hljs-built_in">test tests/smoke/ --reporter=html

<span class="hljs-comment"># Run specific test file only
npx playwright <span class="hljs-built_in">test tests/smoke/02-auth.spec.js

<span class="hljs-comment"># Run in headed mode for debugging
npx playwright <span class="hljs-built_in">test tests/smoke/ --headed --project=smoke

CI Integration

# .github/workflows/smoke.yml
name: Smoke Tests

on:
  deployment_status:
  workflow_call:
    inputs:
      environment_url:
        required: true
        type: string

jobs:
  smoke:
    runs-on: ubuntu-latest
    if: github.event.deployment_status.state == 'success' || github.event_name == 'workflow_call'

    steps:
      - uses: actions/checkout@v3

      - uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install chromium --with-deps

      - name: Run smoke tests
        run: npx playwright test tests/smoke/ --project=smoke
        env:
          BASE_URL: ${{ inputs.environment_url || github.event.deployment_status.environment_url }}
          SMOKE_USER_EMAIL: ${{ secrets.SMOKE_USER_EMAIL }}
          SMOKE_USER_PASSWORD: ${{ secrets.SMOKE_USER_PASSWORD }}

      - name: Upload test report
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: smoke-test-report
          path: playwright-report/
          retention-days: 7

Keeping Tests Fast

A few techniques to keep your smoke suite under 5 minutes:

Disable animations. CSS animations add real time to waits:

// In your playwright.config.js
use: {
  reducedMotion: 'reduce',
}

Use waitForLoadState('domcontentloaded') instead of 'networkidle'. Networkidle waits for all network activity to stop, which includes analytics pings and background polling. domcontentloaded is usually what you actually need.

Skip screenshots in passing tests. Only capture on failure:

use: {
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
}

Run in parallel. Playwright runs tests in parallel by default. With 4 workers and 15 tests averaging 15 seconds each, the suite completes in about 60 seconds.

When Smoke Tests Break

Smoke tests fail for two reasons: the application broke, or the test is wrong.

Distinguishing these matters. Look at the failure mode:

  • Selector not found — the UI changed. Update the selector.
  • URL assertion failed — routing changed. Update the expected URL.
  • Timeout — the page is slow or broken. Check the application.
  • HTTP error (500, 503) — the application is broken. Don't update the test.

A smoke test that fails because of a UI change is telling you the test needs maintenance, not that the application broke. Fix the test. A smoke test that fails because of a 500 error is telling you the application broke. Fix the application.

Keep a zero-tolerance policy for flaky smoke tests. A smoke test that fails intermittently is worse than no smoke test — it trains your team to ignore failures. Fix flaky tests immediately by identifying the root cause (usually timing issues or shared state) rather than adding retries as a band-aid.

Read more