Stub The Unstubbable

Stub and test the window.location methods by wrapping the object.

Let's take a button component that directs the browser to an external site.

src/Login.tsx
1
2
3
4
5
6
7
8
9
10
11
export const LoginBtn = () => {
const handleSubmit = () => {
location.assign('https://cypress.tips')
}

return (
<button onClick={handleSubmit} data-cy="login-button">
Log in
</button>
)
}

🎁 You can find the full source code shown in this blog post in the repo bahmutov/stub-location. Even better, this example and lots more React Cypress component tests are shown in the huge repo muratkeremozcan/cypress-react-component-test-examples.

Our test clicks on the button from a Cypress component test.

src/Login.cy.tsx
1
2
3
4
5
6
7
8
9
import { LoginBtn } from './Login'

describe('Login button', { viewportWidth: 300, viewportHeight: 300 }, () => {
it('tries to click the button', () => {
cy.mount(<LoginBtn />)
cy.getByCy('login-button').click()
// cannot really access the new domain from the component test 😢
})
})

Component test clicks on the button

Ughh, we really don't want to visit the other domain from this button component test. Can we prevent the transition by stubbing the location.assign('https://cypress.tips') method call?

Unfortunately not. If we try to use cy.stub to wrap the location.assign method, it fails

1
2
3
cy.mount(<LoginBtn />)
cy.stub(location, 'assign').as('assign')
cy.getByCy('login-button').click()

Cannot stub the location.assign method

The browser prevents overwriting the location methods for security purposes. The location.assign method is not writable or configurable.

Logging

Let's take a step back from testing the Login button, and instead consider what would you do if I asked you to add logging every time the location changes? Can you log every call to location.assign? What if there are several places in the application call that invoke location.assign? How would you refactor you code? A common solution is to introduce your own wrapper object for window.location and call its methods. So let's introduce a tiny object Location that wraps around window.location methods we use.

src/Location.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import setupDebug from 'debug'

const debug = setupDebug('location')

export const Location = {
assign(url: string) {
debug('assign %s', url)
window.location.assign(url)
},
replace(url: string) {
debug('replace %s', url)
window.location.replace(url)
}
}

We update all calls in our source to call Location.assign instead of location.assign

src/Login.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
import { Location } from './Location'

export const LoginBtn = () => {
const handleSubmit = () => {
Location.assign('https://cypress.tips')
}

return (
<button onClick={handleSubmit} data-cy="login-button">
Log in
</button>
)
}

We can see these calls logged in the browser, even during our test

1
2
cy.mount(<LoginBtn />)
cy.getByCy('login-button').click()

We can see the debug calls if we set localStorage.debug=location

Location method calls logged by the debug module

Location is testable

We introduced the Location.ts object to wrap around the native unstubbable location methods. This Location object gives us another benefit - its methods are stubbable.

src/Login.cy.tsx
1
2
3
4
5
6
7
8
9
10
11
import { LoginBtn } from './Login'
import { Location } from './Location'

describe('Login button', { viewportWidth: 300, viewportHeight: 300 }, () => {
it('stubs location.assign via its proxy object', () => {
cy.stub(Location, 'assign').as('assign')
cy.mount(<LoginBtn />)
cy.getByCy('login-button').click()
cy.get('@assign').should('have.been.calledOnceWith', 'https://cypress.tips')
})
})

Location methods are stubbable from the component tests

End-to-End tests

We can make our Location methods stubs work from end-to-end tests. Instead of importing the Location object from the src/Location.ts source file, we "save" the reference to this singleton object on the window object when running Cypress tests. Modify the Location code:

src/Location.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import setupDebug from 'debug'

const debug = setupDebug('location')

export const Location = {
...
}

// @ts-ignore
if (window.Cypress) {
// @ts-ignore
window.Location = Location
}

In our Cypress E2E test:

cypress/e2e/location.spec.ts
1
2
3
4
5
6
7
8
9
10
11
12
it('stubs the location.assign method', { baseUrl: 'http://localhost:3000' }, () => {
cy.visit('index.html')
// get the application's window object
// it should have our Location wrap object
cy.window()
.its('Location')
.then((Location) => {
cy.stub(Location, 'assign').as('assign')
})
cy.getByCy('login-button').click()
cy.get('@assign').should('have.been.calledOnceWith', 'https://cypress.tips')
})

The test runs beautifully.

Stub the Location method from an end-to-end test

Nice!

See also