はじめに
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の設定
- "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
を作ります。
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テキストフィールドに入力するとテキストフィールドの値が入力した値に変わる
- フォームのパスワードフィールドに入力するとパスワードフィールドの値が入力した値に変わる
- ログインボタンを押下し、ログインに成功すると、
/
へリダイレクト - ログインボタンを押下し、ログインに失敗すると、各フォームの値がクリアされる
ローディングメッセージが表示されること
describe("Login", () => {
+ it("ローディングメッセージが表示されること", () => {
+ const { enzymeWrapper } = setup();
+ expect(enzymeWrapper.find("Login div").text()).toEqual("Now Loading...");
+ });
});
カバレッジも確認しながらテストを実行しましょう、
$ npm t -- --coverage
ログインしていない場合
問い合わせた結果、ログインしていないことが分かり、ログイン情報を入力するフォームが表示されることをテストします。
フォームが表示される
+ 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...
と表示されていることが原因です。
確認してみます。
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>
ローディングが終わっていませんね。
では、問い合わせを終わらせるようにします。
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
であればログインしていないと判断し、ログインフォームを表示するような処理になっているので、そのレスポンスを再現しています。
では、ローディングを完了させた上でフォームが表示されているテストに修正します。
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
イベントを起動します。
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
に追加します。
+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
される
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
を用意します。
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 } }
}
}
}
];
入力内容が空になる
ログインに成功した場合と同じような要領で追加します。
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
を用意します。
const loggedInMocks: MockType[] = [
{
request: { query: loggedUserQuery },
result: { data: { loggedUser: { id: "1" } } }
}
];
このmocks
を使用してテストを追加します。
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
をブラウザで開くと確認出来ます。
おわりに
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");