Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
15
Help us understand the problem. What is going on with this article?
@pure-adachi

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

More than 1 year has passed since last update.

はじめに

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

以前作成したReact Apollo Hooks を使ってログイン機能を作ったにテストを追加しています。
sample-login-with-react-apollo-hooks - Github

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

$ 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の設定

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 を作ります。

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テキストフィールドに入力するとテキストフィールドの値が入力した値に変わる
  • フォームのパスワードフィールドに入力するとパスワードフィールドの値が入力した値に変わる
  • ログインボタンを押下し、ログインに成功すると、/へリダイレクト
  • ログインボタンを押下し、ログインに失敗すると、各フォームの値がクリアされる

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

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

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

$ npm t -- --coverage

ログインしていない場合

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

フォームが表示される

❌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...と表示されていることが原因です。
確認してみます。

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

console.logで出力すると

  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>

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

src/Login/index.test.tsx
     it("フォームが表示されること", async () => {
       const { enzymeWrapper } = setup();
+      await new Promise(resolve => setTimeout(resolve));
+      enzymeWrapper.update();
       console.log(enzymeWrapper.debug());
     });
  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の値で結果を変えることができます。

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

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

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

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イベントを起動します。

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");
      });
    });

今回は値の受け取り方を

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

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

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

とするとできます。

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

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

ログイン処理を実行

ログインに成功した場合

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

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を入力
  • パスワードを入力
  • ログインボタンをクリック
  • ログイン処理を実行し、レスポンスを受け取る
  • /のルートをhistorypushされる
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を用意します。

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 } }
      }
    }
  }
];
入力内容が空になる

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

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を用意します。

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

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

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);
    });
  });

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

スクリーンショット 2019-08-18 13.36.48.png

おわりに

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

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

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

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");
15
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
pure-adachi
ruby on rails や reactで何か作ってます。
pure-system
大手生命保険会社各社をはじめ、自治体、大手企業など、日本を代表する企業様のIT化を推進・支援しています。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
15
Help us understand the problem. What is going on with this article?