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.