Playwright Visual Development with Vizzly
Integrate visual quality into your Playwright workflow. See visual changes instantly with local TDD, then collaborate with your team through automatic CI builds.
If you’re using Playwright for E2E testing, you know it catches functional issues well. But here’s the thing about visual quality: a CSS change breaks your layout in Chrome but not Firefox. A responsive breakpoint shifts content on mobile. A third-party script injects unexpected UI. Your functional tests pass, but your UI looks wrong.
The thing is: visual quality shouldn’t be a separate testing concern. It should be part of your existing development workflow-validated locally while you develop, and automatically checked in CI when you push code.
That’s how Vizzly works with Playwright. You add screenshot calls to your existing tests, iterate locally with the TDD dashboard to see exactly what changed visually, then seamlessly hand off to your team through automatic CI builds for review. No separate testing phase. No proprietary rendering pipeline. Just your actual Playwright tests capturing real screenshots.
Let’s walk through how to integrate visual development into your Playwright workflow.
Integrating Vizzly with Playwright
Vizzly integrates with Playwright through the SDK, letting you send screenshots directly from your tests with full metadata and context:
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
let screenshot = await page.screenshot();
await vizzlyScreenshot('homepage', screenshot, {
properties: {
browser: 'chromium',
viewport: '1920x1080'
}
});
This gives you real-time feedback in the TDD dashboard and lets you attach metadata like browser, viewport, and test context.
Alternative: If you’re already using Playwright’s screenshot API and want a simpler approach, you can upload screenshots after tests run (see upload documentation).
Now let’s look at practical integration patterns.
Basic Integration: Full-Page Screenshots
The simplest way to add visual testing is to capture full-page screenshots at key points in your tests:
import { test } from '@playwright/test';
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
test('should display login form correctly', async ({ page, browserName }) => {
await page.goto('https://example.com/login');
let screenshot = await page.screenshot({ fullPage: true });
await vizzlyScreenshot('login-form', screenshot, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'empty-form',
browser: browserName
}
});
});
This captures the entire page, including content below the fold. The properties object lets you
attach metadata that helps organize and filter screenshots in Vizzly’s dashboard.
Element-Level Screenshots
Want to test specific components instead of entire pages? Playwright lets you screenshot individual elements:
test('hero section matches design', async ({ page }) => {
await page.goto('https://example.com');
let hero = page.locator('.hero-section');
let screenshot = await hero.screenshot();
await vizzlyScreenshot('hero-section', screenshot, {
component: 'hero'
});
});
This is great for design system components or testing specific UI sections without noise from the rest of the page.
Cross-Browser Testing
Playwright makes cross-browser testing easy. Capture screenshots across browsers and let Vizzly track them separately:
import { test, devices } from '@playwright/test';
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
for (let browserType of ['chromium', 'firefox', 'webkit']) {
test(`homepage looks right in ${browserType}`, async ({ browser }) => {
let context = await browser.newContext();
let page = await context.newPage();
await page.goto('https://example.com');
let screenshot = await page.screenshot();
await vizzlyScreenshot(`homepage-${browserType}`, screenshot, {
browser: browserType
});
await context.close();
});
}
Vizzly organizes screenshots by metadata, so you can easily compare the same page across different browsers.
Responsive Testing
Test your responsive breakpoints by capturing screenshots at different viewport sizes:
test('homepage is responsive', async ({ browser }) => {
let viewports = [
{ width: 375, height: 667, name: 'mobile' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 1920, height: 1080, name: 'desktop' }
];
for (let viewport of viewports) {
let context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height }
});
let page = await context.newPage();
await page.goto('https://example.com');
let screenshot = await page.screenshot({ fullPage: true });
await vizzlyScreenshot(`homepage-${viewport.name}`, screenshot, {
viewport: `${viewport.width}x${viewport.height}`
});
await context.close();
}
});
This ensures your layouts work correctly at different screen sizes.
Mobile Device Emulation
Playwright comes with device emulation for real mobile testing:
import { devices } from '@playwright/test';
test('homepage on iPhone 15', async ({ browser }) => {
let context = await browser.newContext({
...devices['iPhone 15 Pro']
});
let page = await context.newPage();
await page.goto('https://example.com');
let screenshot = await page.screenshot({ fullPage: true });
await vizzlyScreenshot('homepage-iphone-15', screenshot, {
device: 'iPhone 15 Pro'
});
await context.close();
});
Test real device viewports, user agents, and touch capabilities.
Testing Interactions and State
Capture screenshots after user interactions to catch visual bugs in dynamic UIs:
test('login flow visual states', async ({ page, browserName }) => {
await page.goto('https://example.com/login');
// Capture initial empty state
let initial = await page.screenshot();
await vizzlyScreenshot('login-form', initial, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'empty-form',
browser: browserName
}
});
// Fill in the form
await page.fill('#login', 'user@example.com');
await page.fill('#password', 'password123');
// Capture filled state
let filled = await page.screenshot();
await vizzlyScreenshot('login-form-filled', filled, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'filled-form',
browser: browserName
}
});
// Submit with invalid credentials to trigger error
await page.fill('#login', 'invalid@example.com');
await page.click('button[type="submit"]');
await page.waitForSelector('.text-red-400');
// Capture error state
let error = await page.screenshot();
await vizzlyScreenshot('login-error-state', error, {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'invalid-credentials-error',
scenario: 'error-handling',
browser: browserName
}
});
});
This catches visual issues in different UI states that functional tests might miss. Using consistent
properties makes it easy to filter and organize screenshots by state, feature, or scenario.
Waiting for Stability
Avoid flaky screenshots by waiting for the DOM state you care about - just like a user would:
test('dashboard after successful login', async ({ page, browserName }) => {
await page.goto('https://example.com/login');
// Log in
await page.fill('#login', 'user@example.com');
await page.fill('#password', 'password123');
await page.click('button[type="submit"]');
// Wait for navigation to dashboard
await page.waitForURL(/.*\/dashboard$/);
// Wait for the specific content that indicates the page is ready
// This is how users interact - they wait for what they need, not all network requests
await page.waitForSelector('[data-testid="dashboard-content"]');
let screenshot = await page.screenshot({ fullPage: true });
await vizzlyScreenshot('dashboard-home', screenshot, {
properties: {
component: 'dashboard',
feature: 'navigation',
state: 'logged-in',
browser: browserName
}
});
});
Pro tip: Wait for specific DOM elements that indicate your page is ready, not arbitrary timeouts
or networkidle. Users don’t wait for all network requests to settle — they interact as soon as the
UI they need is visible. Your tests should do the same. This makes tests faster and more resilient.
Creating a Screenshot Helper
As your test suite grows, you’ll want consistency in how you capture and organize
screenshots. Instead of repeating the same patterns, create a helper function that wraps
vizzlyScreenshot():
// helpers/screenshot-helper.js
import { vizzlyScreenshot } from '@vizzly-testing/cli/client';
export function createScreenshotHelper(page, browserName) {
return {
async capture(name, options = {}) {
let screenshot = await page.screenshot({
fullPage: options.fullPage ?? true
});
await vizzlyScreenshot(name, screenshot, {
properties: {
browser: browserName,
url: page.url(),
...options.properties
},
threshold: options.threshold ?? 0.1
});
},
async waitForNetworkIdle() {
await page.waitForLoadState('networkidle');
}
};
}
Then use it in your tests:
import { test } from '@playwright/test';
import { createScreenshotHelper } from './helpers/screenshot-helper.js';
test('login form displays correctly', async ({ page, browserName }) => {
let screenshot = createScreenshotHelper(page, browserName);
await page.goto('https://example.com/login');
await screenshot.capture('login-form', {
properties: {
component: 'login-page',
feature: 'authentication',
state: 'empty-form'
}
});
});
This pattern gives you a single place to:
- Set default screenshot options
- Add consistent metadata
- Handle errors gracefully
- Extend functionality without changing every test
You can add methods for common patterns like responsive screenshots, element captures, or
interaction states. The key is wrapping vizzlyScreenshot() with your project’s conventions.
Local Development: The vizzly tdd Workflow
Here’s where Vizzly really shines. While you’re iterating on UI:
# Start the dashboard server
vizzly tdd start --open
# In another terminal, run Playwright in watch mode
npx playwright test --ui
The TDD dashboard opens in your browser and updates live as your tests call
vizzlyScreenshot(). You get instant visual diffs, can review changes, and accept new baselines — all
without leaving your development flow.
No waiting for CI. No manual screenshot comparisons. Just save your code, your tests re-run automatically, and you see exactly what changed visually in real-time.
This is the local TDD workflow that makes visual quality feel integrated into development instead of a separate testing phase.
CI Integration: GitHub Actions Example
Vizzly plugs into whatever CI you’re already running. Here’s a GitHub Actions workflow that runs Playwright tests and uploads screenshots:
name: Visual Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
env:
VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
run: npx playwright test
# If using upload approach instead of SDK
- name: Upload screenshots to Vizzly
if: always()
env:
VIZZLY_TOKEN: ${{ secrets.VIZZLY_TOKEN }}
run: npx vizzly upload ./screenshots --wait
The --wait flag makes the build fail if visual differences are detected, gating your PRs on visual approval.
Note: If you’re using the SDK approach with vizzlyScreenshot(), you don’t need the upload
step - screenshots are sent in real-time during test execution. Always store your VIZZLY_TOKEN as a
repository secret, never commit it to your codebase.
When your CI runs, Vizzly automatically creates a build from your test run. This means your entire team can review visual changes, leave position-based comments, and approve changes before they ship - all without you having to do anything beyond running your tests.
This seamless integration is what makes visual quality part of your development process instead of a separate testing phase.
Best Practices: Stable Screenshots
A few tips to keep your visual tests reliable:
Hide dynamic content: Mask timestamps, user-specific data, or anything that changes on every run:
await page.addStyleTag({
content: '.timestamp { visibility: hidden !important; }'
});
Stabilize animations: Disable animations during tests to avoid timing issues:
await page.addStyleTag({
content: '*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }'
});
Wait for fonts to load: Ensure custom fonts are loaded before capturing:
await page.evaluate(() => document.fonts.ready);
Consistent viewport: Always use the same viewport size for each screenshot unless you’re explicitly testing responsive behavior:
await page.setViewportSize({ width: 1920, height: 1080 });
Organizing Your Visual Tests
The key to organizing screenshots in Vizzly is using properties consistently. Properties let you filter, search, and group screenshots in the dashboard:
await vizzlyScreenshot('checkout-page', screenshot, {
properties: {
feature: 'checkout',
component: 'cart',
state: 'empty',
flow: 'purchase',
browser: browserName
}
});
await vizzlyScreenshot('checkout-page', screenshot, {
properties: {
feature: 'checkout',
component: 'cart',
state: 'with-items',
flow: 'purchase',
browser: browserName
}
});
Notice how both screenshots use the same name ('checkout-page') but different state
properties. This is the pattern: use consistent screenshot names and differentiate with
properties.
With consistent properties, you can:
- Filter builds by feature: “Show me all checkout screenshots”
- Group by state: “Compare empty vs filled states”
- Track specific flows: “Review the entire purchase flow”
- Organize by component: “See all cart-related screenshots”
Pro tip: Establish property conventions early. Common ones include feature, component,
state, flow, scenario, and browser. Use them consistently across your test suite and they
become powerful organizational tools in the Vizzly dashboard.
Get Started
- Follow the Vizzly Quick Start to set your auth token
- Add
vizzlyScreenshot()calls to a couple of your existing Playwright tests - Start
vizzly tdd start --openlocally and run your tests to see instant visual diffs - Add the Vizzly upload step to your CI pipeline
Visual quality becomes part of your development process, not a separate testing phase. That’s how it should be.
Questions? Feedback? I’d love to hear how you’re using Vizzly with Playwright. Feel free to reach out.
Happy testing!