Universal Code Test with Cypress

How to confirm that universal JavaScript code outputs the same result in the browser as it does in Node.

Note: you can find the source code for this blog post in the repository bahmutov/cypress-universal-test.

Start

Let's take a piece of JavaScript code

src/sum.js
1
2
3
4
function add(a, b) {
return a + b
}
console.log('2 + 3 =', add(2, 3))

This JavaScript code is universal - it runs the same way in the browser and in Node. Can we confirm this using just the Cypress test runner? Sure. We can directly import this file from the spec and it runs.

1
2
$ npm i -D cypress
+ [email protected]
cypress/integration/spec.js
1
2
3
4
5
6
require('../../src/sum')

describe('Universal code', () => {
it('runs in the browser', function () {
})
})

The test runs, we can see the correct result printed in the browser's console.

The sum is printed to the console

This is far from optimal:

  • the code runs immediately in the bundle, not during the test block
  • we cannot assert the printed sum (since the console.log is used before the test begins)

What I would like to do is to load and run src/sum.js on demand from the test itself, capture its console.log, console.warn and console.error output, and compare these output to the expected text.

Run code on demand

Let's load the src/sum.js file without running it. Since Cypress uses Webpack under the hood by default, we can install Webpack raw-loader to return the source as text.

1
2
$ npm i -D raw-loader
+ [email protected]

Let's change our spec.

cypress/integration/spec.js
1
2
3
4
5
6
describe('Universal code', () => {
it('runs in the browser', function () {
const sum = require('raw-loader!../../src/sum')
window.eval(sum.default)
})
})

The test passes, and the code is evaluated while running the test

Evaluating loaded source file during test

By default, raw-loader returns the loaded source file as ES6 module, thus we have to use sum.default. We can turn the ES6 wrapper off by passing line option to the loader using raw-loader?esModule=false.

cypress/integration/spec.js
1
2
3
4
5
6
describe('Universal code', () => {
it('runs in the browser', function () {
const sum = require('raw-loader?esModule=false!../../src/sum')
window.eval(sum)
})
})

Capture output

The evaluated code printed the result to console.log. Let's buffer that output to assert its value.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const util = require('util')

describe('Universal code', () => {
it('runs in the browser', function () {
const sum = require('raw-loader?esModule=false!../../src/sum')
let log = ''
cy.stub(console, 'log').callsFake(function (...args) {
log += util.format.apply(util, args)
})
window.eval(sum)
// now assert the log has the expected test
expect(log, 'console.log').to.equal('2 + 3 = 5')
})
})

The test runs and shows every intercepted console.log call from the evaluated src/sum.js

Asserting the code log output

Compare to Node

Universal code should produce the same result when running in the browser as in Node. Let's run the same src/sum.js in Node using cy.task.

cypress/plugins/index.js
1
2
3
4
5
6
7
8
9
10
11
module.exports = (on, config) => {
on('task', {
node(filename) {
console.log('running %s', filename)

require(filename)

return null
}
})
}

From the test

cypress/integration/spec.js
1
2
3
4
5
6
...
window.eval(sum)
// now assert the log has the expected test
expect(log, 'console.log').to.equal('2 + 3 = 5')

cy.task('node', '../../src/sum')

The plugin file executes the file as can be seen in the terminal output

Running src/sum in Node using cy.task

💡 Node require caches the module. Thus if you want to fully run the code again, you need to remove it from the module cache. Use require-and-forget to do this.

1
2
$ npm i -D require-and-forget
+ [email protected]
cypress/plugins/index.js
1
2
3
4
const forget = require('require-and-forget')
...
// instead of require(filename)
forget(filename)

We need to capture console.log output, we can do this using the same log buffering in the plugins file and return it - this will be the value yielded by the cy.task command. Since there are no cy commands in the plugins file, we need to wrap the console.log ourselves.

cypress/plugins/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const util = require('util')
const forget = require('require-and-forget')
module.exports = (on, config) => {
on('task', {
node(filename) {
console.log('running %s', filename)

let text = ''
const log = console.log
console.log = function (...args) {
text += util.format.apply(util, args)
log.apply(null, args)
}

forget(filename)

return text
}
})
}

Once the test runs, click on the task in the Command Log to see the yielded text.

Output from the src/sum running in Node returned to the browser test

💡 You can further isolate the plugins file from the Node code to run by running it inside a child process and then comparing the output, the exit code, etc. You can use execa-wrap to do this.

Now that we run the same code in the browser and in Node, we can remove the expected text and just compare the results from the two executions.

cypress/plugins/index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const util = require('util')

describe('Universal code', () => {
it('runs in the browser', function () {
const sum = require('raw-loader?esModule=false!../../src/sum')
let browserOutput = ''
cy.stub(console, 'log').callsFake(function (...args) {
browserOutput += util.format.apply(util, args)
})
window.eval(sum)

cy.task('node', '../../src/sum')
.then(nodeOutput => {
expect(nodeOutput).to.equal(browserOutput)
})
})
})

The test runs and compares the outputs - they are equal.

Comparing browser and Node outputs

Multiple files

We only had a single src/sum.js file to run. What if we have a dynamic number of source files we want to run in the browser and in Node, comparing their outputs one by one? Let's add two more files to the src folder.

src/reverse.js
1
2
3
4
5
function reverse(s) {
return s ? s.split('').reverse().join('') : 'cannot reverse an empty value'
}
console.log(reverse('hello world'))
console.log(reverse())
src/cat.js
1
2
3
4
function cat(...args) {
return args.join('')
}
console.log(cat('a', 'b', 'c', 1, 2, 3))

We need to create tests dynamically - every file in the src folder should get its own it function. To look through the src folder on disk, we can use the plugins file - since the plugins code executes in Node at the project's load. To find files I will use globby

1
2
$ npm i -D globby
+ [email protected]

We can find all src/*.js files and then pass the filenames to the spec running in the browser by saving them in the env object.

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
const util = require('util')
const globby = require('globby')
module.exports = (on, config) => {
// files are with respect to the working folder
const sourceFiles = globby.sync('src/*.js')
console.log('found source files', sourceFiles.join(','))
config.env.sourceFiles = sourceFiles

on('task', {
node(filename) {
console.log('running %s', filename)

let text = ''
const log = console.log
console.log = function (...args) {
text += util.format.apply(util, args)
log.apply(null, args)
}

require(filename)

return text
}
})

// important to return the updated config object
return config
}

The environment object is available outside of individual tests through Cypress.env command. We can create a test by calling it for every source name (array .forEach is a synchronous method).

cypress/integration/all-specs.js
1
2
3
4
5
describe('Universal code', () => {
Cypress.env('sourceFiles').forEach(filename => {
it(filename)
})
})

For now the above suite of tests just has skipped tests - but it shows that our plugins to spec file coordination is working.

A test created for each source file

Let's finish the spec code.

cypress/integration/all-specs.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const util = require('util')
describe('Universal code', () => {
Cypress.env('sourceFiles').forEach(filename => {
it(filename, () => {
const sum = require(`raw-loader?esModule=false!../../${filename}`)
let browserOutput = ''
cy.stub(console, 'log').callsFake(function (...args) {
browserOutput += util.format.apply(util, args)
})
window.eval(sum)

cy.task('node', `../../${filename}`)
.then(nodeOutput => {
expect(nodeOutput).to.equal(browserOutput)
})
})
})
})

The tests pass, and you can see the matching output from the browser and from Node

All source files are universal

The src/*.js scripts are truly universal - they output the same console text when running in the browser as they do when running in Node.