🔥 0
0
Lesson 8 of 10 25 min +200 XP

Visual & Mobile Testing

Functional tests confirm your app works. Visual tests confirm it looks right. For e-commerce, where design impacts conversion, visual testing is critical.

Why Visual Testing Matters for E-Commerce

A CSS change breaks the "Add to Cart" button layout. Functional tests pass (button works), but:

  • Button overlaps the price
  • Mobile users can't see it
  • Conversion drops 15%

Visual testing catches these regressions before they reach production.

Screenshot Comparison Testing

Basic Screenshot Test

import { test, expect } from '@playwright/test';

test('product page visual test', async ({ page }) => {
  await page.goto('/products/wireless-headphones');

  // Wait for content to stabilize
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();

  // Compare against baseline
  await expect(page).toHaveScreenshot('product-page.png');
});

First run creates the baseline in tests/visual.spec.ts-snapshots/. Subsequent runs compare against it.

Element-Level Screenshots

test('product card matches design', async ({ page }) => {
  await page.goto('/products');

  const productCard = page.getByTestId('product-card').first();
  await expect(productCard).toHaveScreenshot('product-card.png');
});

test('checkout form styling', async ({ page }) => {
  await page.goto('/checkout');

  const form = page.getByRole('form');
  await expect(form).toHaveScreenshot('checkout-form.png');
});

test('cart summary component', async ({ page }) => {
  // Setup: Add items to cart
  await page.goto('/products/laptop');
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  await page.goto('/cart');

  const summary = page.getByTestId('cart-summary');
  await expect(summary).toHaveScreenshot('cart-summary.png');
});

Screenshot Options

test('full page screenshot', async ({ page }) => {
  await page.goto('/products');

  await expect(page).toHaveScreenshot('products-page.png', {
    fullPage: true,  // Capture entire scrollable area
  });
});

test('with tolerance for minor differences', async ({ page }) => {
  await page.goto('/products');

  await expect(page).toHaveScreenshot('products.png', {
    maxDiffPixels: 100,  // Allow 100 pixels to differ
  });

  // Or percentage-based
  await expect(page).toHaveScreenshot('products.png', {
    maxDiffPixelRatio: 0.02,  // Allow 2% difference
  });
});

test('custom comparison threshold', async ({ page }) => {
  await page.goto('/products');

  await expect(page).toHaveScreenshot('products.png', {
    threshold: 0.3,  // Per-pixel color difference threshold (0-1)
  });
});

Handling Dynamic Content

test('product page with masked dynamic content', async ({ page }) => {
  await page.goto('/products/laptop');

  await expect(page).toHaveScreenshot('product-detail.png', {
    // Mask elements that change
    mask: [
      page.getByTestId('stock-count'),    // "Only 5 left!" changes
      page.getByTestId('review-count'),   // Review count changes
      page.locator('.timestamp'),          // "Updated 2 hours ago"
    ],
  });
});

test('hide advertisements', async ({ page }) => {
  await page.goto('/products');

  // Remove dynamic ads before screenshot
  await page.evaluate(() => {
    document.querySelectorAll('.advertisement').forEach(el => el.remove());
  });

  await expect(page).toHaveScreenshot('products-no-ads.png');
});

test('wait for images to load', async ({ page }) => {
  await page.goto('/products');

  // Wait for lazy-loaded images
  await page.waitForFunction(() => {
    const images = document.querySelectorAll('img');
    return Array.from(images).every(img => img.complete);
  });

  await expect(page).toHaveScreenshot('products-with-images.png');
});

Animations and Loading States

test('disable animations for consistent screenshots', async ({ page }) => {
  // Disable CSS animations
  await page.addStyleTag({
    content: `
      *, *::before, *::after {
        animation-duration: 0s !important;
        transition-duration: 0s !important;
      }
    `,
  });

  await page.goto('/products');
  await expect(page).toHaveScreenshot('products-static.png');
});

test('screenshot specific visual state', async ({ page }) => {
  await page.goto('/products/laptop');

  // Hover state
  await page.getByRole('button', { name: 'Add to Cart' }).hover();
  await expect(page.getByRole('button', { name: 'Add to Cart' })).toHaveScreenshot('add-to-cart-hover.png');

  // Focus state
  await page.getByLabel('Quantity').focus();
  await expect(page.getByLabel('Quantity')).toHaveScreenshot('quantity-focused.png');
});

Mobile Device Emulation

Built-in Device Profiles

Playwright includes 100+ device profiles:

import { test, devices } from '@playwright/test';

// Use iPhone 13
const iPhone13 = devices['iPhone 13'];

test.use({ ...iPhone13 });

test('mobile product page', async ({ page }) => {
  await page.goto('/products/laptop');

  // Viewport is 390x844
  // User agent is iPhone Safari
  // Touch events are enabled
  await expect(page.getByRole('button', { name: 'Add to Cart' })).toBeVisible();
});

Project-Based Device Configuration

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

export default defineConfig({
  projects: [
    // Desktop
    {
      name: 'Desktop Chrome',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'Desktop Safari',
      use: { ...devices['Desktop Safari'] },
    },

    // Mobile
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 13'] },
    },

    // Tablets
    {
      name: 'iPad',
      use: { ...devices['iPad Pro 11'] },
    },
    {
      name: 'Galaxy Tab',
      use: { ...devices['Galaxy Tab S4'] },
    },
  ],
});

Custom Viewport Configuration

test.use({
  viewport: { width: 375, height: 667 },
  userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)',
  hasTouch: true,
  isMobile: true,
  deviceScaleFactor: 2,
});

test('custom mobile viewport', async ({ page }) => {
  await page.goto('/products');
  await expect(page).toHaveScreenshot('custom-mobile.png');
});

E-Commerce Mobile Testing Patterns

Mobile Navigation

test.describe('Mobile Navigation', () => {
  test.use({ ...devices['iPhone 13'] });

  test('hamburger menu works on mobile', async ({ page }) => {
    await page.goto('/');

    // Desktop nav should be hidden
    await expect(page.getByRole('navigation').getByRole('link', { name: 'Products' }))
      .not.toBeVisible();

    // Hamburger menu should be visible
    const hamburger = page.getByRole('button', { name: 'Menu' });
    await expect(hamburger).toBeVisible();

    // Open menu
    await hamburger.click();

    // Mobile nav appears
    await expect(page.getByRole('link', { name: 'Products' })).toBeVisible();
    await expect(page.getByRole('link', { name: 'Categories' })).toBeVisible();
    await expect(page.getByRole('link', { name: 'Cart' })).toBeVisible();
  });

  test('can navigate to cart from mobile menu', async ({ page }) => {
    await page.goto('/');

    await page.getByRole('button', { name: 'Menu' }).click();
    await page.getByRole('link', { name: 'Cart' }).click();

    await expect(page).toHaveURL(/\/cart/);
  });
});

Mobile Product Browsing

test.describe('Mobile Product Experience', () => {
  test.use({ ...devices['iPhone 13'] });

  test('product images swipeable on mobile', async ({ page }) => {
    await page.goto('/products/laptop');

    const imageCarousel = page.getByTestId('product-images');

    // Verify swipe gesture works
    await imageCarousel.evaluate((el) => {
      el.dispatchEvent(new TouchEvent('touchstart', {
        touches: [{ clientX: 300, clientY: 200 }] as Touch[],
      }));
      el.dispatchEvent(new TouchEvent('touchmove', {
        touches: [{ clientX: 100, clientY: 200 }] as Touch[],
      }));
      el.dispatchEvent(new TouchEvent('touchend', {}));
    });

    // Second image should be visible
    await expect(page.getByTestId('image-indicator-2')).toHaveClass(/active/);
  });

  test('sticky add to cart on mobile', async ({ page }) => {
    await page.goto('/products/laptop');

    // Scroll down
    await page.evaluate(() => window.scrollBy(0, 500));

    // Sticky CTA should appear
    const stickyButton = page.getByTestId('sticky-add-to-cart');
    await expect(stickyButton).toBeVisible();

    // Click sticky button
    await stickyButton.click();
    await expect(page.getByText('Added to cart')).toBeVisible();
  });
});

Mobile Checkout

test.describe('Mobile Checkout', () => {
  test.use({ ...devices['iPhone 13'] });

  test('checkout form is usable on mobile', async ({ page }) => {
    await page.goto('/checkout');

    // Fields should be full width
    const emailInput = page.getByLabel('Email');
    const inputBox = await emailInput.boundingBox();
    const viewport = page.viewportSize();

    // Input should be nearly full width (accounting for padding)
    expect(inputBox?.width).toBeGreaterThan((viewport?.width || 0) * 0.8);

    // Keyboard doesn't obscure fields
    await emailInput.focus();
    await expect(emailInput).toBeVisible();
  });

  test('payment buttons sized for touch', async ({ page }) => {
    await page.goto('/checkout');

    const payButton = page.getByRole('button', { name: 'Pay Now' });
    const box = await payButton.boundingBox();

    // Minimum touch target size: 44x44 pixels
    expect(box?.height).toBeGreaterThanOrEqual(44);
    expect(box?.width).toBeGreaterThanOrEqual(44);
  });

  test('express checkout options on mobile', async ({ page }) => {
    await page.goto('/checkout');

    // Apple Pay should be visible on iOS
    await expect(page.getByRole('button', { name: /apple pay/i })).toBeVisible();

    // Google Pay on Android (different project)
  });
});

Responsive Design Testing

Breakpoint Testing

const breakpoints = [
  { name: 'mobile', width: 375, height: 667 },
  { name: 'tablet', width: 768, height: 1024 },
  { name: 'desktop', width: 1280, height: 800 },
  { name: 'wide', width: 1920, height: 1080 },
];

for (const bp of breakpoints) {
  test(`product grid layout at ${bp.name}`, async ({ page }) => {
    await page.setViewportSize({ width: bp.width, height: bp.height });
    await page.goto('/products');

    await expect(page).toHaveScreenshot(`product-grid-${bp.name}.png`);
  });
}

Layout Verification

test.describe('Responsive Layout', () => {
  test('product grid columns adjust', async ({ page }) => {
    await page.goto('/products');
    const grid = page.getByTestId('product-grid');

    // Desktop: 4 columns
    await page.setViewportSize({ width: 1280, height: 800 });
    let style = await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
    expect(style.split(' ').length).toBe(4);

    // Tablet: 2 columns
    await page.setViewportSize({ width: 768, height: 1024 });
    style = await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
    expect(style.split(' ').length).toBe(2);

    // Mobile: 1 column
    await page.setViewportSize({ width: 375, height: 667 });
    style = await grid.evaluate((el) => getComputedStyle(el).gridTemplateColumns);
    expect(style.split(' ').length).toBe(1);
  });
});

Visual Testing in CI

Configuration for CI

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      // More lenient in CI due to rendering differences
      maxDiffPixelRatio: 0.05,

      // Animations should be disabled
      animations: 'disabled',

      // Consistent rendering
      caret: 'hide',
    },
  },

  // Consistent font rendering
  use: {
    launchOptions: {
      args: ['--font-render-hinting=none'],
    },
  },
});

Docker for Consistent Screenshots

# docker-compose.yml
version: '3'
services:
  playwright:
    image: mcr.microsoft.com/playwright:v1.40.0-jammy
    volumes:
      - .:/app
    working_dir: /app
    command: npx playwright test --update-snapshots
# Update baselines in Docker (consistent environment)
docker-compose run playwright

# Run tests in CI using same image
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/playwright:v1.40.0-jammy npx playwright test

Platform-Specific Snapshots

// Snapshots are stored per-platform by default:
// tests/visual.spec.ts-snapshots/product-page-chromium-darwin.png
// tests/visual.spec.ts-snapshots/product-page-chromium-linux.png

test('product page', async ({ page }) => {
  await page.goto('/products/laptop');
  await expect(page).toHaveScreenshot('product-page.png');
});

GitHub Actions Workflow

# .github/workflows/visual-tests.yml
name: Visual Tests

on: [push, pull_request]

jobs:
  visual-tests:
    runs-on: ubuntu-latest
    container:
      image: mcr.microsoft.com/playwright:v1.40.0-jammy

    steps:
      - uses: actions/checkout@v4

      - name: Install dependencies
        run: npm ci

      - name: Run visual tests
        run: npx playwright test --project=chromium

      - name: Upload failed screenshots
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-test-results
          path: test-results/

Key Takeaways

  • toHaveScreenshot() compares against baseline images
  • Mask dynamic content (timestamps, counters, ads) for stable tests
  • 100+ device profiles built into Playwright
  • Test breakpoints systematically for responsive design
  • Mobile-specific UX (touch targets, hamburger menus, sticky CTAs)
  • Use Docker in CI for consistent screenshot rendering
  • Separate baselines per platform when needed

Next Lesson

Visual tests fail, but why? In the next lesson, we'll learn debugging with Trace Viewer - Playwright's powerful tool for understanding exactly what happened in any test.

Network Interception & Mocking