iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

jestでchrome extentionsのchrome storageのtestを書き方

こんにちは。iimonでエンジニアをしているhayashiと申します。

普段は主に拡張機能を開発しております。

今回はchrome拡張機能のlocalStorageのmock化について解説していきたいと思います。

今まで書いたjestのブログ記事の知識を前提に進めて行きますので、まだの方は是非読んで頂ければ幸いです。

jest基礎とテスト戦略

目指せ!mockマスター!!!!!~jestのMockテストについて~

Jest Mockマスターへの道(番外編) グローバルオブジェクトのmock化

spyOnでの監視やmockテスト解説

chrome storageとは

Google Chrome 拡張機能 (Chrome Extensions) においてデータを保存・取得するための API です。これは拡張機能がユーザーのデータや設定を保存するために使用されます。

利点

ブラウザのlocalStorageとsessionStorageの保存可能な容量は一般的に5MiB(メビバイト)までとされていますが、chrome storageはlocalもsessionも10MBまで保存が可能で、倍近く違うみたいです。

  • 1 MiB (Mebibyte) = 1,048,576 バイト (220 バイト)
  • 1 MB (Megabyte) = 1,000,000 バイト (106 バイト)

localSrorageの容量

chrome.storage容量

さらにchrome storage localはunlimitedStorage権限をリクエストすれば、その制限も外すことが可能なので大量のデータを保存できます。

unlimitedStorage権限を持っている場合の上限の明記は見当たらなかったのですが、ざっと調べるにパソコン自体の空き容量とブラウザが制限をかける可能性もあるとのことで、そこに左右されそうです。

おそらくunlimitedの文字通りリミットを単純に外しているかたちなのでしょう。

また、domainが変わった場合などはlocalStorageの値は使えませんがchrom.storageなら値を保持して使えるので拡張機能ならではサイトを跨いだ色んな操作に役立ちます。

chrome storageの動作

そもそものchrome storageの動作について簡単に解説します。

chrome extentionsのstorage情報はservice workerの中から見れます。

chrome storage localはこんな感じです。

基本的なイメージとしてはブラウザのlocalStorageと似たような挙動ですが、微妙に違うところもあります。

まずchrome storageは基本非同期処理です。

setについて

そしてsetはset(items: {[key: string]: any;})という型で引数を取ります。

例えばコードの中で以下の様にしたら

async setInputData() {
    await chrome.storage.local.set({test: {keyTest: 'value'}});
    await chrome.storage.local.set({testHoge: 'test'});
    await chrome.storage.local.set({testhuga: 0});
}

このようにセットされます

localStorageはsetItem(key: string, value: string)なので

引数の型が違います。

getについて

getの引数はget(keys?: string | string[] | { [key: string]: any } | null, callback: (items: { [key: string]: any }) => void): voidとなるのが基本的です。

上記の通り、第一引数はオプショナルの型でstring | string[] | { [key: string]: any } | null の型を取得し、第二引数はcallbackを返します。

chrome.storage.local.get('key', (items) => {
    console.log(items.key);
});

また、第一引数が省略されている場合はそのままcallbackを返し

chrome.storage.local.get((items) => {
    console.log('result callback null', items)
})

第二引数が省略されている場合はget(keys: string | string[] | { [key: string]: any } | null): Promise<{ [key: string]: any }>となります。

await chrome.storage.local.get('test')

いずれにしてもcallbackも返り値も { [key: string]: any } という型で返ってきます。

localStorage.getItem()は(key: string): string | nullとなるので、ココも違いですね。

chrome.storage.local.set({key1: ‘ value1’, key2: ‘value2’, key3: ‘value3’})として、値をstorageにsetしている場合のgetの挙動は以下のようになります。

単一のキーを指定して取得(string)

chrome.storage.local.get('key1', (items) => {
    console.log(items);
    // console {key1: ‘ value1’}
});

複数のキーを指定して取得(string[])

chrome.storage.local.get(['key1', 'key2'], (items) => {
    console.log(items);
    // console {key1: ‘ value1’, key2: ‘value2’}
});

デフォルト値を指定して取得( { [key: string]: any } )

chrome.storage.local.get({ key1: 'default1', key2: 'default2' }, (items) => {
  console.log(items);
        // console { key1: 'default1', key2: 'default2' }
});

全てのデータを取得(null | undefined)

chrome.storage.local.get(null, (items) => {
    console.log(items);
    // console {key1: ‘ value1’, key2: ‘value2’, key3: ‘value3’}
});
chrome.storage.local.get((items) => {
    console.log(items)
    // console {key1: ‘ value1’, key2: ‘value2’, key3: ‘value3’}
})

remove

removeはremove(keys: string | string[]): Promise となります。

await chrome.storage.local.remove(['test', 'testHoge'])

指定したkeyがstorageから削除されます。

こちらもlocalStorage.removeItem(key: string): voidなので違いがあります。

chrome storageのmock化

それでは本題のchrome storageのmock化について解説していきます。

そもそもの前提条件で言うとchrome storageというものはjestのjsdom(node.jsで作られた仮装のブラウザ環境)には無いので擬似的にプロパティを作ってグローバルに追加する必要があります。

それにより、chrome.storage.localなどのコードをjest上でも使える様にしてテストします。

最初に私がしたmockとしてAPIの動きまでも模倣しすぎた設計の失敗例から紹介したいと思います。

[chromeStorage.ts]

export type chromeDataType = { [key: string]: any };

export const setMockChromeStorage = () => {
    const mockChrome = {
        storage: {
            local: {
                data: {} as chromeDataType,
                set: jest.fn(
                    (keys: chromeDataType) =>
                        new Promise<void>((resolve) => {
                            const data = Object.assign(
                                mockChrome.storage.local.data,
                                keys
                            );
                            mockChrome.storage.local.data = data;
                            resolve();
                        })
                ),
                get: jest.fn(
                    (keys?: string | string[] | null) =>
                        new Promise<chromeDataType>((resolve) => {
                            const result: chromeDataType = {};
                            if (!keys) {
                                resolve(mockChrome.storage.local.data);
                                return;
                            }
                            if (typeof keys === 'string') {
                                const storageData =
                                    mockChrome.storage.local.data?.[keys];
                                result[keys] = storageData;
                                resolve(result);
                                return;
                            }
                            keys.forEach((key: string) => {
                                const storageData =
                                    mockChrome.storage.local.data?.[key];
                                if (!storageData) return;
                                result[key] = storageData;
                            });
                            resolve(result);
                        })
                ),
                remove: jest.fn(
                    (keys: string | string[]) =>
                        new Promise<void>((resolve) => {
                            if (typeof keys === 'string') {
                                delete mockChrome.storage.local.data?.[keys];
                            } else {
                                keys.forEach((key: string) => {
                                    delete mockChrome.storage.local.data?.[key];
                                });
                            }
                            resolve();
                        })
                ),
            },
        },
    };
    Object.assign(global, { chrome: mockChrome });
};

上記はchrome.storageのdataの型は{ [key: string]: any };なので、それをchromeDataTypeとし、setMockChromeStorageのmockChromeという擬似的chrome.storageオブジェクトを作成しchrome storageの動きを模倣したものになります。

このメソッドによりsetMockChromeStorageを宣言した場合にはchrome.storage.localメソッドを使えるようになり、動きもほぼほぼ同じようになります。

動きを細かく見たテストを書くなら以下になります。

上記で紹介したchrome.storage.localの動きもをほぼほぼ再現出来ているとは思います。

もっと簡潔な書き方もあるかもしれませんが、まぁ模倣されている動きを見ているとだけ思っていただければと思います。

[chromeStorage.test.ts]

import { setMockChromeStorage } from '../../helpers/chromeStorage';

describe('setMockChromeStorageメソッドのテスト', () => {
    const localKey = 'testChromeLocalKey';
    const localKey2 = 'testChromeLocalKey2';
    const localKey3 = 'testChromeLocalKey3';
    describe('chrome.storage.local関係のテスト', () => {
        beforeEach(async () => {
            jest.clearAllMocks();
            setMockChromeStorage();
            await chrome.storage.local.set({ localKey, localKey2 });
        });
        test('chrome.storage.local.setのテスト ちゃんとsetされているか', async () => {
            expect(chrome.storage.local.set).toHaveBeenCalledWith({
                localKey,
                localKey2,
            });
            await chrome.storage.local.set({ localKey3 });
            expect(chrome.storage.local.set).toHaveBeenCalledWith({
                localKey3,
            });
        });
        test('chrome.storage.local.getのテスト ちゃんとsetした値をget出来るか', async () => {
            let result = await chrome.storage.local.get('localKey');
            expect(chrome.storage.local.get).toHaveBeenCalledWith('localKey');
            expect(result).toEqual({ localKey: 'testChromeLocalKey' });
            result = await chrome.storage.local.get(['localKey', 'localKey2']);
            expect(chrome.storage.local.get).toHaveBeenCalledWith([
                'localKey',
                'localKey2',
            ]);
            expect(result).toEqual({
                localKey: 'testChromeLocalKey',
                localKey2: 'testChromeLocalKey2',
            });
            await chrome.storage.local.set({ localKey3 });
            result = await chrome.storage.local.get();
            expect(result).toEqual({
                localKey: 'testChromeLocalKey',
                localKey2: 'testChromeLocalKey2',
                localKey3: 'testChromeLocalKey3',
            });
        });

        test('chrome.storage.local.removeのテスト ちゃんとremove出来ているか', async () => {
            let result = await chrome.storage.local.get([
                'localKey',
                'localKey2',
            ]);
            expect(result).toEqual({
                localKey: 'testChromeLocalKey',
                localKey2: 'testChromeLocalKey2',
            });

            await chrome.storage.local.remove(['localKey', 'localKey2']);
            expect(chrome.storage.local.remove).toHaveBeenCalledWith([
                'localKey',
                'localKey2',
            ]);
            result = await chrome.storage.local.get(['localKey', 'localKey2']);
            expect(result).toEqual({});

            await chrome.storage.local.set({ localKey3 });
            result = await chrome.storage.local.get('localKey3');
            expect(result).toEqual({ localKey3: 'testChromeLocalKey3' });
            await chrome.storage.local.remove('localKey3');
            result = await chrome.storage.local.get('localKey3');
            expect(result).toEqual({});
        });
    });
});

上記設計で進めるかの相談を当社CTOとMGに確認したところ、

chrome storageをmock化する場合はそのメソッドがstorageのgetの値を取得して、その後の処理の動作をテストしたいわけなので、

getはスタブ値にしてsetとremoveのテストが必要な部分はmockとすれば良いのでは無いかとアドバイスを頂き、修正しました。

mockとstubの違い

そもそもjestではmockとstubは関数で区別されておらず、jest.fnjest.mockjest.spyOnを使い、mock関数と一括りにされているのですが、主に以下のように違いがあるみたいです。

モック(Mock)

  • 特徴:
    • 呼び出し履歴(引数や回数)を検証。
    • 処理がAPIにちゃんと値を送信しているか(送信メッセージ)のテスト時に使用
    • 必要に応じて、特定の戻り値や動作を設定する
  •   const mockFn = jest.fn();
      mockFn('test');
      expect(mockFn).toHaveBeenCalledWith('test');
    

スタブ(Stub)

  • 特徴:
    • テスト用に戻り値を固定する事。
    • その固定値(受信メッセージ)を使ったメソッドの動作をテストする為に使用
    • mockと異なり、関数の実行回数、関数が正しい引数で呼び出されたかなどの過程には干渉する必要がない
  • :

      const stubFn = jest.fn().mockReturnValue('stubbed value');
      expect(stubFn()).toBe('stubbed value');
    

上記のsetMockChromeStorageで言うと、関数の中に共通のdataオブジェクトを持って、それをchromeStorageを模倣したように実装してましたが、そもそも、この共通のdataオブジェクトにchrome.storage.local.setで保存が行われていることが確認出来たからと言って、実際のchrome storage APIに保存されているテストになっているわけではありません。

実際に保存されているかどうかの責務はAPIの開発元に委ねるとして、こちらのmockテストとしては、送信メッセージとしてAPIに送信出来ているかや、渡している引数が問題ないかのテストに止めるべきとの事です。

getが呼び出されているメソッドも同じでgetを呼び出しが成功することのテストはAPI開発元に任せて、こちらとしては、その値を使った処理のテストを行いたいので、getから返ってくる値をスタブ値として処理のテストを書くのが良いとの事です。

つまりchrome storageはjestに無いのでmock化したものをグローバルオブジェクトに追加はしなくてはならないですが、以下の様に追加して

const setupChromeStorage = () => {
    const chrome = {
        storage: {
            local: {
                set: jest.fn(),
                get: jest.fn(),
                remove: jest.fn(),
            },
        },
    }
    Object.assign(global, { chrome });
}

getはstubとして、都度都度テストによって固定値をセットして、その値を受け取ったメソッドの振る舞いのテストを作っていきます。

setしているところのテストを行いたい場合はtoHaveBeenCalledWithなどで、それが呼び出されているか確認するのに止めるかたちです。

MGが設計イメージのコードやOSSコードや参考記事を紹介して下さり、僕が実装したのを、CTOが纏めて下さり以下のようなシンプルなサンプルのコードを作りましたので紹介したいと思います。

この考えの元、プロダクトのchrome storageのmockを作ろうと思います。

いやー、結構協力して貰っちゃいました。ありがたいです!

例えば以下のChromeStorageValSetクラスはstorage.localからtestValというkeyのデータを取得し、それをinputのvalueに入れる簡単なクラスです。

[ChromeStorageValSet.ts]

export default class ChromeStorageValSet {
    async setInputVal() {
        const { testVal } = await chrome.storage.local.get('testVal');
        const inputElem = document.getElementById('testInput');
        if (!(inputElem instanceof HTMLInputElement)) return;
        inputElem.value = testVal;
    }
}

こちらのテストを書く場合は

[SetupChromeStorage.ts]

export const setupChromeStorage = () => {
    const chrome = {
        storage: {
            local: {
                set: jest.fn(),
                get: jest.fn(),
                remove: jest.fn(),
            },
        },
    }
    Object.assign(global, { chrome });
}

まず、上記のようにglobalにchromeオブジェクトを追加するメソッドを作ります。

こちらはテストの時に都度使うので共通メソッドとして切り出しております。

[ChromeStorageValSet.test.ts]

import { setupChromeStorage } from "@tests/helpers/SetupChromeStorage";
import ChromeStorageValSet from "@tests/ChromeStorageValSet";

describe('TestChromeStorage', () => {
    let instance: ChromeStorageValSet;

    beforeEach(() => {
        jest.clearAllMocks()
    });

    beforeAll(() => {
        instance = new ChromeStorageValSet();
        setupChromeStorage();
        document.body.innerHTML = `
            <input id="testInput" type="text" />
        `;
    });
   
    test('setInputVal sets the input value correctly', async () => {
        const testVal = 'testVal'
        jest.spyOn(global.chrome.storage.local, 'get').mockImplementation(async () => ({testVal}));
        const inputElem = document.getElementById('testInput') as HTMLInputElement;
        expect(inputElem.value).toBe(''); 
        await instance.setInputVal();
        expect(inputElem.value).toBe('testVal'); 
    });
});

そして上記の様にjest.spyOn(global.chrome.storage.local, 'get').mockImplementation(async () => ({testVal}))でスタブ値を作成し、その後の振る舞いをテストしてます。

mockImplementationにしているのはmockResolvedValueだとnever型は割り当てられないって型エラーで怒られるので.mockImplementation(async () => ({testVal}))という形で逃げてます。

この設計思想でmockテストを実装していけば、変更に強いテストをかけそうです。

まとめ

APIに送るデータを作る処理と、APIから返ってきたデータをを受け取って実行する処理を分けることは必須だと分かってはいたのですが、どこまでmockするのかっていうのが、少し曖昧だった部分なので、今回のことでAPIに送るデータを作るテストはmockとしてそのデータをAPIに送信出来ているかテストする。APIを叩いた返り値を使って処理するメソッドの場合はstubとして固定値にして、その処理のテストするのが効率が良いという事を学べてとても勉強になりました!

なまじAPIの動きまで分かっているからと言ってそれを模倣したmockを作ってしまい、そのmockをgetを使っているロジックのテスト部分にまで反映させてしまうと、自分がテストしたい部分の責務の範疇を出てしまうのでmockテスト(stubを含む)を書く上では、外部APIの振る舞いは開発元の責務として、こちらはアウトプット値を揃える事を意識することが保守性を高める秘訣の様です。

この記事を読んで興味を持って下さった方がいらっしゃればカジュアルにお話させていただきたく、是非ご応募をお願いします!

CTOやMGにも相談しやすい環境ですよーーーーーー!笑

もちろんリーダーにも!笑

iimon採用サイト / Wantedly / Green

参考記事

https://developer.chrome.com/docs/extensions/reference/api/storage?hl=ja

https://qiita.com/koji0705/items/191a48e51b9eebb81db1

https://developer.chrome.com/docs/extensions/mv2/reference/storage?hl=ja

https://github.com/bitwarden/clients/blob/907abc9dae3ecb5b994d9e6d852d60576ec64bdb/apps/browser/src/platform/services/abstractions/chrome-storage-api.service.spec.ts#L97

https://github.com/furybee/chrome-tab-modifier/blob/80d821881a4250738baeb438688a9ed3b80957c5/src/common/storage.test.js#L104

https://service.shiftinc.jp/column/8057/

https://qiita.com/koji0705/items/191a48e51b9eebb81db1

https://atgo.rgsis.com/column/about-stub/#:~:text=スタブはテストの焦点,ために使われます。&text=主に受信メッセージの,実装に限定します。

https://zenn.dev/chida/articles/cec625e3b6aa7b

https://zenn.dev/hid3/articles/5180aaf854d252

or