Jest+EnzymeでuseContextをテストする

Jest+EnzymeでReactコンポーネントをテストしようと思ったら`shallow`が`useContext`に対応していなかったので、その対処法のメモです。

## 結論
`useContext`をカスタムhookでラップして、`spyOn`でモック化する

## 説明

### うまくいかないパターン

StudentListという、生徒の情報一覧を表示するコンポーネントを想定します。

```ts:types.ts
export interface Student {
  id: number
  name: string
  gender: 'male' | 'female'
}
```

```tsx:StudentsContext.tsx
import React from 'react'
import { Student } from './types'

interface ProviderProps {
  students?: Student[]
}

export const StudentsContext = React.createContext<Student[]>([])
export const StudentsContextProvider: React.FC<ProviderProps> = ({students = [], ...props}) => (
  <StudentsContext.Provider value={students} {...props} />
)
```

```tsx:StudentList.tsx
import React from 'react'
import { StudentsContext } from './StudentsContext'

export const StudentList: React.FC = () => {
  const students = React.useContext(StudentsContext)
  return (
    <ul>
      {students.map(student => (
        <li key={student.id}>
          {student.name}({student.gender})
        </li>
      ))}
    </ul>
  )
}
```

これをテストしてみます。

```ts:StudentList.test.tsx
import React from 'react'
import { shallow } from 'enzyme'
import { StudentList } from './StudentList'
import { StudentsContextProvider } from './StudentsContext'
import { Student } from './types'

const studentsVal: Student[] = [
  { id: 1, name: 'name1', gender: 'male' },
  { id: 2, name: 'name2', gender: 'female' },
]

describe('StudentList component', () => {
  it('should render all students', () => {
    const wrapper = shallow(
      <StudentsContextProvider students={studentsVal}>
        <StudentList />
      </StudentsContextProvider>
    )
    const items = wrapper.dive().dive().find('li')
    expect(items.length).toBe(2)
  })
})
```

コケました

```text
 ● StudentList component › should render all students

    expect(received).toBe(expected) // Object.is equality

    Expected: 2
    Received: 0
```

`StudentList.tsx`内の`React.useContext(StudentsContext)`が正しく実行できていないことが原因です。

### うまくいくパターン
`useContext`の処理を、カスタムhookでラップして、そのカスタムhookをモック化する形で対応していきます。

```tsx:StudentsContext.tsx
import React from 'react'
import { Student } from './types'

interface ProviderProps {
  students?: Student[]
}

const StudentsContext = React.createContext<Student[]>([])
// useStudentsContextというカスタムhookでuseContextをラップする
export const useStudentsContext = () => React.useContext(StudentsContext)
export const StudentsContextProvider: React.FC<ProviderProps> = ({students = [], ...props}) => (
  <StudentsContext.Provider value={students} {...props} />
)
```

```tsx:StudentList.tsx
import React from 'react'
import { useStudentsContext } from './StudentsContext'

export const StudentList: React.FC = () => {
  // useContextではなくカスタムhookで取得するようにする
  const students = useStudentsContext()
  return (
    <ul>
      {students.map(student => (
        <li key={student.id}>
          {student.name}({student.gender})
        </li>
      ))}
    </ul>
  )
}
```

```tsx:StudentList.test.tsx
import React from 'react'
import { shallow } from 'enzyme'
import { StudentList } from './StudentList'
// spyOnに渡すために`* as ~`の形でインポート
import * as StudentsContextObj from './StudentsContext'
import { Student } from './types'

const studentsVal: Student[] = [
  { id: 1, name: 'name1', gender: 'male' },
  { id: 2, name: 'name2', gender: 'female' },
]

describe('StudentList component', () => {
  it('should render all students', () => {
    // useStudentsContextをモック化
    jest.spyOn(StudentsContextObj, 'useStudentsContext')
      .mockImplementation(() => studentsVal)

    // そしてもはやProviderも不要になる
    const wrapper = shallow(<StudentList />)
    const items = wrapper.find('li')
    expect(items.length).toBe(2)
  })
})
```

これでテストが通るようになります。

### `spyOn`について
ドキュメント:
[https://deltice.github.io/jest/docs/ja/jest-object.html#jestspyonobject-methodname](https://deltice.github.io/jest/docs/ja/jest-object.html#jestspyonobject-methodname)

`spyOn`の第一引数はObject、第二引数はそのメソッドなので、それに合わせて

```tsx
import * as StudentsContext from './StudentsContext'
```
のような形でインポートするところがポイントです。

ちなみに今回の場合、`useStudentsContext`のカスタムhookを作らなくても

```tsx
jest.spyOn(React, 'useContext').mockImplementation(() => studentsVal)
```

で代替できますが、この場合当然全ての`useContext`がモック化されてしまうので、避けたほうが良いでしょう。