Testing the Browser Notification API

January 24, 2020

•

By Gleb Bahmutov

How do you test native browser APIs like Notification? If you don't have a real modern browser during tests, it seems like a challenge. But Cypress controls an Electron or Chrome or (soon) Firefox browser, so your tests can access the real thing. This blog post shows typical tests for a tiny web application showing notification popups - but only if the user has allowed them.

Note: you can find the full source code for this post in "Browser notifications" recipe in cypress-example-recipes repo.

Here is the application under test. It is a single HTML page.

<body>
  <script>
    function notifyMe() {
      // Let's check if the browser supports notifications
      if (!("Notification" in window)) {
        alert("This browser does not support desktop notification");
        return
      }

      // Let's check whether notification permissions have already been granted
      if (Notification.permission === "granted") {
        console.log('permission was granted before')
        // If it's okay let's create a notification
        new Notification("Permission was granted before");
        return
      }

      // Otherwise, we need to ask the user for permission
      if (Notification.permission !== "denied") {
        console.log('need to ask for permission')
        Notification.requestPermission().then(function (permission) {
          console.log('permission requested, the user says'. permission)
          // If the user accepts, let's create a notification
          if (permission === "granted") {
            console.log('permission granted, showing hi')
            console.log(Notification)
            new Notification("Hi there!");
          }
        });

        return
      }

      // At last, if the user has denied notifications, and you
      // want to be respectful there is no need to bother them any more.
      console.log('Permission was denied before')
    }
  </script>
  <button onclick="notifyMe()">Notify me!</button>
</body>

Let's write a first test to confirm that the browser supports notifications and they indeed show up.

/// <reference types="Cypress" />
describe('Browser notifications', () => {
  it('are supported by the test browser', () => {
    cy.visit('index.html')
    cy.window().should('have.property', 'Notification').should('be.a', 'function')
  })
})

If we run the test, and click on the "Notify me!" button, the Mac OS asks me if I want to enable notifications for the Cypress app.

Mac OS notification settings

I will enable notifications - if I want to see real popups during tests. Most of the tests we are about to write will stub Notification properties to avoid tens of popups. We can trust the browser to implement the Notification API correctly - it is our web application code that we need to test.

Enabled Cypress notifications
When you click the button, a notification shows up

What happens if the browser does NOT support notifications? The web app has the following logic:

// Let's check if the browser supports notifications
if (!("Notification" in window)) {
  alert("This browser does not support desktop notification");
  return
}

And here is the test for it: we will simply delete the window.Notification property before loading the web application.

it('shows alert if the browser does not support notifications', () => {
  cy.visit('index.html', {
    onBeforeLoad (win) {
      delete win.Notification
    },
  })

  cy.on('window:alert', cy.stub().as('alerted'))
  cy.get('button').click()
  cy.get('@alerted')
    .should('have.been.calledOnce')
    .and('have.been.calledWith', 'This browser does not support desktop notification')
})

The third test can check the branch of the code when the user has previously granted the web application permission to show notifications.

// Let's check whether notification permissions have already been granted
if (Notification.permission === "granted") {
  console.log('permission was granted before')
  // If it's okay let's create a notification
  new Notification("Permission was granted before");
  return
}

The test has to stab the Notification.permission property and spy or stub the Notification constructor.

it('creates Notification if was previously granted', () => {
  // see cy.visit options in https://on.cypress.io/visit
  cy.visit('index.html', {
    onBeforeLoad (win) {
      // https://on.cypress.io/stub
      cy.stub(win.Notification, 'permission', 'granted')
      cy.stub(win, 'Notification').as('Notification')
    },
  })

  cy.get('button').click()
  cy.get('@Notification')
    .should('have.been.calledWithNew')
    .and('have.been.calledWithExactly', 'Permission was granted before')
})

If the permission was neither denied nor granted, the web application asks the user and shows the notification if the user has agreed.  The application code is:

// Otherwise, we need to ask the user for permission
if (Notification.permission !== "denied") {
  console.log('need to ask for permission')
  Notification.requestPermission().then(function (permission) {
    console.log('permission requested, the user says'. permission)
    // If the user accepts, let's create a notification
    if (permission === "granted") {
      console.log('permission granted, showing hi')
      console.log(Notification)
      new Notification("Hi there!");
    }
  });

  return
}

The test can be clever and check the calls AND the call order.

it('asks for permission first, then shows notification if granted', () => {
  cy.visit('index.html', {
    onBeforeLoad (win) {
      cy.stub(win.Notification, 'permission', 'unknown')
      cy.stub(win.Notification, 'requestPermission').resolves('granted').as('ask')
      cy.stub(win, 'Notification').as('Notification')
    },
  })

  cy.get('button').click()
  cy.get('@ask')
    .should('have.been.calledOnce')
    .and('have.been.calledBefore', cy.get('@Notification'))
})

If the user has denied permission - no calls should be made. We can write a test:

it('does not show notification if permission was denied before', () => {
  cy.visit('index.html', {
    onBeforeLoad (win) {
      cy.stub(win.Notification, 'permission', 'denied')
      cy.stub(win.Notification, 'requestPermission').resolves('denied').as('ask')
      cy.stub(win, 'Notification').as('Notification')
    },
  })

  cy.get('button').click()
  cy.get('@Notification').should('not.have.been.called')
})

To make sure our tests cover all statements and logical paths in the web application, we could use code coverage from E2E tests - but I think we got all the important parts of the app covered. It is a pretty small app after all.

Happy Testing!

See also