はじめに
先日作成したReact & Firebaseを使ったWebサービス開発入門の続編であり、まだ全編を読んでない方はそちらを先にお読みください。
この記事について
この記事は先日開発した「Movie Guide」を用いてReactのユニットテスト(Jest)を試してみようというものです。
私自身、前職でPHPフレームワーク「Synfony」の上で「PHPUnit」を用いたユニットテストを描いたことはありましたが、Jestを使うのは初めてだったので学習の記録としても記事にしたのもあります。
ユニットテストについて
採用においてエンジニアの器量を見極めるには「Docker」「リーダブルコード」そして「テストコード」を見るらしい。
実際これらは個人開発ではそこまで重視されないが、チームでやる上では絶対必要な領域である。
今回はそのユニットテストに関する記事だが、テストの意義とはズバリ
プログラムを構成する小さな単位(コンポーネントや関数)が個々の機能を正しく果たしているかどうかを検証すること
これがあることで致命的欠陥を回避しながら大規模開発できるという。
Jestについて
Jestはシンプルさを重視した快適なJavaScriptテスティングフレームワークでBabel、TypeScript、Node、React、Angular、Vueなど、様々なフレームワークを利用したプロジェクトで動作する。
「create react app」でプロジェクトを作れば標準搭載されている。
それでは早速試してみよう!
開発開始
まずデフォルトであるApp.test.jsの初期コードを少しいじって試しテスト(testフォルダを作って、そこにファイルを置きます)
import { render, screen } from '@testing-library/react';
import App from '../App';
test('test for App component', () => {
render(<App />);
const linkElement = screen.getByText(/movie guide/i);
expect(linkElement).toBeInTheDocument();
});
すると当たり前だが、エラーが出現
SyntaxError: Cannot use import statement outside a module
要はJestはcommonJS(requireで呼び出しする)によるモジュールには対応しているが、axiosなどESM(importで呼び出しする)には対応していないというもの
Package.jsonに下コードを配置すると解決するらしい
"jest": {
"moduleNameMapper": {
"^axios$": "axios/dist/node/axios.cjs"
}
}
ここで注意点だが、一度これで実行を行うとnpmのモジュール構造やキャッシュに変化が生じ、上コードを削除すると「npm ERR!」が発生する。
次なる刺客
これでうまくいったが次はまた別のエラーが登場
ReferenceError: TextDecoder is not defined
どうやら、これはJestが使っている「jsdom」がTextDecoderとTextEncoderをサポートしていないため、ポリフィルしなければならないというもの。
setupTests.jsに下コードを配置する
import { TextEncoder, TextDecoder } from 'util'
if (typeof global.TextEncoder === 'undefined') {
global.TextEncoder = TextEncoder;
}
if (typeof global.TextDecoder === 'undefined') {
global.TextDecoder = TextDecoder;
}
また次なる刺客
なんとか未確認エラーを突破していくが、またエラー発生(そりゃ真っ新のデフォルトコードからだいぶ手を加えてるからストレートにはいかないよね😭)
次のエラーはこれだ!
Module useNavigate() may be used only in the context of a <Router> component
多分App.jsでreact routerが使われているが、囲いはその外側のindex.jsでされているため、テスト内で囲いを作ってくださいというものだろう
下のように修正
test('test for App component', () => {
render(
<BrowserRouter>
<App />
</BrowserRouter>
);
const linkElement = screen.getByText(/movie guide/i);
expect(linkElement).toBeInTheDocument();
});
もうええて
もう飽きてきたと思いますが、次はこれ
Uncaught TypeError: Cannot destructure property 'xxx' of 'useAuth(...)' as it is undefined
調べた感じさっきのRouterのエラー(indexで囲っていたものをテスト内で囲み直す)と問題構造は同じようだった。
test('test for App component', () => {
render(
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
);
const linkElement = screen.getByText(/movie guide/i);
expect(linkElement).toBeInTheDocument();
});
これで最後(たぶん)
肌感的に最後に近づいてきたと思うが、今度のエラーはこれ
TestingLibraryElementError: Unable to find an element with the text
これはTest内でgetByText()を使っており、これは関数の性質上、値が見つけられなけばエラーを吐き出すというもの。
最終的に以下のように修正すればなんとかテストは通った!
import { render, screen, act } from '@testing-library/react';
import App from '../App';
import { BrowserRouter } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { AuthProvider } from '../context/AuthContext';
test('check access route regarding Main Page', async () => {
const renderResult = render(
<HelmetProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</HelmetProvider>
);
expect(screen.queryByRole('heading', {name: /ログイン情報/i})).toBeInTheDocument();
expect(await renderResult.findByRole('heading', {name: /Movie/i})).toBeInTheDocument();
});
この「queryBy」というのが要素を見つけられなくてもエラーを飛ばさず、nullを飛ばしてくれる優れもの。
「findBy」はawaitを用いることによって非同期的に表示させる要素を取得するこれまた優れもの。
テストの醍醐味「モック」について
テストにおいて外部モジュールなどは実際の動きで起動しないが、きちんと狙いのモジュールや関数が叩かれたのかテストしたいケースがある。
それを実現するのが、「モック」だ。
英語通りの意味で外側だけ対象の関数っぽく見せるのだ。
it('check if logOut func is working when putting button', async () => {
// モック化したlogOut()に返り値を設定
logOut.mockImplementation(() => true);
render(
<HelmetProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</HelmetProvider>
);
expect(await screen.findByRole('button', {name: /ログアウト/i})).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: /ログアウト/i}));
expect(logOut).toHaveBeenCalled();
});
例えば上のテストはログアウトボタンを押した時実際それがきちんと叩かれているのか調べるものであるが、
テストする上でこのlogOut関数は決して現実の動きをしなくていい。
そこで
import { logOut } from '../util/auth';
jest.mock('../util/auth');
このようにモック化してあげることでlogOut関数が叩かれたかどうかだけ調べられるようになる。
ちなみにテスト内でのボタンのクリックは「fireEvent」を使うことで再現できる。
テストにおける画面遷移について
ページ内にリンクボタンを設置して、その動きをテストしようと思うことがあるかもしれない。
そんな場合だが、Jestはjsdomを使って模擬ブラウザ上でテストを実行しているため、実際のような画面遷移が行えないのでリンクテストは以下のようになる。
it('check if fav link is working properly', async () => {
render(
<HelmetProvider>
<AuthProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthProvider>
</HelmetProvider>
);
expect(await screen.findByRole('link', { name: 'お気に入りリスト' })).toHaveAttribute('href', '/favorite')
});
これはリンクボタンのhref属性がきちんと適切なリンク先を持っているか見るというものだ。
参照:https://stackoverflow.com/questions/57827126/how-to-test-anchors-href-with-react-testing-library
テストにおけるフォーム入力
テストにおいて入力フォームやボタンの挙動を確認したい場合、「userEvent」を使うのだが
ここではSignInコンポーネントのテストを例にとって説明する。
まずはじめにSignInコンポーネントである。
import '../App.css';
import { useState } from 'react';
import { Helmet } from 'react-helmet-async';
import { Link } from "react-router-dom";
import AuthStateChecker from '../component/AuthStateChecker';
import { handleSingIn } from '../util/auth';
const SignIn = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
return (
<AuthStateChecker isOutside>
<div className="sign_up">
<Helmet>
<title>Sign In</title>
</Helmet>
<h1 className="sign_up_description">ログインしてMovie Guideにアクセス</h1>
<div className="sign_up_form">
<label>メールアドレス</label>
<input
name="email"
type="email"
placeholder="hoge@gmail.com"
onChange={(event) => setEmail(event.target.value)}
/>
</div>
<div className="sign_up_form">
<label>パスワード</label>
<input
name="password"
type="password"
placeholder="******"
onChange={(event) => setPassword(event.target.value)}
/>
</div>
<div className="sign_up_form">
<button
className="sign_up_button login"
onClick={() => handleSingIn(email, password)}
>ログイン</button>
</div>
<Link className="sign_up_link" to="/sign-up">アカウント作成はこちらから</Link>
</div>
</AuthStateChecker>
);
};
export default SignIn;
これのemailフォームとpasswordフォームを入力して実際ログインボタンを押した時の挙動チェックをテストする。
import { render, screen, act, fireEvent } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import SignIn from '../component/SignIn';
import { BrowserRouter } from 'react-router-dom';
import { HelmetProvider } from 'react-helmet-async';
import { AuthProvider } from '../context/AuthContext';
import { Routing } from "../routing";
import { signInWithEmailAndPassword } from 'firebase/auth';
// 'firebase/auth'全体にモックをかけるとAuthProviderが働かなくなるため、一部だけにモック適用を絞る
jest.mock('firebase/auth', () => ({
...jest.requireActual('firebase/auth'),
// ここでは関数設定だけ行い、返り値設定はテストの中で行う
signInWithEmailAndPassword: jest.fn()
}));
describe('Unit Test for SignIn', () => {
it('check action of form input and submit', async () => {
// モック化した関数の返り値を設定(元が非同期処理であるため返り値も同様にする)
signInWithEmailAndPassword.mockImplementation(() => Promise.resolve());
render(
<HelmetProvider>
<AuthProvider>
<BrowserRouter>
<SignIn />
</BrowserRouter>
</AuthProvider>
</HelmetProvider>
);
// emailフォームの挙動確認
const emailInput = await screen.findByPlaceholderText('hoge@gmail.com');
userEvent.type(emailInput, "test@mere.com");
expect(emailInput.value).toBe("test@mere.com");
// passwordフォームが入力されていない時のログインボタンの挙動確認
fireEvent.click(screen.getByRole('button', {name: /ログイン/i}));
expect(signInWithEmailAndPassword).not.toHaveBeenCalled();
// passwordフォームの挙動確認
const passwordInput = await screen.findByPlaceholderText('******');
userEvent.type(passwordInput, "123456");
expect(passwordInput.value).toBe("123456");
// ログインボタンの挙動確認
fireEvent.click(screen.getByRole('button', {name: /ログイン/i}));
expect(signInWithEmailAndPassword).toHaveBeenCalled();
});
});
色々疑問は湧いてくるだろうが、順に説明する。
まずフォーム入力に関して、
「findByPlaceholderText」でフォーム要素を取得し変数に格納。
次に「userEvent.type(要素の変数, "入力値")」で値を設定。
非常に簡単である。
次にログインはemailとpassword両方がないとできないようにしているため、これもチェックする。
まずログインを司る部分を見にいく。
export const handleSingIn = (email, password) => {
if (email && password) {
signInWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
// ログインしたユーザーを返す
const user = userCredential.user;
window.location.href = "/";
})
.catch((error) => {
alert("ログインに失敗しました\n"+error.message);
});
}
}
このコードからemailだけ入力時「signInWithEmailAndPassword」が叩かれなくて、email&password入力時「signInWithEmailAndPassword」が叩かれることを調べれば良いと思われる。
早速signInWithEmailAndPasswordをモック化するが、これが少し複雑
前回同様
import { signInWithEmailAndPassword } from 'firebase/auth';
jest.mock('firebase/auth');
これでモックすると別のfirebase authenticationの関数もモック、つまり形骸化されるため、プログラムに問題が生じる。
そこで今回はピンポイントモックを行う!
import { signInWithEmailAndPassword } from 'firebase/auth';
jest.mock('firebase/auth', () => ({
...jest.requireActual('firebase/auth'),
signInWithEmailAndPassword: jest.fn()
}));
実に良き!!
注意点だが、モックの返り値設定はtest関数内で行わなければエラーが起きるため注意する。
ちょっと複雑なテスト
最後に今までの知識を総動員したテストを紹介する。
これはFrontコンポーネントのテストだ。
import { render, screen, act, fireEvent, waitFor } from '@testing-library/react';
import userEvent from "@testing-library/user-event";
import Front from '../component/Front';
// Jestによる外部モジュールのモック化
import api from '../util/movieApi';
import { existFav } from '../util/handleData';
jest.mock('../util/handleData');
import movieTrailer from 'movie-trailer';
jest.mock('movie-trailer');
describe('Unit Test for Front', () => {
afterEach(() => {
// モック化した全ての関数を元に戻す、これがなければ「../util/movieApi」がモックされたままになり二つ目のテストのrenderで問題が生じる
jest.restoreAllMocks();
});
it('check how useEffect is going', async () => {
jest.spyOn(api, "get").mockResolvedValue({ data: {results: "hoge"} });
existFav.mockImplementation(() => true);
render(<Front />);
expect(api.get).toHaveBeenCalled();
await waitFor(() => {
expect(existFav).toHaveBeenCalled();
});
});
it('check how watch button works', async () => {
movieTrailer.mockImplementation(() => Promise.resolve());
render(<Front />);
// レンダリング直後のtrailerUrlがない初期状態で視聴ボタンを押すとmovieTrailerが走りる
expect(await screen.findByRole('button', { name: /視聴/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', {name: /視聴/i}));
expect(movieTrailer).toHaveBeenCalled();
});
});
これを説明する前にまずFront.jsのuseEffectのところを見て欲しい
useEffect(() => {
const fetchData = async () => {
const request = await api.get(requests.fetchTopRated);
const frontMovie = request.data.results[Math.floor(Math.random() * request.data.results.length - 1)];
setMovie(frontMovie);
return frontMovie;
};
fetchData().then((data) => {
existFav(data, props.uid, favRemRef, favAddRef);
});
}, []);
このuseEffectがしっかり回っているかを調べるのだが、これが見た目以上に厄介。
まずapi.get()のapiは外部モジュールでモック化する必要があり、非同期処理のためexistFav()が叩かれるのは時間差ときた。
挙げ句の果てにもう一つmovieTrailerのテストも作っているため、いつものモックのやり方では不必要にapiモジュールをモックしてしまいエラーが起こる。
正直かなり面倒だった。
まずモック範囲をテスト関数内に限定するため「spyOn」を使う。その際、第一テストが終わった後、モックを初期化するため
afterEach(() => {
jest.restoreAllMocks();
});
これの設置を忘れずに。
次に注意ポイントであるが、もし「render()」をモックの返り値設定(mockResolvedValue)より前におけば
TypeError: cannot read properties of undefined (reading 'data')
というエラーが発生するが、これは「mock→render→return value set」の順で処理するため、render時にモック関数の返り値がないということである。
最後にきちんと「api.get」が叩かれた後、「existFav()」が叩かれたことを確認してテストを終えるのだが、普通に記述するとfetchData関数が非同期処理であるため、上手くいかない。
そこで「waitFor()」を使って、fetchData関数の処理が終わるのを待つのである。
なかなか骨が折れる。
テストファイル実行
今更だが、テストファイルは「〜.test.js」とすればどこに置こうが、システムが勝手にテストファイルとして認識してくれる。
実行方法は
npm test
以上だ!
総括
Jestのテストはまだまだ奥深く説明していないことも多いが、他のエンジニアと差を開けるという意味で是非手を出してみたらいかがだろうか?
情報がそれほど多くなかったり、見たことないエラーが出たりするが、それも良い肥やしとなろう。