June 15, 2026
How to Test Theme Switching, Dark Mode, and User Preference Persistence Without Missing Visual Regressions
Learn how to test dark mode and theme switching, verify saved preferences, and catch visual regressions with practical QA strategies and automation examples.
Theme switching sounds simple until it ships. A light and dark toggle is rarely just a color swap. It can affect contrast, icon legibility, skeleton loaders, charts, shadows, hover states, screenshots, persisted settings, and even browser storage behavior across sessions. The result is a feature that looks tiny in product scope but touches a surprising amount of the UI.
If you need to test dark mode and theme switching well, you have to think in layers. There is the functional layer, where the app remembers the user’s preference. There is the rendering layer, where CSS variables, tokens, and component states need to update correctly. Then there is the visual layer, where a screen can technically work while still looking broken in ways that only a human eye or visual regression tooling will catch.
This tutorial walks through a practical testing strategy for QA engineers, frontend engineers, SDETs, and product teams who want confidence in theme switching without creating a brittle test suite.
What usually breaks when theme support is added
Theme features are often implemented quickly, then patched later when design review finds missing states. The most common regressions are predictable once you know where to look.
Common breakpoints
- Text contrast drops below acceptable thresholds in one theme
- Buttons, links, or focus rings disappear on dark backgrounds
- SVG icons inherit the wrong color or opacity
- Charts, maps, and code editors keep their old palette
- Modal overlays and shadows look too strong or too weak
- Toasts, tooltips, and dropdowns render with mixed theme styles
- Skeleton loaders, placeholders, and disabled states are not themed
- Theme preference saves, but only in the current tab or current browser
- Server-rendered pages briefly flash the wrong theme before hydration
A theme toggle is not one test case, it is a matrix of states, storage behavior, rendering timing, and cross-page consistency.
That is why theme testing needs both functional checks and visual checks. Functional checks tell you whether the app applied the theme. Visual checks tell you whether the result is actually usable.
Start by defining how your app stores theme preference
Before writing tests, make the theme model explicit. You cannot validate persistence if the product team has not decided what “persisted” means.
Most apps use one of three patterns:
- System-only, the app follows the OS preference and does not let the user override it
- User override with persistence, the user selects light, dark, or system, and the choice is saved
- Hybrid fallback, the app defaults to system, but user choice wins when present
For QA, the key question is where the preference lives.
localStorageis common for client-side persistence- Cookies are common when the server must render the correct theme on first paint
- A backend profile setting is common in authenticated products
- Some apps store a value in all three places to support SSR and cross-device sync
Each storage choice changes your test plan. For example, localStorage is easy to inspect in browser automation, but it does not help the first server response. A cookie can help the server render the correct class on the <html> element, but it also creates more cases around expiration, domain scope, and login/logout transitions.
Build a test matrix before writing automation
A theme matrix keeps you from testing the same happy path three times while missing the hard cases.
At minimum, cover these dimensions:
- Theme mode, light, dark, system
- Entry point, fresh visit, reload, navigation between pages, deep link
- Persistence state, first visit, saved preference, cleared storage
- Device size, desktop, tablet, mobile
- Browser engine, Chromium, WebKit, Gecko if supported
- Page type, marketing page, app shell, form page, data-heavy page, modal or drawer state
- OS preference, light system theme, dark system theme
A practical slice for a QA team might look like this:
| Scenario | What it validates |
|---|---|
| First visit with system dark | App respects default OS choice |
| User selects dark, reloads page | Preference persistence works |
| User selects light after dark | Override updates cleanly |
| Theme changes on open modal | Component state updates correctly |
| Theme applies after login redirect | Auth flow does not reset preference |
| Theme on a data-heavy page | Charts, tables, and skeletons remain readable |
You do not need to run every combination on every commit. But you do need a strategy for which combinations are smoke tests, which are nightly, and which are manual exploratory checks.
Test the behavior, not only the toggle
A lot of teams write one test that clicks a toggle and checks for a CSS class like dark. That confirms the switch exists, but not much else.
Better tests verify observable behavior:
htmlorbodygets the expected theme attribute or class- CSS custom properties change to expected values
- Persisted preference survives reload
- Navigation does not reset the theme
- Theme does not drift between server-rendered and hydrated UI
- Components update without needing a full page refresh
If your app uses a theme provider, test the provider behavior too. A bug in the provider can make every page look fine in isolation and still fail for users after a route change.
A solid manual checklist for dark mode and theme switching
Manual testing still matters because many visual bugs are easy to miss in automation.
Verify the basics
- Open the app in light mode
- Switch to dark mode
- Confirm the layout, text, icons, borders, and surfaces update together
- Refresh the page
- Navigate to another page
- Log out and log back in, if your app supports auth
- Clear browser storage and verify the default state returns correctly
Check the usual problem areas
- Forms, especially placeholder text, invalid borders, helper text, and autofill styles
- Focus states for keyboard navigation
- Disabled controls, loading states, and busy spinners
- Fixed headers, sidebars, and mobile menus
- Charts with multiple data series and subtle gridlines
- Tables with sticky columns or zebra striping
- Code blocks and monospace content
- Empty states and onboarding panels
Test with real content, not only lorem ipsum
Dark mode often looks acceptable on mocked content but fails on real data density. Long names, multi-line labels, and wrapped breadcrumbs can expose spacing and contrast problems that static mocks hide.
A Playwright example for theme persistence coverage
For browser automation, a good pattern is to verify the DOM theme state and a visible element that changes with the theme. Then reload and confirm the preference remains.
import { test, expect } from '@playwright/test';
test('persists dark mode after reload', async ({ page }) => {
await page.goto('https://example.com');
await page.getByRole(‘button’, { name: /dark mode/i }).click(); await expect(page.locator(‘html’)).toHaveAttribute(‘data-theme’, ‘dark’);
await page.reload(); await expect(page.locator(‘html’)).toHaveAttribute(‘data-theme’, ‘dark’); await expect(page.getByRole(‘button’, { name: /dark mode/i })).toBeVisible(); });
This is simple on purpose. You want the test to prove the preference is saved and reapplied, not to couple the test to too many implementation details.
If your app uses localStorage, you may also want a direct storage assertion during troubleshooting.
typescript
const theme = await page.evaluate(() => localStorage.getItem('theme'));
expect(theme).toBe('dark');
What to validate when you use CSS variables
CSS variables are a common and flexible way to implement theming. They also make it easy to miss a regression because one variable can be updated while another component still references a hardcoded color.
When theme tokens drive the UI, check a small set of representative surfaces:
- App background
- Card background
- Primary text
- Secondary text
- Border color
- Accent color
- Hover state
- Focus ring
- Disabled state
You do not need to assert every computed color in every test. Instead, use a few sentinel components that reveal whether tokens are wired correctly. For example, a button, a card, and a form input can tell you whether the theme is broadly applied.
The best theme tests catch token wiring mistakes, not every possible CSS value.
Don’t forget server rendering and the flash of the wrong theme
One of the most annoying theme bugs is the flash of incorrect theme on page load. The app eventually settles into the right mode, but users see the wrong one first.
This often happens when:
- The theme is only applied on the client after hydration
- The server does not know the saved preference
- The initial render uses system theme and the client later overrides it
- Scripts that set the class run too late in the load sequence
To test this, use a fresh browser context and load the page directly, not just after navigating within the app. If you can, inspect the rendered HTML before hydration or use visual regression tools to compare the first render against the expected baseline.
A lightweight check might look like this:
typescript
await page.goto('https://example.com', { waitUntil: 'domcontentloaded' });
await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark');
If that assertion fails intermittently, your theme may be applied too late.
Theme persistence testing across sessions and devices
Theme persistence is not only about refreshes. Users expect behavior to survive browser sessions, and sometimes to follow them across devices if the preference is stored server-side.
Test the following cases:
- Same tab reload
- New tab in the same browser profile
- Browser restart
- Incognito or private browsing, if supported
- Logout and login
- New browser on the same account, if theme sync exists
Decide whether the product should keep preferences after logout. Some apps intentionally reset to system defaults after logout for security or simplicity. Others preserve the user’s visual preference. Either choice is valid if it is documented and consistent.
Visual regression dark mode checks that actually help
Traditional functional assertions can tell you the theme changed, but they will not catch subtle regressions like a button label blending into its background or a low-opacity border becoming invisible.
That is where visual regression dark mode coverage earns its keep.
A useful approach is to capture baseline screenshots for a focused set of pages in both light and dark themes:
- Home or dashboard
- Login or sign-up page
- Form-heavy page
- Data table page
- Settings page with the toggle itself
- Modal or drawer state
Keep the scope narrow. Theme changes can cause lots of legitimate pixel differences, so overly broad comparisons are noisy. Check areas that matter, and ignore dynamic regions like timestamps or rotating banners when possible.
What to compare visually
- Full-page layout spacing
- Color contrast on text and controls
- Icon visibility
- Overlay and shadow depth
- Border hierarchy
- Error state and success state styling
- Chart readability
If your visual tool supports region-based comparison, use it to isolate stable regions. That is especially helpful for dashboards, feeds, and pages with ads or live counters.
Test accessibility at the same time
Dark mode can expose accessibility problems that are less obvious in light mode. A color pair that feels “fine” to a designer can still fail for users with low vision or on low-quality screens.
At minimum, validate:
- Contrast for body text and large text
- Keyboard focus visibility in both themes
- Error message readability
- Placeholder text does not replace a label
- Links are distinguishable from normal text
- Disabled controls are still identifiable
You can combine visual checks with automated accessibility scans, but do not treat an accessibility tool as a replacement for theme-specific QA. Theming bugs are often about combinations, not isolated elements.
How to test system preference overrides
System theme support adds a useful but tricky layer. If the OS preference changes, the app may need to update dynamically, unless the user explicitly chose a different mode.
Test these cases:
- System is dark, app starts in system mode
- System is light, app starts in system mode
- User selects light while system is dark
- User selects dark while system is light
- User switches back to system mode
- Browser or OS theme changes while the app is open
The main rule is that user intent should usually win over system preference. If the app says “system” in the UI, then changes should track the OS. If the app says “dark,” system changes should not override it.
Common failure patterns to look for in code reviews
QA teams can catch more issues earlier by reviewing implementation patterns as well as testing behavior.
Watch for these red flags:
- Hardcoded colors instead of theme tokens
- Duplicate theme logic in multiple components
- Theme state stored in component-local state only
- Missing SSR handling for the initial theme class
- Animation transitions that make theme changes feel broken or laggy
- Components that read theme once but never subscribe to changes
- Third-party widgets that are not wrapped in theme-aware containers
A quick code review can save a round of brittle test maintenance later.
Example CI coverage strategy
You do not need a massive matrix on every pull request. A practical strategy is usually enough.
- On pull request: one smoke test per theme, plus one visual check on a representative page
- On merge to main: broader browser coverage, including reload and persistence checks
- Nightly: expanded visual regression coverage across key pages and states
- Before release: manual pass through the highest-risk flows, especially auth and settings
A simple GitHub Actions job can run a small Playwright suite in both theme states.
name: theme-tests
on: pull_request:
jobs: playwright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install –with-deps - run: npx playwright test –grep “theme”
If your suite is unstable, reduce the number of pages first, not the number of assertions. Theme tests fail for two main reasons, real regressions and flaky setup. Fix both, but keep the signal high.
Practical debugging tips when a theme test fails
When a dark mode test fails, the root cause is often one of these:
- The preference did not save
- The theme saved, but the reload logic did not read it
- The page loaded before the theme script ran
- A specific component ignored global theme styles
- A third-party widget brought its own colors
- A visual diff was caused by dynamic content, not a regression
A good debugging sequence is:
- Check the saved theme value in storage or backend state
- Inspect the root theme attribute or class
- Verify the page load timing
- Compare computed styles on the broken element
- Confirm whether the issue is functional or purely visual
The faster you separate “logic bug” from “rendering bug,” the easier it is to assign the fix to the right owner.
A realistic acceptance checklist for release
Before shipping theme support or a major UI refresh, use a short acceptance list:
- Theme toggle updates the UI immediately
- Saved preference survives reload
- Saved preference survives a new browser session, if expected
- System mode responds correctly to OS preference
- First paint uses the correct theme, or the flash is eliminated
- Key pages pass visual review in both themes
- Accessibility checks pass in both themes
- Third-party widgets and embedded components are readable
- Auth and logout flows do not reset preference unexpectedly
That checklist is small enough to use in release reviews, but broad enough to catch most production issues.
When to use automation, and when to keep it manual
Automate the repetitive parts, especially the toggle, persistence, and reload checks. Keep manual review for areas where context matters, such as readability, spacing, and the overall feel of the UI in dark mode.
Good candidates for automation:
- Theme preference save and restore
- Reload and navigation checks
- Root theme attribute assertions
- Limited visual regression baselines
- Cross-browser smoke coverage
Good candidates for manual review:
- New theme design proposals
- High-risk pages with lots of dynamic content
- Accessibility judgment calls
- Brand-sensitive surfaces like marketing pages and onboarding flows
A note on tooling choices
You can test theme switching with Playwright, Cypress, Selenium, or a visual test platform, depending on your stack and team habits. The important thing is to test both behavior and appearance, and to keep the baseline small enough that people trust it.
If your team wants repeatable browser coverage without building every check from scratch, Endtest, an agentic AI [Test automation](https://en.wikipedia.org/wiki/Test_automation) platform,’s Visual AI is one example of a low-code path that can compare visible UI states and help catch regressions that are obvious to the human eye but easy to miss in functional assertions. The main idea is the same regardless of tool choice, validate the important states, keep the tests editable, and limit noise from dynamic content.
Final thoughts
When teams say they “support dark mode,” they often mean the toggle exists. Real support is broader than that. It means the app remembers the user’s choice, renders correctly on first load, handles system preference changes in a sane way, and keeps the UI readable after design updates.
If you structure your checks around persistence, rendering, and visual quality, you can test dark mode and theme switching without drowning in brittle screenshots or shallow assertions. Start with a small matrix, verify the root theme state, cover the highest-risk pages, and add visual regression checks where human perception matters most.
That is usually enough to catch the bugs users actually notice, before they make it to production.