Imagine a small web page application that shows a window Confirm popup when the user clicks a button. The HTML might look like this
<html lang="en">
<body>
<main>
<h1>Assertion counting</h1>
<p>Click on the button, should show Window confirm dialog</p>
<button id="click">Click</button>
</main>
<script>
const confirmIt = () => {
window.confirm('Are you sure?')
}
document.getElementById('click').addEventListener('click', confirmIt)
</script>
</body>
</html>
The application works.
We can write a test to confirm (pun intended) that the application works:
/// <reference types="cypress" />
describe('Window confirm', () => {
it('calls window confirm', () => {
cy.visit('index.html')
cy.on('window:confirm', (message) => {
expect(message).to.equal('Are you sure?')
})
cy.get('#click').click()
})
})
Cypress shows the test is passing.
Note: you can find the source code for this blog post in the Blogs section of the Cypress Example Recipes repository.
But there is a subtle problem with this test, and it becomes obvious when the application under test becomes a little more realistic. Imagine the application shows the Confirm prompt after a delay - maybe the web application needs to make an asynchronous call to the server before showing the prompt. Let's change the source code and add a 1 second delay between the button click and the window.confirm
call.
const confirmIt = () => {
// show the confirm prompt after a delay
setTimeout(() => {
window.confirm('Are you sure?')
}, 1000)
}
document.getElementById('click').addEventListener('click', confirmIt)
Our test is still passing.
Notice how the test finished after 140ms, yet the assertion gets added after 1 second. Let's see what happens if the assertion fails.
/// <reference types="cypress" />
describe('Window confirm', () => {
it('calls window confirm', () => {
cy.visit('index.html')
cy.on('window:confirm', (message) => {
// make the assertion fail on purpose
expect(message).to.equal('A test')
})
cy.get('#click').click()
})
})
The test ... still passes, even with a red failing assertion shown:
We have a problem - our test finishes before the assertion runs. Thus, the assertion when failing cannot change the status of the test. Even worse - if there are tests that follow, the failing assertion can break those tests instead.
A test should not stop until all assertions have passed. We can modify our test to "wait" for the expect
to execute, and we can do it in a variety of ways.
it('waits for window confirm to happen using spy', () => {
cy.visit('index.html')
cy.on('window:confirm', cy.stub().as('confirm'))
cy.get('#click').click()
// test automatically waits for the stub
cy.get('@confirm').should('have.been.calledWith', 'Are you sure?')
})
We can fail the assertion on purpose - the test behaves as expected
it('waits for window confirm to happen using spy', () => {
cy.visit('index.html')
cy.on('window:confirm', cy.stub().as('confirm'))
cy.get('#click').click()
// test automatically waits for the stub
// make the assertion fail on purpose
cy.get('@confirm').should('have.been.calledWith', 'A test')
})
We can also ensure the window:confirm
event has happened by setting a local variable and re-trying should(cb)
until it has been set.
it('waits for window confirm to happen using variable', () => {
cy.visit('index.html')
let called
cy.on('window:confirm', (message) => {
expect(message).to.equal('Are you sure?')
called = true
})
cy.get('#click').click()
// test automatically waits for the variable "called"
cy.wrap(null).should(() => {
expect(called).to.be.true
})
})
The test waits automatically and does not finish prematurely.
We can also chain a Promise-returning function to Cypress commands - the test again will wait for the promise to resolve before finishing
it('waits for window confirm to happen using promises', () => {
cy.visit('index.html')
let calledPromise = new Promise((resolve) => {
cy.on('window:confirm', (message) => {
expect(message).to.equal('Are you sure?')
resolve()
})
})
// test automatically waits for the promise
cy.get('#click').click().then(() => calledPromise)
})
We can even use a very popular plugin, cypress-wait-until, to wait for a predicate:
import 'cypress-wait-until'
it('waits for window confirm to happen using cy.waitUntil', () => {
cy.visit('index.html')
let called
cy.on('window:confirm', (message) => {
expect(message).to.equal('Are you sure?')
called = true
})
cy.get('#click').click()
// see https://github.com/NoriSte/cypress-wait-until
cy.waitUntil(() => called)
})
Assertion counting
In all of the above tests, the user had to write code to ensure the tests finish only after all assertions have run. We can generalize this feature and implement a number of expected assertions in the test. The user should declare how many assertions the test has, and the Test Runner can check after each test if the number of actual assertions counted matches the expected number. If the number is different, the test fails. This feature is present in some testing frameworks, see t.plan in Ava and Tape. I have implemented a similar feature as a user plugin cypress-expect-n-assertions. Here is our test again - I will make it fail on purpose (otherwise the check is silent):
import { plan } from 'cypress-expect-n-assertions'
it('waits for window confirm to happen using stub', () => {
plan(0) // set wrong number of expected assertions
cy.visit('index.html')
let called
cy.on('window:confirm', (message) => {
expect(message).to.equal('Are you sure?')
called = true
})
cy.get('#click').click()
// see https://github.com/NoriSte/cypress-wait-until
cy.waitUntil(() => called)
})
The test finishes after 1 assertion has run, but because of plan(0)
it fails for this demo:
The plugin cypress-expect-n-assertions
has one more surprise under its sleeve. If the number of counted assertions during the test is lower than the expected number, it waits automatically for more assertions to run (up until defaultCommandTimeout). Thus our test can simply be:
import { plan } from 'cypress-expect-n-assertions'
it('waits for planned number of assertion to run', () => {
plan(1)
cy.visit('index.html')
cy.on('window:confirm', (message) => {
expect(message).to.equal('Are you sure?')
})
cy.get('#click').click()
})
That's pretty slick.
See also
- When Can The Test Start?
- When Can the Test Click?
- Ava test runner documentation has a great page explaining when to use assertion counting and when not to use it.
- The source code for this blog post is in the Blogs section of the Cypress Example Recipes repository.