An edge-case when I would use tests that depend on each other.
Imagine I ask you to do the following exercise using Cypress:
Pick 10 random US cities
For each city:
Check the temperature forecast for the next day
If the temperature is between 17C and 20C stop
Otherwise go to the next city
If there are no cities with the ideal temperature forecast, log a message
So this is not a real end-to-end test, but a fun application of Cypress, just like crawling web pages, or playing Wordle using Cypress. Let's see how we can do this.
🎁 You can find the full source code for this blog post in the repo bahmutov/crawl-weather.
This blog post gives a good example of using a few of my favorite Cypress plugins, and I have an entire course about them.
Pick 10 random US cities
First, let's pick 10 cities randomly. We can use Wikipedia's list of US cities. There is a table with the city names in the second column.
Let's do this. We can scaffold a new project and add Cypress. We can put the Wikipedia base url as our test base url because that is the page we intend to visit.
The test at first visits the page so we can see the HTML markup for the table.
cypress/e2e/spec.cy.js
1 2 3
it('fetches 10 random US cities', () => { cy.visit('/wiki/List_of_United_States_cities_by_population') })
The page loads and we can see the table with the cities is the second table on the page. Let's grab its second column and extract the inner text from each cell
cypress/e2e/spec.cy.js
1 2 3 4 5 6 7 8 9
it('fetches 10 random US cities', () => { cy.visit('/wiki/List_of_United_States_cities_by_population') cy.get('table.wikitable.sortable') .should('have.length.gte', 1) .first() .find('tbody') .invoke('find', 'tr th+td') .should('have.length.greaterThan', 10) })
The table with the city names are found.
Let's pick 10 random cells from the 330 cells returned by the query.
Let's start the second test. It will fetch the weather for one city for now using wttr.in. We want the output in JSON format.
cypress/e2e/spec.cy.js
1 2 3 4 5 6 7 8 9 10
it('fetches weather', () => { cy.readFile('cities.json').then((cities) => { const cityName = cities[0] cy.request(`https://wttr.in/${cityName}?format=j1`) .its('body') // cy.tap() comes from cypress-map // and by default prints the current subject using console.log .tap() }) })
Tip: we can "see" the weather by requesting the default HTML page that draws the weather in ASCII art.
To get the forecast, we need to take the nested property weather.0.avgtempC
cypress/e2e/spec.cy.js
1 2 3 4 5 6 7 8 9 10 11 12 13
it('fetches weather', () => { cy.readFile('cities.json').then((cities) => { const cityName = cities[0] cy.request(`https://wttr.in/${cityName}?format=j1`) .its('body') // cy.tap() comes from cypress-map // and by default prints the current subject using console.log .tap() .its('weather.0.avgtempC') // cy.print in cypress-map .print(`${cityName} average tomorrow is %dC`) }) })
Recursion
We want to find the magical city with a comfortable weather forecast in the range 17C-20C. We have the code to check one city. Now let's use the recursion to decide if we need to check the next city or stop.
it('fetches weather until we find a comfortable city', () => { constgetForecast = (cities) => { if (cities.length < 1) { cy.log('No more cities to check') return } cy.print(`${cities.length} cities remaining`) // always check the last city // and remove it from the remaining list const cityName = cities.pop() cy.request(`https://wttr.in/${cityName}?format=j1`) .its('body') // cy.tap() comes from cypress-map // and by default prints the current subject using console.log .tap() .its('weather.0.avgtempC') .print(`${cityName} average tomorrow is %dC`) .then((temperature) => { if (temperature >= 17 && temperature <= 20) { cy.log(`People in ${cityName} are lucky`) } else { // call the weather check again // with the shorter list of cities to check getForecast(cities) } }) }
cy.readFile('cities.json') // kick off the search .then(getForecast) })
Every time you use recursion you need 3 things in your code:
The function R that checks if it needs to stop
1 2 3 4 5 6 7
constgetForecast = (cities) => { if (cities.length < 1) { cy.log('No more cities to check') return } ... }
The recursive call with a smaller data set
1 2 3 4 5 6 7 8 9 10 11 12 13
// always check the last city // and remove it from the remaining list const cityName = cities.pop() ... .then((temperature) => { if (temperature >= 17 && temperature <= 20) { cy.log(`People in ${cityName} are lucky`) } else { // call the weather check again // with the shorter list of cities to check getForecast(cities) } })
The initial call to the function R to start it
1 2 3
cy.readFile('cities.json') // kick off the search .then(getForecast)
Ok, in our case we checked 10 cities and could not find any with a comfortable temperature forecast.
Let's rerun both tests together, refetching the new list, saving it, then searching through it.
it('finds the city with comfortable weather using cypress-recurse', () => { cy.readFile('cities.json').then((cities) => { // always check the last city // and remove it from the remaining list let cityName = cities.pop() recurse( // get the temperature for the current city // yields the temperature number () => cy .request(`https://wttr.in/${cityName}?format=j1`) .its('body.weather.0.avgtempC') .then(Number) .should('be.within', -30, 50) .print(`${cityName} average tomorrow is %dC`), // predicate to check if we should stop (temperature) => temperature >= 17 && temperature <= 20, // recursion options { log(temperature, { successful }) { if (successful) { cy.log(`People in ${cityName} are lucky`) } }, limit: cities.length, timeout: 10_000, post() { // go to the next city cityName = cities.pop() }, }, ) }) })
Cities fixture file
We could merge the two tests into one. But I do like the cy.writeFile / cy.readFile mechanism, since it allows me to quickly iterate over each test without waiting for the cities to be re-fetched from the Wikipedia, while I am tweaking the recursive code in getForecast. Normally, having a dependency between the test is an anti-pattern. But in our case, there is a real dependency: if we cannot fetch the cities from Wikipedia, our recursive search would not work at all. A middle ground between two independent tests and a single test via cities.json file seems ok.
Just to make sure we can always verify the wttr.in service, we can save a copy of cities.json as fixture file in our repo and have a separate test that goes through the cities.
cypress/fixtures/two.json
1
["Boston","Detroit"]
Let's write a test that confirms we can fetch the temperatures. We load the fixture JSON file and call the getForecast. We can even add a little bit of validation to make sure we extract reasonable temperature numbers
cypress/e2e/spec.cy.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// in getForecast const cityName = cities.pop() cy.request(`https://wttr.in/${cityName}?format=j1`) .its('body') // cy.tap() comes from cypress-map // and by default prints the current subject using console.log .tap() .its('weather.0.avgtempC') .then(Number) .should('be.within', -30, 50) .print(`${cityName} average tomorrow is %dC`)
it.only('fetches forecast', () => { cy.fixture('two.json') // kick off the search .then(getForecast) })
Finally, lets check the response from wttr.in service using cy-spok
Let's imagine you do want to make the tests independent of each other, yet preserve the speed. Saving the cities.json in one test and reading it from another test might not be ideal. Let's get the list of cities in the test itself, yet cache it using cypress-data-session plugin.
constcheckForecast = (cities) => { // always check the last city // and remove it from the remaining list let cityName = cities.pop() recurse( () => cy .request(`https://wttr.in/${cityName}?format=j1`) .its('body.weather.0.avgtempC') .then(Number) .should('be.within', -30, 50) .print(`${cityName} average tomorrow is %dC`), (temperature) => temperature >= 17 && temperature <= 20, { log(temperature, { successful }) { if (successful) { cy.log(`People in ${cityName} are lucky`) } }, limit: cities.length, timeout: 30_000, post() { cityName = cities.pop() }, }, ) }
it('fetches the cities and checks the forecast', () => { // if there are no cities yet (cached in memory) // fetches the list and stores it in memory // else returns the same list cy.dataSession({ name: 'cities', setup: fetchCities, }) // because our recursive function modifies the list // let's make sure it is a copy of the cached list of cities .apply(structuredClone) .print() .then(checkForecast) })
The first time we run this function, the list is fetched from Wikipedia
Then the list is run through the checkForecast recursive function. Let's say we want to tweak the test. If we simply tweak the checkForecast steps or click "R" to re-run the test, the cached copy of the list will be used from memory, skipping the fetchCities function completely.
Stay warm.
PS: This blog post was written in Montreal during a -22C day.