DEV Community

Cover image for The Playwright Playbook — Part 5: Visual Regression Testing
Faizal
Faizal

Posted on

The Playwright Playbook — Part 5: Visual Regression Testing

The Playwright Playbook — Part 5: Visual Regression Testing

"Your assertions say the button exists. Visual regression says it's not buried under the header anymore."

In Part 1, we built the foundation. Part 2 owned the network. Part 3 handled multiple users. Part 4 replaced Postman with a full API testing layer.

Every single assertion we've written so far has one thing in common.

They all test behaviour — what the app does. Not appearance — what the user actually sees.

And that's where bugs hide.

The button is there. toBeVisible() passes. But a CSS regression pushed it behind the navigation bar. The user can't click it. Your test doesn't know.

The task list renders. The count is correct. But a font-size change made the text overflow and truncate. Half the title is hidden. Your test doesn't know.

The dashboard loads. All assertions pass. But the mobile layout is completely broken. Your test doesn't know — because you never checked a mobile viewport.

Visual regression testing catches all of this. And Playwright has it built in — no third-party tool required.

Let's add it to the framework. 🎯


🏗️ Where We Left Off

After Part 4, our full project structure is:

playwright-playbook/
├── tests/
│   ├── auth/
│   │   └── login.spec.ts                        ✅ Part 1
│   ├── tasks/
│   │   └── task-management.spec.ts              ✅ Part 1
│   ├── network/                                 ✅ Part 2
│   │   ├── api-mocking.spec.ts
│   │   ├── error-simulation.spec.ts
│   │   └── network-assertions.spec.ts
│   ├── multi-user/                              ✅ Part 3
│   │   ├── role-permissions.spec.ts
│   │   └── realtime-collaboration.spec.ts
│   ├── multi-tab/                               ✅ Part 3
│   │   └── multi-tab-flows.spec.ts
│   └── api/                                     ✅ Part 4
│       ├── tasks-api.spec.ts
│       ├── auth-api.spec.ts
│       ├── graphql-api.spec.ts
│       └── api-ui-chain.spec.ts
├── pages/
│   ├── LoginPage.ts                             ✅ Part 1
│   ├── TaskPage.ts                              ✅ Part 1
│   └── DashboardPage.ts                         ✅ Part 3
├── api/                                         ✅ Part 4
│   ├── TaskApiClient.ts
│   └── AuthApiClient.ts
├── fixtures/
│   ├── auth.fixture.ts                          ✅ Part 1
│   ├── tasks.json                               ✅ Part 2
│   ├── empty-tasks.json                         ✅ Part 2
│   ├── tasks-har.har                            ✅ Part 2
│   ├── multi-user.fixture.ts                    ✅ Part 3
│   └── api.fixture.ts                           ✅ Part 4
├── scripts/
│   └── record-har.ts                            ✅ Part 2
├── utils/
│   └── schema-validator.ts                      ✅ Part 4
├── .auth/
│   ├── admin.json
│   └── user.json
├── global-setup.ts                              ✅ Part 1
├── playwright.config.ts                         ✅ Part 1 (updated Parts 3 & 4)
└── .env
Enter fullscreen mode Exit fullscreen mode

By the end of Part 5, we add:

playwright-playbook/
├── tests/
│   └── visual/                                  ← NEW
│       ├── dashboard-visual.spec.ts
│       ├── task-visual.spec.ts
│       └── responsive-visual.spec.ts
├── utils/
│   └── visual-helpers.ts                        ← NEW
├── snapshots/                                   ← NEW (auto-generated, committed to git)
│   └── [baseline PNG files live here]
Enter fullscreen mode Exit fullscreen mode

Every file gets fully built below. 👇


🧠 How Playwright Visual Regression Works — The Mental Model

Before we write code, understand what's happening under the hood.

First run (no baseline exists):
  test runs → screenshot taken → saved as baseline PNG → test PASSES

Subsequent runs:
  test runs → screenshot taken → compared pixel-by-pixel to baseline
  ├── Match within threshold → ✅ PASS
  └── Diff exceeds threshold → ❌ FAIL + diff image generated

When UI intentionally changes:
  run with --update-snapshots flag → baseline replaced → commit new baseline
Enter fullscreen mode Exit fullscreen mode

The baseline PNGs live in your repo alongside your tests. Every developer, every CI run, compares against the same baseline. That's what makes it reliable.

Three things that make or break VRT in practice:

  1. Masking — hide dynamic content (timestamps, avatars, ads) that changes every run
  2. Threshold — how many pixels can differ before it's a failure
  3. Consistent environment — fonts, OS rendering, and viewport must be identical across runs

We'll handle all three. 🎯


⚙️ Updating playwright.config.ts for Visual Testing

Visual regression needs its own project config — consistent viewport, single browser (Chromium for VRT), and snapshot settings.

// playwright.config.ts — full updated version
import { defineConfig, devices } from '@playwright/test';
import * as dotenv from 'dotenv';

dotenv.config();

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  workers: process.env.CI ? 4 : undefined,
  globalSetup: './global-setup.ts',

  reporter: [
    ['html', { open: 'never' }],
    ['list'],
  ],

  // Snapshot configuration — applies to all toHaveScreenshot calls
  expect: {
    toHaveScreenshot: {
      // Allow up to 0.2% of pixels to differ — handles anti-aliasing differences
      maxDiffPixelRatio: 0.002,
      // How long to wait for the screenshot to stabilize
      timeout: 10000,
      // Animations must be disabled for consistent screenshots
      animations: 'disabled',
    },
  },

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

  projects: [
    {
      name: 'admin',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/admin.json',
      },
      testMatch: ['**/auth/**', '**/tasks/**', '**/network/**'],
    },
    {
      name: 'user',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/user.json',
      },
      testMatch: ['**/tasks/**'],
    },
    {
      name: 'multi-context',
      use: { ...devices['Desktop Chrome'] },
      testMatch: ['**/multi-user/**', '**/multi-tab/**'],
    },
    {
      name: 'api',
      use: {},
      testMatch: ['**/api/**'],
    },
    {
      // Visual regression — Chromium only, fixed viewport, admin auth
      // Single browser keeps baselines consistent across machines
      name: 'visual',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/admin.json',
        // Fixed viewport — VRT breaks if viewport varies
        viewport: { width: 1280, height: 720 },
        // Disable animations — they cause pixel differences mid-screenshot
        launchOptions: {
          args: ['--disable-gpu', '--force-device-scale-factor=1'],
        },
      },
      testMatch: ['**/visual/**'],
      // Store snapshots next to the test files
      snapshotDir: './snapshots',
    },
    {
      // Responsive VRT — separate project, different viewports per test
      name: 'visual-responsive',
      use: {
        ...devices['Desktop Chrome'],
        storageState: '.auth/admin.json',
      },
      testMatch: ['**/visual/responsive**'],
      snapshotDir: './snapshots/responsive',
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

🛠️ Building the Visual Helpers Utility

Masking and screenshot config comes up in every VRT test. Instead of repeating it, we centralise it.

// utils/visual-helpers.ts
import { Page, Locator } from '@playwright/test';

/**
 * Elements that change every run — timestamps, avatars, live counters.
 * Pass these to toHaveScreenshot({ mask: [...] }) to hide them from comparison.
 */
export function getDynamicMasks(page: Page): Locator[] {
  return [
    page.getByTestId('timestamp'),           // "Created 2 mins ago"
    page.getByTestId('user-avatar'),         // Profile photos
    page.getByTestId('notification-count'),  // Live badge counts
    page.getByTestId('last-updated'),        // "Last updated: 14:32:01"
    page.getByTestId('session-timer'),       // Session countdown
  ];
}

/**
 * Standard screenshot options used across all full-page VRT tests.
 * Disable animations, set threshold, mask dynamic elements.
 */
export function fullPageScreenshotOptions(page: Page) {
  return {
    fullPage: true,
    mask: getDynamicMasks(page),
    maxDiffPixelRatio: 0.002,
    animations: 'disabled' as const,
  };
}

/**
 * Component-level screenshot options — for isolated widget/card snapshots.
 * Tighter threshold since we're testing a smaller, more controlled area.
 */
export function componentScreenshotOptions(page: Page) {
  return {
    mask: getDynamicMasks(page),
    maxDiffPixelRatio: 0.001,
    animations: 'disabled' as const,
  };
}

/**
 * Wait for all images on the page to fully load before screenshotting.
 * Prevents half-loaded images causing false failures.
 */
export async function waitForImagesLoaded(page: Page): Promise<void> {
  await page.waitForFunction(() => {
    const images = Array.from(document.images);
    return images.every(img => img.complete && img.naturalHeight !== 0);
  });
}

/**
 * Wait for all network activity to settle before screenshotting.
 * Prevents loading spinners appearing in snapshots.
 */
export async function waitForPageStable(page: Page): Promise<void> {
  await page.waitForLoadState('networkidle');
  await waitForImagesLoaded(page);
  // Small buffer for CSS transitions to complete
  await page.waitForTimeout(300);
}
Enter fullscreen mode Exit fullscreen mode

📸 Dashboard Visual Tests — Full Page & Component Level

// tests/visual/dashboard-visual.spec.ts
import { test, expect } from '@playwright/test';
import { DashboardPage } from '../../pages/DashboardPage';
import { TaskApiClient } from '../../api/TaskApiClient';
import { AuthApiClient } from '../../api/AuthApiClient';
import {
  fullPageScreenshotOptions,
  componentScreenshotOptions,
  waitForPageStable,
} from '../../utils/visual-helpers';

test.describe('Dashboard — Full Page Visual', () => {
  test('dashboard full page snapshot — default state', async ({ page, request }) => {
    // Seed known data via API so the screenshot is always consistent
    const authApi = new AuthApiClient(request);
    const token = await authApi.getAdminToken();
    const taskApi = new TaskApiClient(request, token);

    // Create exactly 3 known tasks for a stable snapshot
    const seeded = await Promise.all([
      taskApi.createTask({ title: 'Write unit tests', status: 'pending' }),
      taskApi.createTask({ title: 'Review pull request', status: 'completed' }),
      taskApi.createTask({ title: 'Fix flaky test in CI', status: 'pending' }),
    ]);

    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await waitForPageStable(page);

    // Full page snapshot — first run creates baseline, subsequent runs compare
    await expect(page).toHaveScreenshot(
      'dashboard-default.png',
      fullPageScreenshotOptions(page)
    );

    // Cleanup
    await Promise.all(seeded.map(({ task }) => taskApi.deleteTask(task.id)));
  });

  test('dashboard snapshot — empty state (no tasks)', async ({ page, request }) => {
    // Ensure there are no tasks for a clean empty state screenshot
    const authApi = new AuthApiClient(request);
    const token = await authApi.getAdminToken();
    const taskApi = new TaskApiClient(request, token);

    const allTasks = await taskApi.getAllTasks();
    await Promise.all(allTasks.map(t => taskApi.deleteTask(t.id)));

    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await waitForPageStable(page);

    await expect(page).toHaveScreenshot(
      'dashboard-empty.png',
      fullPageScreenshotOptions(page)
    );
  });
});

test.describe('Dashboard — Component Level Visual', () => {
  test('task list component snapshot', async ({ page }) => {
    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await waitForPageStable(page);

    // Screenshot just the task list — not the whole page
    await expect(dashboard.taskList).toHaveScreenshot(
      'task-list-component.png',
      componentScreenshotOptions(page)
    );
  });

  test('notification panel snapshot — when open', async ({ page }) => {
    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await dashboard.openNotifications();

    // Wait for panel animation to settle
    await page.waitForTimeout(300);

    await expect(dashboard.notificationPanel).toHaveScreenshot(
      'notification-panel-open.png',
      componentScreenshotOptions(page)
    );
  });

  test('admin panel component snapshot', async ({ page }) => {
    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await waitForPageStable(page);

    await expect(dashboard.adminPanel).toHaveScreenshot(
      'admin-panel-component.png',
      componentScreenshotOptions(page)
    );
  });
});

test.describe('Dashboard — State Change Visual', () => {
  test('task status changes are reflected visually', async ({ page, request }) => {
    const authApi = new AuthApiClient(request);
    const token = await authApi.getAdminToken();
    const taskApi = new TaskApiClient(request, token);

    const { task } = await taskApi.createTask({
      title: 'Task for visual status test',
      status: 'pending',
    });

    const dashboard = new DashboardPage(page);
    await dashboard.goto();
    await waitForPageStable(page);

    // Screenshot in pending state
    const taskLocator = dashboard.getTaskLocator('Task for visual status test');
    await expect(taskLocator).toHaveScreenshot(
      'task-item-pending.png',
      componentScreenshotOptions(page)
    );

    // Mark as complete via API
    await taskApi.updateTask(task.id, { status: 'completed' });
    await page.reload();
    await waitForPageStable(page);

    // Screenshot in completed state — should show visual difference (strikethrough, green badge etc.)
    await expect(taskLocator).toHaveScreenshot(
      'task-item-completed.png',
      componentScreenshotOptions(page)
    );

    // Cleanup
    await taskApi.deleteTask(task.id);
  });
});
Enter fullscreen mode Exit fullscreen mode

🎨 Task Page Visual Tests — States & Error Handling

// tests/visual/task-visual.spec.ts
import { test, expect } from '@playwright/test';
import { TaskPage } from '../../pages/TaskPage';
import { TaskApiClient } from '../../api/TaskApiClient';
import { AuthApiClient } from '../../api/AuthApiClient';
import {
  fullPageScreenshotOptions,
  componentScreenshotOptions,
  waitForPageStable,
} from '../../utils/visual-helpers';

test.describe('Task Page — Visual States', () => {
  test('task list page — with data', async ({ page, request }) => {
    // Seed consistent data via API
    const authApi = new AuthApiClient(request);
    const token = await authApi.getAdminToken();
    const taskApi = new TaskApiClient(request, token);

    const seeded = await Promise.all([
      taskApi.createTask({ title: 'Design new feature', status: 'pending' }),
      taskApi.createTask({ title: 'Write tests for login', status: 'in_progress' }),
      taskApi.createTask({ title: 'Deploy to staging', status: 'completed' }),
    ]);

    const taskPage = new TaskPage(page);
    await taskPage.goto();
    await waitForPageStable(page);

    await expect(page).toHaveScreenshot(
      'task-page-with-data.png',
      fullPageScreenshotOptions(page)
    );

    await Promise.all(seeded.map(({ task }) => taskApi.deleteTask(task.id)));
  });

  test('task page — empty state visual', async ({ page }) => {
    // Use network mock to force empty state cleanly
    await page.route('**/api/tasks', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: '[]',
      });
    });

    const taskPage = new TaskPage(page);
    await taskPage.goto();
    await waitForPageStable(page);

    await expect(page).toHaveScreenshot(
      'task-page-empty.png',
      fullPageScreenshotOptions(page)
    );
  });

  test('task page — error state visual (API 500)', async ({ page }) => {
    // Use network mock to force error state
    await page.route('**/api/tasks', async route => {
      await route.fulfill({
        status: 500,
        contentType: 'application/json',
        body: JSON.stringify({ error: 'Internal Server Error' }),
      });
    });

    const taskPage = new TaskPage(page);
    await taskPage.goto();
    await waitForPageStable(page);

    await expect(page).toHaveScreenshot(
      'task-page-error.png',
      fullPageScreenshotOptions(page)
    );
  });

  test('task creation modal — open state', async ({ page }) => {
    const taskPage = new TaskPage(page);
    await taskPage.goto();
    await waitForPageStable(page);

    // Open the new task modal
    await taskPage.newTaskButton.click();
    await page.waitForTimeout(300); // wait for open animation

    // Screenshot the modal — checks layout, spacing, form fields
    await expect(page.getByTestId('task-modal')).toHaveScreenshot(
      'task-creation-modal.png',
      componentScreenshotOptions(page)
    );
  });

  test('task item — hover state visual', async ({ page, request }) => {
    const authApi = new AuthApiClient(request);
    const token = await authApi.getAdminToken();
    const taskApi = new TaskApiClient(request, token);

    const { task } = await taskApi.createTask({ title: 'Hover state test task' });

    const taskPage = new TaskPage(page);
    await taskPage.goto();
    await waitForPageStable(page);

    // Hover to reveal action buttons — screenshot the hover state
    const taskItem = taskPage.getTaskLocator('Hover state test task');
    await taskItem.hover();

    await expect(taskItem).toHaveScreenshot(
      'task-item-hover.png',
      componentScreenshotOptions(page)
    );

    await taskApi.deleteTask(task.id);
  });
});
Enter fullscreen mode Exit fullscreen mode

📱 Responsive Visual Tests — Mobile, Tablet, Desktop

This is the VRT layer most teams skip entirely. A layout that looks perfect on desktop can be completely broken on mobile. toBeVisible() will never tell you that.

// tests/visual/responsive-visual.spec.ts
import { test, expect, devices } from '@playwright/test';
import { DashboardPage } from '../../pages/DashboardPage';
import { TaskPage } from '../../pages/TaskPage';
import {
  fullPageScreenshotOptions,
  waitForPageStable,
} from '../../utils/visual-helpers';

// Define the viewports we care about
const viewports = [
  { name: 'mobile', width: 375, height: 812 },    // iPhone 14
  { name: 'tablet', width: 768, height: 1024 },   // iPad
  { name: 'desktop', width: 1280, height: 720 },  // Standard desktop
  { name: 'wide', width: 1920, height: 1080 },    // Large monitor
];

test.describe('Dashboard — Responsive Visual', () => {
  for (const viewport of viewports) {
    test(`dashboard layout — ${viewport.name} (${viewport.width}x${viewport.height})`, async ({
      page,
    }) => {
      await page.setViewportSize({
        width: viewport.width,
        height: viewport.height,
      });

      const dashboard = new DashboardPage(page);
      await dashboard.goto();
      await waitForPageStable(page);

      await expect(page).toHaveScreenshot(
        `dashboard-${viewport.name}.png`,
        fullPageScreenshotOptions(page)
      );
    });
  }
});

test.describe('Task Page — Responsive Visual', () => {
  for (const viewport of viewports) {
    test(`task list layout — ${viewport.name} (${viewport.width}x${viewport.height})`, async ({
      page,
    }) => {
      // Seed stable data via mock so screenshots are consistent
      await page.route('**/api/tasks', async route => {
        await route.fulfill({
          status: 200,
          contentType: 'application/json',
          body: JSON.stringify([
            { id: 1, title: 'Design new feature', status: 'pending', assignee: 'admin' },
            { id: 2, title: 'Write tests for login', status: 'in_progress', assignee: 'user' },
            { id: 3, title: 'Deploy to staging', status: 'completed', assignee: 'admin' },
          ]),
        });
      });

      await page.setViewportSize({
        width: viewport.width,
        height: viewport.height,
      });

      const taskPage = new TaskPage(page);
      await taskPage.goto();
      await waitForPageStable(page);

      await expect(page).toHaveScreenshot(
        `task-page-${viewport.name}.png`,
        fullPageScreenshotOptions(page)
      );
    });
  }
});

test.describe('Login Page — Responsive Visual', () => {
  // Login page is special — clear storageState so we see the actual login form
  test.use({ storageState: { cookies: [], origins: [] } });

  for (const viewport of viewports) {
    test(`login page layout — ${viewport.name}`, async ({ page }) => {
      await page.setViewportSize({
        width: viewport.width,
        height: viewport.height,
      });

      await page.goto('/login');
      await waitForPageStable(page);

      await expect(page).toHaveScreenshot(
        `login-page-${viewport.name}.png`,
        fullPageScreenshotOptions(page)
      );
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

🔄 The Update Workflow — When UI Changes Are Intentional

When a developer intentionally changes the UI — a redesign, a new component, a colour update — your VRT tests will fail. That's correct behaviour.

The workflow:

# Step 1 — Run VRT to see what changed
npx playwright test --project=visual

# Step 2 — Open the HTML report to review the diffs visually
npx playwright show-report

# Step 3 — If the change is intentional, update the baselines
npx playwright test --project=visual --update-snapshots

# Step 4 — Commit the new baseline PNGs alongside your code change
git add snapshots/
git commit -m "chore: update visual baselines for new dashboard redesign"
Enter fullscreen mode Exit fullscreen mode

This is the critical discipline: baseline updates must be a conscious decision, reviewed before committing. Never update baselines automatically in CI without human review. That defeats the entire purpose. ⚠️


🤔 When to Use Component vs Full Page Screenshots

This comes up constantly. Here's the decision framework:

Full page screenshot — use when:
  ├── Testing overall page layout and composition
  ├── Testing how components relate to each other spatially
  ├── Testing page-level responsive behaviour
  └── First-time coverage of a new page

Component screenshot — use when:
  ├── Testing a specific widget in isolation (modal, card, dropdown)
  ├── Testing state changes within a component (hover, active, error)
  ├── The page has a lot of dynamic content you can't fully mask
  └── You want faster, more targeted failure messages
Enter fullscreen mode Exit fullscreen mode

In practice — start with full page, move to component when you find your full page tests are too noisy to maintain. 🎯


🚫 Common VRT Mistakes — And How We Avoid Them

❌ Screenshotting before the page is stable

// 🔴 Bad — loading spinner might still be visible
await page.goto('/dashboard');
await expect(page).toHaveScreenshot('dashboard.png');

// ✅ Good — use waitForPageStable from our utils
await page.goto('/dashboard');
await waitForPageStable(page);
await expect(page).toHaveScreenshot('dashboard.png', fullPageScreenshotOptions(page));
Enter fullscreen mode Exit fullscreen mode

❌ Not masking dynamic content

// 🔴 Bad — timestamp changes every run → test always fails
await expect(page).toHaveScreenshot('dashboard.png');

// ✅ Good — masks hide the dynamic elements
await expect(page).toHaveScreenshot('dashboard.png', {
  mask: getDynamicMasks(page),
});
Enter fullscreen mode Exit fullscreen mode

❌ Running VRT across different OS/browsers

Screenshots render differently on macOS vs Linux vs Windows. A baseline taken on your MacBook will fail on a Linux CI runner — every single time.

// ✅ Good — lock VRT to one browser and use Docker in CI
// playwright.config.ts visual project already uses Desktop Chrome only
// In CI, run Playwright inside the official Docker image:
// mcr.microsoft.com/playwright:v1.44.0-jammy
Enter fullscreen mode Exit fullscreen mode

❌ Setting threshold too high

// 🔴 Bad — 10% pixel difference allowed = real bugs slip through
maxDiffPixelRatio: 0.1

// ✅ Good — tight threshold, mask dynamic content instead of widening tolerance
maxDiffPixelRatio: 0.002
Enter fullscreen mode Exit fullscreen mode

📁 Final Project Structure After Part 5

Every file listed below has been fully built across Parts 1 through 5:

playwright-playbook/
├── tests/
│   ├── auth/
│   │   └── login.spec.ts                        ✅ Part 1
│   ├── tasks/
│   │   └── task-management.spec.ts              ✅ Part 1
│   ├── network/                                 ✅ Part 2
│   │   ├── api-mocking.spec.ts
│   │   ├── error-simulation.spec.ts
│   │   └── network-assertions.spec.ts
│   ├── multi-user/                              ✅ Part 3
│   │   ├── role-permissions.spec.ts
│   │   └── realtime-collaboration.spec.ts
│   ├── multi-tab/                               ✅ Part 3
│   │   └── multi-tab-flows.spec.ts
│   ├── api/                                     ✅ Part 4
│   │   ├── tasks-api.spec.ts
│   │   ├── auth-api.spec.ts
│   │   ├── graphql-api.spec.ts
│   │   └── api-ui-chain.spec.ts
│   └── visual/                                  ✅ Part 5
│       ├── dashboard-visual.spec.ts
│       ├── task-visual.spec.ts
│       └── responsive-visual.spec.ts
├── pages/
│   ├── LoginPage.ts                             ✅ Part 1
│   ├── TaskPage.ts                              ✅ Part 1
│   └── DashboardPage.ts                         ✅ Part 3
├── api/                                         ✅ Part 4
│   ├── TaskApiClient.ts
│   └── AuthApiClient.ts
├── fixtures/
│   ├── auth.fixture.ts                          ✅ Part 1
│   ├── tasks.json                               ✅ Part 2
│   ├── empty-tasks.json                         ✅ Part 2
│   ├── tasks-har.har                            ✅ Part 2
│   ├── multi-user.fixture.ts                    ✅ Part 3
│   └── api.fixture.ts                           ✅ Part 4
├── scripts/
│   └── record-har.ts                            ✅ Part 2
├── utils/
│   ├── schema-validator.ts                      ✅ Part 4
│   └── visual-helpers.ts                        ✅ Part 5
├── snapshots/                                   ✅ Part 5 (committed to git)
│   ├── dashboard-default.png
│   ├── dashboard-empty.png
│   ├── task-list-component.png
│   ├── task-page-with-data.png
│   ├── task-page-empty.png
│   ├── task-page-error.png
│   └── responsive/
│       ├── dashboard-mobile.png
│       ├── dashboard-tablet.png
│       ├── dashboard-desktop.png
│       ├── dashboard-wide.png
│       └── [more responsive baselines...]
├── .auth/                                       ← git-ignored
│   ├── admin.json
│   └── user.json
├── global-setup.ts                              ✅ Part 1
├── playwright.config.ts                         ✅ Part 1 (updated Parts 3, 4 & 5)
├── .env                                         ← git-ignored
└── package.json
Enter fullscreen mode Exit fullscreen mode

🗺️ What's Coming in This Series

Part 1 — Stop Writing Tests Like a Beginner              ✅ Done
Part 2 — Network Interception: The Complete Guide        ✅ Done
Part 3 — Multi-User, Multi-Tab & Context Testing         ✅ Done
Part 4 — API Testing (The Underrated Superpower)         ✅ Done
Part 5 — Visual Regression Testing                       ← You are here
Part 6 — Debugging Like a Pro: Trace Viewer & Inspector
Part 7 — The CI/CD Setup Nobody Shows You
Part 8 — Playwright Meets AI: Agents, MCP & Self-Healing Tests
Enter fullscreen mode Exit fullscreen mode

In Part 6, we switch from writing tests to understanding what went wrong when they fail. Playwright's Trace Viewer, Inspector, VS Code integration, and page.pause() — the debugging toolkit that replaces console.log entirely.


🔖 Before You Go

Five parts in, and look at what you've built:

  • A POM-based UI layer with proper auth management
  • Full network interception and API mocking
  • Multi-user and multi-tab test coverage
  • A typed API testing layer with schema validation
  • Visual regression across full pages, components, and four viewports

Every assertion type. Every test layer. One framework. One tool. One pipeline.

The bugs that slipped through before? They have nowhere to hide now. 🔥


Follow me so you don't miss Part 6 — where we go deep on debugging. When a test fails at 2am in CI, you need to know exactly what happened. Trace Viewer will show you every click, every network call, every DOM snapshot — in a timeline.

Drop a comment below 👇

  • Are you currently doing any visual regression testing?
  • What tool are you using — Percy, Chromatic, or something else?
  • Did the responsive VRT loop pattern click for you?

Let's talk in the comments. 🙌


Faizal Shaikh | Senior Automation Engineer | Playwright & AI Testing
Connect with me on LinkedIn

Top comments (0)