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

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

About this issue

  • Original URL
  • State: closed
  • Created 7 years ago
  • Reactions: 121
  • Comments: 84 (13 by maintainers)

Commits related to this issue

Most upvoted comments

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/'
  }
});

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');
  });
});

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.

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

A solution similar to @sahalsaad :

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;

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)

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");
});

@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,
});

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.

Maybe it’s help anyone.

function shallowClone(obj, assignObj) {
  const clone = Object.create(Object.getPrototypeOf(obj));
  const descriptors = Object.getOwnPropertyDescriptors(obj);
  const assignObjDescriptors = Object.getOwnPropertyDescriptors(
    Object.getPrototypeOf(assignObj),
  );
  Object.defineProperties(clone, { ...descriptors, ...assignObjDescriptors });
  Object.keys(assignObj).forEach((key) => {
    Object.defineProperty(clone, key, {
      configurable: true, value: assignObj[key], writable: true,
    });
  });
  return clone;
}

const safelyStubAndThenCleanup = (target, method, value = {}) => {
  const original = target[method];

  beforeEach(() => {
    Object.defineProperty(target, method, {
      configurable: true, value: shallowClone(original, value),
    });
  });
  afterEach(() => {
    delete target[method];
    target[method] = original;
  });
};

const safelyStubAndThenCleanupWindow = () => {
  MockLocation.mockHistoryState();
  safelyStubAndThenCleanup(window, 'location', new MockLocation(window.location.href));
};

const getAbsoluteUrl = (relativeUrl) => {
  const { location: { origin } } = window;

  if (relativeUrl) {
    const preparedRelativeUrl = relativeUrl[0] === '/'
      ? relativeUrl.substring(1, relativeUrl.length)
      : relativeUrl;

    return relativeUrl ? `${origin}/${preparedRelativeUrl}` : origin;
  }

  return origin;
}

class MockLocation {
  constructor(url) {
    const {
      hash,
      hostname,
      origin,
      pathname,
      port,
      protocol,
      search,
    } = new URL(url);

    this.hash = hash;
    this.hostname = hostname;
    this.protocol = protocol;
    this.pathname = pathname;
    this.search = search;
    this.origin = origin || hostname;
    this.port = port;
  }

  static mockHistoryState() {
    const { pushState, replaceState } = window.history;

    window.history.pushState = (...arg) => {
      const url = arg[2];
      window.location.href = url;
      pushState.apply(window.history, arg);
    };

    window.history.replaceState = (...arg) => {
      const url = arg[2];
      window.location.href = url;
      replaceState.apply(window.history, arg);
    };
  }

  get href() {
    return `${this._protocol}//${this.host}${this._pathname}${this._search}`;
  }

  set href(value) {
    const isRelativeUrl = !value.includes(this.protocol);
    const {
      hash,
      href,
      hostname,
      origin,
      pathname,
      port,
      protocol,
      search,
    } = new URL(isRelativeUrl ? getAbsoluteUrl(value) : value);
    this.hash = hash;
    this.hostname = hostname;
    this.protocol = protocol;
    this.pathname = pathname;
    this.search = search;
    this.origin = origin || hostname;
    this.port = port;

    return href;
  }

  get protocol() {
    return this._protocol;
  }

  set protocol(value) {
    this._protocol = value;
  }

  get host() {
    return `${this._hostname}${this.port ? ':' : ''}${this.port}`;
  }

  get hostname() {
    return this._hostname;
  }

  set hostname(value) {
    this._hostname = value;
  }

  get port() {
    return this._port;
  }

  set port(value) {
    this._port = value;
  }

  get hash() {
    return this._hash;
  }

  set hash(value) {
    this._hash = value;
  }

  get pathname() {
    return this._pathname;
  }

  set pathname(value) {
    this._pathname = value;
  }

  get search() {
    return this._search;
  }

  set search(value) {
    this._search = value;
  }

  get origin() {
    return this._origin;
  }

  set origin(value) {
    this._origin = value;
  }

  assign(value) {
    this.href = value;
  }

  replace(value) {
    this.href = value;
  }

  toString() {
    return this.href;
  }
}

You just need write in setupTests.js

safelyStubAndThenCleanupWindow();

I used

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

along with testURL in jest config

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

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

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?

I solved this doing:

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

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";
    
  2. 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
    });
    
  3. 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" });

Most of the workarounds in here haven’t been working for me. In particular, the jest-environment-jsdom-global doesn’t seem to work because installing jest-environment-jsdom causes setImmediate to no longer be defined in the global scope and we rely on it fairly heavily for use in the flushPromises solution. I had some issues with some of the other solutions causing security exceptions with history when trying to change the origin.

In the end, I decided that we didn’t need to change the origin for our purposes and in that case, we might as well just use the built in browser mechanism for changing the URL using history.replaceState. If your application doesn’t rely on history listeners, this might be perfectly acceptable for you and doesn’t rely on any other libraries or hacks. I wish jest would just implement a proper mechanism for changing the URL.

/**
 * Changes the URL used by the global JSDOM instance in the current window.
 *
 * NOTE: This does not change the origin which would result in a security exception.
 */
function changeJSDOMURL(url: string) {
  const newURL = new URL(url);
  const href = `${window.origin}${newURL.pathname}${newURL.search}${newURL.hash}`;
  history.replaceState(history.state, null, href);
}

try:

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

@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.

@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.

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!

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')

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');
  });

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

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 😢

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

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, 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();
  });

@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.

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)
})

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.

Had the same issue, but this piece of code worked with me

  Object.defineProperty(window, "location", {
    value: new URL("http://example.com"),
    configurable: true,
  });

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.

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

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

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

@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’ve fixed this issue by re-implementing window.location & window.history: https://gist.github.com/tkrotoff/52f4a29e919445d6e97f9a9e44ada449

It’s heavily inspired by https://github.com/jestjs/jest/issues/5124#issuecomment-792768806 (thx @sanek306) and firefox-devtools window-navigation.js (thx @gregtatum & @julienw)

It comes with unit tests and it works well with our source base using Vitest.

You can then use window.location in your tests like you would expect:

it('should ...', () => {
  window.location.pathname = '/product/308072';

  render(<MyComponent />);

  const link = screen.getByRole<HTMLAnchorElement>('link', { name: 'Show more' });
  expect(link.href).toBe('http://localhost:3000/product/308072/more');
});

it('should ...', () => {
  const assignSpy = vi.spyOn(window.location, 'assign');

  render(<MyComponent />);

  const input = screen.getByRole<HTMLInputElement>('searchbox');
  fireEvent.change(input, { target: { value: 'My Search Query' } });
  fireEvent.submit(input);

  expect(assignSpy).toHaveBeenCalledTimes(1);
  expect(assignSpy).toHaveBeenNthCalledWith(1, '/search?query=My+Search+Query');

  assignSpy.mockRestore();
});

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.

@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!

Using window.history.pushState and testUrl worked for me

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

@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.

@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

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

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

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

@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.

@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!

@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.

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

@andrewBalekha What about this?

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