Stub an import from a Cypress v10 component test

How to bypass side effects in a Cypress React component test by stubbing the import.

A user has recently asked in the Cypress Discord channel how to write a component test for a component that executes the following code window.location.host.split("."). Grabbing the window.location is a side-effect and is generally unavailable from a component test. It would work great in an end-to-end test, of course, but we need to make it work right now.

How to handle the window.location access from the component

Any time you have a piece of code that gives you problems, move it into its own function or source file and stub it from the Cypress test to bypass the problem. Of course, you want to stub the smallest piece of your code to make sure you still test the most of it, see the blog post Stub The Form That Opens The Second Browser Tab that shows it nicely. Let's see how the same principle could work for a React component test.

I have started the a new React application using the create-react-app to scaffold it.

🎁 You can find the complete source code at bahmutov/stub-react-import.

1
2
$ npx create-react-app stub-react-import
$ cd stub-react-import

I installed Cypress and Prettier

1
2
3
$ npm i -D cypress prettier
+ [email protected]
+ [email protected]

I love using Prettier to format my code. Let's configure the Cypress component testing.

Cypress detects the framework used to bundle the application code

Cypress wizard suggests the following cypress.config.js file

cypress.config.js
1
2
3
4
5
6
7
8
9
10
const { defineConfig } = require('cypress')

module.exports = defineConfig({
component: {
devServer: {
framework: 'create-react-app',
bundler: 'webpack',
},
},
})

The component

Let's put have our App.js show the host and the path

src/App.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import logo from './logo.svg'
import './App.css'

function App() {
const { hostname, pathname } = window.location

return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p data-cy="location">
{hostname} {pathname}{' '}
</p>
</header>
</div>
)
}

export default App

End-to-end test

First, let's confirm the component is showing the expected host and path when running in an end-to-end test. Our test is simple:

cypress/e2e/spec.cy.js
1
2
3
4
5
/// <reference types="cypress" />
it('shows the host and path', () => {
cy.visit('/')
cy.contains('[data-cy=location]', 'localhost /')
})

The end-to-end test confirms the app is showing the right host and path

Component test

Nice, let's take a look at our component. Without any changes to the source code, our component test could be

src/App.cy.js
1
2
3
4
5
6
7
8
/// <reference types="cypress" />
import React from 'react'
import './App.css'
import App from './App'

it('shows the location host and path', () => {
cy.mount(<App />)
})

The component test has its test window location

Ughh, the component runs in the test window, thus it shows the spec's pathname. Can we simply stub the window.location? Not really, we cannot redefine the window.location property, it is pretty locked down in the browser for security reasons.

1
2
3
4
5
it('shows the location host and path', () => {
// try stubbing the window.location.hostname property
cy.stub(window.location, 'hostname').value('localhost')
cy.mount(<App />)
})

The browser does not allow stubbing the location properties

Move the side effect

Let's start by moving the problematic code that accesses the window.location object to its own source file.

src/Location.js
1
2
3
4
export const getLocation = () => {
const { hostname, pathname } = window.location
return { hostname, pathname }
}

The App.js imports the getLocation function and calls it to get the window.location properties.

src/App.js
1
2
3
4
5
6
import { getLocation } from './Location'

function App() {
const { hostname, pathname } = getLocation()
...
}

Stub the import

Now we need to add one more plugin @babel/plugin-transform-modules-commonjs to our development dependencies for our tests to work

1
2
$ npm i -D @babel/plugin-transform-modules-commonjs
+ @babel/[email protected]

We have relied on the default Webpack settings found in the repository to bundle each component during Cypress component tests. Now we need to insert the @babel/plugin-transform-modules-commonjs into the transpiling pipeline. Thus I will expand the cypress.config.js file to pass the full Webpack config

cypress.config.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
const { defineConfig } = require('cypress')

module.exports = defineConfig({
component: {
devServer: {
framework: 'create-react-app',
bundler: 'webpack',
webpackConfig: {
mode: 'development',
devtool: false,
module: {
rules: [
{
test: /\.?js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: [
[
'@babel/plugin-transform-modules-commonjs',
{ loose: true },
],
],
},
},
},
],
},
},
},
},
// e2e config
})

The plugin's loose: true option makes all imports accessible from other files, thus a spec file can stub an import and the stub will be used in the source files. If you run the component test again, nothing should change. But now let's modify our component test and stub the getLocation import

src/App.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
/// <reference types="cypress" />
import React from 'react'
import './App.css'
import App from './App'
import * as Location from './Location'

it('shows the location host and path', () => {
cy.stub(Location, 'getLocation').returns({
hostname: 'cy-test',
pathname: '/App',
})
cy.mount(<App />)
cy.contains('[data-cy=location]', 'cy-test /App')
})

The component test stubs the import getLocation

We can make the test stricter by confirming our getLocation stub was used and the test has not passed accidentally.

src/App.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/// <reference types="cypress" />
import React from 'react'
import './App.css'
import App from './App'
import * as Location from './Location'

it('shows the location host and path', () => {
cy.stub(Location, 'getLocation')
.returns({
hostname: 'cy-test',
pathname: '/App',
})
.as('getLocation')
cy.mount(<App />)
cy.contains('[data-cy=location]', 'cy-test /App')
cy.get('@getLocation').should('have.been.calledOnce')
})

The test confirms the import stub was called by the component

Happy stubbing!

See also