Jest: Unable to change window.location using Object.defineProperty

Created on 19 Dec 2017  ·  78Comments  ·  Source: facebook/jest

Do you want to request a _feature_ or report a _bug_? Report a bug

What is the current behavior?

Calling the following from inside a test suite:

Object.defineProperty(location, "hostname", {
  value: "example.com",
  writable: true
});

throws the following error:

    TypeError: Cannot redefine property: hostname
        at Function.defineProperty (<anonymous>)

What is the expected behavior?

The code should not throw an exception, and window.location.hostname === "example.com" should evaluate true.

From the looks of it, jsdom now sets window.location to be unforgeable. The only way to change the values within window.location is to use reconfigure, but (per #2460) Jest doesn't expose jsdom for tests to play around with.

Please provide your exact Jest configuration and mention your Jest, node,
yarn/npm version and operating system.

Jest version: 22.0.1
Node version: 8.6.0
Yarn version: 1.2.0
OS: macOS High Sierra 10.13.2

Discussion Wontfix

Most helpful comment

I've published a new package on npm called jest-environment-jsdom-global, which may help with the problems some people are having with Object.defineProperty.

All 78 comments

I have the similar issue. You can create your own JSDOMEnvironment and expose jsdom object to the global like this.

const JSDOMEnvironment = require('jest-environment-jsdom');

module.exports = class CustomizedJSDomEnvironment extends JSDOMEnvironment {
  constructor(config) {
    super(config);
    this.global.jsdom = this.dom;
  }

  teardown() {
    this.global.jsdom = null;
    return super.teardown();
  }
};

And then you can call jsdom.reconfigure in your test case as you like

That's a good workaround, thanks for sharing!

You should return super.teardown(); as it's a promise, btw

Perfect, @oliverzy - I'll give that a try. Thanks!

Is there an appropriate place to document this? It seems to be a question that comes up reasonably often; hopefully, future issues could be cut down if this were integrated in the docs?

This solution didn't quite work.

Inside our test files, it seems like global is set to be JSDom's window object.

In other words, inside a test suite, global is the same as window, but inside the class that extends JSDOMEnvironment, global comes from Node's environment.

As a result, having this:

describe("test suite", () => {
  it("should not fail", () => {
    global.jsdom.reconfigure({
      url: "https://www.example.com/"
    });
  });
});

fails because global.jsdom is undefined.

I got around it by doing this, but I'm not super fussed about it.

const JSDOMEnvironment = require("jest-environment-jsdom");

module.exports = class JSDOMEnvironmentGlobal extends JSDOMEnvironment {
  constructor(config) {
    super(config);

    this.dom.window.jsdom = this.dom;
  }
};

With this environment, global.jsdom inside a test suite is equal to this.dom, and the test suite above works.

To me, it feels like setting jsdom to be a property of its own window object is bound to fall apart eventually - is there a cleaner way to do it?

you need to write jsdom rather than global.jsdom in your tests.

@oliverzy Like this?

describe("test suite", () => {
  it("should not fail", () => {
    jsdom.reconfigure({
      url: "https://www.example.com/"
    });
  });
});

That throws jsdom is not defined, but I may be misinterpreting.

@simon360 Please configure the testEnvironment with the code from @oliverzy, see https://facebook.github.io/jest/docs/en/configuration.html#testenvironment-string

@danielbayerlein my Jest config has this:

"testEnvironment": "@wel-ui/jest-environment-jsdom-global"

where @wel-ui/jest-environment-jsdom-global is the name of a package in our monorepo. The environment is getting used correctly, though, because the solution that sets jsdom on window works as expected.

BTW, does anyone know why the original solution doesn't work in the new version?
This one:

Object.defineProperty(location, "hostname", {
  value: "example.com",
  writable: true
});

@modestfake we have upgraded from JSDOM@9 to JSDOM@11, my guess is that they changed how the variable is defined

@SimenB Got it. Just found a description of jsdom reconfigure method.

The top property on window is marked [Unforgeable] in the spec, meaning it is a non-configurable own property and thus cannot be overridden or shadowed by normal code running inside the jsdom, even using Object.defineProperty.

I added a new repository to demonstrate this behaviour. Is anyone able to reproduce it by cloning locally?

https://github.com/simon360/test-environment-for-jest

@simon360 reproduced
image

@simon360 I've found. You've missed this keyword when defining global.jsdom:

const JSDOMEnvironment = require("jest-environment-jsdom");

module.exports = class JSDOMEnvironmentGlobal extends JSDOMEnvironment {
  constructor(config) {
    super(config);

    this.global.jsdom = this.dom;
  }

  teardown() {
    this.global.jsdom = null;

    return super.teardown();
  }
};

What about location.search ? I didn't find any mention about it https://github.com/tmpvar/jsdom/blob/05a6deb6b91b4e02c53ce240116146e59f7e14d7/README.md#reconfiguring-the-jsdom-with-reconfiguresettings

@andrewBalekha What about this?

jsdom.reconfigure({
  url: 'https://www.example.com/endpoint?queryparam1=15&queryparam2=test'
});

Thanks @modestfake - sorry for the dumb mistake!

Ok, I see it now - this.global on a Jest environment object gets set as global in a Jest test file. That makes sense - thanks for helping me through it! If there's enough interest, I could package the repaired version of that repo and put it on npm as jest-environment-jsdom-global.

However, I do hope there's a cleaner way to do this in Jest in the future. This isn't a low friction way to change window.location -

Could there be a new docblock, like there is for @jest-environment? For example...

/**
 * @jest-url https://www.example.com/
 */

Or, maybe JSDom can be exposed on a special part of the jest object - something like:

jest.environment.jsdom.reconfigure({
  url: "https://www.example.com/"
});

(which would have the added benefit of being able to change window.top)

We have merged #5003 now. being able to add it as a docblock might make sense, not sure. @cpojer? We could deprecate testUrl as well, as it can be provided through that new option.

If there's enough interest, I could package the repaired version of that repo and put it on npm as jest-environment-jsdom-global.

I think that makes sense in any case, as it does more than just let you set url - it exposes the full JSDOM to the environment

@andrewBalekha Object.defineProperty(location, 'search', { ...options }); throws the same error as window.location. Thanks for the suggestion though.

Object.defineProperty(window.location, 'href', {
set: newValue => { currentUrl = newValue; },
});
I had this in previous versions and now throws error.
If I add writable: true
throws another exception that I can't specify both accessor and writable

I've published a new package on npm called jest-environment-jsdom-global, which may help with the problems some people are having with Object.defineProperty.

Does anyone have a workaround for { writable: true }?

For example:

Object.defineProperty(window.location, 'href', { writable: true })

...

Object.defineProperty(window.location, 'hash', { writable: true })

...

Object.defineProperty(window.location, 'search', { writable: true })

@danielbayerlein read this thread. You need to create custom environment. Previous message contains url with example

@modestfake I've already read this thread and https://github.com/facebook/jest/issues/5124#issuecomment-352749005 works fine. But I've another use case. With Jest 21.x.x I have set Object.defineProperty(window.location, 'href', { writable: true }) without the URL - only { writable: true }. If I set the URL, then the test makes no sense.

@danielbayerlein what the use case to make it writable but not override it actually? Maybe understanding this can help me to come up with workaround

I've a function that changes the URL.

routing.js

...

export function redirectToErrorPage () {
  window.location.href = '/error.html'
}

...

routing.test.js

test('redirect to the error page', () => {
  ...
  expect(window.location.href).toBe('/error.html')
  ...
})

With Jest 21.x.x I have set Object.defineProperty(window.location, 'href', { writable: true })

I'd recommend switching to window.location.assign, that way you can mock the function.

@simon360 Works like a charm! Thank you. 🤝

I used

history.pushState({}, "page 2", "/bar.html");

along with testURL in jest config

The location part isn't the only problem. I cannot import jsdom separately to call jsdom.reconfigureWindow (because that function no longer exists in the latest version of jsdom). I was doing this to test code that runs differently if window !== top. There's no longer a way to achieve this in the latest version of jest, which uses a newer version of jsdom.

@andyearnshaw the jsdom.reconfigure method has a windowTop member in its object.

If you're using the JSDOM environment (jest-environment-jsdom-global) I linked above, you could do:

jsdom.reconfigure({
  windowTop: YOUR_VALUE
});

in your tests to mock out a top value.

I am using that and it works great, thanks!

On Tue, 30 Jan 2018, 13:40 simon360, notifications@github.com wrote:

@andyearnshaw https://github.com/andyearnshaw the jsdom.reconfigure
method has a windowTop member in its object.

If you're using the JSDOM environment (jest-environment-jsdom-global) I
linked above, you could do:

jsdom.reconfigure({
windowTop: YOUR_VALUE
});

in your tests to mock out a top value.


You are receiving this because you were mentioned.
Reply to this email directly, view it on GitHub
https://github.com/facebook/jest/issues/5124#issuecomment-361595999, or mute
the thread
https://github.com/notifications/unsubscribe-auth/ABvdEzCLlWtzr0udscL0C6KUxpgXHZRhks5tPxvJgaJpZM4RGN7C
.

Using window.history.pushState and testUrl worked for me

https://github.com/facebook/jest/issues/5124#issuecomment-359411593

Going to close this as there's nothing to do on Jest's side, and workarounds exist (feel free to continue the discussion!)

Upgrading from jsdom v8.5.0 to v11.6.2 solved the issue for me. So my package.json includes:

"jest": "^21.2.1",
"jest-cli": "^21.2.1",
"jsdom": "^11.6.2",

It breaks if I upgrade jest and jest-cli to v22.2.2.

@a-n-d-r-3-w any particular reason why you have jsdom in your package.json? It comes bundled with Jest.

@SimenB, good question, there is no need for jsdom in our package.json. Thanks!

So, without using jsdom directly, here is solution I came up for my stuff:

Works for Jest 21.2.1 (I tested on that one):

Go into your Jest settings (for example I'll use package.json):

"jest": { "testURL": "http://localhost" }

Now you will be able to change window.location object and then you can set URL to whatever you like during tests.

it('Should set href url to testURL', () => {
    // Here I set href to my needs, opinionated stuff bellow
    const newUrl = 'http://localhost/editor.html/content/raiweb/it/news/2018/02/altered-carbon-best-cyberpunk-tv-series-ever.html';
    Object.defineProperty(window.location, 'href', {
        writable: true,
        value: newUrl
    });

    console.log(window.location.href);
});

it('Should set pathname url to testURL', () => {
    // Here I set href to my needs, opinionated stuff bellow
    const newUrl = '/editor.html/content/raiweb/it/news/2018/02/altered-carbon-best-cyberpunk-tv-series-ever.html';
    Object.defineProperty(window.location, 'pathname', {
        writable: true,
        value: newUrl
    });

    console.log(window.location.pathname);
});

Hopefully this helps someone.

Stop posting this, it does not work on jest": "^22.4.2"

@UserNT I noted version on which it works here and I use it on production testing suits extensively. If it doesnt work on a newer versions, I’m sorry, come up with your own solution instead of just random bashing.

Just for completeness, since the solution is stranded in the middle of this thread...

@petar-prog91's solution will work on Jest 21, but not Jest 22 with the updated jsdom.

In order to run on the latest Jest, use something like jest-environment-jsdom-global (full disclosure, this is my package) to expose the jsdom object and use jsdom.reconfigure, which will have the same (or, at least, a similar) effect.

https://github.com/facebook/jest/issues/5124#issuecomment-359411593 also works on jest 22 for me

@simon360 Hi, what should I do if I need to hack location.href's setter?
My previous tests have many tests like this and now there are all failed...

const setHrefMockFn = jest.fn();
beforeAll(() => {
  Object.defineProperty(location, "href", {
    get: () => "https://xxxxx",
    set: setHrefMockFn
  });
});
it("xxx", () => {
  //...
  expect(setHrefMockFn.mock.calls[0][0]).toBe(xxx);
});

@simon360 can you give me an example how to update test like below with your library

beforeAll(() => {
  =======

  Object.defineProperty(window.location, 'href', {
    writable: true
  });
});

@abhijeetNmishra have you seen the documentation? I believe that would answer your question.

@simon360 Yes, based on my understanding of documentation, it takes about

jsdom.reconfigure({
      url: "https://www.example.com/"
    });

which overrides url globally and not per test. Please help!

@abhijeetNmishra I'm not sure that this issue is the best place to discuss. Would you mind opening an issue on the jest-environment-jsdom-global repository where we can work through it? Thanks!

@SimenB the stated workaround ("use jest-environment-jsdom-global") feels like an extremely suboptimal solution to what's obviously a very common problem. Anybody upgrading to Jest 22 now needs to add a dependency to that third party package and (from a user's perspective) re-write some of their tests. This is a regression in Jest's behavior.

Is there a solution to this that we could build into the default jest-environment-jsdom? Happy to make a PR with your guidance.

It's very unfortunate that someone has to jump though this many hoops just to change window.location.href. I just started using Jest and I am about to reconsider my choice of testing framework given this issue. Is there really no better solution than the ugly hacks suggested above?

@ydogandjiev feel free to contribute to the project to solve this issue. Remember that this is open source, so rampaging in with comments like “unacceptable” and “ridiculous” do nothing to help anyone.

@msholty-fd I would love to help out if I can. Since I am just getting started with jest and jsdom I am not sure that I have a deep enough understanding of these libraries to know exactly what the best route for improving this experience is. Is this something best addressed in jest or jsdom? It seems like one of these libraries made a change at some point that broke the Object.defineProperty approach; was that a change made in jest or jsdom?

So here are all the options that I would consider preferable based on the number of lines required to change window.location; none of these currently work:

  1. Setting href directly doesn't work because jsdom throws an exception with message "Error: Not implemented: navigation (except hash changes)":
    window.location.href = "https://www.example.com";
  1. Using Object.defineProperty doesn't work because JSDOM has made the window's location property [Unforgeable]:

    Object.defineProperty(window.location, "href", {
      value: "https://www.example.com",
      configurable: true
    });
    
  2. Creating an instance of jsdom and configuring it doesn't work because jest seems to use its own instance which is not exposed for easy access:

    import { JSDOM } from "jsdom";
    ...
    const dom = new JSDOM();
    dom.reconfigure({ url: "https://www.example.com" });
    

Making options 1 or 2 work would require jsdom to backtrack on their current goals of behaving more like a real browser. Therefore, it seems like the only option we have is to make it easier to reconfigure the instance of jsdom that jest uses. Would it make sense to expose that instance directly on the global jest object; i.e. permitting something like this:

jest.dom.reconfigure({ url: "https://www.example.com" });

I still think doing location.assign('some-url') is better than location.href = 'some-url'. I find a function call more explicit than assignment, and you can mock the function

@SimenB in cases where code is trying to _set_ location.href, yes, location.assign() is better. But if you're testing behaviour that _reads_ location.href, location.assign() doesn't solve the problem, especially since location.assign() in JSDOM doesn't actually do anything.

The idea behind using reconfigure is to activate a code path that's only enabled when location.href is formed in a particular way. In our case, we had some code that changed depending on the current domain - the code is smelly, yes, but it's also necessary, and the best way to mitigate smelly code is to have testing fixtures that capture the behaviour and ensure that it stays in place.

Is there a way to use this with Enzyme and a mounted component to test for redirects?

The below test passed before upgrading Jest:

```
it('routes to correct route', () => {

Object.defineProperty(window.location, 'href', {
  writable: true,
  value: 'https://mysuperawesomesite.com/',
});

const component = mount(
  <App {...props} />
);

const link = component.find('.class');

link.simulate('click');

expect(window.location.href).toEqual('https://mysuperawesomesite.com/new');

});

After upgrading Jest and implementing [jest-environment-jsdom-global](https://www.npmjs.com/package/jest-environment-jsdom-global), I tried the following to no avail:

  ```
it('routes to correct route', () => {

    jsdom.reconfigure({
      url: 'https://mysuperawesomesite.com/',
    });

    const component = mount(
      <App {...props} />
    );

    const link = component.find('.class');

    link.simulate('click');

    expect(window.location.href).toEqual('https://mysuperawesomesite.com/new');
  });

(window.location.href still equals 'https://mysuperawesomesite.com/', didn't get changed to ('https://mysuperawesomesite.com/new').

The click event on the element does not redirect when using this method, and the redirect occurs by setting window.location.href.

Unclear on how to properly test this or if the tests that had previously used Object.defineProperty were poorly constructed to begin with. Thanks in advance for any assistance.

EDIT: SOLVED

Was able to solve this by using window.location.assign(url) instead of window.location.href = href. This allowed me to stub out the assign method and test whether it was being properly called. See below:

it('routes to correct route', () => {
    window.location.assign = jest.fn();

    const component = mount(
      <App {...props} />
    );

    const link = component.find('.class');

    link.simulate('click');

    expect(window.location.assign).toBeCalledWith('https://mysuperawesomesite.com/new');
    window.location.assign.mockRestore();
  });

@SimenB There is a big difference between .assign and .href You can read on MDN. First one has a major cross-domain restriction. In my code I want to redirect parent page from an iframe where my code is running. Those are cross-domains. And the only way I can do this is by changing href.
I would love if this issue be re-opened, since I don't see a current workaround and I will have to just not to have test for this function. Which obviously sucks.

I'm in the same boat as @soswow. It would be great if you could provide a mechanism for overriding the url, as I am also removing several unit tests until this functionality is restored.

I would love if this issue be re-opened, since I don't see a current workaround and I will have to just not to have test for this function. Which obviously sucks.

There's nothing we can do on jest's side. I'm sure jsdom would love a PR supporting it. https://github.com/jsdom/jsdom/issues/2112

Here's a simple solution that works.

describe('changing location', () => {
  const testURL = location.href;

  beforeEach(() => history.replaceState({}, 'Login', '/login'));
  afterEach(() => history.replaceState({}, 'Home', '/'));

  it('works', () => {
    expect(location.pathname).toBe('/login');
  });
});

@vastus As I explicitly pointed out - the problem is with cross-domains. history API won't allow switching to a different domain as far as I remember.

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

I use this to solve the issue:

const windowLocation = JSON.stringify(window.location);
delete window.location;
Object.defineProperty(window, 'location', {
  value: JSON.parse(windowLocation)
});

Inspired by @RubenVerborgh & annemarie35

Adding an example of testing location.search based on @vastus's solution:

  test('gets passed query param and returns it as a string if it exists', () => {
    history.replaceState({}, 'Test', '/test?customer=123');
    const customerId = getQueryParam('customer');
    expect(customerId).toBe('123');
  });

@RubenVerborgh Works like a charm.

try:

window.history.pushState({}, null, '/pathname?k=v');

A solution similar to @sahalsaad :
```javascript
const oldWindow = window.location;
delete window.location;
window.location = {
...oldWindow,
// include any custom overwrites such as the following sinon stub
replace: sinon.stub(),
};

// do your magic

window.location = oldWindow;
````

@sahalsaad thanks! I used a variation of your solution to mock window.location.search:

const location = {
    ...window.location,
    search: queryString,
};
Object.defineProperty(window, 'location', {
    writable: true,
    value: location,
});

probably a better solution:

import { URL } from 'whatwg-url';

const location = new URL(window.location.href);
location.assign = jest.fn()
location.replace = jest.fn()
location.reload = jest.fn()

delete window.location
window.location = location

Solved my issue using the solution given by @kdelmonte, I've had to mock the window.location.search variable. so I've used

window.history.pushState({}, null, '?skuId=1234')

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

your answer is the only one that works for my situation, thanks!

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

your answer is the only one that works for my situation, thanks!

This is no longer workin 😢

Since location cannot be overridden directly on the jsdom window object, one possible approach is to override it on a derived object:

global.window = Object.create(window);
Object.defineProperty(window, 'location', {
  value: {
    href: 'http://example.org/'
  }
});

your answer is the only one that works for my situation, thanks!

This is no longer workin 😢

Not working for me either

I solved this doing:

delete window.location
window.location = {
  href: 'http://example.org/,
}

I'm using the following mock as a utility for working with Location

export class MockLocation extends URL implements Location {
  ancestorOrigins: any = []
  toString = jest.fn().mockImplementation(() => this.toString())
  assign = jest.fn(href => this.href = href)
  replace = jest.fn(href => this.href = href)
  reload = jest.fn()

  constructor(
    url: string = 'http://mock.localhost',
  ) {
    super(url)
  }

  onWindow(window: Window) {
    Object.defineProperty(window, 'location', { 
      writable: true,
      value: this
    });
    return this
  }
}

Then in my tests

let location: MockLocation

beforeEach(() => {
    location = new MockLocation(MOCK_PARTNER_URL).onWindow(window)
})

I found myself stubbing tricky objects like these all the time, and created a flexible helper function:

export const safelyStubAndThenCleanup = (target, method, value) => {
  const original = target[method]
  beforeEach(() => {
    Object.defineProperty(target, method, { configurable: true, value })
  })
  afterEach(() => {
    Object.defineProperty(target, method, { configurable: true, value: original })
  })
}

And then usage:

describe('when on /pages', () => {
  safelyStubAndThenCleanup(window, 'location', { pathname: '/pages' })

  it('should do something neat', () => { /* ... */ })
})

And you can stub whatever you want: pathname, href, etc... This gets you the added free benefit of cleanup.

The key is you can't mess with location itself, so just swap out location with a fake, and then put it back when the test is done.

As I can see in my debugging session, global.location is implemented via getter but not a simple property. Wouldn't it be safer to redefine it like this?

let originalLocationDescriptor;
beforeAll(() => {
  originalLocationDescriptor = Object.getOwnPropertyDescriptor(global, 'location');
  delete global.location;
  global.location = {};
});
afterAll(() => {
  Object.defineProperty(global, 'location', originalLocationDescriptor);
}):

Though it's hard to imagine why would I want to use the original global.location, it seems just a bit more correct.
And this code works fine for me, of course. I just access location.pathname, but this object may be easily extended with some jest.fn() if needed.

This has been working for me, using jest 26.5

function stubLocation(location) {
  beforeEach(() => {
    jest.spyOn(window, "location", "get").mockReturnValue({
      ...window.location,
      ...location,
    });
  });
}

stubLocation({ pathname: "/facebook/jest/issues/5124" });

test("mocks location prop", () => {
  expect(window.location.pathname).toEqual("/facebook/jest/issues/5124");
});

Adding an example of testing location.search based on @vastus's solution:

  test('gets passed query param and returns it as a string if it exists', () => {
    history.replaceState({}, 'Test', '/test?customer=123');
    const customerId = getQueryParam('customer');
    expect(customerId).toBe('123');
  });

This worked exactly well for the problem I was having

Was this page helpful?
0 / 5 - 0 ratings