Playwright Visual Testing
When to Use
- Catching unintended CSS/layout changes
- Validating pixel-perfect designs after refactors
- Testing responsive breakpoints visually
- Preventing visual regressions in CI/CD pipelines
- Comparing UI across browsers (Chromium vs Firefox vs WebKit)
Procedure
1. Install Dependencies
pip install playwright==1.36.0 pytest-playwright==0.3.0
# Playwright has built-in screenshot comparison — no extra library needed
2. Visual Test Fixture
fixtures/visual_fixtures.py
import pytest
import os
from pathlib import Path
SNAPSHOTS_DIR = Path("tests/snapshots")
ACTUAL_DIR = Path("reports/visual/actual")
DIFF_DIR = Path("reports/visual/diff")
@pytest.fixture(scope="session", autouse=True)
def setup_visual_dirs():
"""Create directories for visual testing."""
SNAPSHOTS_DIR.mkdir(parents=True, exist_ok=True)
ACTUAL_DIR.mkdir(parents=True, exist_ok=True)
DIFF_DIR.mkdir(parents=True, exist_ok=True)
@pytest.fixture
def visual_page(page):
"""Page fixture with visual comparison helper."""
page.page.set_default_timeout(15000)
return page
3. Visual Assertion Helper
utils/visual_compare.py
from pathlib import Path
from playwright.sync_api import Page, expect
from utils.logger import get_logger
logger = get_logger(__name__)
SNAPSHOTS_DIR = Path("tests/snapshots")
ACTUAL_DIR = Path("reports/visual/actual")
class VisualCompare:
"""Visual screenshot comparison using Playwright expect."""
def __init__(self, page: Page):
self.page = page
def assert_matches_snapshot(self, name: str, selector: str = None,
threshold: float = 0.1, full_page: bool = False):
"""
Compare screenshot against baseline.
On first run — creates the baseline snapshot.
On subsequent runs — compares and fails if difference > threshold.
"""
snapshot_path = SNAPSHOTS_DIR / f"{name}.png"
if selector:
locator = self.page.locator(selector)
if snapshot_path.exists():
expect(locator).to_have_screenshot(
snapshot_path,
threshold=threshold
)
logger.info(f"Visual match PASS: {name}")
else:
# First run — save baseline
locator.screenshot(path=str(snapshot_path))
logger.info(f"Baseline created: {name}")
else:
actual_path = ACTUAL_DIR / f"{name}.png"
self.page.screenshot(path=str(actual_path), full_page=full_page)
if snapshot_path.exists():
expect(self.page).to_have_screenshot(
snapshot_path,
threshold=threshold,
full_page=full_page
)
logger.info(f"Visual match PASS: {name}")
else:
# First run — copy actual as baseline
import shutil
shutil.copy(str(actual_path), str(snapshot_path))
logger.info(f"Baseline created: {name}")
4. Visual Tests
tests/visual/test_visual_regression.py
import pytest
from utils.visual_compare import VisualCompare
from playwright.sync_api import expect
@pytest.mark.visual
class TestVisualRegression:
"""Visual regression tests using Playwright snapshots."""
@pytest.fixture(autouse=True)
def setup(self, page):
self.page = page.page
self.visual = VisualCompare(self.page)
# ── Full Page Snapshots ────────────────────────────────────
def test_login_page_full(self):
"""Full page visual snapshot of login page."""
self.page.goto("https://app.example.com/login")
self.page.wait_for_load_state("networkidle")
self.visual.assert_matches_snapshot("login_page_full", full_page=True)
def test_dashboard_full(self):
"""Full page visual snapshot of dashboard."""
self.page.goto("https://app.example.com/dashboard")
self.page.wait_for_load_state("networkidle")
self.visual.assert_matches_snapshot("dashboard_full", full_page=True)
# ── Component Snapshots ────────────────────────────────────
def test_navigation_bar(self):
"""Snapshot just the navigation bar."""
self.page.goto("https://app.example.com")
self.visual.assert_matches_snapshot("nav_bar", selector="#navbar")
def test_login_form(self):
"""Snapshot login form component only."""
self.page.goto("https://app.example.com/login")
self.visual.assert_matches_snapshot("login_form", selector="form.login-form")
def test_footer(self):
"""Snapshot footer component."""
self.page.goto("https://app.example.com")
self.visual.assert_matches_snapshot("footer", selector="footer")
# ── Responsive / Mobile Snapshots ─────────────────────────
def test_mobile_login_page(self, mobile_page):
"""Visual snapshot at mobile viewport."""
mobile_page.page.goto("https://app.example.com/login")
mobile_page.page.wait_for_load_state("networkidle")
visual = VisualCompare(mobile_page.page)
visual.assert_matches_snapshot("mobile_login_page", full_page=True)
def test_tablet_dashboard(self, page):
"""Visual snapshot at tablet viewport."""
page.page.set_viewport_size({"width": 768, "height": 1024})
page.page.goto("https://app.example.com/dashboard")
visual = VisualCompare(page.page)
visual.assert_matches_snapshot("tablet_dashboard", full_page=True)
# ── Cross-Browser Visual ───────────────────────────────────
def test_cross_browser_login(self, multi_browser_page):
"""Same page snapshot across Chromium, Firefox, WebKit."""
browser_name = multi_browser_page.page.context.browser.browser_type.name
multi_browser_page.page.goto("https://app.example.com/login")
visual = VisualCompare(multi_browser_page.page)
visual.assert_matches_snapshot(f"login_{browser_name}", full_page=True)
# ── State-Based Snapshots ──────────────────────────────────
def test_error_state_visual(self):
"""Snapshot the error state of login form."""
self.page.goto("https://app.example.com/login")
self.page.fill("#username", "wrong@example.com")
self.page.fill("#password", "badpass")
self.page.click("#loginBtn")
self.page.wait_for_selector(".error-message")
self.visual.assert_matches_snapshot("login_error_state")
def test_loading_skeleton(self):
"""Snapshot loading skeleton before data loads."""
# Slow down API to capture loading state
self.page.route("**/api/**", lambda route: route.continue_())
self.page.goto("https://app.example.com/dashboard")
# Capture immediately before networkidle
self.visual.assert_matches_snapshot("dashboard_loading")
5. Update Baseline Snapshots
# First run — creates all baseline snapshots automatically
pytest tests/visual/ -m visual
# Update baselines after intentional UI change
# Delete old snapshots and re-run
Remove-Item tests/snapshots/*.png
pytest tests/visual/ -m visual
# Run visual tests only on Chromium
BROWSER=chromium pytest tests/visual/ -m visual
# Run with HTML report showing diff images
pytest tests/visual/ --html=reports/visual_report.html
6. CI/CD Integration
# .github/workflows/visual.yml
- name: Run Visual Regression Tests
run: pytest tests/visual/ -m visual --html=reports/visual_report.html
- name: Upload Visual Diff Report
uses: actions/upload-artifact@v3
if: failure()
with:
name: visual-diff-report
path: reports/visual/
Best Practices
- Commit baseline snapshots to git (
tests/snapshots/) - Use
threshold=0.1(10%) tolerance for anti-aliasing differences - Separate snapshots per browser — they render slightly differently
- Always snapshot after
wait_for_load_state("networkidle") - Snapshot components, not just full pages — easier to debug diffs