How to Test Your Web App in Dark Mode With Cypress
Test dark mode in Cypress by exercising the theme input your app actually uses, then asserting rendered styles and behavior. Cover toggles, system preferences, and visual checks.
To test dark mode with Cypress, first identify how your app chooses its theme. If users select a theme with a control, exercise that control and assert the rendered result. If the app follows prefers-color-scheme, arrange for the test browser to expose the dark preference using a verified setup for your Cypress version and browser, then assert rendered output. cy.viewport() changes dimensions; it does not emulate a color-scheme preference.
The Cypress documentation covered here describes browser-launch customization in general, but does not provide a specific supported Cypress command for forcing prefers-color-scheme: dark. The toggle-based example below is runnable once you adapt its route, selectors, and expected colors to your app. Treat system-preference setup as browser-specific and verify it independently before relying on it in CI.
1. Choose the theme behavior to test
| App behavior | Test input | What to verify |
|---|---|---|
| User-controlled theme | Click the app’s theme control | The selected state and visible dark styles; persistence if the app promises it |
| System-preference theme | A verified browser setup that provides a dark color-scheme preference | The app responds to that preference and renders its dark appearance |
| Both | Test both inputs separately | The contract for each, including which one wins if both are present |
Keep theme choice separate from responsive coverage. Test dark mode at relevant viewport sizes with cy.viewport(), but do not treat a viewport change as a preference change. Cypress documents a default application viewport of 1000 by 660 pixels and resets it between tests. Starting with Cypress 16.0.0, viewport width and height cannot be changed through Cypress.config() while a test is executing; use cy.viewport() or test configuration instead. Cypress viewport documentation and configuration documentation describe the options and scope.
2. Test a theme toggle with Cypress end to end
This example assumes the app exposes a button with data-testid="theme-toggle", applies data-theme="dark" to the document root, and renders stable CSS colors. Add stable selectors and theme attributes in the application if needed; avoid selectors tied to styling implementation details that may change accidentally.
describe('dark theme', () => {
beforeEach(() => {
cy.visit('/settings');
});
it('switches to dark mode and renders representative content', () => {
cy.get('[data-testid="theme-toggle"]').click();
cy.get('html').should('have.attr', 'data-theme', 'dark');
cy.get('body').should('have.css', 'background-color', 'rgb(18, 18, 18)');
cy.get('main').should('have.css', 'color', 'rgb(240, 240, 240)');
cy.get('a').first().should('have.css', 'color', 'rgb(128, 180, 255)');
cy.get('[data-testid="theme-toggle"]')
.should('have.attr', 'aria-pressed', 'true');
});
});
Replace the example colors with the values your app actually computes. If your design system uses CSS variables, it is often more stable to assert the user-visible computed property or a semantic theme attribute than to assert a particular internal variable name. Cypress assertions retry while their subject is available, which helps when a theme update is asynchronous; avoid adding fixed waits unless the app has a genuine timing requirement.
Check the contract, not just a root class
A root theme class proves only that the app changed a marker. Verify representative rendered surfaces and controls too:
- Page background, primary text, muted text, links, and borders.
- Inputs, buttons, disabled states, focus indicators, and validation messages.
- Menus, dialogs, tooltips, and other overlays rendered through portals.
- A key interactive state, such as an expanded panel or selected navigation item.
- Accessible state on the theme control, such as its label or pressed state.
Prefer a handful of representative checks over asserting every element. Large brittle lists often mirror CSS implementation rather than user-visible behavior.
3. Verify theme persistence and navigation
If choosing dark mode is supposed to persist, test the documented behavior across a reload or route change. Use a deterministic starting state: clear the relevant storage or reset the preference through an app-supported mechanism before each test. Do not assume Cypress clears application local storage automatically.
it('keeps the selected theme after reload', () => {
cy.visit('/settings');
cy.get('[data-testid="theme-toggle"]').click();
cy.get('html').should('have.attr', 'data-theme', 'dark');
cy.reload();
cy.get('html').should('have.attr', 'data-theme', 'dark');
});
it('keeps the selected theme while navigating', () => {
cy.visit('/settings');
cy.get('[data-testid="theme-toggle"]').click();
cy.get('[data-testid="theme-toggle"]').click();
cy.get('html').should('have.attr', 'data-theme', 'dark');
cy.get('[data-testid="account-link"]').click();
cy.location('pathname').should('eq', '/account');
cy.get('html').should('have.attr', 'data-theme', 'dark');
});
Adapt the navigation example to your app. If the setting is per session, per account, or intentionally not persistent, assert that stated contract instead of assuming permanent storage. When a test depends on a logged-in user, use a controlled test account and a repeatable authentication setup.
4. Test a system-preference-driven theme
For an app that uses CSS or JavaScript to follow prefers-color-scheme, the test needs the browser to report the dark preference before the app reads it. First confirm the exact Cypress and browser versions used locally and in CI. Cypress supports browser launch customization for launch arguments, preferences, environment, and extensions, and supports Chrome-family browsers, Firefox, and WebKit. That general extension point does not by itself establish a dark-mode-specific recipe.
Do not copy an unverified launch argument or browser preference snippet into a test and treat it as proof. Verify the mechanism with a small test that observes the media query in the same browser and CI image used by your suite, then test the app’s result. If the setup cannot reliably establish the preference, test the app’s theme provider in a controlled component test and keep an end-to-end test for the user-visible flow. See Cypress’s component styling guide and browser launching documentation.
Once the preference has been set by a verified setup, the application-level assertions can be straightforward:
// This assertion checks what the browser reports. It does not set the preference.
it('renders dark theme when the test browser reports dark preference', () => {
cy.visit('/');
cy.window().then((win) => {
expect(win.matchMedia('(prefers-color-scheme: dark)').matches).to.equal(true);
});
cy.get('html').should('have.attr', 'data-theme', 'dark');
cy.get('main').should('have.css', 'color', 'rgb(240, 240, 240)');
});
If your app uses only a CSS media query and does not add a theme attribute, assert computed styles or visible behavior instead. The media-query assertion is a setup check, not an application test by itself.
5. Choose component tests or end-to-end tests
| Use component testing when | Use end-to-end testing when |
|---|---|
| You need a controlled check of a component rendered with a theme provider. | You need to verify the full route, theme control, persistence, and navigation. |
| You want to isolate a component’s dark styles and interactions. | You need confidence that app initialization and stored preferences work together. |
| You can supply the theme state directly in the test harness. | The theme depends on browser preference, application storage, or user actions. |
Cypress component tests render CSS in a real browser, so they can check rendered styles and interactions. Cypress notes that browser dark-mode support and the application’s theme-provider setup can explain differences between component-test rendering and production. Make the test harness’s theme input explicit so that a passing component test means what you intend.
6. Add visual regression checks carefully
DOM and computed-style assertions verify selected properties. A screenshot comparison can catch broad visual changes such as unreadable text, missing borders, or an incorrectly themed overlay. Keep the baseline and comparison environment consistent, use a fixed viewport, seed stable application data, and avoid capturing during transitions or other animations. Control time-dependent content where possible. These practices reduce diffs caused by the test environment instead of a product change. See Cypress visual testing guidance.
Use separate baselines for light and dark states. Capture only after the theme has settled and the important content is visible. A screenshot is complementary: retain focused assertions for behavior and accessibility because a visual diff does not explain why a state changed.
7. Test responsive layouts separately
Dark theme can expose contrast or overflow problems at narrow widths, so test both concerns together when the combination matters. The viewport changes dimensions only; establish the theme through the app’s toggle or the verified browser-preference setup.
it('keeps dark navigation usable on a narrow viewport', () => {
cy.viewport(390, 844);
cy.visit('/');
cy.get('[data-testid="theme-toggle"]').click();
cy.get('html').should('have.attr', 'data-theme', 'dark');
cy.get('[data-testid="mobile-menu-button"]').click();
cy.get('[data-testid="mobile-navigation"]').should('be.visible');
});
Choose dimensions that represent layouts your product supports. Cypress documents preset sizes and custom width and height values on its viewport command page.
8. Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
The page stays light after cy.viewport(). |
Viewport dimensions do not set the system color preference. | Exercise the theme control or use a verified browser setup for prefers-color-scheme. |
| The test passes on a root class but the page still looks wrong. | The assertion checks a marker rather than rendered surfaces. | Check representative computed styles, controls, overlays, and interactive states. |
| Expected CSS color does not match. | The actual computed value differs due to a token, opacity, inherited style, or browser serialization. | Inspect the rendered computed style in the same browser, then assert the intended stable value or semantic state. |
| Component test is light while production is dark. | The component harness may not provide the same theme provider or browser color-scheme environment. | Make the theme input explicit and compare the test and production setup. |
| Screenshot diffs change between runs. | Viewport, data, time-dependent content, fonts, animations, or environment varies. | Fix the viewport and data, wait for meaningful readiness, and disable or finish animations before capture. |
| Theme leaks from one test into another. | Stored state or app-level state is not reset. | Set a deterministic initial preference or clear the specific storage used by the app before visiting. |
Cypress.config() does not change viewport dimensions during a test. |
Cypress 16.0.0 and later do not allow changing viewport width or height this way during execution. | Use cy.viewport() in the test or configure the viewport at test or suite scope. |
| A preference override works locally but not in CI. | The browser binary, version, launch setup, or CI environment differs. | Verify the preference with matchMedia in the exact CI browser, and pin or align the relevant environment. |
9. Performance, reliability, and cost
Keep the suite focused: a few representative style and behavior checks usually provide a clearer failure signal than repeatedly capturing many full-page screenshots. Component tests can isolate rendering; end-to-end tests cover initialization and persistence but may take longer because they exercise more of the application. Visual comparisons add value when broad appearance regressions matter, and add maintenance when data or rendering is unstable.
For reliability, use stable selectors, deterministic initial state, fixed viewport dimensions, controlled test data, and the same browser setup in local and CI runs. Avoid arbitrary sleeps; wait for an observable app state such as the theme attribute or a visible component. Cypress is software, so the task does not require purchasing physical equipment. Cypress’s own documentation and your existing test infrastructure determine any software or CI costs; no price or performance benchmark is asserted here.
Or skip the browser setup
ScreenshotNeo can capture a page with one GET request. The cookie banners, newsletter popups, and chat widgets it recognizes are removed before the shot. Bot checks, blank pages, failed loads, timeouts, and cache hits are not billed; response headers report the page verdict and billing status. Its MCP server lets AI agents use take_screenshot, get_page_info, and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000. ScreenshotNeo is a screenshot API and MCP server by ScreenshotNeo; see the API documentation.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get(
"https://api.screenshotneo.com/v1/shot",
params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
timeout=90,
)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await Bun.write('shot.webp', res);
These examples capture the supplied URL; to inspect your dark-mode page, replace it with your route. A screenshot is useful for reviewing appearance, but it does not emulate a browser preference or replace Cypress assertions for theme behavior. Start with 1,000 free screenshots a month, no card required.
FAQ
Does cy.viewport() turn on dark mode?
No. It changes viewport dimensions and orientation. Set the theme through your app or a verified browser preference setup.
Should every dark-theme test compare screenshots?
No. Use focused assertions for behavior and representative styles; add visual comparisons for broad appearance changes that matter to your team.
Can I test a CSS-only dark theme?
Yes. Establish the intended color-scheme input and assert rendered styles or visible behavior. A theme attribute is not required if the app does not use one.
What should a passing theme test mean?
It should show that the intended input produces the user-visible theme contract, including key content and controls, under a controlled browser and app state.


