Resource library

QA How-To

How to Test dark mode in Cypress (2026)

Learn cypress how to test dark mode with theme toggles, localStorage, matchMedia stubs, durable data-theme assertions, and dual-theme critical journeys.

22 min read | 2,478 words

TL;DR

Set theme with the UI toggle or localStorage in onBeforeLoad, stub matchMedia for system dark preference, assert html data-theme or class markers, then exercise a critical flow. Do not double the entire suite by theme.

Key Takeaways

  • Drive theme via the real toggle, stored preference, or matchMedia for system mode.
  • Assert durable html class or data-theme markers before overfitting RGB values.
  • Set localStorage in onBeforeLoad so the app boots already in the target theme.
  • Stub matchMedia early with a complete MediaQueryList shape for auto theme.
  • Dual-run only critical contrast-sensitive journeys to control CI cost.
  • Cover modals, forms, and error states where dark contrast often fails.
  • Separate visual baselines per theme from ordinary failure screenshots.

The practical answer to cypress how to test dark mode is to drive the same inputs users and systems use: a theme toggle, a persisted preference such as localStorage, and optionally prefers-color-scheme. Then assert document markers (class, data-theme), critical computed styles, and key user flows in both themes. Dark mode tests fail when they only screenshot colors without checking the application state that selects the palette.

This guide covers theme strategies, stubbing matchMedia, toggling UI controls, persistence across reloads, contrast-sensitive assertions, visual checks without false pixel noise, and how to keep dual-theme coverage maintainable.

TL;DR

Theme source How to control in Cypress Primary assertion
In-app toggle click sun/moon control data-theme or class on html
localStorage / cookie set before cy.visit theme restored on load
OS prefers-color-scheme stub matchMedia in onBeforeLoad auto theme applied
URL or feature flag visit with param / intercept flag correct branch theme

Test behavior and durable markers first. Treat full-page visual diffs as optional reinforcement.

1. Cypress How to Test Dark Mode: Understand Theme Architecture

Before writing commands, read how the app chooses theme:

  1. Class strategy: document.documentElement.classList.add('dark').
  2. Data attribute: <html data-theme="dark">.
  3. CSS variables switched by the markers above.
  4. Component library tokens (MUI, Chakra, Tailwind dark:) bound to those markers.
  5. Auto mode that listens to prefers-color-scheme until the user overrides.
// discovery assertion in a spike
cy.visit('/')
cy.document().its('documentElement.className').then(cy.log)
cy.get('html').invoke('attr', 'data-theme').then(cy.log)

Your tests should set and read the same marker the CSS uses. Asserting a single button color without checking the root marker is brittle when a component hard-codes a color.

2. Toggle Theme From the User Interface

The highest fidelity path clicks the real control.

it('switches to dark mode from the header toggle', () => {
  cy.visit('/dashboard')
  cy.get('html').should('not.have.attr', 'data-theme', 'dark')

  cy.get('[data-cy=theme-toggle]').click()
  cy.get('html').should('have.attr', 'data-theme', 'dark')
  cy.get('[data-cy=theme-toggle]').should(
    'have.attr',
    'aria-pressed',
    'true',
  )
})

If the control is a menu:

cy.get('[data-cy=user-menu]').click()
cy.get('[data-cy=theme-dark]').click()
cy.get('html').should('have.class', 'dark')

Assert accessible names and pressed state when the control exposes them. Theme toggles are easy to regress for keyboard and screen reader users.

Also verify critical surfaces still work after toggle: navigation, forms, and modals. Theme bugs often show as white text on white inputs in one mode only.

cy.get('[data-cy=theme-toggle]').click()
cy.get('[data-cy=new-project]').click()
cy.get('[role=dialog]').should('be.visible')
cy.get('[data-cy=project-name]').type('Dark mode project')

3. Persist Theme With localStorage and Cookies

Most apps persist the user choice. Tests should set storage before load when they need a stable starting theme without clicking.

it('starts in dark mode when preference is stored', () => {
  cy.visit('/dashboard', {
    onBeforeLoad(win) {
      win.localStorage.setItem('theme', 'dark')
    },
  })
  cy.get('html').should('have.attr', 'data-theme', 'dark')
})

Alternatively:

cy.clearLocalStorage()
// cy.session can also wrap login + theme for speed

Round-trip persistence:

it('remembers dark mode after reload', () => {
  cy.visit('/settings/appearance')
  cy.get('[data-cy=theme-dark]').click()
  cy.get('html').should('have.attr', 'data-theme', 'dark')
  cy.reload()
  cy.get('html').should('have.attr', 'data-theme', 'dark')
  cy.window()
    .its('localStorage')
    .invoke('getItem', 'theme')
    .should('eq', 'dark')
})

Match the real key and serialization. Some apps store JSON like {"mode":"dark"}. Do not invent keys; read the implementation or application docs.

For session-heavy setups, see Cypress cy.session when login plus theme bootstrap is expensive.

4. Stub prefers-color-scheme With matchMedia

Auto theme uses window.matchMedia('(prefers-color-scheme: dark)'). Stub it early in onBeforeLoad so the app reads the fake preference during bootstrap.

function stubPrefersColorScheme(
  win: Window,
  scheme: 'light' | 'dark',
) {
  cy.stub(win, 'matchMedia').callsFake((query: string) => {
    const darkQuery = '(prefers-color-scheme: dark)'
    const matches =
      query.includes('prefers-color-scheme: dark') && scheme === 'dark'
        ? true
        : query.includes('prefers-color-scheme: light') && scheme === 'light'
          ? true
          : win.document.defaultView
            ? // fall back: non-theme queries can use a minimal MediaQueryList shape
              false
            : false

    return {
      matches:
        scheme === 'dark'
          ? query.includes('prefers-color-scheme: dark')
          : query.includes('prefers-color-scheme: light')
            ? query.includes('light')
            : false,
      media: query,
      onchange: null,
      addListener: () => {},
      removeListener: () => {},
      addEventListener: () => {},
      removeEventListener: () => {},
      dispatchEvent: () => false,
    } as MediaQueryList
  })
}

it('uses OS dark preference in auto mode', () => {
  cy.visit('/dashboard', {
    onBeforeLoad(win) {
      win.localStorage.setItem('theme', 'system')
      // implement matches carefully for your app queries
      cy.stub(win, 'matchMedia').callsFake((query: string) => ({
        matches: query === '(prefers-color-scheme: dark)',
        media: query,
        onchange: null,
        addListener: () => {},
        removeListener: () => {},
        addEventListener: () => {},
        removeEventListener: () => {},
        dispatchEvent: () => false,
      }))
    },
  })

  cy.get('html').should('have.attr', 'data-theme', 'dark')
})

Notes:

  1. Stub before application code reads matchMedia.
  2. Implement the MediaQueryList shape your app needs (addEventListener for modern code).
  3. If the app caches the first result, reload tests with the stub in place rather than changing scheme mid-flight without events.

Some teams prefer launching the browser with dark color scheme emulation via Cypress browser launch options or DevTools-like emulation where supported. Prefer the approach that matches your Cypress and browser versions; matchMedia stubbing remains widely used and explicit in specs.

5. Assert Styles Without Overfitting Pixels

Root markers are stable. Exact hex channels can be unstable across browsers.

// durable
cy.get('html').should('have.class', 'dark')

// sometimes useful
cy.get('[data-cy=app-shell]').should(
  'have.css',
  'background-color',
  'rgb(15, 23, 42)',
)

Computed colors return RGB(A) strings in browsers, not always the hex from your CSS source. Prefer CSS variables when you assert design tokens:

cy.get('html').should('have.attr', 'data-theme', 'dark')
cy.get('[data-cy=app-shell]')
  .invoke('css', 'background-color')
  .should('be.a', 'string')

If you assert RGB, take values from the running app once, do not invent them. Prefer fewer style assertions on critical shells over dozens of per-component color checks.

For true visual regression, use a visual testing approach with theme-specific baselines. See Cypress visual testing. Keep failure screenshots separate from baselines (screenshot on failure patterns).

6. Cypress How to Test Dark Mode Across Key User Journeys

Do not only open the settings page. Theme bugs appear on:

  • login and error states
  • tables with hover and selected rows
  • charts and canvas
  • modals and drawers
  • toasts and alerts
  • disabled inputs
  • code blocks and markdown
  • maps and third-party embeds

Sample matrix (keep small):

Flow Light Dark
Login validation yes yes
Dashboard widgets yes smoke
Invoice modal yes yes
Marketing landing yes optional
const themes = ['light', 'dark'] as const

themes.forEach((theme) => {
  it(`submits login validation errors in ${theme} mode`, () => {
    cy.visit('/login', {
      onBeforeLoad(win) {
        win.localStorage.setItem('theme', theme)
      },
    })
    cy.get('html').should('have.attr', 'data-theme', theme)
    cy.get('[data-cy=submit-login]').click()
    cy.get('[data-cy=email-error]').should('be.visible')
  })
})

Parameterize carefully. Doubling every E2E test is expensive. Pick revenue-critical and high-contrast-risk screens for dual-theme coverage.

7. Images, Illustrations, and Theme-Aware Assets

Dark mode may swap logos and illustrations (src changes or CSS filters). Assert the contract your product defines.

it('uses the light logo asset in dark mode for contrast', () => {
  cy.visit('/', {
    onBeforeLoad(win) {
      win.localStorage.setItem('theme', 'dark')
    },
  })
  cy.get('[data-cy=brand-logo]')
    .should('have.attr', 'src')
    .and('include', 'logo-light')
})

If assets are CSS background images, assert the root theme and a smoke visual rather than brittle full URL strings with cache busters.

Third-party widgets that ignore theme are product risks. Decide whether E2E should only open the page and assert the shell, or whether you mock the widget.

8. Flash of Incorrect Theme (FOIT/FOUC Style Issues)

A common bug: page loads light, then flips dark after hydration. Users notice a flash. Tests can catch a coarse version by reading the marker as early as practical.

it('applies dark class before interactive UI appears', () => {
  cy.visit('/dashboard', {
    onBeforeLoad(win) {
      win.localStorage.setItem('theme', 'dark')
    },
  })
  cy.get('html.dark, html[data-theme=dark]').should('exist')
  cy.get('[data-cy=dashboard-ready]').should('be.visible')
})

Preventing flash is often implemented with an inline script in index.html that reads storage before paint. E2E may not catch a 50ms flash reliably; combine with component or manual checks. Still, wrong theme after ready is fully testable.

9. Charts, Canvas, and Third-Party Components

Canvas charts often set colors in JavaScript config, not CSS. Theme toggle must rebuild the chart. Assert via:

  1. Theme marker.
  2. Chart container visibility.
  3. Optionally stubbed config if exposed.
  4. Visual snapshot of the chart region in dedicated visual tests.
cy.get('[data-cy=theme-toggle]').click()
cy.get('[data-cy=revenue-chart]').should('be.visible')
// optional intentional screenshot for human review
cy.get('[data-cy=revenue-chart]').screenshot(`revenue-chart-dark`)

Do not parse canvas pixels in raw Cypress for routine CI unless you have a visual engine. Keep expectations honest.

10. Accessibility and Contrast Considerations

Dark mode is not automatically accessible. E2E is a weak full contrast oracle, but you can still:

  1. Run axe or similar on key pages in both themes if your stack supports it.
  2. Assert focus outlines remain visible (not pure outline: none regressions).
  3. Check error text is visible against dark backgrounds by presence and contrast tooling.
// illustrative: if cypress-axe is installed in your project
// cy.injectAxe()
// cy.checkA11y()

Only use libraries you actually install. If you do not have axe wired, do not invent commands. Prefer documenting a manual contrast pass plus a few dual-theme functional tests.

Keyboard theme toggle:

cy.get('[data-cy=theme-toggle]').focus().type('{enter}')
cy.get('html').should('have.attr', 'data-theme', 'dark')

11. CI Strategy: When to Run Dark Mode Specs

Options:

Strategy Pros Cons
Always dual-theme on critical paths high confidence longer CI
Nightly full theme matrix broad coverage slower feedback
Dark-only smoke job catches theme-unique bugs can miss light regressions
Visual baselines per theme strong UI signal maintenance cost

A balanced default: functional tests default to light (or team default), plus a small theme.cy.ts that proves toggle, persistence, system preference, and 2-3 critical screens in dark mode.

Keep artifacts: when dark mode fails, screenshots are especially helpful because style bugs are visual. Ensure CI uploads them.

12. Team Conventions and Anti-Brittleness Rules

  1. One source of truth for the storage key and marker attribute.
  2. Test helpers: setTheme('dark') used by specs.
  3. No random RGB assertions across the whole design system in E2E.
  4. Dual-theme coverage listed in a short inventory, not accidental duplication.
  5. Design tokens reviewed when dark palette changes.
  6. Third-party embeds tracked as known limitations.
// cypress/support/theme.ts
export const visitWithTheme = (
  path: string,
  theme: 'light' | 'dark' | 'system',
) => {
  cy.visit(path, {
    onBeforeLoad(win) {
      win.localStorage.setItem('theme', theme)
    },
  })
  if (theme !== 'system') {
    cy.get('html').should('have.attr', 'data-theme', theme)
  }
}

Small helpers beat copy-paste onBeforeLoad blocks.

13. Server-Driven Themes, Multi-Tenant Branding, and Feature Flags

Some products store theme on the user profile in the API rather than only in localStorage. Tests must seed the profile or intercept the session payload.

cy.intercept('GET', '/api/me', {
  body: {
    id: 'u_1',
    preferences: { theme: 'dark' },
  },
}).as('me')

cy.visit('/dashboard')
cy.wait('@me')
cy.get('html').should('have.attr', 'data-theme', 'dark')

Multi-tenant branding may force a light header with partner colors even when the app shell is dark. Assert the shell and the branded region separately so a forced light logo does not fail a dark shell test.

Feature flags can gate dark mode entirely. If the flag is off, the toggle should be absent and the marker should stay light.

cy.intercept('GET', '/api/flags', {
  body: { darkMode: false },
}).as('flags')
cy.visit('/settings/appearance')
cy.wait('@flags')
cy.get('[data-cy=theme-toggle]').should('not.exist')

When flags flip mid-session, decide whether tests should hard-reload. Mid-session theme migrations are rare and often better covered manually or in unit tests of the flag subscription.

14. Print Styles, Embedded Views, and Email Previews

Dark app chrome does not mean print output should be dark. If users print invoices, assert that print-specific CSS still yields a readable light document, or that the product offers a print stylesheet independent of theme.

cy.visit('/invoices/inv_1', {
  onBeforeLoad(win) {
    win.localStorage.setItem('theme', 'dark')
  },
})
cy.get('html').should('have.attr', 'data-theme', 'dark')
// print CSS verification is limited in E2E; smoke that print view opens
cy.get('[data-cy=print-invoice]').should('be.visible')

Embedded admin views inside iframes may not inherit host theme. If your app documents theme bridging via postMessage, test the bridge with a controlled stub. If not, record the limitation so QA does not file false bugs.

Email previews rendered in the product should generally ignore app dark mode and show email clients' realities. Assert the preview root does not pick up data-theme=dark unless the product intentionally themes emails.

15. Motion, Transitions, and Reduced Preference Coupling

Theme toggles sometimes animate background transitions that can make screenshots mid-tween. For functional tests, assert the end state with retryability. For visual tests, wait for a data-theme-transition="idle" marker if the app exposes one.

cy.get('[data-cy=theme-toggle]').click()
cy.get('html')
  .should('have.attr', 'data-theme', 'dark')
  .and('have.attr', 'data-theme-transition', 'idle')

prefers-reduced-motion can be stubbed similarly to color scheme if your animations respect it. Coupling reduced motion and dark mode in one test is usually unnecessary; keep preferences orthogonal unless the bug report combines them.

16. Documentation Snippets for the Team Theme Helper

Publish a short internal example so every engineer sets theme the same way. Inconsistency is the main reason dark mode tests flake across specs.

// cypress/support/commands.ts
Cypress.Commands.add('setTheme', (theme: 'light' | 'dark' | 'system') => {
  cy.window().then((win) => {
    win.localStorage.setItem('theme', theme)
  })
})

Cypress.Commands.add(
  'visitThemed',
  (path: string, theme: 'light' | 'dark' | 'system') => {
    cy.visit(path, {
      onBeforeLoad(win) {
        win.localStorage.setItem('theme', theme)
      },
    })
  },
)

Type the custom commands in cypress.d.ts so TypeScript specs stay clean. Review pull requests that invent alternate storage keys. One key, one marker, one helper keeps cypress how to test dark mode knowledge institutionalized rather than tribal.

17. Marketing Pages, Auth Screens, and Logged-Out Theme Defaults

Logged-out marketing pages may ignore user storage and follow system preference only, while the authenticated app respects stored theme. Tests should not assume one rule sitewide.

it('marketing landing follows system dark preference', () => {
  cy.visit('/', {
    onBeforeLoad(win) {
      cy.stub(win, 'matchMedia').callsFake((query: string) => ({
        matches: query === '(prefers-color-scheme: dark)',
        media: query,
        onchange: null,
        addListener: () => {},
        removeListener: () => {},
        addEventListener: () => {},
        removeEventListener: () => {},
        dispatchEvent: () => false,
      }))
    },
  })
  cy.get('html').should('have.attr', 'data-theme', 'dark')
})

Auth screens should remain readable in both themes because password managers and error banners stack densely. Include at least one invalid login assertion in dark mode in the focused theme suite.

Email Verification and External Returns

When users return from external identity providers, theme flash can reappear if the callback route forgets to reapply storage. A single test that lands on the callback URL with storage preset catches obvious regressions.

cy.visit('/auth/callback?status=ok', {
  onBeforeLoad(win) {
    win.localStorage.setItem('theme', 'dark')
  },
})
cy.get('html').should('have.attr', 'data-theme', 'dark')

18. Rollout Plan for Introducing Dark Mode Tests to a Legacy Suite

If the suite predates dark mode, adopt tests gradually:

  1. Add document marker assertions to the theme settings page.
  2. Add persistence and system preference specs.
  3. Dual-run login and the top revenue flow.
  4. Add visual baselines only after functional markers are stable.
  5. Inventory third-party components that ignore theme and track them as known gaps.

Avoid a big-bang PR that doubles runtime and fails half the suite on contrast issues you cannot fix in one sprint. Dark mode quality is a program, not a single test file. The techniques in this article for cypress how to test dark mode scale best when tied to that rollout order and to design-system token ownership.

Production Readiness Notes for Theme Suites

Ship dark mode tests with the same discipline as feature flags. If dark mode is still beta, tag specs accordingly and skip them in production smoke until the flag defaults on. Nothing erodes trust faster than a forced dark suite failing on an environment where the toggle is hidden.

Provide designers with a short list of screens under dual-theme automation so visual QA does not randomly expand scope during release. When a token changes, update visual baselines in the same PR as the token change when you use visual tooling. Functional marker tests should remain green without baseline churn.

On support tickets about "the app flashed white," capture whether the user is on system theme, a forced theme, or a shared kiosk account that clears storage. Your Cypress coverage for storage and system preference becomes the reproduction script for those tickets. Link the spec names in the runbook so support and SDET share a language.

If the organization runs white-label clients, track which tenants hard-code brand light headers. Theme tests for those tenants belong in tenant-specific folders rather than the core suite, or they will create permanent conflicts with global dark expectations.

Interview Questions and Answers

Q: How do you test dark mode in Cypress?

I set the theme the way the app persists it, usually localStorage in onBeforeLoad or by clicking the toggle. I assert the root class or data-theme marker and then exercise a critical flow so components render under that palette.

Q: How do you simulate OS-level dark preference?

I stub window.matchMedia for (prefers-color-scheme: dark) in onBeforeLoad before app code runs, with the app theme set to system/auto. Then I assert the applied marker.

Q: Should every test run twice for light and dark?

No. That doubles runtime. I dual-run critical, contrast-sensitive journeys and keep a focused theme suite for toggle, persistence, and a few screens.

Q: What is a durable assertion for theme?

The document marker the CSS uses, such as html[data-theme=dark] or html.dark. Exact pixel colors are secondary and better handled with visual tooling when needed.

Q: How do you test theme persistence?

Select dark mode, assert storage and marker, reload, and assert the marker remains. I also test that choosing light overrides system preference when that is the product rule.

Q: Why did matchMedia stubbing not work?

Usually the stub was installed too late, the MediaQueryList shape was incomplete, or the app listened for change events the stub never emitted. I install the stub in onBeforeLoad and match the API surface the app calls.

Q: How do canvas charts affect dark mode testing?

Chart colors are often JS-driven. I verify theme state and chart visibility, and I use visual checks for the chart region rather than reading canvas pixels manually in every test.

Common Mistakes

  • Only clicking the toggle without asserting the root theme marker.
  • Inventing localStorage keys that the app does not read.
  • Stubbing matchMedia after bootstrap.
  • Asserting dozens of fragile RGB values in E2E.
  • Doubling the entire suite by theme without prioritization.
  • Ignoring modals and error states where contrast fails.
  • Committing visual baselines that mix failure screenshots and theme screenshots.
  • Forgetting system/auto mode entirely.
  • Not testing persistence across reload.
  • Assuming dark mode is only a CSS class with no JS chart reconfiguration.

Conclusion

For cypress how to test dark mode, control theme through the same toggle, storage, and matchMedia signals the product uses, then assert durable document markers and critical journeys in both palettes. Keep dual-theme coverage intentional, not universal, and reserve pixel-heavy checks for visual tooling with per-theme baselines.

Next step: add a focused theme.cy.ts that covers stored dark preference, UI toggle, system preference stubbing, and one modal flow under dark mode.

Interview Questions and Answers

How do you test dark mode with Cypress?

I control theme through toggle, storage, or matchMedia stubs, assert the document theme marker, and run critical journeys under that theme. I keep dual-theme coverage intentional rather than universal.

How do you simulate OS dark mode?

With the app in system/auto mode, I stub matchMedia for prefers-color-scheme queries in onBeforeLoad so bootstrap reads dark as matching, then assert the applied marker.

What is a durable theme assertion?

The same class or data attribute the stylesheet keys off, for example html.dark or data-theme=dark. That is more stable than many hard-coded component colors.

How do you balance CI time with theme coverage?

Default functional suites stay on one theme. A small theme spec covers toggle, persistence, system preference, and a few contrast-sensitive screens in dark mode.

How do charts affect dark mode tests?

Canvas colors are often set in JavaScript. I verify theme state and chart visibility and use visual checks for chart regions instead of parsing raw canvas pixels in every test.

How do you prevent flaky theme tests?

I set storage before load, avoid racing hydration without ready markers, and do not assert unstable pixel values. I also keep helpers for visitWithTheme to reduce copy-paste errors.

What product bugs are unique to dark mode?

Unreadable inputs, invisible borders, wrong logo assets, charts that stay light, and modals with white text on light backgrounds. Functional dual-theme smoke tests surface these quickly.

Frequently Asked Questions

How do I force dark mode in Cypress?

Set the app storage key in onBeforeLoad before visit, or click the theme toggle after load. Assert the root data-theme or dark class the CSS uses.

How do I mock prefers-color-scheme in Cypress?

Stub window.matchMedia in onBeforeLoad so queries for prefers-color-scheme: dark return matches true when testing system dark mode. Install the stub before app bootstrap.

What should I assert for dark mode?

Prefer document markers such as html[data-theme=dark] or html.dark, then critical functional flows. Use limited computed style checks or visual tooling for pixels.

Should all tests run in both themes?

No. Dual-run critical paths and maintain a focused theme suite for toggle, persistence, and high-risk screens to balance coverage and runtime.

How do I test that the theme persists?

Choose dark mode, assert storage and marker, reload, and assert the marker remains. Also verify light selection overrides system preference when that is the product rule.

Why are RGB style assertions brittle?

Browsers return computed rgb values that can differ by engine and anti-aliasing. Design tokens and root markers are more durable; visual tools handle pixel diffs better.

Can Cypress catch a flash of light theme before dark applies?

Coarse wrong-theme-after-ready bugs are testable. Sub-frame flashes are hard to catch reliably in E2E and often need inline bootstrap scripts plus manual checks.

Related Guides