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 đượcVisual 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 diff2. 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ừ testsHTTP API Endpoints:
| Endpoint | Method | Mục Đích |
|---|---|---|
/session/start | POST | Tạo browser session mới |
/navigate | POST | Navigate đến URL |
/screenshot | GET | Chụp screenshot |
/console | GET | Đọc console logs |
/evaluate | POST | Chạy JS trong page |
/click | POST | Click 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 = 04. 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 imageThreshold 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: PASSWorkflow 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 / FAILTHỰ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/testBướ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.0Bà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 passedBướ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 1Workflow 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:
- Why Visual Tests: Logic pass ≠ UI correct (styling, layout, colors)
- Playwright Daemon: HTTP API browser automation, singleton instance
- Golden Images: Baseline screenshots lưu trong git
- Visual Diff: Pixel-perfect comparison, configurable thresholds
- Regression Detection: Auto-fail nếu diff > threshold
- Workflow: Capture golden → develop → test → update → commit
- CI/CD: Auto-run visual tests, block merge nếu regressions phát hiện
- 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
- Tại sao? Tại sao unit tests pass nhưng UI có thể vẫn broken?
- Golden Image: Là gì và được lưu ở đâu?
- Daemon: cm browse daemon hoạt động thế nào?
- Diff Workflow: Khi phát hiện regression, 3 bước làm gì?
- Threshold: maxDiffPixels và threshold configuration ý nghĩa gì?
- CI/CD: Visual tests fail thì block merge như thế nào?
- Responsive: Làm sao test visual regression cho mobile + tablet?
BÀI TẬP VỀ NHÀ
- Setup Playwright: cài đặt packages + browsers
- Create visual tests: implement test/visual-regression.test.ts
- Capture golden: chạy tests --update-snapshots, commit golden images
- Simulate regression: CSS change → detect diff → review → fix
- CI/CD workflow: tạo visual-regression.yml, test trên PR
- Document thresholds: ghi lại maxDiffPixels + threshold cho từng test
- Advanced: add responsive tests (mobile 375px, tablet 768px, desktop)
- Report: generate Playwright HTML report, phân tích visual diffs