Resource library

QA How-To

How to Test dark mode in Playwright (2026)

Learn playwright how to test dark mode with colorScheme emulation, theme toggles, persistence, CSS assertions, screenshots, and accessible contrast checks.

18 min read | 3,551 words

TL;DR

Create a Playwright project with `colorScheme: "dark"` to test system preference, and separately test the application theme toggle and persistence. Assert semantic state and selected computed styles, then use stable screenshot comparisons for broad visual coverage.

Key Takeaways

  • Create a Playwright project with `colorScheme: "dark"` to test system preference, and separately test the application theme toggle and persistence. Assert semantic state and selected computed styles, then use stable screenshot comparisons for broad visual coverage.
  • Prefer web-first assertions over manual polling where possible.
  • Create event and response waits before the action that triggers them.
  • Use accessible locators and stable product contracts.
  • Keep loops and waits bounded for CI safety.
  • Retain traces and targeted artifacts for useful failure diagnosis.

Create a Playwright project with colorScheme: "dark" to test system preference, and separately test the application theme toggle and persistence. Assert semantic state and selected computed styles, then use stable screenshot comparisons for broad visual coverage. This guide answers playwright how to test dark mode with production-oriented Playwright Test patterns for TypeScript.

The examples focus on reliable synchronization, accessible locators, useful assertions, and diagnostics. Replace the illustrative URLs and accessible names with contracts from your application, while keeping the event ordering and assertion strategy intact.

TL;DR

Decision Recommended approach Why
colorScheme project Initial system preference Browser media feature
page.emulateMedia() Change preference inside a test Runtime media update
Application toggle User override and persistence Product behavior

The core rule is simple: observe the outcome that matters, start event waits before the triggering action, and let Playwright's locator assertions retry. Never repair uncertainty with a fixed sleep.

1. playwright how to test dark mode: Separate preference from application state

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

2. Configure a dark-mode project

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

3. Change color scheme at runtime

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

4. Test the user theme control

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

5. Verify persistence and precedence

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

6. Assert semantic theme state

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

7. Check critical computed styles

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

8. Use visual comparisons responsibly

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

9. Cover images icons and charts

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

10. Include accessibility checks

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

11. Debug environment differences

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

12. Apply playwright how to test dark mode

Testing dark mode, system preference, and theme persistence starts with an observable product contract. Write down the trigger, the state transition, and the evidence a user can see. A weak test proves that an automation command completed. A strong test proves that the application reached the intended state and remained usable. This distinction matters because Playwright's auto-waiting covers actionability, but it cannot decide what business outcome is correct.

For this part of the workflow, the useful Playwright surfaces include use: { colorScheme: "dark" }, page.emulateMedia(), toHaveCSS(), storage inspection, and toHaveScreenshot(). Choose the smallest API that expresses user intent, and pair it with a web-first assertion. Locators are evaluated again when Playwright retries, so prefer them over element handles or cached DOM values. Keep test data explicit, avoid arbitrary timeouts, and make every loop or network wait bounded. Those choices turn a demonstration into a test that can survive CI load and normal rendering variation.

Review the negative path as well as the happy path. Ask what happens when data arrives late, the control is disabled, navigation fails, or the user repeats the action. Assert only stable public behavior. Internal class names, framework-generated IDs, implementation-specific request timing, and exact pixel colors are poor primary contracts unless the product explicitly guarantees them. A failure should identify which contract broke, not merely report that a generic timeout expired.

Runnable TypeScript example

import { test, expect } from '@playwright/test';

test.use({ colorScheme: 'dark' });

test('uses dark theme and persists an explicit choice', async ({ page }) => {
  await page.goto('https://example.com/settings');
  await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
  await page.getByRole('button', { name: /theme/i }).click();
  await page.getByRole('menuitemradio', { name: 'Light' }).click();
  await page.reload();
  await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
});

test('dark page visual', async ({ page }) => {
  await page.goto('https://example.com');
  await expect(page).toHaveScreenshot('home-dark.png', { animations: 'disabled' });
});

Run the example with npx playwright test. For framework setup, review the Playwright TypeScript framework guide, then strengthen locator choices with Playwright locator best practices. Teams troubleshooting intermittent runs can also use the Playwright flaky test guide.

Interview Questions and Answers

Q: What is the first principle you would explain for dark mode, system preference, and theme persistence?

I would define an observable contract before selecting an API. The action alone is not proof. I would assert the state a user or downstream system depends on, using a locator or response that Playwright can retry safely.

Q: Why should a test avoid waitForTimeout()?

A fixed delay guesses how long the application needs. It wastes time when the UI is fast and still fails when CI is slower. I wait for a precise event, response, locator state, or polled condition with a meaningful timeout.

Q: How do you prevent races?

I create the promise for a relevant event or response before performing the action that can emit it. Then I await the action and the promise in a clear order. This prevents the application from completing before the listener exists.

Q: Which locators are most maintainable?

I prefer roles, accessible names, labels, and stable test IDs. These represent user-facing contracts and are less coupled to DOM structure. I use CSS only where it expresses a stable product contract.

Q: What belongs in a failure report?

The report should show the expected contract, actual state, relevant URL, and diagnostic artifacts. A retained trace is often more useful than a screenshot alone because it includes actions, DOM snapshots, console messages, and network activity.

Q: How would you make this coverage CI-safe?

I isolate data, bound waits and loops, avoid order dependence, and run against a controlled environment. I retain artifacts on failure and validate the same test under configured browser projects without adding browser-specific sleeps.

Common Mistakes

  • Waiting a fixed number of milliseconds instead of waiting for evidence.
  • Using brittle selectors tied to layout or generated classes.
  • Checking only that an action succeeded, without asserting business state.
  • Creating an event or response wait after the triggering action.
  • Allowing unbounded loops that can hang a worker.
  • Sharing mutable state between parallel tests.
  • Hiding a product defect with retries or broad exception handling.
  • Capturing secrets or customer data in traces and screenshots.

Treat each mistake as a review prompt. A stable suite is not one that never fails. It is one whose failures are reproducible, correctly classified, and rich enough to diagnose. Review helper abstractions periodically because a helper that hides event ownership or swallows assertions makes failures harder to understand.

Conclusion

The practical answer to playwright how to test dark mode is to use Playwright's supported APIs, synchronize on observable outcomes, and assert the product contract with retrying locators. The exact command matters, but event ordering, isolation, and a meaningful final assertion matter more.

Start with one representative happy-path test, add a negative or boundary case, and run both repeatedly in the same CI configuration used by the team. Once those tests are trustworthy, extract only the repeated mechanics into a small helper or fixture.

Interview Questions and Answers

How would you design this test?

I define the observable contract, perform the smallest user-level action, and assert the resulting state with a retrying locator. I keep waits bounded and attach them before their trigger. I also isolate test data and retain traces on failure so CI failures are actionable.

How do you avoid flaky synchronization?

I define the observable contract, perform the smallest user-level action, and assert the resulting state with a retrying locator. I keep waits bounded and attach them before their trigger. I also isolate test data and retain traces on failure so CI failures are actionable.

Which locator strategy would you choose?

I define the observable contract, perform the smallest user-level action, and assert the resulting state with a retrying locator. I keep waits bounded and attach them before their trigger. I also isolate test data and retain traces on failure so CI failures are actionable.

How do you handle negative paths?

I define the observable contract, perform the smallest user-level action, and assert the resulting state with a retrying locator. I keep waits bounded and attach them before their trigger. I also isolate test data and retain traces on failure so CI failures are actionable.

What diagnostics do you retain?

I define the observable contract, perform the smallest user-level action, and assert the resulting state with a retrying locator. I keep waits bounded and attach them before their trigger. I also isolate test data and retain traces on failure so CI failures are actionable.

How do you keep the test maintainable?

I define the observable contract, perform the smallest user-level action, and assert the resulting state with a retrying locator. I keep waits bounded and attach them before their trigger. I also isolate test data and retain traces on failure so CI failures are actionable.

Frequently Asked Questions

What is the recommended starting point for dark mode, system preference, and theme persistence?

Start from the user-visible contract and use a supported Playwright API with a web-first assertion. Avoid fixed sleeps, keep synchronization explicit, and retain a trace when the failure needs more context.

Should I use a fixed timeout for dark mode, system preference, and theme persistence?

Start from the user-visible contract and use a supported Playwright API with a web-first assertion. Avoid fixed sleeps, keep synchronization explicit, and retain a trace when the failure needs more context.

How should I choose locators for dark mode, system preference, and theme persistence?

Start from the user-visible contract and use a supported Playwright API with a web-first assertion. Avoid fixed sleeps, keep synchronization explicit, and retain a trace when the failure needs more context.

How do I debug a CI-only failure for dark mode, system preference, and theme persistence?

Start from the user-visible contract and use a supported Playwright API with a web-first assertion. Avoid fixed sleeps, keep synchronization explicit, and retain a trace when the failure needs more context.

Should I create a reusable helper for dark mode, system preference, and theme persistence?

Start from the user-visible contract and use a supported Playwright API with a web-first assertion. Avoid fixed sleeps, keep synchronization explicit, and retain a trace when the failure needs more context.

What should the final assertion prove for dark mode, system preference, and theme persistence?

Start from the user-visible contract and use a supported Playwright API with a web-first assertion. Avoid fixed sleeps, keep synchronization explicit, and retain a trace when the failure needs more context.

Related Guides