メモログ

💡 Personal notes about somthing I'm interested in

Can not mock localStorage in jest

jsdomが11.12.0でlocalStorageに対応した(Implement Web storage - localStorage, sessionStorage, StorageEvent)のだけど、Jestで使用しているjsdomのバージョンが^11.5.1という指定になっていたので、package.lockとかしていないとnpm install時にアップデートされてlocalStorageが対応されたjsdomが使われるようになった。それで今までlocalStorageにMockを使っていたところが使われなくなってしまい、テストが通らないケースが出てきてしまった。という話が出ている(jsdom update 5 days ago breaks back compatability

workroundはJsdom v11.12.0 adds localStorage and sessionStorageにあるように、jsdomのインターナルな実装である_localStorageに対してdefinePropertyすることで解消できる。

import { LocalStorage } from './localstorage';

if (typeof global._localStorage !== 'undefined') {
  Object.defineProperty(global, '_localStorage', {
    value: new LocalStorage(jest),
  });
} else {
  global.localStorage =  new LocalStorage(jest);
}

if (typeof global._sessionStorage !== 'undefined') {
  Object.defineProperty(global, '_sessionStorage', {
    value: new LocalStorage(jest),
  });
} else {
  global.sessionStorage =  new LocalStorage(jest);
}

これはバージョン管理としてはだいぶ微妙なところで、jest的には既存のコードが動かなくなるという意味でbreaking changesであるのだけど、jsdom的には(機能を追加しただけで)breaking changesには当たらないというが感じがある。

それでなぜ今までObject.defineProperty(global, 'localStorage' ...)で設定できたものができなくなったかというと、これがなかなか難しい。

memolog/jest-jsdom-localstorage-testみたいな感じでいろいろ試してみたところ、この問題はNodeのバージョンが8.x(試したのは8.10)では再現するが、バージョン9.x(試したのは9.2)では再現しないことが分かった。また、jsdomのrunScripts: 'dangerously'を外すと、バージョンが8.xでも現象が再現しなくなる。

runScripts: 'dangerously'で何が変わるかというと、Window.jscontextifyWindowが実行されるようになる。これが実行されるとwindow._globalProxyの値がvm.runInContextで実行した結果になる。

function contextifyWindow(window) {
  if (vm.isContext(window)) {
    return;
  }

  vm.createContext(window);
  const documentImpl = idlUtils.implForWrapper(window._document);
  documentImpl._defaultView = window._globalProxy = vm.runInContext("this", window);
}

jsdomでwindowを取得すると、このwindow._globalProxyの値が返ってくる。

  define(this, {
    get length() {
      return window._length;
    },
    get window() {
      return window._globalProxy;
    },

以下のような感じで、globalに渡されたlocalStorageの中身を確認してみると、

console.log(Object.getOwnPropertyDescriptor(global, 'localStorage'));
console.log(Object.getOwnPropertyDescriptor(global, '_localStorage'));

Node 8.xでは

  { value: Storage {},
    writable: true,
    enumerable: true,
    configurable: true }

  { value: Storage {},
    writable: true,
    enumerable: true,
    configurable: true }

と同じ結果が返ってくるのに対して、Node 9.xでは

  { get: [Function: get localStorage],
    set: undefined,
    enumerable: true,
    configurable: true }

  { value: Storage {},
    writable: true,
    enumerable: true,
    configurable: true }

と返ってくる。jsdom内でのlocalStorageの実装は

    get localStorage() {
      if (this._document.origin === "null") {
        throw new DOMException("localStorage is not available for opaque origins", "SecurityError");
      }

      return this._localStorage;
    },

という感じになっているので、Node 9.xの返りは正しい。localStorageのgetterで_localStorageの内部実装を返すようになっている。

Node 8.xの方は_localStorageの値が直接返ってきている感じになっている。runScripts: 'dangerously'が設定されていないと8.xの方でも9.xと同じような結果になるので、vm.runInContext_globalProxyへ渡される結果がNode.jsのバージョン間で異なるところに原因がありそうに思われる。

つまり、もともとの実装に対して、Object.defineProperty(global, 'localStorage', { value: localStorageMock }); と変更すると反映されるが、内部実装の値が返ってくる状態でdefinePropertyを実行しても変更が反映されない。次にlocalStorageを呼びだされる時に新たに内部実装から呼び出されるために、global.localStorageへの変更が無意味な状態になってるのではなかろうかと想像している。

ちなみに global.localStorage = localStorageMockみたいな感じでMockを設定できないのは、global.localStorageにはsetterが定義されていないため(と思う)。

というメモ。