iimon TECH BLOG

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

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

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

前回はjestのmockの概要やマッチャーや外部APIを叩く処理のmock化をハンズオン形式で紹介しましたが、今回は番外編としてグローバルオブジェクトのmock化についてlocalStorageのmock化を例にして解説していきたいと思います。

こちら参照して頂ければ考え方は同じなのでグローバルオブジェクト(windowオブジェクト)にあるメソッドのmock化は大体問題なく出来るかなって思います。

ぜひご覧頂ければ幸いです。

こちらは以前に記載した2つの記事の知識を前提に解説させて頂いておりますので、まだ未読で気になった方は是非併せてご確認お願いいたします。

jest基礎とテスト戦略

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

グローバルオブジェクトのmock化について

グローバルオブジェクトは基本的にmock化することが一般的のようです。

理由としては以下になります。

  • mock化することによって処理が軽くなる
  • モック化によりmockマッチャーが使えるようになるので挙動を監視する事が可能
  • window.location.hrefなどjsdomだと読み取り専用のプロパティはmock化しないと値が変更できない。

mock化手法

windowオブジェクトにあるメソッドをmock化していくにあたり、色々な方法があります。

例えば以下のようにjest.fnを使ってlocalStorageをmock化する方法です。

これは個別にsetItemとgetItemをmock化しているメソッドとなります。

export const setLocalStorageMock = () => {
    const mockLocalStorage: { [key: string]: string } = {};
    window.localStorage.setItem = jest.fn((key: string, value: string) => {
        mockLocalStorage[key] = value
    })
    window.localStorage.getItem = jest.fn((key: string) => mockLocalStorage[key])
};

localStorage自体をmock化して一つのオブジェクトとして一元管理したい場合は以下のようにする必要があるのですが、これは全てのlocalStorageのプロパティをmock化しないとエラーになるのでfnで必要なメソッドのみmock化したい場合は上記のように個別にmock化していくような形になります。

export const setLocalStorageMock = () => {
    const mockLocalStorage: { [key: string]: string } = {};
    window.localStorage = {
        setItem: jest.fn((key: string, value: string) => {
            mockLocalStorage[key] = value;
        }),
        getItem: jest.fn((key: string) => mockLocalStorage[key])
    } 
    // removeItemなど他のメソッドが足りなよってエラーになる
};

spyを使ってmock化する場合は以下のような形になるかなって思います。

export const setLocalStorageMock = () => {
    const setItemSpy = jest.spyOn(
        Object.getPrototypeOf(window.localStorage),
        'setItem'
    );
    const getItemSpy = jest.spyOn(
        Object.getPrototypeOf(window.localStorage),
        'getItem'
    );

    const mockLocalStorage: { [key: string]: string } = {};
    setItemSpy.mockImplementation((key, value) => {
        mockLocalStorage[key as string] = value as string;
    });
    getItemSpy.mockImplementation((key) => mockLocalStorage[key as string]);
};

spyを使ってのmock化は度々見られますし、部分的にmock化する際には手軽だとは思うのですが、jest.requireActualとの併用でjest.mockでも部分的にmock化が可能なのと、mock化した場合はmockマッチャーが使えて、いずれにしても監視が出来るのでspyはmock化してないメソッドを監視する時に使ってこそ用途にあっている。そこがオーバラップしていてどっち使っても挙動の差異をあまり感じられないfnとspyの使い分けのラインだと個人的には考えています。

ここら辺の話も前回の記事でしているので気になった方は是非!

猛烈宣伝中ですwww

因みに監視する場合は以下みたいな感じになるかと思います。

const getItemSpy = jest.spyOn(
        Object.getPrototypeOf(window.localStorage),
        'getItem'
    );
localStorage.getItem('image');
expect(getItemSpy).toHaveBeenCalledWith('image');

今回はjest.fnを使い、尚且つlocalStorageのメソッドを一つ一つmock化するのではなくオブジェクトで一元管理したいし、現状mock化する必要のないものはmock化したくない。

そんな時はObject.definePropertyを使います。今回はこれでmock化していきましょう

Object.defineProperty

 Object.defineProperty(プロパティを定義するオブジェクト, 'プロパティ名', {value: {...}}

Object.definePropertyはオブジェクトに直接新しいプロパティを定義するか、既存のプロパティを変更するために使用されます。このメソッドは3つの引数を受け取ります

  1. プロパティを定義するオブジェクト
  2. プロパティ名(既にある場合は上書き)
  3. プロパティ記述子(property descriptor)オブジェクト。このオブジェクトは以下のプロパティを持つことができます:
    • value: プロパティの値
    • writable: プロパティの値が変更可能かどうかを示す(Boolean)
    • get: プロパティのgetter関数
    • set: プロパティのsetter関数
    • configurable: プロパティのタイプが変更可能かどうか、またはプロパティがオブジェクトから削除可能かどうかを示すBoolean値
    • enumerable: プロパティが列挙可能かどうかを示すBoolean値

localStorageのvalueをmock化するので、それ以外のproperty descriptorのプロパティについてこ気になった場合はMDNの参照をお願いします。

localStorageのvalueは以下のようになっているのでgetItem,setItemのメソッドをmock化します。

具体的には以下のような感じになります。

export const setLocalStorageMock = () => {
    const mockLocalStorage: { [key: string]: string } = {};
    Object.defineProperty(window, 'localStorage', {
        value: {
            setItem: jest.fn((key: string, value: string) => {
                mockLocalStorage[key] = value;
            }),
            getItem: jest.fn((key: string) => mockLocalStorage[key]),
        },
    });
};

const mockLocalStorage: { [key: string]: string } = {};でmock用のローカルストレージオブジェクトを用意し、各メソッドはそちらを参照するようにしてます。

ハンズオン

それでは実際にハンズオンで見ていきます。

前の記事で作成した、dogボタンを押して犬の画像が切り替わるシステムにprevDogボタンを追加して、それを押したら変更前の犬の画像が出てくるようにします。

これはlocalStorageに前回の犬のimageUrlを保存してボタンを押したら前回の犬画像が入るようなロジックになります。 以下のようにコードを変更すると実装できます。

[index.html]

<!DOCTYPE html>
<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Vite App</title>
    </head>
    <body>
      <div>
        <img src="https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg" id="dogImg" alt="dog">
      </div>
      <br>
      <div>
        <button id="dogBtn">dog</button>
        <button id="prevDogBtn">prevDog</button> <!-- 追加 -->
      </div>
      <script type="module" src="/src/dogApi.ts"></script>
    </body>
</html>

[dogApi.ts]

export default class DogApi {
  setDogImageEvent(){
    this.setDogImage()
    this.setPrevDogImage()
  }

  setDogImage(){
    const btn = document.getElementById('dogBtn')
    if(!btn) return
    btn.addEventListener('click', async () => {
      const dogImg = document.getElementById('dogImg')
      const image = await this.fetchDogImage()
      if (!(dogImg instanceof HTMLImageElement)) return
      // 画像を取得時にlocalStorageに保存
      if (dogImg.src) localStorage.setItem('image', dogImg.src)
      dogImg.src = image
      dogImg.style.width = '300px'; 
      dogImg.style.height = '300px';
    })
  }

  setPrevDogImage() {
    const btn = document.getElementById('prevDogBtn')
    if (!btn) return
    btn.addEventListener('click',() => {
      const prevImage = localStorage.getItem('image')
      const dogImg = document.getElementById('dogImg')
      if (!(dogImg instanceof HTMLImageElement) || !prevImage) return
      dogImg.src = prevImage
      dogImg.style.width = '300px'; 
      dogImg.style.height = '300px';
    })
  }

  async fetchDogImage() {
    try {
      const res = await fetch('https://dog.ceo/api/breed/akita/images/random')
      if (!res.ok) {
        throw new Error(`HTTP error! status: ${res.status}`);
      }
      const data = await res.json()
      const imageUrl = data.message
      return imageUrl
    } catch (error) {
        throw new Error(`${error}`)
    }
  }
}

new DogApi().setDogImageEvent()

そしてテストを書きます。

以下のようにコードを変更させて下さい

[dogApiHtml.ts]

export const dogApiHtml = `<!DOCTYPE html>
<html lang="en">
    <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <title>Vite App</title>
    </head>
    <body>
      <div>
        <img src="https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg" id="dogImg" alt="dog">
      </div>
      <br>
      <div>
        <button id="dogBtn">dog</button>
        <button id="prevDogBtn">prevDog</button>
      </div>
      <script type="module" src="/src/dogApi.ts"></script>
    </body>
</html>`

export default dogApiHtml

[test/globalObjectMocks.ts]

export const setLocalStorageMock = () => {
  const mockLocalStorage: { [key: string]: string } = {};
  Object.defineProperty(window, 'localStorage', {
      value: {
          setItem: jest.fn((key: string, value: string) => {
              mockLocalStorage[key] = value;
          }),
          getItem: jest.fn((key: string) => mockLocalStorage[key]),
      },
  });
};

[dogApi.test.ts]

import DogApi from '../src/dogApi';
import { setLocalStorageMock } from './globalObjectMocks';
import dogApiHtml from './html/dogApiHtml'

jest.mock('../src/dogApi.ts')
let instance = new DogApi()
const defaultImgUrl = 'https://images.dog.ceo/breeds/borzoi/n02090622_5956.jpg'
const imgTestUrl ='https://testdog/img/testUrl.jpg'
document.body.innerHTML = dogApiHtml;

const watchImgSrcObserver = (elem: HTMLImageElement) => {
  return new Promise<void>((resolve) => {
    const observer = new MutationObserver(() => {
      observer.disconnect();
      resolve();
    });
    observer.observe(elem, { attributes: true, attributeFilter: ['src'] });
  });
}

describe('DogApiのManual Mockのテスト', () => {
  beforeEach(() => {
    jest.clearAllMocks();
    instance = new DogApi();
  })

  test('fetchDogImageのmockテスト', async () => {
      const res = await instance.fetchDogImage()
      expect(res).toEqual(imgTestUrl)

  }) 

  describe('setDogImageEventのmockテスト', () =>{
    test('dogBtnを押してprevDogBtnを押した場合のテスト', async () => {
      // ローカルストレージをモック化
      setLocalStorageMock()
      instance.setDogImageEvent()
      const dogBtn = document.getElementById('dogBtn')
      const dogImg = document.getElementById('dogImg')
      if (!(dogBtn instanceof HTMLButtonElement) || !(dogImg instanceof HTMLImageElement)) return
      expect(dogImg.src).toEqual(defaultImgUrl)
      dogBtn.click()
      await watchImgSrcObserver(dogImg);
          expect(instance.fetchDogImage).toHaveBeenCalled()
      expect(instance.fetchDogImage).toHaveBeenCalledTimes(1)
          expect(instance.fetchDogImage).toHaveBeenCalledWith()
      expect(dogImg.src).toEqual(imgTestUrl)
      // ローカルストレージに保存されていることがわかる
      expect(localStorage.setItem).toHaveBeenCalled();
      expect(localStorage.setItem).toHaveBeenCalledTimes(1);
      expect(localStorage.setItem).toHaveBeenCalledWith('image', defaultImgUrl);
      const prevDogBtn = document.getElementById('prevDogBtn')
      if (!(prevDogBtn instanceof HTMLButtonElement)) return
      // prevBtnをクリックしたらsrcがlocalStorageに保存したurlに変更されている事がわかる
      prevDogBtn.click()
      expect(localStorage.getItem('image')).toEqual(defaultImgUrl)
      expect(localStorage.getItem).toHaveBeenCalled()
      expect(localStorage.getItem).toHaveBeenCalledTimes(2)
      expect(localStorage.getItem).toHaveBeenCalledWith('image');
      // 画像が切り替わって元のに戻っている事がわかる
      expect(dogImg.src).not.toEqual(imgTestUrl)
      expect(dogImg.src).toEqual(defaultImgUrl)
    })
  })
})

上記テストでprevDogBtnを押したら元のimgにsrcが戻っている事がテスト出来ます

以下のように通りました。

まとめ

最後まで読んでくださりありがとうございます。

如何だったでしょうか?グローバルオブジェクトのmock化がまで理解できれば更にこちらが作ったメソッドの振る舞いをテスト出来、より安全性が高いプロダクト開発が出来るのでは無いでしょうか。一先ずはjest編は今回が最後になります(多分。。。)

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

参考記事

MDN

https://developer.mozilla.org/ja/docs/Glossary/Global_object

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty#enumerable