Skip to content

Buổi 13: Visual Regression Testing

Mục Tiêu Học Tập

Sau buổi này, bạn sẽ:

  • Hiểu tại sao logic tests không đủ để đảm bảo UI chính xác
  • Triển khai Playwright daemon cho browser automation
  • Tạo visual regression tests sử dụng screenshot comparison
  • Implement golden image workflow
  • Phát hiện CSS/UI regressions tự động trong CI/CD

LÝ THUYẾT

1. Tại Sao Logic Tests ≠ UI Correct?

Vấn đề: Code tests có thể pass nhưng UI có thể bị broken

javascript
// ✓ Unit test pass
describe("Button Component", () => {
  it("should render text", () => {
    const btn = render(<Button>Click me</Button>);
    expect(btn.getByText("Click me")).toBeInTheDocument();
    // ✓ Text renders → test pass
  });
});

// ❌ Nhưng UI có thể như thế này:
// [Button text is WHITE on WHITE background]
// [Button position is OUTSIDE viewport]
// [Button size is 1px × 1px]
// ❌ User không thể thấy hay click được

Visual Regression Testing giải quyết:

  • Logic pass ≠ UI correct
  • Cần kiểm tra pixel-perfect rendering
  • Phát hiện unintended CSS changes
  • Catch layout shifts, color changes, typos

Workflow:

Code change → Unit tests pass? → YES

                         Visual tests pass? 
                           ↓            ↓
                          YES          NO
                           ↓            ↓
                         Merge      Block merge
                                    Inspect diff

2. Playwright Daemon — Local Browser Automation

Tại sao Playwright daemon?

  • Singleton browser instance
  • Reuse giữa tests
  • HTTP API thay vì direct library calls
  • Better isolation, less memory

Architecture:

┌──────────────────────────────┐
│     Test Code                │
├──────────────────────────────┤
│  HTTP POST /session/start    │
│  HTTP POST /navigate         │
│  HTTP GET /screenshot        │
│  HTTP GET /console           │
├──────────────────────────────┤
│   Playwright Daemon          │
│   (headless browser pool)    │
├──────────────────────────────┤
│  Chromium / Firefox / Webkit │
└──────────────────────────────┘

Cài đặt daemon:

bash
# Khởi động daemon
cm browse start --port 17395 --token "$CM_BROWSE_TOKEN"

# Daemon chạy ở localhost:17395
# Sử dụng HTTP API từ tests

HTTP API Endpoints:

EndpointMethodMục Đích
/session/startPOSTTạo browser session mới
/navigatePOSTNavigate đến URL
/screenshotGETChụp screenshot
/consoleGETĐọc console logs
/evaluatePOSTChạy JS trong page
/clickPOSTClick element

3. Golden Image Comparison

Golden Image: Ảnh "tốt" được lưu trữ làm reference

Workflow:

1️⃣ BASELINE CAPTURE
   npm run test:visual -- --update

   ├─ Tạo screenshots mới
   ├─ So sánh với golden images
   ├─ Lưu golden-images/ folder
   └─ Commit vào git

2️⃣ REGRESSION DETECTION
   npm run test:visual

   ├─ Chạy tests → capture screenshots
   ├─ So sánh: actual vs. golden
   ├─ Tính diff pixels
   ├─ Nếu >1% diff → FAIL
   └─ Lưu diff images

3️⃣ REVIEW & FIX
   Visual diff → CSS thay đổi intentional?

   ├─ Yes → Update golden: --update
   ├─ No → Fix CSS → rerun test
   └─ Merge khi diff = 0

4. Visual Diff Workflow — Pixel Perfect

Diff Detection:

javascript
// Ví dụ: CSS change detected
// golden-images/header-light-theme.png
// vs.
// actual-screenshots/header-light-theme.png

// Diff report:
// {
//   "file": "header-light-theme",
//   "diffPixels": 2847,
//   "totalPixels": 1920000,
//   "diffPercentage": 0.148,
//   "threshold": 1.0,
//   "status": "FAIL"
// }

// ✗ FAIL: 0.148% diff > 0% threshold
// → Block merge, inspect diff image

Threshold Configuration:

javascript
// playright.config.ts
export default {
  webServer: {
    command: "npm run dev",
    port: 5173,
  },
  use: {
    baseURL: "http://localhost:5173",
    screenshot: "only-on-failure",
    // Visual regression thresholds
    snapshotPathTemplate: "{snapshotDir}/{testFileDir}/{testFileName}-{platform}{ext}",
    snapshotMatcherOptions: {
      maxDiffPixels: 100,        // Allow max 100 diff pixels
      threshold: 0.2,             // Allow 0.2% color variance
    },
  },
};

5. cm-qa-visual-cli Integration

Công cụ tích hợp:

bash
cm qa-visual --url http://localhost:5173 \
             --port 17395 \
             --output cm-qa-visual.png

# Output:
# ✓ Screenshot saved: cm-qa-visual.png
# ✓ Golden image comparison: PASS

Workflow tích hợp:

bash
# 1. Start Playwright daemon
cm browse start --port 17395 --token "$CM_BROWSE_TOKEN"

# 2. Start dev server
npm run dev &

# 3. Run visual tests
cm qa-visual --url http://localhost:5173 --port 17395

# 4. Compare golden images (auto)
# - Load: golden-images/visual-snapshot.png
# - Load: cm-qa-visual.png
# - Diff comparison
# - Report: PASS / FAIL

THỰC HÀNH

Bài Tập 1: Cài Đặt Playwright & Browsers

Bước 1: Cài đặt Playwright

bash
cd /path/to/taskflow
npm install --save-dev @playwright/test

Bước 2: Cài đặt browsers

bash
npx playwright install chromium

# Hoặc cài đặt tất cả:
npx playwright install

# Output:
# ✓ chromium 1234 (152 MB)
# ✓ firefox 1234 (193 MB)
# ✓ webkit 1234 (208 MB)

Bước 3: Kiểm tra cài đặt

bash
npx playwright --version
# Playwright 1.40.0

Bài Tập 2: Tạo Visual Regression Test

Tạo test/visual-regression.test.ts:

typescript
import { test, expect } from "@playwright/test";
import path from "path";

// Test metadata
test.describe("Visual Regression — TaskFlow UI", () => {
  test.beforeEach(async ({ page }) => {
    // Navigate to app
    await page.goto("http://localhost:5173");
    // Wait for hydration
    await page.waitForLoadState("networkidle");
  });

  test("should render header consistently (light theme)", async ({
    page,
    browserName,
  }) => {
    // Select header element
    const header = page.locator("header, nav, [data-testid='header']");

    // Wait for element to be visible
    await header.waitFor({ state: "visible" });

    // Capture screenshot
    await expect(header).toHaveScreenshot(
      `header-${browserName}-light.png`,
      {
        maxDiffPixels: 50,  // Allow max 50 diff pixels
        threshold: 0.2,      // Allow 0.2% color variance
      }
    );
  });

  test("should render dashboard layout consistently", async ({
    page,
    browserName,
  }) => {
    // Navigate to dashboard
    await page.goto("http://localhost:5173/dashboard");
    await page.waitForLoadState("networkidle");

    // Select main content area
    const main = page.locator("main, [data-testid='dashboard']");
    await main.waitFor({ state: "visible" });

    // Visual regression check
    await expect(main).toHaveScreenshot(
      `dashboard-${browserName}.png`,
      {
        maxDiffPixels: 100,
        threshold: 0.2,
      }
    );
  });

  test("should render button states consistently", async ({
    page,
    browserName,
  }) => {
    // Click button to trigger hover/active state
    const button = page.locator("button:first-of-type");

    // Screenshot default state
    await expect(button).toHaveScreenshot(
      `button-default-${browserName}.png`
    );

    // Screenshot hover state
    await button.hover();
    await expect(button).toHaveScreenshot(
      `button-hover-${browserName}.png`
    );

    // Screenshot active state
    await button.click();
    await expect(button).toHaveScreenshot(
      `button-active-${browserName}.png`
    );
  });

  test("should render form inputs consistently", async ({
    page,
    browserName,
  }) => {
    // Navigate to form page
    await page.goto("http://localhost:5173/form");
    await page.waitForLoadState("networkidle");

    // Select form container
    const form = page.locator("form, [data-testid='task-form']");
    await form.waitFor({ state: "visible" });

    // Capture full form
    await expect(form).toHaveScreenshot(
      `form-${browserName}.png`,
      {
        maxDiffPixels: 150,  // Forms may have more pixel variance
        threshold: 0.3,
      }
    );

    // Test input focus state
    const input = form.locator("input:first-of-type");
    await input.focus();
    await expect(form).toHaveScreenshot(
      `form-input-focus-${browserName}.png`
    );
  });

  test("should render responsive layout (mobile)", async ({
    page,
    browserName,
  }) => {
    // Set mobile viewport
    await page.setViewportSize({ width: 375, height: 812 });

    // Navigate to main page
    await page.goto("http://localhost:5173");
    await page.waitForLoadState("networkidle");

    // Capture full page
    await expect(page).toHaveScreenshot(
      `mobile-layout-${browserName}.png`,
      {
        maxDiffPixels: 200,  // Mobile layouts have more variance
        threshold: 0.5,
      }
    );
  });

  test("should render responsive layout (tablet)", async ({
    page,
    browserName,
  }) => {
    // Set tablet viewport
    await page.setViewportSize({ width: 768, height: 1024 });

    // Navigate and capture
    await page.goto("http://localhost:5173");
    await page.waitForLoadState("networkidle");

    // Full page screenshot
    await expect(page).toHaveScreenshot(
      `tablet-layout-${browserName}.png`
    );
  });

  test("should render dark theme consistently", async ({
    page,
    browserName,
  }) => {
    // Apply dark theme
    await page.evaluate(() => {
      document.documentElement.setAttribute("data-theme", "dark");
    });

    // Wait for CSS transitions
    await page.waitForTimeout(300);

    // Capture with dark theme
    const main = page.locator("main, body");
    await expect(main).toHaveScreenshot(
      `dark-theme-${browserName}.png`,
      {
        maxDiffPixels: 80,
        threshold: 0.2,
      }
    );
  });

  test("should maintain color accuracy", async ({
    page,
    browserName,
  }) => {
    // Test specific color elements
    const accentButton = page.locator(
      "[data-testid='primary-button'], .btn-primary"
    );

    if (await accentButton.isVisible()) {
      await expect(accentButton).toHaveScreenshot(
        `color-accent-${browserName}.png`,
        {
          maxDiffPixels: 30,
          threshold: 0.1,  // Strict color matching
        }
      );
    }
  });
});

Bài Tập 3: Cấu Hình Playwright Config

Tạo playwright.config.ts:

typescript
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./test",
  testMatch: "**/*visual*.test.ts",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ["html"],
    ["json", { outputFile: "test-results/visual-regression.json" }],
  ],

  use: {
    baseURL: "http://localhost:5173",
    trace: "on-first-retry",
    screenshot: "only-on-failure",
  },

  webServer: {
    command: "npm run dev",
    url: "http://localhost:5173",
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },

  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
    {
      name: "Mobile Chrome",
      use: { ...devices["Pixel 5"] },
    },
  ],
});

Bài Tập 4: Capture & Update Golden Images

package.json scripts:

json
{
  "scripts": {
    "test:visual": "playwright test --config=playwright.config.ts",
    "test:visual:ui": "playwright test --ui",
    "test:visual:update": "playwright test --update-snapshots",
    "test:visual:report": "playwright show-report test-results/visual-regression.json"
  }
}

Bước 1: Capture baseline (golden images)

bash
# Chạy test lần đầu tiên → tạo golden images
npm run test:visual:update

# Output:
# ✓ chromium: 9 snapshots created
# ✓ firefox: 9 snapshots created
# ✓ webkit: 9 snapshots created
#
# ✓ All tests passed

Bước 2: Xác minh golden images

bash
# Folder structure:
test/
├── visual-regression.test.ts
└── visual-regression.test.ts-snapshots/
    ├── header-chromium-light-png
    ├── dashboard-chromium-png
    ├── button-default-chromium-png
    ├── form-chromium-png
    ├── mobile-layout-chromium-png
    └── ... (firefox, webkit variants)

Bước 3: Commit golden images

bash
git add test/visual-regression.test.ts-snapshots/
git commit -m "chore: add visual regression golden images"

Bài Tập 5: Phát Hiện & Fix Regressions

Scenario 1: CSS Change Intentional

bash
# 1. Modify CSS
# styles/button.css:
# .btn-primary { background: blue; }
# → change to: background: green;

npm run test:visual

# Output:
# ✗ button-default-chromium.png failed
# ✗ button-hover-chromium.png failed
# ✗ 2 failed out of 9
#
# Visual diff:
# Diff pixels: 1240
# Diff %: 0.65%
# Status: FAIL

# 2. Review diff images
# test-results/visual-regression/button-default-chromium-actual.png
# test-results/visual-regression/button-default-chromium-expected.png
# test-results/visual-regression/button-default-chromium-diff.png

# 3. Update golden if change is intentional
npm run test:visual:update

# 4. Commit changes
git add test/visual-regression.test.ts-snapshots/
git commit -m "feat: change primary button color to green"

Scenario 2: Unintended CSS Regression

bash
# 1. Bug: CSS typo
# styles/header.css:
# header { height: 64px; }
# → typo: height: 6px;  (should be 64px)

npm run test:visual

# Output:
# ✗ header-chromium-light.png failed
# Visual diff: 8.4%
# Status: FAIL

# 2. Inspect diff → discover header height issue
# test-results/visual-regression/header-chromium-light-diff.png
# → Shows header is 1/10th normal height!

# 3. Fix the bug
# styles/header.css: height: 64px;

# 4. Re-run tests
npm run test:visual

# Output:
# ✓ header-chromium-light.png passed
# ✓ All tests passed

# 5. No snapshot update needed (golden is correct)
git commit -m "fix: correct header height CSS typo"

Bài Tập 6: CI/CD Integration

.github/workflows/visual-regression.yml:

yaml
name: Visual Regression Tests

on:
  pull_request:
  push:
    branches: [main, develop]

jobs:
  visual-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Start dev server
        run: npm run dev &
        env:
          NODE_ENV: test

      - name: Wait for dev server
        run: npx wait-on http://localhost:5173 --timeout 60000

      - name: Run visual regression tests
        run: npm run test:visual
        env:
          CI: true

      - name: Upload visual test report
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: test-results/

      - name: Comment PR with results
        if: github.event_name == 'pull_request' && failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '❌ Visual regression tests failed. See artifacts for diff images.'
            })

      - name: Block merge if visual tests failed
        if: failure()
        run: exit 1

Workflow behavior:

  • Chạy trên mỗi PR + push
  • Install browsers
  • Start dev server
  • Run visual tests
  • Upload report → artifacts
  • Comment PR nếu fail
  • Block merge nếu fail

TỔNG KẾT

Key Points:

  1. Why Visual Tests: Logic pass ≠ UI correct (styling, layout, colors)
  2. Playwright Daemon: HTTP API browser automation, singleton instance
  3. Golden Images: Baseline screenshots lưu trong git
  4. Visual Diff: Pixel-perfect comparison, configurable thresholds
  5. Regression Detection: Auto-fail nếu diff > threshold
  6. Workflow: Capture golden → develop → test → update → commit
  7. CI/CD: Auto-run visual tests, block merge nếu regressions phát hiện
  8. cm-qa-visual-cli: Tích hợp công cụ, so sánh golden images

Tools Stack:

  • Playwright: Browser automation
  • cm-browse: Daemon server
  • cm-qa-visual: Screenshot comparison
  • Playwright test reporter: Visual diff reports

CÂU HỎI KIỂM TRA

  1. Tại sao? Tại sao unit tests pass nhưng UI có thể vẫn broken?
  2. Golden Image: Là gì và được lưu ở đâu?
  3. Daemon: cm browse daemon hoạt động thế nào?
  4. Diff Workflow: Khi phát hiện regression, 3 bước làm gì?
  5. Threshold: maxDiffPixels và threshold configuration ý nghĩa gì?
  6. CI/CD: Visual tests fail thì block merge như thế nào?
  7. Responsive: Làm sao test visual regression cho mobile + tablet?

BÀI TẬP VỀ NHÀ

  1. Setup Playwright: cài đặt packages + browsers
  2. Create visual tests: implement test/visual-regression.test.ts
  3. Capture golden: chạy tests --update-snapshots, commit golden images
  4. Simulate regression: CSS change → detect diff → review → fix
  5. CI/CD workflow: tạo visual-regression.yml, test trên PR
  6. Document thresholds: ghi lại maxDiffPixels + threshold cho từng test
  7. Advanced: add responsive tests (mobile 375px, tablet 768px, desktop)
  8. Report: generate Playwright HTML report, phân tích visual diffs

Powered by CodyMaster × VitePress