Test Your Web App in Dark Mode

December 13, 2019

By Gleb Bahmutov

Recently, operating systems iOS13, Android 10, MacOS Catalina and Windows 10 have introduced Dark Mode support with most browsers supporting CSS prefers-color-scheme. On Mac, you can pick Light (default), Dark or Auto mode via System Preferences / General options. I believe the Apple UI designers are really in love with this feature - it is placed at the very first position!

MacOS Catalina 10.15 System Preferences / General

Dark and Light styles

If you are building a website or a web application you can specify styles to apply when the user's OS has Dark or Light scheme appearance.

/* default CSS styles */
@media (prefers-color-scheme: dark) {
  /* overwrite any default styles when 
     the user has set the Dark appearance */
}
@media (prefers-color-scheme: light) {
  /* overwrite any default styles when 
     the user has set the Light appearance */
}

I recommend NOT placing the additional overrides in the same CSS file - just like media: print they will just increase the download size even if they are NOT applied. Instead I recommend placing the overrides in a separate stylesheet resource with media=(prefers-color-scheme: ...) attribute.

<head>
  <meta charset="utf-8">
  <title>React • TodoMVC</title>
  <link rel="stylesheet" href="node_modules/todomvc-common/base.css">
  <link rel="stylesheet" href="node_modules/todomvc-app-css/index.css">
  <link rel="stylesheet" media="(prefers-color-scheme: dark)" href="dark.css">
</head>

The dark.css will only be downloaded and applied if the user's OS has a Dark appearance and the browser supports it.

Note: you can find the source code for this blog post in bahmutov/todomvc-light-and-dark repository.

The Dark appearance might really play havoc with your web application. A typical TodoMVC app with todomvc-app-css styles using the "standard" black fonts on a white or gray background might look ok.

React TodoMVC example with Light appearance

If you decide to support a Dark appearance, and just flip the background and foreground colors like this

/* dark.css */
body,
.todoapp {
  color: #ddd;
  background-color: #222;
}

The result does not look good

Supporting a Dark appearance goes beyond inverting background and font colors

Forcing Dark appearance

Designing a good Dark appearance style takes work, which brings me to the main topic of this blog post - how do you test your web application using a Light or Dark appearance?

By passing a special Chrome browser command line switch when running End-to-End tests of course! Cypress has a mechanism to specify extra browser flags when launching. We can modify the cypress/plugins/index.js file like this:

module.exports = (on, config) => {
  // `on` is used to hook into various events Cypress emits
  // `config` is the resolved Cypress config

  // modify browser launch arguments
  // https://on.cypress.io/browser-launch-api
  on('before:browser:launch', (browser = {}, args) => {
    console.log('browser', browser)

    if (browser.family === 'chrome') {
      console.log('adding dark mode browser flags')
      args.push('--force-dark-mode=true')

      return args
    }
  })
}

The command line flag --force-dark-mode=true will force the Chrome browser to use the Dark appearance even if the host OS has the Light appearance set. Here is an example test that adds a couple of items and completes the first one.

it('adds 2 todos', function () {
  cy.visit('/')
  cy.get('.new-todo')
    .type('learn testing{enter}')
    .type('be cool{enter}')

  cy.get('.todo-list li').should('have.length', 2)
    .first().find('.toggle').check()

  cy.contains('li', 'learn testing')
    .should('have.class', 'completed')
})

Here is the test in action - with our plugins file forcing Chrome to use Dark appearance styles.

Test running against a website with forced dark media preference

Pro tip: you can see all Chrome command line flags that Cypress uses to launch Chrome, plus your extra command line switches by opening a new tab during testing and going to chrome://version url.

Forcing a Dark appearance using JavaScript

Unfortunately, I could not find a command line switch to do the opposite: force the Light mode while the host OS has the Dark appearance set. As a work-around I have changed the dark stylesheet link to be loaded using JavaScript based on the window.matchMedia method call.

<head>
  <!-- other styles -->
  <script>
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
	  const link = document.createElement("link")
	  link.type = 'text/css';
	  link.rel = 'stylesheet';
	  link.href = 'dark.css';
	  document.head.appendChild(link)
	}
  </script>
</head>

We can now remove the Chrome command line argument --force-dark-mode=true from the plugins file. Instead, during tests, we can stub the window.matchMedia call as needed. For example: cypress/integration/dark-spec.js can test the Dark appearance.

/// <reference types="cypress" />
beforeEach(() => {
  cy.visit('/', {
    onBeforeLoad (win) {
      cy.stub(win, 'matchMedia')
      .withArgs('(prefers-color-scheme: dark)')
      .returns({
        matches: true,
      })
    },
  })
})

it('adds 2 todos', function () {
  cy.get('.new-todo')
  .type('learn testing{enter}')
  .type('be cool{enter}')

  cy.get('.todo-list li').should('have.length', 2)
  .first().find('.toggle').check()

  cy.contains('li', 'learn testing').should('have.class', 'completed')
})

The test passes, and we can see in the Command Log that the matchMedia stub was really called.

We can even refactor the test itself into a reusable function to test both the dark and the light appearances. The following spec file runs the same test with two different media preferences.

const visit = (darkAppearance) =>
  cy.visit('/', {
    onBeforeLoad (win) {
      cy.stub(win, 'matchMedia')
      .withArgs('(prefers-color-scheme: dark)')
      .returns({
        matches: darkAppearance,
      })
    },
  })

const addsTodos = () => {
  cy.get('.new-todo')
  .type('learn testing{enter}')
  .type('be cool{enter}')

  cy.get('.todo-list li').should('have.length', 2)
  .first().find('.toggle').check()

  cy.contains('li', 'learn testing').should('have.class', 'completed')
}

it('adds 2 todos with light appearance', function () {
  visit(false)
  addsTodos()
})

it('adds 2 todos with dark appearance', function () {
  visit(true)
  addsTodos()
})

Reviewing the spec video that runs through most or all features of the site is a great way to catch weird or incorrect application styles that users might see when their OS prefers the Dark mode.

The same test running with a different media preference

Once you are loading the default, dark and light styles in your tests, you can run accessibility color tests using the Cypress plugin cypress-axe. You can also make the appearance tests part of your smart smoke tests.