Mục lục
- Mục Tiêu Bài Học
- Cơ Chế Hoạt Động — Tại Sao Site Phải Support
- Config Và 4 Values
- Ảnh Hưởng Lên CSS và JS
- Runtime:
page.emulateMedia - Pattern Multi-Mode Project
- Test Cả Hai Mode Trong 1 Test
- Test Site Auto-Detect
- Phân Biệt
colorSchemeVới Manual Class Toggle - Visual Snapshot Dark Mode
- 4 Pitfalls
- Quiz + Bài Tiếp
Mục Tiêu Bài Học
- Hiểu
colorSchemehoạt động ở tầng nào và tại sao phía site phải implement@media (prefers-color-scheme)thì option này mới có hiệu lực. - Nắm 4 values hợp lệ:
'light','dark','no-preference',null— ý nghĩa từng giá trị. - Dùng
page.emulateMedia({ colorScheme })để đổi theme runtime trong cùng một test. - Áp dụng pattern multi-mode project: chạy cùng test suite với cả light và dark song song.
- Assert CSS property dark mode đúng cách —
toHaveCSSvới giá trị computed. - Phân biệt
colorScheme(media query level) với manual class toggle (class.darktrên<html>). - Nhận biết và tránh 4 pitfall phổ biến khi test với
colorScheme.
Cơ Chế Hoạt Động — Tại Sao Site Phải Support
Option colorScheme hoạt động ở tầng browser emulation: Playwright set giá trị preference mà browser sẽ report ra web qua CSS media query và JS matchMedia API. Nó không trực tiếp thay đổi bất kỳ pixel nào — nó chỉ thay đổi câu trả lời của browser khi CSS hỏi "user thích dark hay light?".
Điều đó có nghĩa: nếu site không có CSS @media (prefers-color-scheme: dark) và không có JS check matchMedia, set colorScheme: 'dark' sẽ không làm gì cả. Site render y hệt.
Chuỗi hoạt động đúng:
- Playwright set
colorScheme: 'dark'ở context level. - Browser report media feature
prefers-color-scheme: dark. - CSS engine evaluate
@media (prefers-color-scheme: dark) { ... }→ match → apply dark styles. - JS
window.matchMedia('(prefers-color-scheme: dark)').matches→true. - App tự đổi giao diện nếu nó implement logic theo hai signal trên.
Không giống geolocation hay permissions, colorScheme là emulation thuần — không có API call nào cần intercept, không có permission dialog nào cần dismiss. Toàn bộ hiệu ứng xảy ra ở layer CSS/JS rendering.
Playwright emulate ở context level
Khi đặt trong use của config, colorScheme được set khi context được tạo:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
use: {
colorScheme: 'dark',
},
});
Sau khi context tạo xong, mọi page trong context đó bắt đầu với dark preference. Khác geolocation, context không có method setColorScheme() — thay vào đó, dùng page.emulateMedia() để đổi runtime (xem Bước 5).
Config Và 4 Values
TypeScript type của colorScheme:
type ColorScheme = 'light' | 'dark' | 'no-preference' | null;
'light'
Default của Playwright khi không set. Browser report light preference. @media (prefers-color-scheme: light) match, @media (prefers-color-scheme: dark) không match.
'dark'
Browser report dark preference. @media (prefers-color-scheme: dark) match. Đây là value cần set để test dark mode UI.
'no-preference'
Browser không expose preference — cả light lẫn dark media query đều không match. Giá trị này từng là một option hợp lệ trong CSS Media Queries Level 5 nhưng đã bị loại khỏi spec hiện tại. Chrome 99+, Firefox 110+, Safari 17+ không expose no-preference nữa — behavior có thể tùy browser version. Trong thực tế ít khi cần set giá trị này.
null
Reset về default — browser dùng OS preference của host. Chỉ có tác dụng khi dùng với page.emulateMedia() để hủy emulation đang active:
// Xóa emulation, browser đọc OS pref thật
await page.emulateMedia({ colorScheme: null });
Config trong test.use
// Áp cho toàn file
test.use({ colorScheme: 'dark' });
// Áp cho một describe block
test.describe('Dark mode UI', () => {
test.use({ colorScheme: 'dark' });
test('body background tối', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
});
});
Ảnh Hưởng Lên CSS và JS
CSS media query
Khi browser report 'dark', CSS engine evaluate @media (prefers-color-scheme: dark) → match → apply block tương ứng:
/* CSS variables pattern phổ biến */
:root {
--bg: #ffffff;
--text: #1a1a1a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14141e;
--text: #f0f0f0;
}
}
body {
background-color: var(--bg);
color: var(--text);
}
Test assert CSS computed value sau khi emulate:
test.use({ colorScheme: 'dark' });
test('dark mode CSS variables apply đúng', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
await expect(page.locator('body')).toHaveCSS('color', 'rgb(240, 240, 240)');
});
Giá trị truyền vào toHaveCSS phải là computed value dạng rgb(), không phải hex. Playwright resolve CSS variables trước khi compare.
JS matchMedia
App có thể check preference qua JS:
test.use({ colorScheme: 'dark' });
test('matchMedia dark trả true', async ({ page }) => {
await page.goto('/');
const isDark = await page.evaluate(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
);
expect(isDark).toBe(true);
});
Khi colorScheme: 'dark', matchMedia('(prefers-color-scheme: dark)').matches trả true và matchMedia('(prefers-color-scheme: light)').matches trả false. Ngược lại khi 'light'.
picture element với media attribute
Site dùng <picture> để serve ảnh khác theo theme — Playwright emulation hoạt động đúng:
<picture>
<source srcset="logo-dark.webp" media="(prefers-color-scheme: dark)">
<img src="logo-light.webp" alt="Logo">
</picture>
test.use({ colorScheme: 'dark' });
test('logo dark version load khi dark mode', async ({ page }) => {
await page.goto('/');
const src = await page.locator('picture img').getAttribute('currentSrc');
expect(src).toContain('logo-dark.webp');
});
Runtime: page.emulateMedia
page.emulateMedia() đổi colorScheme giữa test mà không cần tạo context mới. Sau khi gọi, CSS media query tự re-evaluate, JS change listener tự fire — không cần reload page.
await page.emulateMedia({ colorScheme: 'dark' });
await page.emulateMedia({ colorScheme: 'light' });
await page.emulateMedia({ colorScheme: null }); // reset về OS default
Method còn nhận các option khác cùng lúc:
await page.emulateMedia({
colorScheme: 'dark',
reducedMotion: 'reduce',
forcedColors: 'none',
});
Scope: emulateMedia áp dụng cho page gọi, không lan sang page khác trong cùng context. Nếu test dùng nhiều tab, mỗi page cần set riêng.
Detect JS change listener
App có thể listen matchMedia('change') để update theme runtime. emulateMedia trigger đúng event này:
test('App phản ứng khi preference đổi runtime', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
// App init với light
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
// Simulate OS switch sang dark
await page.emulateMedia({ colorScheme: 'dark' });
// App change listener fire, update data-theme
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
});
Lưu ý: test trên chỉ pass nếu app có logic listen matchMedia change và dùng data-theme attribute. Nếu app chỉ dùng CSS media query thuần (không JS), CSS tự đổi không cần data-theme.
Pattern Multi-Mode Project
Khi muốn toàn bộ test suite chạy với cả light và dark, định nghĩa hai project trong config:
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'light',
use: {
...devices['Desktop Chrome'],
colorScheme: 'light',
},
},
{
name: 'dark',
use: {
...devices['Desktop Chrome'],
colorScheme: 'dark',
},
},
],
});
Mỗi lần chạy npx playwright test, toàn bộ test spec chạy hai lần — một với light, một với dark. Test nào fail ở dark mode sẽ hiển thị dưới project dark trong HTML report.
Assert theo project hiện tại
Trong test file cần chia assertion theo theme, dùng testInfo.project.name:
import { test, expect } from '@playwright/test';
test('background đúng theme', async ({ page }, testInfo) => {
await page.goto('/');
const expectedBg = testInfo.project.name === 'dark'
? 'rgb(20, 20, 30)'
: 'rgb(255, 255, 255)';
await expect(page.locator('body')).toHaveCSS('background-color', expectedBg);
});
Chỉ chạy một project
npx playwright test --project=dark
npx playwright test --project=light
Khi nào dùng pattern này
Multi-mode project phù hợp khi app có logic JS theo theme (load asset khác, render component khác, gọi API khác) và cần đảm bảo tất cả test functional pass ở cả hai theme. Nếu dark mode chỉ là cosmetic (chỉ đổi màu CSS, layout giống nhau), chạy visual snapshot riêng cho dark đủ — không cần duplicate toàn bộ functional suite.
Test Cả Hai Mode Trong 1 Test
Khi cần verify site đổi màu đúng khi chuyển từ light sang dark, dùng emulateMedia để switch trong cùng một test:
test('dark mode toggle', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
await page.emulateMedia({ colorScheme: 'dark' });
await page.reload();
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(0, 0, 0)');
});
Lưu ý page.reload() sau khi switch: một số site chỉ apply prefers-color-scheme khi load — JS init code chạy một lần tại DOMContentLoaded. Nếu site listen matchMedia change event, reload là không cần thiết — CSS tự cập nhật ngay. Cần biết cơ chế app dùng để quyết định có reload hay không.
Verify cả CSS và JS signal
test('dark signal nhất quán CSS và JS', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
// CSS apply
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
// JS matchMedia nhất quán
const isDark = await page.evaluate(() =>
window.matchMedia('(prefers-color-scheme: dark)').matches
);
expect(isDark).toBe(true);
});
Test Site Auto-Detect
Site có tính năng "follow OS theme" — tự switch theme khi detect preference. Test flow: set preference trước goto, verify site apply đúng theme ngay khi load.
test.describe('Auto-detect OS theme', () => {
test('light OS → site load với light theme', async ({ browser }) => {
const ctx = await browser.newContext({ colorScheme: 'light' });
const page = await ctx.newPage();
await page.goto('/');
// Site detect light → không apply dark CSS
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(255, 255, 255)');
// Hoặc kiểm tra data attribute nếu app dùng
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
await ctx.close();
});
test('dark OS → site load với dark theme', async ({ browser }) => {
const ctx = await browser.newContext({ colorScheme: 'dark' });
const page = await ctx.newPage();
await page.goto('/');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
await ctx.close();
});
});
Pattern này dùng browser.newContext() trực tiếp thay vì test.use để kiểm soát explicit. Hữu ích khi cần tạo nhiều context với preference khác nhau trong cùng một test file mà không cần nhiều describe block.
Kết hợp với locale và timezone
colorScheme kết hợp tốt với các fixture khác (bài 9) để simulate user environment đầy đủ:
test.describe('User Nhật Bản — dark mode', () => {
test.use({
colorScheme: 'dark',
locale: 'ja-JP',
timezoneId: 'Asia/Tokyo',
});
test('dashboard hiển thị đúng dark + locale Nhật', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
// Date format theo ja-JP locale
await expect(page.locator('[data-testid="date"]')).toContainText('年');
});
});
Phân Biệt colorScheme Với Manual Class Toggle
Có hai cách site implement dark mode — cách site dùng quyết định cách test:
Cách 1: CSS media query (colorScheme hoạt động)
Site dùng @media (prefers-color-scheme: dark) trong CSS. Set colorScheme: 'dark' → CSS apply ngay.
@media (prefers-color-scheme: dark) {
body { background: #14141e; }
}
// Test đúng cách
test.use({ colorScheme: 'dark' });
test('dark background', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
});
Cách 2: Class toggle (colorScheme KHÔNG có hiệu lực)
Site dùng class .dark trên <html> hoặc <body>, lưu preference vào localStorage, toggle qua nút button:
/* CSS không dùng media query */
.dark body {
background: #14141e;
}
// JS app — toggle theo button click, không theo matchMedia
document.querySelector('.theme-toggle').addEventListener('click', () => {
document.documentElement.classList.toggle('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
Set colorScheme: 'dark' không có tác dụng gì với site dạng này — class .dark không tự thêm vào. Để test dark mode của site kiểu này, phải click button toggle hoặc inject class qua page.evaluate:
test('dark mode qua class toggle', async ({ page }) => {
await page.goto('/');
// Inject class trực tiếp (hoặc click toggle button)
await page.evaluate(() => {
document.documentElement.classList.add('dark');
});
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
});
Hoặc nếu site đọc localStorage khi load:
test('dark mode qua localStorage', async ({ page }) => {
// Set trước khi load page
await page.addInitScript(() => {
localStorage.setItem('theme', 'dark');
});
await page.goto('/');
// Site đọc localStorage → add class .dark → dark CSS apply
await expect(page.locator('html')).toHaveClass(/dark/);
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)');
});
Bảng so sánh nhanh
| Cách site implement | colorScheme fixture | Cách test |
|---|---|---|
CSS @media (prefers-color-scheme) |
Hoạt động | test.use({ colorScheme: 'dark' }) |
Class .dark toggle + localStorage |
Không có hiệu lực | addInitScript set localStorage, hoặc click toggle button |
| Kết hợp cả hai | Chỉ ảnh hưởng phần media query | Set cả colorScheme lẫn localStorage |
Visual Snapshot Dark Mode
Visual regression snapshot cần pin colorScheme explicit — không để theo OS của máy chạy test. Nếu không pin, snapshot sẽ khác nhau giữa CI (thường light) và máy dev (có thể dark).
Pattern chuẩn: tạo snapshot riêng cho mỗi theme với tên khác nhau.
test('homepage snapshot — light', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-light.png');
});
test('homepage snapshot — dark', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-dark.png');
});
Hai snapshot này độc lập nhau — baseline light và baseline dark được update riêng. Regression test sẽ detect nếu một trong hai thay đổi ngoài dự kiến.
Kết hợp với multi-mode project
Khi dùng multi-mode project (light + dark), toHaveScreenshot tự gắn tên project vào tên file — không cần đặt tên riêng:
test('homepage snapshot', async ({ page }) => {
await page.goto('/');
// Sẽ tạo: homepage-snapshot-1-chromium-light.png và homepage-snapshot-1-chromium-dark.png
await expect(page).toHaveScreenshot();
});
Playwright tự thêm project name vào snapshot path — hai project tạo hai baseline tách biệt.
4 Pitfalls
Pitfall 1 — Site không support media query
Set colorScheme: 'dark' nhưng site không có CSS @media (prefers-color-scheme: dark). Test assert dark background sẽ fail vì site render giống hệt light mode.
// Set đúng syntax nhưng site không support → test luôn fail
test.use({ colorScheme: 'dark' });
test('dark background — có thể fail nếu site không support media query', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)'); // FAIL
});
Khi debug: dùng page.evaluate(() => window.matchMedia('(prefers-color-scheme: dark)').matches) để xác nhận emulation đang active. Nếu trả true mà UI không đổi → site không implement media query.
Pitfall 2 — State leak: 'no-preference' không reset sau test
Khi một test set 'no-preference' qua emulateMedia mà không reset về null, test kế tiếp trong cùng context có thể nhận state đó. Mỗi test Playwright dùng context riêng nên thường không xảy ra — nhưng nếu test tự tạo page và tái dùng, cần chú ý:
test('test A — set no-preference', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'no-preference' });
await page.goto('/');
// ...
// Quên reset: await page.emulateMedia({ colorScheme: null });
});
// Nếu page được reuse (không phải context mới), test B nhận no-preference
test('test B — expect dark', async ({ page }) => {
// Không set colorScheme → nhận default của context
// Nếu context default là 'dark' nhưng bị override sang 'no-preference', test fail
});
Rule: khi dùng emulateMedia trong test, reset về null trước khi kết thúc nếu test tái dùng page object.
Pitfall 3 — Visual snapshot không pin colorScheme → flaky
// KHÔNG pin colorScheme → kết quả phụ thuộc OS của máy chạy test
test('snapshot homepage', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('homepage.png'); // Flaky
});
// Đúng: pin explicit
test('snapshot homepage', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'light' }); // Pin rõ ràng
await page.goto('/');
await expect(page).toHaveScreenshot('homepage-light.png');
});
CI server thường default light OS, nhưng developer local đang dùng dark macOS. Không pin → snapshot diff mỗi khi dev commit từ máy dark.
Pitfall 4 — Nhầm CSS media query với data-theme attribute
Site dùng JavaScript để set data-theme="dark" attribute, và CSS target [data-theme="dark"] — KHÔNG phải media query:
/* Site này dùng data attribute, không dùng media query */
[data-theme="dark"] body {
background: #14141e;
}
// Playwright colorScheme không làm gì với site dùng data attribute
test.use({ colorScheme: 'dark' }); // Không có tác dụng
test('dark mode — sẽ fail', async ({ page }) => {
await page.goto('/');
// data-theme vẫn là 'light' vì site dùng localStorage / user preference
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(20, 20, 30)'); // FAIL
});
Cách phân biệt nhanh: inspect CSS của site. Nếu thấy @media (prefers-color-scheme: dark) → colorScheme fixture hoạt động. Nếu thấy .dark, [data-theme="dark"], [class*="dark"] → phải dùng class/attribute injection hoặc click toggle button.
Quiz + Bài Tiếp
Quiz
-
Set
colorScheme: 'dark'nhưngwindow.matchMedia('(prefers-color-scheme: dark)').matchestrảtruetrong khi UI vẫn sáng. Nguyên nhân khả năng cao nhất là gì?Đáp án
Emulation đang hoạt động đúng (matchMedia trả true), nhưng site không implement
@media (prefers-color-scheme: dark)trong CSS. Site có thể dùng class toggle hoặc data attribute thay vì media query. -
Sau khi gọi
await page.emulateMedia({ colorScheme: 'dark' }), có cầnpage.reload()để CSS dark mode apply không?Đáp án
Không cần reload nếu site dùng CSS media query hoặc JS matchMedia change listener — CSS engine tự re-evaluate và listener tự fire. Reload cần thiết nếu site chỉ đọc preference một lần tại DOMContentLoaded mà không listen change event.
-
Config sau có vấn đề gì không?
use: { colorScheme: 'dark' }trongplaywright.config.ts, test file không settest.usegì thêm, visual snapshot test gọitoHaveScreenshot('page.png').Đáp án
Config đúng về syntax. Vấn đề: tên snapshot không phân biệt theme. Nếu sau này thêm project light, cả hai project sẽ cố gắng dùng cùng file
page.pnglàm baseline — conflict. Nên đặt tên có theme (page-dark.png) hoặc dùng multi-project (Playwright tự thêm project name vào path). -
Site dùng class
.dark-modetrên<body>để toggle dark. Settest.use({ colorScheme: 'dark' })có đủ để test dark UI không?Đáp án
Không đủ.
colorSchemechỉ ảnh hưởng CSS media query và JS matchMedia. Site dùng class toggle → phải inject class vào DOM (page.evaluate(() => document.body.classList.add('dark-mode'))) hoặc click nút toggle, hoặc set localStorage trước khi load. -
Multi-mode project dùng hai project
'light'và'dark'. Test có câuawait expect(page).toHaveScreenshot()không truyền tên file. Playwright lưu baseline ở đâu, tên file như thế nào?Đáp án
Playwright tự tạo tên file gồm: test name + số thứ tự + platform + project name + OS. Ví dụ:
homepage-snapshot-1-chromium-light.pngvàhomepage-snapshot-1-chromium-dark.png. Hai project tạo hai file baseline riêng trong folder__snapshots__tương ứng.
Bài Tiếp
Bài 12: Option Fixture storageState — persist auth state, cookies, localStorage giữa các test để tránh re-login.
