React Hooks + TypeScript + React Apollo Hooks + Jest + enzyme を使ってテストを書く

# はじめに

`react hooks` + `react-apollo-hooks` で作成したアプリを `jest` を使ってテストを書く方法を紹介します。

以前作成した[React Apollo Hooks を使ってログイン機能を作った](https://qiita.com/pure-adachi/items/980fc8ae644de70335f1)にテストを追加しています。
[sample-login-with-react-apollo-hooks - Github](https://github.com/pure-adachi/sample-login-with-react-apollo-hooks)

# 必要なパッケージのインストール

```sh
$ npm i --save-dev @types/enzyme enzyme enzyme-adapter-react-16 @types/enzyme-adapter-react-16 apollo-link-mock ts-jest @apollo/react-testing
```

# バージョン

| パッケージ | バージョン |
|:--|:--|
|react|16.9.0|
|typescript|3.5.3|
|react-apollo-hooks|0.5.0|
|@types/jest|24.0.17|
|@types/enzyme|3.10.3|
|enzyme|3.10.0|
|enzyme-adapter-react-16|1.14.0|
|@types/enzyme-adapter-react-16|1.0.5|
|apollo-link-mock|1.0.1|
|ts-jest|24.0.2|
|@apollo/react-testing|3.0.1|

# テストコマンドの修正とJestの設定

```diff:package.json
-    "test": "react-scripts test",
+    "test": "jest",
     "eject": "react-scripts eject"
   },
+  "jest": {
+    "testURL": "http://localhost",
+    "moduleFileExtensions": [
+      "ts",
+      "tsx",
+      "js"
+    ],
+    "transform": {
+      "^.+\\.(ts|tsx)$": "ts-jest"
+    }
+  },
```

これで `npm test` or `npm t` が叩けるようになりました。

# テストファイルを作成

今回作成するテストは `src/Login/index.tsx` に対して作成するので、
`src/Login/index.test.tsx` を作ります。

```tsx:src/Login/index.test.tsx
import { InMemoryCache } from "apollo-cache-inmemory";
import { ApolloClient } from "apollo-client";
import { MockLink } from "apollo-link-mock";
import { GraphQLRequest } from "apollo-link";
import Enzyme, { mount } from "enzyme";
import Adapter from "enzyme-adapter-react-16";
import gql from "graphql-tag";
import React from "react";
import { ApolloProvider } from "react-apollo-hooks";
import { BrowserRouter as Router } from "react-router-dom";
import { MockedProvider } from "@apollo/react-testing";
import Login from "../Login";

Enzyme.configure({ adapter: new Adapter() });

const loggedUserQuery = gql`
  {
    loggedUser {
      id
    }
  }
`;

type MockType = {
  request: {
    query: GraphQLRequest;
    variables?: { loginid: string; password: string };
  };
  result: { data: any };
};

const successMocks: MockType[] = [
  {
    request: { query: loggedUserQuery },
    result: { data: { loggedUser: null } }
  }
];

const setup = (mocks = successMocks) => {
  const client = new ApolloClient({
    cache: new InMemoryCache({ addTypename: false }),
    link: new MockLink(mocks)
  });

  const enzymeWrapper = mount(
    <MockedProvider mocks={mocks} addTypename={false}>
      <ApolloProvider client={client}>
        <Router>
          <Login />
        </Router>
      </ApolloProvider>
    </MockedProvider>
  );

  return {
    enzymeWrapper
  };
};

describe("Login", () => {
});
```

`src/Login/index.tsx`には以下の機能があるので、これらにあわせてテストを追加していきます。

- 既にログイン済みか問い合わせる
    - 問い合わせ中は`Now Loading...`と表示
    - 問い合わせ完了後、ログイン済みであれば、`/`へリダイレクト
    - 問い合わせ完了後、未ログインであれば、フォームを表示
- フォームのログインIDテキストフィールドに入力するとテキストフィールドの値が入力した値に変わる
- フォームのパスワードフィールドに入力するとパスワードフィールドの値が入力した値に変わる
- ログインボタンを押下し、ログインに成功すると、`/`へリダイレクト
- ログインボタンを押下し、ログインに失敗すると、各フォームの値がクリアされる

## ローディングメッセージが表示されること

```diff:src/Login/index.test.tsx
 describe("Login", () => {
+  it("ローディングメッセージが表示されること", () => {
+    const { enzymeWrapper } = setup();
+    expect(enzymeWrapper.find("Login div").text()).toEqual("Now Loading...");
+  });
  });
```

カバレッジも確認しながらテストを実行しましょう、

```sh
$ npm t -- --coverage
```

## ログインしていない場合

問い合わせた結果、ログインしていないことが分かり、ログイン情報を入力するフォームが表示されることをテストします。

### フォームが表示される

```diff:❌src/Login/index.test.tsx
+  describe("ログインしていない場合", () => {
+    it("フォームが表示されること", async () => {
+      const { enzymeWrapper } = setup();
+      expect(enzymeWrapper.find("Login input[type='text']").exists()).toEqual(
+        true
+      );
+      expect(
+        enzymeWrapper.find("Login input[type='password']").exists()
+      ).toEqual(true);
+      expect(enzymeWrapper.find("Login input[type='submit']").exists()).toEqual(
+        true
+      );
+    });
+  });
```

```
  ● Login › ログインしていない場合 › フォームが表示されること

    expect(received).toEqual(expected) // deep equality

    Expected: true
    Received: false

      58 |     it("フォームが表示されること", async () => {
      59 |       const { enzymeWrapper } = setup();
    > 60 |       expect(enzymeWrapper.find("Login input[type='text']").exists()).toEqual(
         |                                                                       ^
      61 |         true
      62 |       );
      63 |       expect(
```

これは、ログイン済みか問い合わせているロードが終わっていないので、まだ `Now Loading...`と表示されていることが原因です。
確認してみます。

```tsx:src/Login/index.test.tsx
    it("フォームが表示されること", async () => {
      const { enzymeWrapper } = setup();
      console.log(enzymeWrapper.debug());
    });
```

`console.log`で出力すると

```console
  console.log src/Login/index.test.tsx:60
    <MockedProvider mocks={{...}} addTypename={false}>
      <ApolloProvider client={{...}}>
        <ApolloProvider client={{...}}>
          <BrowserRouter>
            <Router history={{...}}>
              <withRouter(Login)>
                <Login history={{...}} location={{...}} match={{...}} staticContext={[undefined]}>
                  <div>
                    Now Loading...
                  </div>
                </Login>
              </withRouter(Login)>
            </Router>
          </BrowserRouter>
        </ApolloProvider>
      </ApolloProvider>
    </MockedProvider>
```

ローディングが終わっていませんね。
では、問い合わせを終わらせるようにします。

```diff:src/Login/index.test.tsx
     it("フォームが表示されること", async () => {
       const { enzymeWrapper } = setup();
+      await new Promise(resolve => setTimeout(resolve));
+      enzymeWrapper.update();
       console.log(enzymeWrapper.debug());
     });
```

```console
  console.log src/Login/index.test.tsx:62
    <MockedProvider mocks={{...}} addTypename={false}>
      <ApolloProvider client={{...}}>
        <ApolloProvider client={{...}}>
          <BrowserRouter>
            <Router history={{...}}>
              <withRouter(Login)>
                <Login history={{...}} location={{...}} match={{...}} staticContext={[undefined]}>
                  <div>
                    <div>
                      <label>
                        ID
                        <br />
                        <input type="text" value="" onChange={[Function: onChange]} />
                      </label>
                    </div>
                    <div>
                      <label>
                        PW
                        <br />
                        <input type="password" value="" onChange={[Function: onChange]} />
                      </label>
                    </div>
                    <div>
                      <input type="submit" value="ログイン" onClick={[Function: onClick]} />
                    </div>
                  </div>
                </Login>
              </withRouter(Login)>
            </Router>
          </BrowserRouter>
        </ApolloProvider>
      </ApolloProvider>
    </MockedProvider>
```

問い合わせのローディングが終わり、フォームが表示されていることが分かります。
問い合わせた結果`useQuery(loggedUserQuery);` のレスポンスについては、`MockedProvider`に渡している`mocks`の値で結果を変えることができます。

```tsx
const successMocks: MockType[] = [
  {
    request: { query: loggedUserQuery },
    result: { data: { loggedUser: null } }
  }
];
```

`loggedUser: null`であればログインしていないと判断し、ログインフォームを表示するような処理になっているので、そのレスポンスを再現しています。

では、ローディングを完了させた上でフォームが表示されているテストに修正します。


```tsx:src/Login/index.test.tsx
  describe("ログインしていない場合", () => {
    it("フォームが表示されること", async () => {
      const { enzymeWrapper } = setup();
      await new Promise(resolve => setTimeout(resolve));
      enzymeWrapper.update();
      expect(enzymeWrapper.find("Login input[type='text']").exists()).toEqual(
        true
      );
      expect(
        enzymeWrapper.find("Login input[type='password']").exists()
      ).toEqual(true);
      expect(enzymeWrapper.find("Login input[type='submit']").exists()).toEqual(
        true
      );
    });
  });
```

### ログインIDを入力した場合、テキストフィールドの値が更新されること

文字の入力は`simulate`を使い、`change`イベントを起動します。

```tsx:src/Login/index.test.tsx
    describe("ログインIDを入力した場合", () => {
      it("テキストフィールドの値が更新されること", async () => {
        const { enzymeWrapper } = setup();
        await new Promise(resolve => setTimeout(resolve));
        enzymeWrapper.update();
        expect(
          enzymeWrapper.find("Login input[type='text']").prop("value")
        ).toEqual("");
        enzymeWrapper
          .find("Login input[type='text']")
          .simulate("change", { target: { value: "loginid" } });
        expect(
          enzymeWrapper.find("Login input[type='text']").prop("value")
        ).toEqual("loginid");
      });
    });
```

今回は値の受け取り方を

```tsx
onChange={e => setLoginid(e.target.value || "")}
```

と`target`で受け取りましたが、`currentTarget`を使用する場合は`simulate`の書き方を

```ts
const textField = enzymeWrapper.find("Login input[type='text']");
const inputElement = textField.getDOMNode() as HTMLInputElement;
inputElement.value = "loginid";
textField.simulate("change");
```

とするとできます。

### パスワードを入力した場合、パスワードフィールドの値が更新されること

テキストフィールドの入力テストと同じ要領で追加します。

### ログイン処理を実行
#### ログインに成功した場合

ログインに成功した場合のレスポンスを受け取る必要があるので、`mocks`に追加します。

```diff:src/Login/index.test.tsx
+const loginMutation = gql`
+  mutation login($loginid: String!, $password: String!) {
+    login(input: { loginid: $loginid, password: $password }) {
+      user {
+        accessToken {
+          token
+        }
+      }
+      result
+    }
+  }
+`;

const successMocks: MockType[] = [
   {
     request: { query: loggedUserQuery },
     result: { data: { loggedUser: null } }
+  },
+  {
+    request: {
+      query: loginMutation,
+      variables: { loginid: "loginid", password: "password" }
+    },
+    result: {
+      data: {
+        login: { result: true, user: { accessToken: { token: "token" } } }
+      }
+    }
   }
 ];
```

##### ルートにリダイレクトされる

以下の流れで行います。

- ログイン済みか問い合わせを完了させる
- ログインIDを入力
- パスワードを入力
- ログインボタンをクリック
- ログイン処理を実行し、レスポンスを受け取る
- `/`のルートを`history`に`push`される

```tsx:src/Login/index.test.tsx
    describe("ログイン処理を実行", () => {
      describe("ログインに成功した場合", () => {
        it("ルートにリダイレクトされる", async () => {
          const { enzymeWrapper } = setup();
          await new Promise(resolve => setTimeout(resolve));
          enzymeWrapper.update();
          enzymeWrapper
            .find("Login input[type='text']")
            .simulate("change", { target: { value: "loginid" } });
          enzymeWrapper
            .find("Login input[type='password']")
            .simulate("change", { target: { value: "password" } });
          enzymeWrapper.find("Login input[type='submit']").simulate("click");
          await new Promise(resolve => setTimeout(resolve));
          enzymeWrapper.update();
          const history: any = enzymeWrapper.find("Login").prop("history");
          expect(history.length).toEqual(2);
          expect(history.action).toEqual("PUSH");
          expect(history.location.pathname).toEqual("/");
        });
      });
    });
  });
```

#### ログインに失敗した場合

ログインに失敗した場合は、ログイン処理で受け取るレスポンスを変える必要があるので、エラー用の`mocks`を用意します。

```tsx:src/Login/index.test.tsx
const errorMocks: MockType[] = [
  {
    request: { query: loggedUserQuery },
    result: { data: { loggedUser: null } }
  },
  {
    request: {
      query: loginMutation,
      variables: { loginid: "loginid", password: "password" }
    },
    result: {
      data: {
        loggedUser: { result: false, user: { accessToken: null } }
      }
    }
  }
];
```

##### 入力内容が空になる

ログインに成功した場合と同じような要領で追加します。

```tsx:src/Login/index.test.tsx
      describe("ログインに失敗した場合", () => {
        it("入力内容が空になる", async () => {
          const { enzymeWrapper } = setup(errorMocks);
          await new Promise(resolve => setTimeout(resolve));
          enzymeWrapper.update();
          enzymeWrapper
            .find("Login input[type='text']")
            .simulate("change", { target: { value: "loginid" } });
          enzymeWrapper
            .find("Login input[type='password']")
            .simulate("change", { target: { value: "password" } });
          enzymeWrapper.find("Login input[type='submit']").simulate("click");
          await new Promise(resolve => setTimeout(resolve));
          enzymeWrapper.update();
          expect(
            enzymeWrapper.find("Login input[type='text']").prop("value")
          ).toEqual("");
          expect(
            enzymeWrapper.find("Login input[type='password']").prop("value")
          ).toEqual("");
        });
      });
```

## ログイン済みの場合ルートにリダイレクトされる

最後に、ログイン済みと問い合わせた結果、判断された場合は、ルートにリダイレクトするテストを追加します。
これも、ログイン済みかどうかを問い合わせた結果を変更するので、`mocks`を用意します。

```tsx:src/Login/index.test.tsx
const loggedInMocks: MockType[] = [
  {
    request: { query: loggedUserQuery },
    result: { data: { loggedUser: { id: "1" } } }
  }
];
```

この`mocks`を使用してテストを追加します。

```tsx:src/Login/index.test.tsx
  describe("ログイン済みの場合", () => {
    it("ルートにリダイレクトされる", async () => {
      const { enzymeWrapper } = setup(loggedInMocks);
      await new Promise(resolve => setTimeout(resolve));
      enzymeWrapper.update();
      expect(enzymeWrapper.find("Login Redirect").exists()).toEqual(true);
    });
  });
```

これで`coverage`は`100%`になりました。
`npm t -- --coverage`とオプションをつけるとカバレッジのレポートファイルが作成され、`coverage/lcov-report/index.html`をブラウザで開くと確認出来ます。

<img width="922" alt="スクリーンショット 2019-08-18 13.36.48.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/304414/edddef69-7e1c-caff-a574-96e1833adca3.png">

# おわりに

`react-apollo-hooks`でテストを書くのは初めてだったので、躓きも多く、この１ファイルのテストを書くだけで結構時間使いました。

リクエストを返した状態にするのはどうやるかであったり、`mount`の方法、あとテキストフィールドに入力した値が正常に変更されているかの検証に手こずりました。

`textField`を一旦変数に入れる以下の方法だと、変更後の値が取れない罠にかかりました。

```tsx
const textField = enzymeWrapper.find("Login input[type='text']");
expect(textField.prop("value")).toEqual("");
textField.simulate("change", { target: { value: "loginid" } });
textField.update();
expect(textField.prop("value")).toEqual("loginid");
```