$shibayu36->blog;

株式会社はてなでエンジニアをしています。プログラミングや読書のことなどについて書いています。

JSDOM環境でlocalStorageをfakeする(with TypeScript)

JSDOM環境を使っていると、いくつか実装されていないAPIがある。もしそのAPIを使っている実装のテストを書きたい場合、そのAPIと近い動きをする仮のオブジェクトに置き換える、つまりfakeする必要がある。

localStorageもJSDOMでは現在動かないAPIなので、localStorageを使った機能をテストするにはfakeする必要がある。普通のJSの環境だと、https://github.com/tmpvar/jsdom/issues/1137#issuecomment-173039751 に書いてあるようなオブジェクトを代入すれば良いのだけど、TypeScriptだと型定義どおりに実装しないとうまくいかないので、今回はTypeScript環境でfakeするのをやってみた。

一番単純にfakeする

方法としては同じインターフェースを持った別のものをwindow.localStorageに代入すればよい。TypeScriptの型定義lib.d.tsとかを参照すると、localStorageはStorageインターフェースを実装する必要があり、

interface WindowLocalStorage {
    localStorage: Storage;
}

さらにStorageインターフェースを確認すると

interface Storage {
    length: number;
    clear(): void;
    getItem(key: string): any;
    key(index: number): string;
    removeItem(key: string): void;
    setItem(key: string, data: string): void;
    [key: string]: any;
    [index: number]: string;
}

のようにプロパティとしてlength, 関数としてclear, getItem, key, removeItem, setItem, 他にプロパティとしてはstring -> anyか、number -> stringのものを入れられることがわかる。

この中空最低限getItem, setItem, removeItemくらいを実装してみれば、簡単なfakeはできそう。fake用のクラスを定義したのは以下のとおり。

class FakeStorage implements Storage {
    length: number;
    [key: string]: any;
    [index: number]: string;

    constructor() {}

    getItem(key: string): any {
        return this[key];
    }

    setItem(key: string, data: string): void {
        this[key] = data;
    }

    removeItem(key: string): void {
        delete this[key];
    }

    key(index: number): string {
        // not implement
    }

    clear(): void {
        // not implement
    }
}

export { FakeStorage };

あとはこれを使って、JSDOM環境のwindow.localStorageに代入しておけば使える。

import { FakeStorage } from "./FakeStorage";
window.localStorage = new FakeStorage();
localStorage.setItem('hoge', 'fuga');
console.log(localStorage.getItem('hoge')); // fuga
localStorage.removeItem('hoge');
console.log(localStorage.getItem('hoge')); // null

// 使い終わったら元に戻す
window.localStorage = undefined;

これで実装でsetItem, getItem, removeItemしか使っていないなら、最低限のテストができるようになった。

他のAPIも実装する

上ので大体良いけど、興味本位でさらに他のAPIも実装してみた。実装したのは以下のとおり。

class FakeStorage implements Storage {
    [key: string]: any;
    [index: number]: string;

    constructor() {}

    get length(): number {
        return this._keys().length;
    }

    getItem(key: string): any {
        return this[key];
    }

    setItem(key: string, data: string): void {
        this[key] = data;
    }

    removeItem(key: string): void {
        delete this[key];
    }

    key(index: number): string {
        let key = this._keys()[index];
        return !!key ? key : null;
    }

    clear(): void {
        this._keys().forEach((key) => {
            this.removeItem(key);
        });
    }

    private _keys(): string[] {
        return Object.keys(this);
    }
}

lengthをgetterで実装し、あとはこのオブジェクトのkeysを取得し実装している。

localStorageをfakeするための便利ユーティリティを用意する

このままでもやりたいことは既にできたが、もしJSDOM環境にlocalStorageが実装された場合、最後にundefinedを代入してしまうとおかしくなってしまう。もし実装されたとしても、最初にwindow.localStorageを退避しておいて、最後に退避したオブジェクトを代入すればおかしくならないはず。

そこでそのようなことを簡単にできる便利なユーティリティを作ってみた。

interface FakeLocalStorage {
    restore(): void;
}

function useFakeLocalStorage(): FakeLocalStorage {
    let _origLocalStorage = window.localStorage;
    let fakeLocalStorage  = new FakeStorage();

    window.localStorage = fakeLocalStorage;

    return {
        restore: function () {
            window.localStorage = _origLocalStorage;
        }
    }
}

このuseFakeLocalStorage()は以下のように使える。

// useFakeLocalStorage()を呼び出すとwindow.localStorageが置き換わり
// まっさらなlocalStorageを使っている状態になる
let fake = useFakeLocalStorage();

// fakeしたものを使っていろいろ書ける
localStorage.setItem('hoge', 'fuga');
console.log(localStorage.getItem('hoge')); // fuga
localStorage.removeItem('hoge');
console.log(localStorage.getItem('hoge')); // null

// fake.restore()を呼ぶと元の状態に戻る
fake.restore();

これを使ったらもしlocalStorageがJSDOMに実装されたとしても、特定のテストで一回localStorageがまっさらな状態でテストし、その後元に戻すということを簡単にできて嬉しい。

まとめ

今回はTypeScriptでJSDOM環境のlocalStorageをfakeする方法について書いた。TypeScriptの型定義ファイルを見るとインターフェースを参照できるので、それにしたがって作るだけで良いということが分かってよかった。他にもいい方法があれば教えて下さい。

そういえば https://github.com/tmpvar/jsdom/issues/1137#issuecomment-215997211 の議論で見つけた、node.js v6を使っていると、localStorageがPROXIESされる(?)ということがよく分かっていないけど、どういうことだろう?node v6を使えばJSDOMでlocalStorage使える?