如何使用Jest + Vuejs模拟window.location.href?

179

目前,我正在为我的项目实施单元测试,其中有一个包含window.location.href的文件。

我想要模拟这个进行测试,下面是我的示例代码:

it("method A should work correctly", () => {
      const url = "http://dummy.com";
      Object.defineProperty(window.location, "href", {
        value: url,
        writable: true
      });
      const data = {
        id: "123",
        name: null
      };
      window.location.href = url;
      wrapper.vm.methodA(data);
      expect(window.location.href).toEqual(url);
    });

但是我收到了这个错误:

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

我该如何解决它?


1
这个问题已经在另一个帖子中得到了回答这是答案链接 - Mustafa J
4
虽然不完全符合您的要求,但 window.location.assign(url) 函数实际上可以达到同样的效果,因此您可以使用 jest.spyOn(window.location, 'assign').mockImplementation(() => {}); 进行模拟。 - Brady Dowling
这个回答能解决你的问题吗?如何使用Jest模拟JavaScript中的'window'对象? - Michael Freidgeim
22个回答

0
我通过重新实现window.location和window.history来解决了这个问题:https://gist.github.com/tkrotoff/52f4a29e919445d6e97f9a9e44ada449 这主要受到https://github.com/jestjs/jest/issues/5124#issuecomment-792768806(感谢sanek306)和firefox-devtools window-navigation.js(感谢gregtatum和julienw)的启发。
它附带了单元测试,并且在我们的源代码库中运行良好。
然后,您可以像预期的那样在您的测试中使用window.location。
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();
});

这里是实现(从gist中复制粘贴,更多细节请参见gist):
/* eslint-disable unicorn/no-null */

/*
 * Resetting window.location between tests is unfortunately a hard topic with JSDOM.
 *
 * https://gist.github.com/tkrotoff/52f4a29e919445d6e97f9a9e44ada449
 *
 * FIXME JSDOM leaves the history in place after every test, so the history will be dirty.
 * Also its implementations for window.location and window.history are lacking.
 * - https://github.com/jsdom/jsdom/blob/22.1.0/lib/jsdom/living/window/Location-impl.js
 * - https://github.com/jsdom/jsdom/blob/22.1.0/lib/jsdom/living/window/History-impl.js
 * - https://github.com/jsdom/jsdom/blob/22.1.0/lib/jsdom/living/window/SessionHistory.js
 *
 * What about Happy DOM? Implementations are empty:
 * - https://github.com/capricorn86/happy-dom/blob/v12.10.3/packages/happy-dom/src/location/Location.ts
 * - https://github.com/capricorn86/happy-dom/blob/v12.10.3/packages/happy-dom/src/history/History.ts
 *
 *
 * window.location and window.history should work together:
 * window.history should update the location, and changing the location should push a new state in the history
 *
 * Solution: re-implement window.location and window.history
 * The code is synchronous instead of asynchronous, yet it fires "popstate" events
 *
 * Inspired by:
 * - https://github.com/jestjs/jest/issues/5124#issuecomment-792768806
 * - https://github.com/firefox-devtools/profiler/blob/f894531be77dee00bb641f49a657b072183ec1fa/src/test/fixtures/mocks/window-navigation.js
 *
 *
 * Related issues:
 * - https://github.com/jestjs/jest/issues/5124
 * - https://github.com/jestjs/jest/issues/890
 * - https://github.com/jestjs/jest/issues/5987
 * - https://dev59.com/ulQJ5IYBdhLWcg3wW0my
 *
 * - Huge hope on jsdom.reconfigure() (tried by patching Vitest JSDOM env), doesn't work
 *   https://github.com/vitest-dev/vitest/discussions/2383
 *   https://github.com/simon360/jest-environment-jsdom-global/blob/v4.0.0/environment.js
 *   https://github.com/simon360/jest-environment-jsdom-global/blob/v4.0.0/README.md#using-jsdom-in-your-test-suite
 */

class WindowLocationMock implements Location {
  private url: URL;

  internalSetURLFromHistory(newURL: string | URL) {
    this.url = new URL(newURL, this.url);
  }

  constructor(url: string) {
    this.url = new URL(url);
  }

  toString() {
    return this.url.toString();
  }

  readonly ancestorOrigins = [] as unknown as DOMStringList;

  get href() {
    return this.url.toString();
  }
  set href(newUrl) {
    this.assign(newUrl);
  }

  get origin() {
    return this.url.origin;
  }

  get protocol() {
    return this.url.protocol;
  }
  set protocol(v) {
    const newUrl = new URL(this.url);
    newUrl.protocol = v;
    this.assign(newUrl);
  }

  get host() {
    return this.url.host;
  }
  set host(v) {
    const newUrl = new URL(this.url);
    newUrl.host = v;
    this.assign(newUrl);
  }

  get hostname() {
    return this.url.hostname;
  }
  set hostname(v) {
    const newUrl = new URL(this.url);
    newUrl.hostname = v;
    this.assign(newUrl);
  }

  get port() {
    return this.url.port;
  }
  set port(v) {
    const newUrl = new URL(this.url);
    newUrl.port = v;
    this.assign(newUrl);
  }

  get pathname() {
    return this.url.pathname;
  }
  set pathname(v) {
    const newUrl = new URL(this.url);
    newUrl.pathname = v;
    this.assign(newUrl);
  }

  get search() {
    return this.url.search;
  }
  set search(v) {
    const newUrl = new URL(this.url);
    newUrl.search = v;
    this.assign(newUrl);
  }

  get hash() {
    return this.url.hash;
  }
  set hash(v) {
    const newUrl = new URL(this.url);
    newUrl.hash = v;
    this.assign(newUrl);
  }

  assign(newUrl: string | URL) {
    window.history.pushState(null, 'origin:location', newUrl);
    this.reload();
  }

  replace(newUrl: string | URL) {
    window.history.replaceState(null, 'origin:location', newUrl);
    this.reload();
  }

  // eslint-disable-next-line class-methods-use-this
  reload() {
    // Do nothing
  }
}

const originalLocation = window.location;

export function mockWindowLocation(url: string) {
  //window.location = new WindowLocationMock(url);
  //document.location = window.location;
  Object.defineProperty(window, 'location', {
    writable: true,
    value: new WindowLocationMock(url)
  });
}

export function restoreWindowLocation() {
  //window.location = originalLocation;
  Object.defineProperty(window, 'location', {
    writable: true,
    value: originalLocation
  });
}

function verifyOrigin(newURL: string | URL, method: 'pushState' | 'replaceState') {
  const currentOrigin = new URL(window.location.href).origin;
  if (new URL(newURL, currentOrigin).origin !== currentOrigin) {
    // Same error message as Chrome 118
    throw new DOMException(
      `Failed to execute '${method}' on 'History': A history state object with URL '${newURL.toString()}' cannot be created in a document with origin '${currentOrigin}' and URL '${
        window.location.href
      }'.`
    );
  }
}

export class WindowHistoryMock implements History {
  private index = 0;
  // Should be private but making it public makes it really easy to verify everything is OK in some tests
  public sessionHistory: [{ state: any; url: string }] = [
    { state: null, url: window.location.href }
  ];

  get length() {
    return this.sessionHistory.length;
  }

  scrollRestoration = 'auto' as const;

  get state() {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-return
    return this.sessionHistory[this.index].state;
  }

  back() {
    this.go(-1);
  }

  forward() {
    this.go(+1);
  }

  go(delta = 0) {
    if (delta === 0) {
      window.location.reload();
    }
    const newIndex = this.index + delta;
    if (newIndex < 0 || newIndex >= this.length) {
      // Do nothing
    } else if (newIndex === this.index) {
      // Do nothing
    } else {
      this.index = newIndex;

      (window.location as WindowLocationMock).internalSetURLFromHistory(
        this.sessionHistory[this.index].url
      );

      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      dispatchEvent(new PopStateEvent('popstate', { state: this.state }));
    }
  }

  pushState(data: any, unused: string, url?: string | URL | null) {
    if (url) {
      if (unused !== 'origin:location') verifyOrigin(url, 'pushState');
      (window.location as WindowLocationMock).internalSetURLFromHistory(url);
    }
    this.sessionHistory.push({
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      state: structuredClone(data),
      url: window.location.href
    });
    this.index++;
  }

  replaceState(data: any, unused: string, url?: string | URL | null) {
    if (url) {
      if (unused !== 'origin:location') verifyOrigin(url, 'replaceState');
      (window.location as WindowLocationMock).internalSetURLFromHistory(url);
    }
    this.sessionHistory[this.index] = {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
      state: structuredClone(data),
      url: window.location.href
    };
  }
}

const originalHistory = window.history;

export function mockWindowHistory() {
  //window.history = new WindowHistoryMock();
  Object.defineProperty(window, 'history', {
    writable: true,
    value: new WindowHistoryMock()
  });
}

export function restoreWindowHistory() {
  //window.history = originalHistory;
  Object.defineProperty(window, 'history', {
    writable: true,
    value: originalHistory
  });
}

-1

可以通过在每个测试中删除此全局变量来重写 window.location。

delete global.window.location;
const href = 'http://localhost:3000';
global.window.location = { href };

为什么要删除?为什么只重新分配不起作用? - karlosos

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接