Pick Tests By Network Calls

Published on April 2, 2025

How do you pick the tests to run? Do you run the changed specs first? Run tests that visit a particular page? Pick tests using test tags? This blog post will show yet another way of picking end-to-end Cypress tests to execute: by the network calls the tests make.

We can track the network calls each test makes using the super-powerful and awesome cy.intercept command:

cypress/support/e2e.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
beforeEach(function () {
cy.intercept(
{
resourceType: 'xhr',
},
(req) => {
const method = req.method
const parsed = new URL(req.url)

let pathname = parsed.pathname
// remove the random part of the pathname
if (/\/todos\/\d+/.test(pathname)) {
pathname = '/todos/:id'
}
console.log('intercepted', method, pathname)
},
)
})

We can see the intercepted network calls in the DevTools console

Intercepted network calls

We need to store these API calls somewhere. If you are using my plugin cypress-visited-urls it exposes a static method Cypress.addVisitedTestEvent that you can use to send custom events. The plugin stores these events and even keeps track of the event counts for each test.

cypress/support/e2e.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
beforeEach(function () {
// this method comes from the plugin
// https://github.com/bahmutov/cypress-visited-urls
if (Cypress.addVisitedTestEvent) {
cy.intercept(
{
resourceType: 'xhr',
},
(req) => {
const method = req.method
const parsed = new URL(req.url)

let pathname = parsed.pathname
// remove the random part of the pathname
if (/\/todos\/\d+/.test(pathname)) {
pathname = '/todos/:id'
}
console.log('intercepted', method, pathname)

Cypress.addVisitedTestEvent({
label: 'API',
data: { method, pathname },
})
},
)
}
})

🎁 You can find the complete source code shown in this blog post in the branch blog-post of the repo bahmutov/called-urls-examples.

Super, so if we run this test even once, it will save a JSON file

cypress-visited-urls.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"cypress/e2e/complete-todo.cy.js": {
"Todo app / completes a todo": {
"urls": [
{
"url": "/app/index.html",
"duration": 280,
"commandsCount": 13
}
],
"testEvents": [
{
"label": "API",
"data": {
"method": "GET",
"pathname": "/todos"
},
"count": 1
},
{
"label": "API",
"data": {
"method": "PATCH",
"pathname": "/todos/:id"
},
"count": 1
},
{
"label": "API",
"data": {
"method": "DELETE",
"pathname": "/todos/:id"
},
"count": 1
}
]
}
}
}

You should commit this file with your source code update it periodically when running the tests on CI.

Finding the specs

Next, we need to define a Node script to find the specs by a network call. For example, we want to find all specs that delete items.

bin/find-specs.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
#!/usr/bin/env node

// @ts-check

const debug = require('debug')('called-urls-examples')
const core = require('@actions/core')
const arg = require('arg')
const args = arg({
// HTTP method name, case insensitive like "GET"
'--method': String,
// pathname, case sensitive like "/todos/:id"
'--path': String,
// output a list of specs or a table, list is the default
'--output': String,
// name of the GHA output, like "foundSpecs"
'--set-gha-outputs': String,
// limit the number of results, default is Infinity
'--max': Number,
})
debug('args', args)

// HTTP method is case-insensitive
const method = (args['--method'] || '*').toUpperCase()
// Path is case-sensitive
const path = args['--path'] || '*'
const outputFormat = args['--output'] || 'list'
const max = args['--max'] || Infinity
debug({ method, path, outputFormat, max })

const matches = (eventData) => {
if (method !== '*' && eventData.method !== method) {
return false
}

if (path !== '*' && !eventData.pathname.includes(path)) {
return false
}

return true
}

const visitedUrls = require('../cypress-visited-urls.json')
const summed = {}
debug('found info on %d specs', Object.keys(visitedUrls).length)

Object.entries(visitedUrls).forEach(([specFilename, testData]) => {
// console.log(specFilename)
Object.entries(testData).forEach(([testName, test]) => {
const testEvents = test.testEvents
testEvents
.filter((event) => event.label === 'API')
.forEach((event) => {
if (matches(event.data)) {
debug('Found match', event.data)
const eventCount = event.count || 1
if (!summed[specFilename]) {
summed[specFilename] = { count: eventCount }
debug('first match', specFilename, event.data)
} else {
debug(
'adding match count %d to the existing count %d',
eventCount,
summed[specFilename].count,
)
summed[specFilename].count += eventCount
}
}
})
})
})

const sorted = Object.entries(summed)
.sort((a, b) => {
return b[1].count - a[1].count
})
.map(([specFilename, data]) => {
return { specFilename, ...data }
})
.slice(0, max)
debug('found %d specs', sorted.length)
debug(sorted)

if (outputFormat === 'list') {
console.log(sorted.map((s) => s.specFilename).join(','))
} else if (outputFormat === 'table') {
if (sorted.length) {
console.table(sorted)
} else {
console.log('No matching events found.')
}
}

if (args['--set-gha-outputs']) {
const outputName = args['--set-gha-outputs']
debug('setting GHA outputs under name %s', outputName)
const names = sorted.map((s) => s.specFilename).join(',')
core.setOutput(outputName + 'N', sorted.length)
core.setOutput(outputName, names)
}

Let's find the specs with tests where the application makes the network call DELETE /todos

1
2
$ node ./bin/find-specs.js --method DELETE --path /todos
cypress/e2e/delete.cy.js,cypress/e2e/complete-todo.cy.js

So there are two specs that have tests that exercise the "delete item" feature. Let's see how many such calls the tests make

1
2
3
4
5
6
7
$ node ./bin/find-specs.js --method DELETE --path /todos --output table
┌─────────┬───────────────────────────────────┬───────┐
│ (index) │ specFilename │ count │
├─────────┼───────────────────────────────────┼───────┤
│ 0 │ 'cypress/e2e/delete.cy.js' │ 4 │
│ 1 │ 'cypress/e2e/complete-todo.cy.js' │ 1 │
└─────────┴───────────────────────────────────┴───────┘

So during delete.cy.js spec execution the app deletes 4 different items, and that is why this spec filename is shown first. We can feed this list directly into Cypress "run" command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ npx cypress run --spec $(node ./bin/find-specs.js --method DELETE --path /todos)

====================================================================================================

(Run Starting)

┌────────────────────────────────────────────────────────────────────────────────────────────────┐
│ Cypress: 14.2.1 │
│ Browser: Electron 130 (headless) │
│ Node Version: v20.11.1 (/Users/bahmutov/.nvm/versions/node/v20.11.1/bin/node) │
│ Specs: 2 found (delete.cy.js, complete-todo.cy.js) │
│ Searched: cypress/e2e/delete.cy.js, cypress/e2e/complete-todo.cy.js │
│ Experiments: experimentalRunAllSpecs=true │
└────────────────────────────────────────────────────────────────────────────────────────────────┘

Beautiful.