Mock The Location Href Property

Tricking the app to use a mock `Location` object using the JavaScript `with` keyword.

Let's say the application changes the URL using the assignment:

1
location.href = 'https://acme.com'

Can we prevent the URL change? For example, we might want to limit our test to the current origin. Let's do it using the following example page:

index.html
1
2
3
4
<body>
<h1>Hello World</h1>
<script src="app.js"></script>
</body>
app.js
1
2
3
4
setTimeout(() => {
console.log('changing window location to acme.com')
location.href = 'https://acme.com'
}, 1000)

The initial Cypress test does not prevent the navigation to "acme.com"

cypress/e2e/spec.cy.js
1
2
3
4
5
6
7
it('sets the location HREF', () => {
cy.visit('index.html')
cy.contains('h1', 'Hello World')
// confirm but do not allow the application
// to navigate away to the new URL
// Tip: app sets it using "location.href = ..." command
})

Acme site

🎁 The source code for this blog post is located in the repo bahmutov/with-window.

We need to prevent location.href = ... assignment by making the property read-only or better: using a custom object property definition with a setter stub function. Unfortunately, we cannot overwrite the location.href property:

Trying to redefine location.href property fails

Ok, maybe we can mock the entire location object? It is a property of the global window object:

Window.location is the location object

Unfortunately, the location property itself cannot be overwritten either.

Cannot redefine window.location property

We need another way. In the blog post Stub The Unstubbable I have shown one possible solution that modifies the application's source code to create an intermediate proxy "Location" instance. The E2E test can control that object. It is imperfect solution, as it requires source code modifications, which might be unavailable.

Let's find another way.

JavaScript with keyword

JavaScript has a pretty obscure operator with that you should definitely NOT use in production, but can help us with our testing needs.

1
2
3
with (expression) {
...
}

The above syntax adds the expression result into the variable lookup chain. For example:

1
2
3
4
5
6
7
8
9
let a, x, y;
const r = 10;

with (Math) {
// where is PI, cos, and sin defined?
a = PI * r * r;
x = r * cos(PI);
y = r * sin(PI / 2);
}

In the example above, the PI, cos, and sin are properties of the Math object. By using with (Math) we are forcing the browser to look up these identifiers in the Math object (before going up to the window object).

Tip: the with (expression) syntax is hard to read and understand. A simple spread operator would be much more preferable way of coding the above example:

1
2
3
4
5
6
let a, x, y;
const r = 10;
const { PI, cos, sin } = Math
a = PI * r * r;
x = r * cos(PI);
y = r * sin(PI / 2);

The E2E test

The application's code accessing the location object is loaded by the <script src="app.js"></script> resource. Let's wrap this code using a fake window object just so we can "sneak" in a fake "location" object of our creation.

cypress/e2e/solution.cy.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
it('sets the location HREF', () => {
cy.intercept('GET', 'app.js', (req) => {
req.continue((res) => {
// wrap app's code with a fake window object
// that has overwritten location object
res.body = `
const fakeWindowObject = {
location: {
href: '',
},
}
with (fakeWindowObject) {
${res.body}
}
`
})
}).as('appJs')
cy.visit('index.html')
cy.wait('@appJs')
cy.contains('h1', 'Hello World')
})

When the test runs, the application loads app.js and receives the following (modified) script

app.js
1
2
3
4
5
6
7
8
9
10
11
const fakeWindowObject = {
location: {
href: '',
},
}
with (fakeWindowObject) {
setTimeout(() => {
console.log('changing window location to acme.com')
location.href = 'https://acme.com'
}, 1000)
}

The test stays on the same page, but we do see the application printing "changing window location to acme.com".

Mock location object

We need to verify the location.href property really changes to acme.com string. We can put the fake window object on the real window object. Then we can get its value using the cy.window command.

One last tip: the browser caches the app.js resource, thus we need to remove the caching headers in order to receive the full JavaScript source code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
cy.intercept('GET', 'app.js', (req) => {
// delete common cache headers
// so the browser gets the real app.js source code
delete req.headers['if-none-match']
delete req.headers['if-modified-since']
req.continue((res) => {
// wrap app's code with a fake window object
// that has overwritten location object
res.body = `
window.fakeWindowObject = {
location: {
href: '',
},
}
with (window.fakeWindowObject) {
${res.body}
}
`
})
}).as('appJs')

Proxy to the real location

We don't want to create a completely fake location object, since we want to be able to use the real properties and methods in Location.prototype. Instead of plain object, our fake location can proxy to the real thing:

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
it('sets the location HREF', () => {
cy.intercept('GET', 'app.js', (req) => {
// delete common cache headers
// so the browser gets the real app.js source code
delete req.headers['if-none-match']
delete req.headers['if-modified-since']
req.continue((res) => {
// wrap app's code with a fake window object
// that has overwritten location object
res.body = `
let href = ''
const fakeLocation = new Proxy(location, {
set(target, prop, value) {
if (prop === 'href') {
href = value
// do not allow the app to navigate away
return false
}
target[prop] = value
return true
},
get(target, prop) {
if (prop === 'href') {
return href
}
return target[prop]
},
})
window.fakeWindowObject = {
location: fakeLocation,
}
with (window.fakeWindowObject) {
${res.body}
}
`
})
}).as('appJs')
cy.visit('index.html')
cy.wait('@appJs')
cy.contains('h1', 'Hello World')
// confirm but do not allow the application
// to navigate away to the new URL
// Tip: app sets it using "location.href = ..." command
cy.window()
.should('have.property', 'fakeWindowObject')
// the query retries until the app sets the location href
// and the test passes
.its('location')
.should('have.property', 'href', 'https://acme.com')
})

To see the proxy in action, I will modify the console.log message the application prints:

app.js
1
2
3
4
5
6
7
setTimeout(() => {
console.log(
'changing window location from %s to acme.com',
location.hostname,
)
location.href = 'https://acme.com'
}, 1000)

The test runs and we see the real host name, yet href = ... assignment is trapped.

Proxy to the real location object

Nice.