# Playwright Visual Testing

> Implement visual regression testing with Playwright screenshot comparisons. Use when: catching unintended UI changes, validating design consistency, testing responsive layouts, preventing visual regressions in CI/CD.

- Skill: `bavithiranhardy14/playwright-visual-testing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add bavithiranhardy14/playwright-visual-testing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bavithiranhardy14/playwright-visual-testing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: bavithiranhardy14 (https://skillmd.com/u/bavithiranhardy14)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/bavithiranhardy14/playwright-visual-testing

---


# 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

```bash
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**

```python
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**

```python
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**

```python
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

```bash
# 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

```yaml
# .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

