Jestでglobalなオブジェクトをmockしたテストを書く

この記事は [Ateam Lifestyle Inc. Advent Calendar 2021](https://qiita.com/advent-calendar/2021/ateam-lifestyle) 22日目の記事です。

タイトル通り、Jestでglobalなオブジェクトをmockしたテストを書く方法です。


## globalオブジェクトを使用するコードのサンプル
下記のような位置情報を取得する簡単なhooksを書いてみました。

```use-geolocation.ts
import { useEffect, useState } from 'react'

export const useGeolocation = (): GeolocationPosition | null => {
  const [geo, setGeo] = useState<GeolocationPosition | null>(null)

  useEffect(() => {
    navigator.geolocation.getCurrentPosition(setGeo)
  }, [])

  return geo
}
```

まずは何も考えずに愚直にこのhooksのテストを書いてみます。

```use-gelocation.spec.ts
import { useGeolocation } from './use-geolocation'
import { renderHook } from '@testing-library/react-hooks'

const geolocationPosition = {
  coords: {
    accuracy: 1,
    altitude: null,
    altitudeAccuracy: null,
    heading: null,
    latitude: 1,
    longitude: 1,
    speed: null
  },
  timestamp: 1
}

describe('useGeolocation', () => {
  it('must return geolocation', () => {
    const { result } = renderHook(() => useGeolocation())
    expect(result.current).toEqual(geolocationPosition)
  })
})

```

このテストは`navigator.geolocation.getCurrentPosition(setGeo)`の箇所で失敗します。
このようなAPIへのアクセスは結果をこちらでコントロールすることが出来ないのでmock化するのが定石です。

## jest.spyOnを使用したやり方

`axios`などのようなnpmパッケージを使用する場合は`jest.mock`を使用してモジュール自体をmock化すればいいですが、今回のような`navigator`や、他には`window`や`console`や`setTimeout`などはモジュールを指定できません。
なので今回は`jest.spyOn`を使用し、ドキュメントにある通り`mockImplementation`を使用して元の関数を上書きします。

https://jestjs.io/ja/docs/jest-object#jestspyonobject-methodname


```ts
const originalNavigator = { ...navigator }
const originalGeolocation = { ...navigator.geolocation }
const navigatorSpy = jest.spyOn(global, 'navigator', 'get')
navigatorSpy.mockImplementation(() => ({
  ...originalNavigator,
  geolocation: {
    ...originalGeolocation,
    getCurrentPosition: (successCallback) => {
      successCallback({
        coords: {
          accuracy: 1,
          altitude: null,
          altitudeAccuracy: null,
          heading: null,
          latitude: 1,
          longitude: 1,
          speed: null
        },
        timestamp: 1
      })
    }
  }
}))
```

`navigatorSpy.mockImplementation`で元々のオブジェクトの関数を定義しつつmock化したい箇所だけを上書きします。
このときTypeScriptだと`Navigator`の型定義に従って内容をサジェストをしてくれるのでタイポやコールバックの引数の内容を漏らすのを防いでくれます。

元々のオブジェクトの関数を`originalNavigator`, `originalGeolocation`として取り出しているのは、そのまま展開しようとすると`navigator`を呼び出したところでまたmockを作成しようとして無限ループになってしまうのでそれを防ぐためです。

また、`jest.spyOn`でmock化されたものを元に戻すためにテスト後は`mockRestore()`を呼び出しておきます。

```ts
navigatorSpy.mockRestore()
```

これでテストを書く準備は完了です。

### 実際のコード
上記を用いて出来た実際のテストコードがこちらです。
今回はテストケースが1パターンしかありませんが複数のテストケースが作成されることも考慮してmockの作成を`beforeEach`に、`mockRestore `を`afterEach`に書いています。

```use-geolocation.spec.ts
import { useGeolocation } from './use-geolocation'
import { renderHook } from '@testing-library/react-hooks'

const geolocationPosition = {
  coords: {
    accuracy: 1,
    altitude: null,
    altitudeAccuracy: null,
    heading: null,
    latitude: 1,
    longitude: 1,
    speed: null
  },
  timestamp: 1
}

describe('useGeolocation', () => {
  let navigatorSpy: jest.SpyInstance<Navigator, []> | undefined

  beforeEach(() => {
    const originalNavigator = { ...navigator }
    const originalGeolocation = { ...navigator.geolocation }
    navigatorSpy = jest.spyOn(global, 'navigator', 'get')
    navigatorSpy.mockImplementation(() => ({
      ...originalNavigator,
      geolocation: {
        ...originalGeolocation,
        getCurrentPosition: (successCallback) => {
          successCallback(geolocationPosition)
        }
      }
    }))
  })

  afterEach(() => {
    navigatorSpy?.mockRestore()
  })
  
  it('must return geolocation', () => {
    const { result } = renderHook(() => useGeolocation())
    expect(result.current).toEqual(geolocationPosition)
  })
})
```

## その他のやり方
やり方を調べているときに見かけた他の方法についても載せておきます。
ただし、いずれのやり方もグローバル空間を汚染するものであるため他のテストケースに影響を与える可能性があります。
なので基本的には`spyOn`を使いテスト終了後に`mockRestore`がいいと思います。

### global.navigator.geolocationを上書き

`navigator.geolocation`をmockで上書きする方法です。

```ts
const mockGeolocation = {
  getCurrentPosition: jest.fn((successCallback: PositionCallback) => {
    successCallback({
      coords: {
        latitude: 1,
        longitude: 1,
        // ...省略
      },
      timestamp: 1
    })
  })
};

global.navigator.geolocation = mockGeolocation
```

TypeScriptだと下記のようにエラー表示になりますがJavaScriptの場合は何も出ずに普通に書けてしまうため気をつけなくてはなりません。
![スクリーンショット 2021-12-16 20.14.42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7695/e1f1e66d-6d17-ee35-a05d-2aea36d99113.png)
ちなみにTypeScriptでも実行すれば動きはします。

### Object.definePropertyを使用したやり方

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

```ts
Object.defineProperty(global, 'navigator', {
  get: () => {
    return {
      geolocation: {
        getCurrentPosition: (successCallback: PositionCallback) => {
          successCallback({
            coords: {
              latitude: 1,
              longitude: 1,
              // ...省略
            },
            timestamp: 1
          })
        }
      }
    }
  }
})
```
`navigator`にアクセスしたときに取得できる内容を書き換えるという方法です。
こちらは特にエラーなども出ないので他に影響を与える可能性があることに気づきにくいです。
また、TypeScriptでも元々の型定義によらず完全に自由に定義できてしまうため型の恩恵を受けることができません。



## 感想
`navigator.geolocation.getCurrentPosition`のテストの仕方がわからないと聞かれて軽い気持ちで「mockすればいいじゃん」と言ったものの、いざ自分が書こうとしたら`jest.mock`で書けないため意外とすぐには出来ず調べたので記事にして共有することにしました。


明日は @yutaroud さんです。