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.