Stubbing The Non-configurable

How Cypress test can stub a method defined with property descriptor configurable false

Recently I have shown how to verify that a React Native application in bahmutov/expo-cypress-examples opens the help URL when the user clicks on the help link. The application used the following code:

1
2
3
4
5
6
import * as WebBrowser from 'expo-web-browser';
function handleHelpPress() {
WebBrowser.openBrowserAsync(
'https://docs.expo.io/...'
);
}

The application was tested while running in the browser, served by the Expo tool. I guessed that in the browser, the second window is opened using window.open called somewhere inside the expo-web-browser module. Thus I could prevent the second browser window from opening using the test code below:

1
2
3
4
5
6
7
8
9
it('opens the help link in the browser', () => {
cy.visit('/')
.then(win => {
cy.stub(win, 'open').as('open')
})
cy.contains('[data-testid=help]', 'Tap here').click()
const url = 'https://docs.expo.io/...'
cy.get('@open').should('have.been.calledOnceWith', url, '_blank')
})

The test works and confirms the application calling the window.open with expected arguments.

Application calls window.open

You can see me write the above test in the video Testing WebBrowser.openBrowserAsync. Except the title is wrong - we are not confirming that the method WebBrowser.openBrowserAsync is called, right? We are confirming window.open is called. Why can't we directly confirm the WebBrowser.openBrowserAsync method call?

Stubbing WebBrowser.openBrowserAsync - does not work

Let's try stubbing the web browser method directly. We can expose the imported WebBrowser object from the application, and the test code can access it.

components/EditScreenInfo.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import * as WebBrowser from 'expo-web-browser';

// @ts-ignore
if (window.Cypress) {
// application is running inside Cypress test
// @ts-ignore
window.WebBrowser = WebBrowser
}

function handleHelpPress() {
WebBrowser.openBrowserAsync(
'https://docs.expo.io/...'
);
}

From the test we can access the window.WebBrowser.openBrowserAsync method before clicking the link.

cypress/integration/spec.ts
1
2
3
4
5
6
7
8
9
it('calls openBrowserAsync', () => {
cy.visit('/')
.its('WebBrowser').then(WebBrowser => {
cy.stub(WebBrowser, 'openBrowserAsync').as('open')
})
cy.contains('[data-testid=help]', 'Tap here').click()
const url = 'https://docs.expo.io/...'
cy.get('@open').should('have.been.calledOnceWith', url)
})

Stub was not called

We can see the second browser window open - so our stub did NOT work. Let's look at the property we are trying to stub.

1
2
3
4
5
cy.visit('/')
.its('WebBrowser').then(WebBrowser => {
console.log(Object.getOwnPropertyDescriptor(WebBrowser, 'openBrowserAsync'))
cy.stub(WebBrowser, 'openBrowserAsync').as('open')
})

When the test runs, open the browser DevTools console, which shows:

1
{set: undefined, enumerable: true, configurable: false, get: ƒ}

The property openBrowserAsync cannot be overwritten by the cy.stub method unfortunately. We will need to modify the application code to make this property stubbable.

Stub the wrapper method

We can solve our problem by creating an intermediate object with stubbable methods.

cypress/integration/spec.ts
1
2
3
4
5
6
7
8
9
10
11
import * as _WebBrowser from 'expo-web-browser';
// wrapper that allows stubbing methods
// unlike the expo-web-browser import
const WebBrowser = { ..._WebBrowser }

// @ts-ignore
if (window.Cypress) {
// application is running inside Cypress test
// @ts-ignore
window.WebBrowser = WebBrowser
}

The wrapper WebBrowser that spreads the methods from the imported expo-web-browser module is the key. Let's run the test again.

The wrapper was stubbed and tested

Beautiful.