Posted at

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


はじめに

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