Danh sách bài viết

Bài 11: Option Fixture colorScheme

Bài 11 nhóm Fixtures - Options. colorScheme emulate OS color scheme preference ở tầng BrowserContext — ảnh hưởng trực tiếp CSS media query @media (prefers-color-scheme: dark) và JS API window.matchMedia('(prefers-color-scheme: dark)').matches. Values: 'light' | 'dark' | 'no-preference' | null (reset về OS). Config qua use: { colorScheme: 'dark' } trong playwright.config.ts hoặc test.use per-describe. Runtime emulate: await page.emulateMedia({ colorScheme: 'dark' }) — đổi mid-test không cần tạo context mới. Pattern nâng cao: multi-mode project (light + dark song song), test cả hai theme trong 1 test, test site auto-detect prefers-color-scheme. Phân biệt rõ colorScheme (CSS media level) với manual class toggle (app tự set class .dark vào <html>). 4 pitfall: site không support media query, state leak khi set 'no-preference', visual snapshot không pin colorScheme, nhầm CSS media với data-theme attribute. Bài Series 1 bài 327 đã cover syntax cơ bản và matchMedia API; bài này tập trung vào pattern nâng cao và tình huống thực tế.

27/05/2026
12 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

  • Hiểu colorScheme hoạ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 — toHaveCSS với giá trị computed.
  • Phân biệt colorScheme (media query level) với manual class toggle (class .dark trên <html>).
  • Nhận biết và tránh 4 pitfall phổ biến khi test với colorScheme.
2

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:

  1. Playwright set colorScheme: 'dark' ở context level.
  2. Browser report media feature prefers-color-scheme: dark.
  3. CSS engine evaluate @media (prefers-color-scheme: dark) { ... } → match → apply dark styles.
  4. JS window.matchMedia('(prefers-color-scheme: dark)').matchestrue.
  5. 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).

3

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)');
  });
});
4

Ả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ả truematchMedia('(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');
});
5

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.

6

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.

7

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);
});
8

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('年');
  });
});
9

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
10

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.

11

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.

12

Quiz + Bài Tiếp

Quiz

  1. Set colorScheme: 'dark' nhưng window.matchMedia('(prefers-color-scheme: dark)').matches trả true trong 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.

  2. Sau khi gọi await page.emulateMedia({ colorScheme: 'dark' }), có cần page.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.

  3. Config sau có vấn đề gì không? use: { colorScheme: 'dark' } trong playwright.config.ts, test file không set test.use gì thêm, visual snapshot test gọi toHaveScreenshot('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.png là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).

  4. Site dùng class .dark-mode trên <body> để toggle dark. Set test.use({ colorScheme: 'dark' }) có đủ để test dark UI không?

    Đáp án

    Không đủ. colorScheme chỉ ả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.

  5. Multi-mode project dùng hai project 'light''dark'. Test có câu await 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.pnghomepage-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.