How to Automate Dark Mode Testing (Step-by-Step)
How to Automate Dark Mode Testing (Step-by-Step) begins with understanding why dark mode matters for modern applications. Users expect interfaces that adapt to ambient lighting, reduce eye strain, and
How to Automate Dark Mode Testing (Step-by-Step) begins with understanding why dark mode matters for modern applications. Users expect interfaces that adapt to ambient lighting, reduce eye strain, and conserve battery on OLED screens. When a feature is visible to every visitor, regressions in dark mode can damage brand perception and increase support tickets. Manual verification of every screen, state, and theme toggle quickly becomes unsustainable as the product grows. Automation provides repeatable coverage, catches contrast failures early, and frees exploratory testers to focus on edge‑case scenarios that scripts cannot anticipate. This guide walks you through a complete, production‑ready process: choosing a framework, building stable tests, managing locators, handling waits, setting up data, integrating with CI, and reporting results. Each step includes concrete code snippets, a test matrix, and a tool‑comparison table to help you decide where to invest effort.
When Automation Pays Off for Dark Mode Testing
Automation is not a universal silver bullet; it shines when certain conditions are met. First, consider the frequency of theme toggles in your release cycle. If you ship UI changes weekly and each change could affect color contrast, automated checks give you immediate feedback. Second, evaluate the number of distinct screens or components that support dark mode. A mature design system with dozens of reusable widgets benefits from a single test suite that validates each variant. Third, assess the risk of visual regressions. Contrast violations, missing overrides, and hard‑coded colors often slip through manual review because they are subtle and depend on the device’s theme setting. Finally, factor in team capacity. When manual testers spend more than an hour per release on theme verification, automating that effort yields a measurable ROI.
Decision Matrix: Manual vs Automated Dark Mode Checks
| Criteria | Manual Testing | Automated Testing |
|---|---|---|
| Test execution time per release | 60‑120 minutes (depends on screen count) | 5‑15 minutes (parallel execution) |
| Ability to catch contrast failures | Low (relies on human perception) | High (uses automated contrast algorithms) |
| Maintenance overhead | Low (no code) | Medium (test scripts, locator updates) |
| Scalability to new components | Poor (each new screen adds time) | Good (add test cases or reuse existing) |
| Flakiness due to timing/theme switches | None (human waits as needed) | Possible (requires explicit waits) |
| Suitability for exploratory edge cases | Excellent | Limited (scripted paths) |
If your product scores high on frequency, screen count, and risk, move toward automation. Use the matrix as a starting point; adjust weights to reflect your team’s context.
How to Automate Dark Mode Testing (Step-by-Step) – Framework Selection
Choosing the right test framework determines how easily you can switch themes, locate elements, and assert visual properties. For web applications, Playwright and Cypress dominate because they provide built‑in support for emulating CSS media features. For native mobile apps, Appium (with Espresso or XCUITest drivers) lets you query the system’s UI mode and inspect view attributes. Below is a comparison of the most popular options.
Tool‑Comparison Table for Dark Mode Automation
| Framework | Language Support | Theme Switch Mechanism | Contrast Checking | Parallel Execution | Learning Curve | Ideal For |
|---|---|---|---|---|---|---|
| Playwright | JS/TS, Python, .NET, Java | page.emulateMedia({ colorScheme: 'dark' }) | Custom axe‑core integration or manual RGB checks | Yes (browser contexts) | Low‑Medium | Web apps needing cross‑browser |
| Cypress | JS/TS | cy.viewport('preset') + cy.injectAxe() | axe‑core plugin | Limited (single browser) | Low | Teams already using Cypress |
| Selenium | JS, Java, Python, C#, Ruby | Execute script to toggle CSS class or prefer‑color‑scheme | axe‑selenium or manual | Yes (Selenium Grid) | Medium | Legacy suites, broad language support |
| Appium | JS, Java, Python, Ruby, C# | adb shell cmd uimode night yes (Android) or UIAppearance (iOS) | UIAutomator/Accessibility inspector + custom contrast | Yes (parallel sessions) | Medium‑High | Native Android/iOS apps |
| Espresso | Java/Kotlin | UiModeManager#setNightMode | AccessibilityTest + custom | Yes (Android Test Orchestrator) | High | Android‑only, fast UI tests |
When your stack already includes a framework for functional testing, extend it rather than introduce a new one. For example, if you run Cypress for regression, add the cypress-axe plugin and a custom command to emulate dark mode. If you rely on Selenium Grid for cross‑browser tests, wrap the theme switch in a helper method that injects a media query override.
Minimal Setup Example: Playwright (Python)
# dark_mode_test.py
from playwright.sync_api import expect, sync_playwright
import axe_core_playwright # hypothetical wrapper; replace with actual axe integration
def test_contrast_in_demo_page():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
page = context.new_page()
# Emulate dark color scheme
page.emulate_media_color_scheme('dark')
page.navigate('https://example.com/dashboard')
# Wait for main content to appear
page.wait_for_selector('main')
# Run axe for contrast violations
results = axe_core_playwright.run(page)
violations = [v for v in results['violations'] if v['id'] == 'color-contrast']
assert len(violations) == 0, f"Contrast violations: {violations}"
context.close()
browser.close()
The snippet shows three essential actions: emulating the media feature, waiting for stable DOM, and invoking an accessibility engine to flag contrast problems. Replace axe_core_playwright.run with the actual axe‑core API for your language; the principle stays identical.
How to Automate Dark Mode Testing (Step-by-Step) – Writing Stable Tests
Stability hinges on three pillars: deterministic state, reliable locators, and explicit synchronization. Dark mode introduces an extra dimension of state (the theme) that must be set before any interaction and cleared after each test to avoid bleed‑over.
Isolating Theme State
For web, the simplest approach is to override the prefers-color-scheme media feature via the testing framework. Avoid relying on a UI toggle that may itself be flaky; instead, force the theme at the context level. In mobile, use the platform’s UI mode commands before launching the app or, if the app reads a persisted setting, reset that setting in a setup() method.
#### Web (Playwright) Helper
def new_dark_context(browser):
return browser.new_context(
color_scheme='dark',
viewport={'width': 1280, 'height': 800}
)
#### Android (Appium) Helper
public void setDarkMode() throws Exception {
// API 29+ uses UiModeManager
driver.executeScript("mobile: shell",
ImmutableMap.of("command": "cmd", "args": List.of("uimode", "night", "yes")));
}
Always revert to the system default in a teardown hook so that subsequent tests start from a known baseline.
Writing Assertions That Survive Theme Changes
Avoid asserting on hard‑coded color values (e.g., rgb(30,30,30)). Instead, compute contrast ratios dynamically or rely on an accessibility library that understands the current color scheme. If you must check a specific brand color, retrieve the computed style after the theme is applied and compare against a tolerance.
#### Example: Dynamic Contrast Assertion (JavaScript, Playwright)
test('header meets WCAG AA in dark mode', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto('/products');
const header = page.locator('header');
const bgColor = await header.evaluate(el =>
getComputedStyle(el).backgroundColor);
const textColor = await header.evaluate(el =>
getComputedStyle(el).color);
const contrast = await page.evaluate(({bg, txt}) => {
// simple relative luminance formula; replace with a library if desired
const lum = c => {
const rgb = c.match(/\d+/g).map(Number);
const [r,g,b] = rgb.map(v => {
v /= 255;
return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4);
});
return 0.2126*r + 0.7152*g + 0.0722*b;
};
const L1 = lum(bg);
const L2 = lum(txt);
return (Math.max(L1,L2)+0.05)/(Math.min(L1,L2)+0.05);
}, {bgColor, textColor});
expect(contrast).toBeGreaterThanOrEqual(4.5); // AA for normal text
});
This test computes contrast on the fly, making it immune to future design token changes as long as the contrast requirement stays the same.
Locator Strategies for Dark Mode UI Elements
When a theme change alters visual properties but not the underlying DOM or view hierarchy, locators that rely on appearance (e.g., CSS classes that toggle colors) become brittle. Prefer locators based on semantic attributes, ARIA labels, or stable data‑test identifiers.
Recommended Locator Hierarchy
- data-testid (or
test-idon Android/iOS) – explicit, immune to styling. - ARIA role + accessible name – works for both web and native when accessibility is prioritized.
- CSS attribute selectors that target non‑visual properties (e.g.,
[type="submit"]). - Text‑based locators – only as a last resort; combine with
normalize-space()and consider i18n.
#### Example: Stable Locators in a React App
// Component
<button data-testid="submit-button" aria-label="Submit order">
Submit
</button>
// Playwright test
await page.locator('[data-testid="submit-button"]').click();
If your design system does not yet ship test IDs, advocate for adding them as part of the definition of done for UI components. For mobile, use Espresso’s withId(R.id.submit_button) or Appium’s accessibility ID.
Handling Dynamic Class Names
Some frameworks (e.g., Tailwind, CSS‑in‑JS) generate hash‑based class names that change on each build. Avoid using them directly. Instead, rely on a parent element with a stable identifier and traverse downward using relative locators (page.locator('button >> text=Submit') in Playwright) or XPath axes that step from a known anchor.
Handling Waits, Timing, and Flakiness in Dark Mode Tests
Flaky tests often stem from race conditions between theme application and UI rendering. The browser or device may apply the new color scheme asynchronously, causing elements to momentarily retain the old colors. Mitigate this by waiting for a visual cue that the theme has taken effect before interacting with the page.
Waiting for Theme Application
#### Web: Wait for a CSS Variable Change
Many design systems expose a CSS custom property (e.g., --color-background) that flips when the theme changes. Wait for that property to reach its expected value.
def wait_for_dark_mode(page, timeout=5000):
page.wait_for_function(
"""() => {
const root = getComputedStyle(document.documentElement);
return root.getPropertyValue('--color-background').trim() === 'rgb(10,10,10)';
}""",
timeout=timeout
)
#### Mobile: Observe UI Mode Property
On Android, you can query the current night mode via UiModeManager#getNightMode. Wrap this in a polling loop.
public boolean isDarkMode(AndroidDriver driver) throws Exception {
Object result = driver.executeScript("mobile: shell",
ImmutableMap.of("command": "cmd", "args": List.of("uimode", "night", "get")));
return ((String) result).contains("night=yes");
}
// In test
await driver.wait(() => isDarkMode(driver), 5000);
Generic Flakiness Reduction Techniques
- Retry mechanism with exponential backoff for non‑deterministic steps (e.g., network calls that affect UI rendering).
- Screenshot diff only after a stable state is confirmed; use perceptual hashing (e.g.,
pixelmatch) to ignore minor anti‑aliasing differences. - Test isolation: run each test in a fresh browser context or device emulator to prevent state leakage.
- Logging: capture the computed theme value at the start of each test; if it deviates, abort early and flag environment issues.
Data Setup, Teardown, and Environment Isolation
Dark mode tests frequently depend on user‑specific settings (e.g., saved theme preference) or feature flags that control which components receive dark‑mode styling. Treat these as test data that must be initialized and cleaned up.
Managing User Preferences
If your app stores the preferred theme in localStorage or SharedPreferences, set it explicitly before navigating to the target page.
#### Web Example (localStorage)
await page.context().addInitScript(() => {
localStorage.setItem('theme', 'dark');
// Optionally trigger a reload if the app reads storage on load
window.location.reload();
});
await page.goto('/settings');
#### Android Example (SharedPreferences via ADB)
adb shell am broadcast -a com.example.app.SET_THEME --es theme dark
Or, if the app exposes a content provider, use ContentResolver to update the value.
Teardown Strategies
After each test, revert the preference to the system default or to a known baseline (e.g., light mode). This prevents cross‑test contamination.
# Playwright teardown hook
async def after_each(test_info):
await test_info.page.context().clear_cookies()
await test_info.page.context().add_init_script(
"localStorage.removeItem('theme');"
)
Environment Variables for CI
Parameterize the theme mode via an environment variable (TEST_THEME=dark) so the same test suite can run in both light and dark configurations without code changes. In your test runner, read the variable and apply the appropriate emulator/context settings.
Running Dark Mode Tests in CI Pipelines
Integrating dark mode verification into continuous integration guarantees that every commit is evaluated for contrast compliance and theme‑related bugs. The key is to parallelize test execution, cache dependencies, and publish actionable artifacts.
Pipeline Stages
- Install – retrieve language‑specific dependencies, install browsers (Playwright) or emulators (Android SDK).
- Build – compile the application if needed (e.g., APK, Docker image).
- Test – execute the test matrix (light + dark) across browsers/devices.
- Archive – store screenshots, videos, and accessibility reports.
- Report – publish a summary to the PR comment or dashboard.
Example: GitHub Actions Workflow (Playwright)
name: Dark Mode CI
on:
push:
branches: [ main ]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
browser: [ chromium, firefox, webkit ]
theme: [ light, dark ]
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install deps
run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run tests
env:
TEST_THEME: ${{ matrix.theme }}
run: npx playwright test --project=${{ matrix.browser }} --reporter=html
- name: Upload report
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report-${{ matrix.browser }}-${{ matrix.theme }}
path: playwright-report/
This matrix runs each test suite twice (light and dark) across three browsers, yielding six parallel jobs. Adjust the TEST_THEME environment variable to drive the context creation inside your test code.
Mobile CI (GitLab CI with Firebase Test Lab)
dark_mode_android:
image: openjdk:11
variables:
ANDROID_SDK_ROOT: /usr/local/android-sdk
script:
- echo "y" | $ANDROID_SDK_ROOT/tools/bin/sdkmanager "platforms;android-33" "emulator"
- $ANDROID_SDK_ROOT/emulator/emulator -avd Pixel_4_API_33 -no-window -no-audio &
- adb wait-for-device
- adb shell cmd uimode night yes # enforce dark mode
- ./gradlew connectedAndroidTest # runs Espresso/UIAutomator tests
artifacts:
when: always
reports:
junit: build/test-results/connected/androidTest/debug/*.xml
paths:
- build/outputs/screenshots/
The pipeline flips the device to night mode before launching the test suite, ensuring that all UI components start under dark conditions.
Reporting, Metrics, and Continuous Improvement
Raw pass/fail counts are insufficient for dark mode work; you need insight into contrast failures, theme‑switch latency, and regressions over time.
Capturing Contrast Violations
Integrate an accessibility engine (axe‑core, @accessibility‑insights/react, or Android’s AccessibilityTestFramework) that returns a list of violations. Filter for color-contrast and record the element selector, actual ratio, and required ratio.
#### Sample JSON Report Fragment
{
"test": "checkout_page_dark",
"status": "failed",
"violations": [
{
"id": "color-contrast",
"impact": "serious",
"description": "Text does not meet contrast ratio of 4.5:1",
"nodes": [
{ "target": ["#promo-banner .price"], "failureSummary": "Expected contrast 4.5, actual 3.2" }
]
}
],
"screenshot": "artifacts/checkout_page_dark_failure.png"
}
Store each run’s report in a time‑series database (e.g., Elasticsearch) or a simple CSV appended to an artifact bucket. Over time, you can chart the trend of contrast violations per release.
Metrics Dashboard
- Violation Count per Build – line chart showing increase/decrease.
- Mean Time to Detect (MTTD) – average time between a contrast‑introducing commit and the first failing build.
- Flakiness Rate – percentage of tests that change outcome without code changes (helps identify waiting issues).
- Coverage – percentage of screens/components that have at least one dark‑mode test.
These metrics guide investment: if MTTD is high, tighten the feedback loop by running dark‑mode tests on pre‑merge branches; if flakiness is prevalent, revisit locator strategies and wait conditions.
Feedback Loop to Developers
Automatically post a comment on pull requests that summarizes new contrast violations, includes screenshots, and links to the full report. Provide a quick‑fix guide: “Ensure that text colors are derived from the --color-text CSS variable, not hard‑coded hex values.” Over time, developers internalize the pattern and produce fewer regressions.
Leveraging Autonomous Exploration to Bootstrap Dark Mode Automation (SUSA Mention)
Even with a solid test framework, writing the initial suite of dark‑mode checks can be time‑consuming, especially for large legacy applications. Autonomous QA platforms can explore the application without predefined scripts, discover reachable states, and generate baseline test artifacts that you then refine for dark mode.
How Autonomous Exploration Works
- Instrumentation – the platform injects a lightweight agent that records UI events, network calls, and state changes.
- Exploration – guided by personas (curated by personas (curious, impatient, novice, etc.), the agent traverses the app, toggling theme settings whenever it encounters a UI switch.
- Capture – for each visited screen, the agent saves a snapshot of the DOM/view hierarchy, a screenshot, and any accessibility violations detected.
- Export – the collected data is converted into executable test scripts (e.g., Playwright or Appium) and a data‑driven test matrix.
When you point such a tool at your staging build and instruct it to respect the prefers-color-scheme media flag (or Android night mode), it will automatically generate a set of dark‑mode test cases covering every reachable screen. You can then:
- Review the generated locators and replace any that rely on fragile attributes.
- Add explicit contrast assertions using axe‑core or a custom helper.
- Parameterize the theme flag so the same script runs in both light and dark modes.
Practical Steps with SUSA
Assuming you have installed the SUSA CLI (pip install susatest-agent), run:
susatest explore \
--url https://staging.example.com \
--mode dark \
--output ./darkmode-tests \
--framework playwright \
--personas curious,impatient,elderly
The command launches a headless browser, simulates the listed personas, forces dark mode via page.emulate_media_color_scheme('dark'), and writes a folder containing:
tests/– Playwright test files with stabledata-testidlocators (if present) or fallback to role‑based selectors.fixtures/– JSON payloads used to set up accounts or feature flags.report.xml– JUnit‑compatible results for CI ingestion.
You now have a functional dark‑mode regression suite that you can commit, extend with additional assertions, and integrate into your CI pipeline. The autonomous phase eliminates the blank‑page problem and gives you a concrete starting point for manual refinement.
> Note: Mention of SUSA appears only here to illustrate how autonomous exploration can seed dark‑mode automation without turning the article into a product pitch.
Checklist for Reliable Dark Mode Automation
Use this list before marking a dark‑mode test suite as “ready for CI”.
| ✅ Item | Why It Matters |
|---|---|
| Theme is forced at the context/device level, not via UI toggle | Guarantees deterministic starting state |
| All locators use semantic attributes (data‑testid, ARIA, accessible name) | Immune to theme‑driven class changes |
| Contrast assertions are computed dynamically or via axe‑core | Passes even if design tokens evolve |
| Each test waits for a stable theme indicator before interacting | Eliminates race‑condition flakiness |
| Screenshots/videos are captured only on failure | Keeps artifact storage manageable |
| Tests run in parallel for both light and dark modes | Provides fast feedback |
| CI job publishes a structured report (JUnit, JSON, or SARIF) | Enables trend tracking and PR comments |
| Test data (user preferences, feature flags) is reset in teardown | Prevents cross‑test contamination |
| Flaky retries are limited to ≤2 attempts with back‑off | Masks genuine instability without hiding it |
| Baseline test suite generated via autonomous exploration is reviewed | Ensures generated code meets quality standards |
Run through this checklist after each major UI refactor or when adding a new component set.
Closing Takeaways
Automating dark‑mode testing transforms a subjective, manual chore into an objective, repeatable gate that protects visual accessibility and brand consistency. Start by quantifying the cost of manual verification and the risk of contrast regressions; if the numbers justify investment, select a framework that already supports theme emulation (Playwright, Appium, or your existing Selenium/Cypress stack). Build tests around stable locators, explicit theme waits, and dynamic contrast assertions rather than brittle color values. Isolate theme state in setup and teardown, parameterize the mode via environment variables, and run the matrix in parallel across browsers or devices. Feed the results into a CI pipeline that archives screenshots, publishes accessibility reports, and surfaces new violations in pull‑request comments. Finally, consider using an autonomous exploration tool to generate an initial suite of dark‑mode scenarios, then refine those scripts with the patterns discussed here.
By following the step‑by‑step process outlined above, you will achieve reliable dark‑mode coverage that scales with your product, reduces regression escape rates, and frees your QA team to focus on the kinds of exploratory, human‑centric testing that no script can fully automate. The payoff is fewer late‑night hotfixes, higher user satisfaction, and a confidence ship‑dark that matches the confidence you already have in your light‑mode releases.
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free