← All posts
Best practices

Dark mode is a second UI you probably don't test

Ship a dark theme and you've shipped two apps. Same routes, same components, same behavior — and a second, parallel set of surfaces where every color decision gets made again. Most teams test the first app thoroughly and the second one by squinting at it once before release.

That gap is bigger than it feels, because dark mode isn't a niche preference anymore. It's the default a large slice of your users live in all day, set at the OS level and never turned off. A site validated only in light mode is, for those users, untested — and the failures that hide in the dark theme are a specific, well-catalogued set that your light-mode suite is structurally blind to.

The dark-mode failure modes are known

You don't have to guess where a dark theme breaks. The common visual bugs are well documented, and dark mode concentrates a few of them.

The leading cause is hardcoded color. Somewhere a component was written with a literal #fff background or a color: #333 instead of a theme token, and when the rest of the app flips to dark, that component doesn't get the memo. You get white cards stranded on a dark page, or dark-gray text on a near-black surface that no one can read. The component looked fine for years because it was only ever seen in light mode.

Contrast is the second one, and it's sneaky because it inverts. Text that clears contrast thresholds comfortably against a white background can fall below them against a dark one — a muted gray that reads as "secondary" on white becomes "invisible" on charcoal. Passing in light mode tells you nothing about the dark side of the same palette.

The third is missing interaction states. A hover, focus ring, disabled style, or active state gets defined for one theme and forgotten in the other. The button works; you just can't see that it's focused, or the disabled state looks identical to the enabled one. These only surface when you actually drive the interaction in the theme where it was skipped.

None of these are backend bugs. They're all presentation, all theme-specific, and all invisible to a test that never enters the dark theme or never looks at pixels once it's there.

Why your functional suite won't catch any of this

Here's the uncomfortable part: a well-built end-to-end suite makes the dark-mode blind spot worse, not better, and for a good reason.

The whole point of a resilient functional test is to anchor to things that don't change when the styling does. You locate a button by its role and accessible name, a field by its label, a heading by its text. Those locators survive a CSS refactor, a class rename, a color change — which is exactly what you want, because a test that goes red every time a designer nudges a shade is a test nobody trusts.

But that resilience is precisely why the functional layer can't see a contrast bug. getByRole('button', { name: 'Checkout' }) finds the checkout button and clicks it whether the button is crisp black-on-white or unreadable dark-gray-on-black. The role is there. The name is there. The click succeeds, the flow completes, the assertion passes — and the button was invisible the whole time. The test is doing its job correctly and telling you nothing about how the page looked.

This is the clearest case of why functional and visual testing are different instruments measuring different things. Functional testing asks "does it work?" Visual testing asks "does it look right?" A dark-mode contrast failure is a definitive yes to the first question and a no to the second. You need both instruments, and you need to point the visual one at both themes.

The playbook: drive the toggle, baseline per theme

Covering the second UI doesn't require a second test suite or some special dark-mode mode — it's three moves on top of what you already have.

One: switch themes the way a user does. Your app almost certainly has a theme toggle — a switch in settings, an icon in the header, a system-preference follow. Describe using it in plain language, the same as any other step: "switch to the dark theme, then open the pricing page." The generated test drives that control like a real person, and now the rest of the scenario runs against the dark surfaces. No emulation flag, no separate configuration — you're exercising the exact code path your users trigger when they flip the switch, which is the thing you actually want to verify works. TestVibe generates from that description and verifies it against a live run of your app before you ever see the test, so "switch to dark, then check the cart" arrives already proven to drive the real toggle.

Two: establish a visual baseline per theme for the surfaces that matter. Turn on the Visual plugin and your generated tests can call page.visual.matchSnapshot({ name, threshold }) — it screenshots the live page and pixel-diffs it against a stored baseline. The move for dark mode is to capture a baseline in each theme for your highest-value surfaces: the dashboard, the checkout, the settings page, the marketing hero. Name them distinctly (dashboard-light, dashboard-dark) so each theme gets its own reference image. The first run establishes the baseline and passes with a note; a later run where a refactor stranded a hardcoded #fff on the dark page fails that scenario and attaches the expected, actual, and diff images so you can see the exact regressed region. When a surface has genuinely dynamic content — a timestamp, an avatar, a live counter — page.visual.ignoreRegion({ selector }) masks it so real churn doesn't cause false diffs. This is one of the more load-bearing uses of the plugin system that extends what generated tests can do: the functional suite proves the flow, the visual plugin proves the pixels, per theme.

Three: keep behavior assertions theme-agnostic. Don't write a separate dark-mode functional test. The role and label locators don't care about colors, so the same behavioral scenario runs identically in both themes — you just add the toggle step and layer the visual snapshot on top. One flow, asserted once for behavior, snapshotted twice for appearance.

Fold it into the coverage you already run

Themes multiply the same way viewports do, and you likely already think about that axis. A layout that reflows cleanly at desktop width overlaps its own controls on a 390-wide phone — which is the entire reason to run one suite across multiple viewports instead of hoping the breakpoints hold. Dark mode is that same idea rotated ninety degrees: another dimension the same flows have to survive.

The practical combination is to pick your critical surfaces and cover the grid that matters for your users — not every theme against every viewport, which is a lot of snapshots to maintain, but the intersections that carry real traffic. Your dashboard in dark mode on desktop. Your checkout in dark mode on mobile, where a stranded-white card is most jarring. You attach the browser and viewport coverage through run configurations, and you add theme coverage through the toggle step and per-theme baselines. Same run, same results view, one more axis of confidence.

A dark theme is not a coat of paint you apply once and forget. It's a full parallel surface your users see, with its own failure modes your functional suite is designed not to notice. Drive the toggle, baseline each theme, and the second app stops being the one you find out about from a screenshot in a support ticket.

Cover both themes without doubling your suite

Turn on the Visual plugin, add a "switch to dark theme" step to the flows that matter, and baseline each theme for your key surfaces — the functional suite you already have does the rest. Get early access to try it against your own app, or read more on how visual and functional testing divide the work.

Early access

Ready for tests that write themselves?