Testing an Application in Offline Network Mode

November 12, 2020

•

By Gleb Bahmutov

Modern web applications need to continue working when a network is unavailable, or at least show users the current network status. In this blog post, I will show how Cypress can simulate an offline network status during a test.

Note: the source code for this blog post can be found in the "Offline" recipe.

A web application can ask the browser if there is a network connection; there is a widely supported navigator.onLine API. For example, the application can show the current network status to the user:

const updateNetworkStatus = () => {
  const el = document.getElementById('network-status')
  const text = window.navigator.onLine ? '🟢 online' : '🟥 offline'

  el.innerText = text
}

updateNetworkStatus()
window.addEventListener('offline', updateNetworkStatus)
window.addEventListener('online', updateNetworkStatus)
Application shows the current network status

We can simulate the offline network mode right from the browser. For example, in Chrome we can open the DevTools, click Command + Shift + P to open the command palette and execute the "Go offline" command to switch the browser to offline mode. The application behaves the same way as when turning the laptop's WiFi off.

Go Offline During Test

Let's see how we can go offline during a Cypress test. One of the things Cypress has (but does not document yet) is access to the native Chrome automation commands. These commands are exactly the same commands as the ones sent by the DevTools itself. During the test we can turn on Network control by first calling the following command from the spec file:

Cypress.automation('remote:debugger:protocol',
  {
    command: 'Network.enable',
  }
)

The command Network.enable comes from Chrome Debugger Protocol. Because this is a Promise-returning command, we need to "tell" Cypress to wait for it to complete. We can do this by placing it inside a .then command. For example, I like logging a message to the console and then turning the network off.

cy.log('**go offline**')
  .then(() => {
    return Cypress.automation('remote:debugger:protocol',
      {
        command: 'Network.enable',
      })
  })

But that's not all. Once we enable Network control, we need to actually go offline using the Network.emulateNetworkConditions command. This command requires several parameters, which we can pass as the third argument to the Cypress.automation call.

const goOffline = () => {
  cy.log('**go offline**')
  .then(() => {
    return Cypress.automation('remote:debugger:protocol',
      {
        command: 'Network.enable',
      })
  })
  .then(() => {
    return Cypress.automation('remote:debugger:protocol',
      {
        command: 'Network.emulateNetworkConditions',
        params: {
          offline: true,
          latency: -1,
          downloadThroughput: -1,
          uploadThroughput: -1,
        },
      })
  })
}

We are only interested in the full offline mode, thus we set latency and other parameters to their defaults.

Our full test now looks like this:

it('shows network status', () => {
  cy.visit('/')
  cy.contains('#network-status', 'online')
    .wait(1000) // for demo purpose

  goOffline()
  cy.contains('#network-status', 'offline')
    .wait(1000) // for demo purpose
})

Let's start the server and run the test.

Going Online

While Cypress is running, it needs to communicate with the browser. Thus, the browser cannot be offline forever—it needs to communicate the status of every test back to the Cypress Electron App. So we need to make sure to enable network back again at the end of every test.

const goOnline = () => {
  // disable offline mode, otherwise we will break our tests :)
  cy.log('**go online**')
  .then(() => {
    // https://chromedevtools.github.io/devtools-protocol/1-3/Network/#method-emulateNetworkConditions
    return Cypress.automation('remote:debugger:protocol',
      {
        command: 'Network.emulateNetworkConditions',
        params: {
          offline: false,
          latency: -1,
          downloadThroughput: -1,
          uploadThroughput: -1,
        },
      })
  })
  .then(() => {
    return Cypress.automation('remote:debugger:protocol',
      {
        command: 'Network.disable',
      })
  })
}

Just to be safe, we need to enable the online mode before and after each test - just to be sure we are starting the test in a "good" state and that we don't rely on the previous test to clean it up.

// make sure we get back online, even if a test fails
// otherwise the Cypress can lose the browser connection
beforeEach(goOnline)
afterEach(goOnline)

Network status

If our tests enable and disable the network, it would be nice to "wait" from the spec for the change to take effect. We can listen to the same window.navigator.onLine value as the application itself, and we can use built-in retry-ability to wait declaratively:

// use window.navigator.onLine property to determine
// if the browser is offline or online
// https://caniuse.com/online-status
const assertOnline = () => {
  return cy.wrap(window).its('navigator.onLine').should('be.true')
}

const assertOffline = () => {
  return cy.wrap(window).its('navigator.onLine').should('be.false')
}

// from the test
goOffline()
assertOffline()

Notice we wrapped the window object without using cy.window. Cypress spec runs in an iframe, while the application under test loads in its own iframe. The window objects ARE different in these iframes, but the browser sets the window.navigator.onLine status on every window object, so we can check the spec's own window object.

Supported browsers

The automation commands only use the Chrome Debugger Protocol for now, thus we need to limit our Test Runner to run on a compatible browser like the built-in Electron, Chrome, and even the new Microsoft Edge. The only browser that cannot run these tests is Firefox. Thus, we can skip a single test, or a suite of tests using test configuration parameter.

// since we are using Chrome debugger protocol API
// we should only run these tests when NOT in Firefox browser
// see https://on.cypress.io/configuration#Test-Configuration
describe('offline mode', { browser: '!firefox' }, () => {
  it('shows network status', () => {
     ...
  })
})

Application Behavior

Let's confirm that the application shows an error message when trying to fetch users in the offline mode. Here is the relevant piece of the app's code

document.getElementById('load-users').addEventListener('click', () => {
  console.log('loading users')
  document.querySelector('#users').innerText = ''

  fetch('https://jsonplaceholder.cypress.io/users?_limit=3')
  .then((r) => r.json())
  .then((users) => {
    console.table(users)

    const usersHtml = users.map((user) => {
      return `<li class="user">${user.id} - ${user.email}</li>`
    }).join('\n')

    document.querySelector('#users').innerHTML = usersHtml
  })
  .catch((e) => {
    console.error('problem fetching users', e)
    document.querySelector('#users').innerText = 
      `Problem fetching users ${e.message}`
  })
})

The test can confirm the error message appears in the DOM, and then enable the network and try fetching the users again.

it('shows error trying to fetch users in offline mode', () => {
  cy.visit('/')
  assertOnline()

  // since this call returns a promise, must tell Cypress to wait
  // for it to be resolved
  goOffline()
  assertOffline()

  cy.get('#load-users').click()
  cy.contains('#users', 'Problem fetching users Failed to fetch')

  // now let's go back online and fetch the users
  goOnline()
  cy.get('#load-users').click()
  cy.get('.user').should('have.length', 3)
})
The application shows an error when failing to fetch data in offline mode

See the cypress/integration/offline-spec.js from the recipe for more tests showing fetch and network spying using cy.route2 when in offline and online modes.

More info