バグをボコボコなぎ倒す👊自動テスト導入でWebアプリを守ろう!
こんにちは。
フロントエンドエンジニアのぴです。
近頃社内のフロントエンドエンジニアの間で、自動テストをもっと導入していきたいという意見が自分の耳に入るようになってきました。サイト制作系の案件が多くどうしても自動テストに関する知見が少なかったので、自動テストを書く上で知っておいた方が良さそうな基礎知識から、ハンズオンの部分までを最低限まとめてみます。
この記事のターゲット
この記事の想定するターゲットは以下です。
- 自動テスト全然わからないけど書いてみたい人
- 自動テストいろんな手法あるけど、どれ選べばいいんだっけって悩む人
- フロントエンドエンジニアの人
こんな人をターゲットに、テストに関する概要的な知識から、コンポーネントの単体テストと結合テストに触れていきたいと思います。
自動テストの概要
まず、自動テストとは、ソフトウェアを使って、テストの作成や実行を自動化する事を指します。手動テストに対して、短時間で多くのテストケースをカバーできるので、カバレッジをあげてプロダクトの品質を上げるのにとても重要なプロセスになります。
テストの目的
自動テストを導入する際には、アプリケーションの機能的な部分をテストしたいのか、見た目的な部分をテストしたいのか、目的によって取る手法が変わってきます。
例えば、フォームに変な文字が入力されたら送信できないようにしたい!このような機能的な部分をテストしたい場合。リファクタリングで意図しないレイアウト崩れが発生してしまった!このような、見た目的な部分をテストしたい場合などです。
この記事では、アプリケーションの機能的な部分を軸に深ぼっていきます。
テストの範囲
一概に、自動テストと言っても言葉の範囲がとても広いです。
例えば、モダン開発では必須であるESLintなどの静的解析から、関数やコンポーネントの単体テスト。複数の関数やコンポーネントが組み合わさった際の結合テスト。E2Eテストまで様々な手法があってそれぞれテストの範囲が異なります。
フロントエンドのアプリケーションを作るとなった時を想定して考えてみると。
- 必要な
npmパッケージ
をインストールする - 関数を作る
- UIを担当するコンポーネントを作る
- APIと繋ぐ
バックエンドの実装は今回範囲外とすると、ざっくりこんな流れになると思います。
先ほど挙げた、テスト手法はそれぞれこの中のどの範囲をテストするか役割が分かれています。
静的解析
静的解析が最もお世話になっている人が多いと思うのですが、ESLintなどのLint系のツールや、型システムなど、ソースコードを実行する前に潜在的な不具合や不具合になり得るコードを探すのが主な目的です。
npmからインストールした、TypeScriptのライブラリの関数を使っている場合、その関数に渡す引数があっているかどうかや、コンポーネントが呼び出している関数の返り値を適切に扱えているかどうかなど、モジュールごとのつながりをテストしてくれます。
あまり自動テストという感覚では使っていないかもですが、1番身近に導入されていてありがたさを痛感しているモノだと思います。JavaScriptは辛いって思っている方はもう逃げられません!
単体テスト
続いては単体テストです、これは作成した関数やコンポーネントが単体で意図した通りに動作するかをテストするのが主な目的です。
例えば、emailのバリデーション結果を返す以下のような関数を作成したとします。
const emailRegexp: RegExp = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
isValidEmail (email: string): boolean {
return emailRegexp.test(email)
}
この関数を使ったフォームをリリースするときに、フォームに手入力をして動作を確認していると、色々なパターンのメールアドレスを入力してみてと繰り返す必要があって、変更が必要になった時のテストが大変です。
こんな時に単体テストの出番です。
有効なメールアドレスと、無効なメールアドレスのリストを最初に作って、一度自動テストを作っておけば、先ほどのisValidEmail
関数に変更をしてもコマンド一つで不具合が起きていないこと保証されます。
この例だと一つの関数ですが、一つのコンポーネントについても同様で、何か一つの物を範囲として行うテストを単体テストと呼びます。
結合テスト
先ほどの単体テストに対して、複数の関数やコンポーネント、モジュールが組み合わさった際のテストを担当するのが結合テストです。
先ほど作った、isValidEmail関数を使ってメールアドレスのバリデーションをチェックするコンポーネントをReactで作成したとします。
export const EmailForm () {
const [email, setEmail] = useState('');
const [isValid, setIsValid] = useState(true);
const handleEmailChange = (event) => {
const newEmail = event.target.value;
setEmail(newEmail);
setIsValid(!isValidEmail(newEmail));
};
return (
<div>
<label htmlFor="email">Email:</label>
<input
type="text"
id="email"
aria-label="email-input"
value={email}
onChange={handleEmailChange}
/>
{!isValid && <p style={{ color: 'red' }}>Invalid email address</p>}
</div>
);
}
違和感を覚えた人もいるかもですが、このコードには明らかなバグがあります。
setIsValid(!isValidEmail(newEmail));
の条件が反転していて、正しいメールアドレスを入力するとフォームがエラーになります。
こうなってしまうと、isValidEmail
関数の単体テストをどれだけ増やしても意味がありません。
こんな場合に、結合テストの出番です。
単体テストの時と同様に、有効なメールアドレスと無効なメールアドレスを用意しておいて、実際にフォームをレンダリング、メールアドレスを入力、どの後のDomの状態を確認すればテスト完了です。
こうすることで、フォームコンポーネントと、isValidEmail
関数の両方が結合された場合の動作を自動テストで保証できるようになりました。
複数のモジュールに分かれたシステムを、結合した状態でテストするのが結合テストです。
E2Eテスト
結合テストでは、だいぶ実際のアプリケーションに近い状態でテストができたと思うのですが、実際のアプリケーションを自動で操作してテストするE2E(End to End)と呼ばれるテストがあります。
先ほどまでのテストとの大きな違いとしては、テストの範囲がアプリケーション全体にまたがることです。
例えばショッピングサイトのカート機能をテストしたい場合など、商品の購入ボタンを押すところから、カートに移動して購入を確定する部分まで、実際にユーザーが操作するワークフロー通りにテストをできるので、自動テストの中で1番アプリケーションらしい自動テストをすることができます。
ここまで聞くとE2E最強じゃん?🤟って思った方もいるかもしれませんが、簡単にそういうわけにもいかないので、自動テストにかかるコストについてみていきます。
テスト戦略モデル
Test pyramid などで検索すると出てくる、お馴染みの図ですが、以下のようなテストにかかるコストや実行時間の関係を表すピラミッド図があります。
画像引用 https://www.headspin.io/blog/the-testing-pyramid-simplified-for-one-and-all
この図は、上に行けば行くほど開発にかかるコストが高く、実行時間が長く、でも信頼性が高いことを示しています。
E2Eテストを実行する場合は、バックエンドのサーバーがあってフロントエンドがあって、中身はわからないけど自動で操作をすることになります。当然APIがエラーを起こしている場合はテストが通らないし、確率で通らないみたいなこともよく発生します。
以前働いていた会社ではこのことをよく考えずにE2Eをどんどん導入してしまったのですが、CIの実行時間が長く、確率で通らないみたいな負債になってしまっていました。
要はバランス
先ほど結合テストを説明した際に、isValidEmail
関数とコンポーネントを結合した状態でのテストを考えたのですが、実際にはそれぞれ単体にすることができます。
isValidEmail
関数をコンポーネントをテストするときに、今はtrueを返してね、次はfalseを返してねと都合のいいようにすり替えることで、そのコンポーネントの機能単体でテストをすることができます。
ただ、全ての結合をなくして単体でテストするのは、逆にテストのコストが上がってしまうことが容易に想像できると思います。
このことから、プロジェクトの状況によっても変わりますが、結合テストが一番コスパの良い自動テストになりやすいです。
実際に自動テストを導入する時には、プロジェクトの事情によって変わるので、チームのメンバーと相談しながらになるのですが、どの範囲をテストしたいのか、どういう目的でテストをしたいのかを明確にした上で導入を進められると自動テストが自動テストとしての役割をしっかり果たせるようになると思います。
自動テストを書いてみる
自動テストの概要を掴んだところで、実際にReact
のプロジェクトで単体テストと結合テストを書いてみます。
create-react-appを使用している場合は、jestなどのテストランナーの構成が含まれているのでこちらを使用します。
Reactコンポーネントのテストには、React Testing Libraryを使用しているので、コンポーネントのテストで困ったらこのドキュメントが参考になります。
このリポジトリのmasterブランチにテスト対象のファイルを、testsブランチに書いたテストも含めてコミットしているので、よかったら手を動かしつつ進めてみてください。
関数の単体テスト
テスト対象の関数は、先ほど出てきたメールアドレスのバリデーション関数です。
const emailRegexp = /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i;
export const isValidEmail = (email: string): boolean => {
return emailRegexp.test(email);
};
メールアドレスをテストするので、有効なメールアドレスと無効なメールアドレスの一覧を用意します。
export const validEmailList = ["hoge@fuga.com", "test-email@example.com"];
export const invalidEmailList = ["hoge@fuga", "hoge@fuga.", "hoge@fuga.c"];
次に、関数をテストするためのコードを書いていきます。
import { isValidEmail } from "../utils/validation";
import { invalidEmailList, validEmailList } from "./patterns";
describe("isValidEmail関数のテスト", () => {
validEmailList.forEach((email) => {
it(`${email}を入力するとtrueになる`, () => {
expect(isValidEmail(email)).toBe(true);
});
});
invalidEmailList.forEach((email) => {
it(`${email}を入力するとfalseになる`, () => {
expect(isValidEmail(email)).toBe(false);
});
});
});
テストを書くうえで便利な記法をJestが提供してくれていて、describe
とit
がそれです。
describe
はテストスイートと呼ばれるもので、複数のテストケースをグループにします。it
が各テストケースです。
Jestがテストケース内にある、expect().toBe()
はアサーションと呼ばれるもので、この実行結果からテストの成功失敗をレポートしてくれます。
この状態でテストを実行すると以下のようなログが出ます。
PASS src/tests/validation.spec.ts
isValidEmail関数のテスト
✓ hoge@fuga.comを入力するとtrueになる (1 ms)
✓ test-email@example.comを入力するとtrueになる (1 ms)
✓ hoge@fugaを入力するとfalseになる
✓ hoge@fuga.を入力するとfalseになる
✓ hoge@fuga.cを入力するとfalseになる
describe
関数でテストケースをグループ化したことによって、テストした関数と入力に対する出力がわかりやすく表示されていると思います。
自動テストは後から見たときに、意図が明確でないと負債になってしまうので、何をテストしたいか明確な説明があって、コードがシンプルであると嬉しいです。
Reactコンポーネントの結合テスト
簡単な関数をテストできたので、次にReactコンポーネントをテストしてみます。
こちらも先ほどと同じく、メールアドレス入力フォームである以下のコンポーネントをテストしてみます。
import { ChangeEvent } from "react";
import { useState } from "react";
import { isValidEmail } from "../utils/validation";
export const Form = () => {
const [email, setEmail] = useState("");
const [isValid, setIsValid] = useState(true);
const handleEmailChange = (event: ChangeEvent<HTMLInputElement>) => {
const newEmail = event.target.value;
setEmail(newEmail);
setIsValid(isValidEmail(newEmail));
};
return (
<div>
<label htmlFor="email">Email:</label>
<input
type="text"
id="email"
value={email}
onChange={handleEmailChange}
aria-label="email-input"
/>
{!isValid && <p style={{ color: "red" }}>Invalid email address</p>}
</div>
);
};
今回テストしたいことは、フォームに文字を入力した時に、不正なメールアドレスが入力されていたらエラーが表示されるかどうかです。
Form
コンポーネントをレンダリングして文字を入力、その時のDomの状態をテストするようにしてみます。
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { Form } from "../components/form";
import { invalidEmailList, validEmailList } from "./patterns";
const setup = (email: string) => {
// Formコンポーネントをレンダリングする
const utils = render(<Form />);
// email-inputラベルが付いた要素を取得する
const input = screen.getByLabelText("email-input");
// https://testing-library.com/docs/example-input-event/
// メールアドレスを入力する
fireEvent.change(input, { target: { value: email } });
return utils;
};
validEmailList.forEach((email) => {
it(`${email}を入力するとエラーが表示されない`, () => {
const { container } = setup(email);
// レンダリングしたコンポーネントの中にInvalid email addressという文字列が含まれないこと
expect(container).not.toHaveTextContent("Invalid email address");
});
});
invalidEmailList.forEach((email) => {
it(`${email}を入力するとエラーが表示される`, () => {
const { container } = setup(email);
// レンダリングしたコンポーネントの中にInvalid email address が含まれること
expect(container).toHaveTextContent("Invalid email address");
});
});
関数をテストしたときといくつか違うところがあるので順番に見ていきます。
当たり前ですが、JSX式
を扱いたいのでテストファイルの拡張子が.tsx
になっています。
コンポーネントをテストしたいので、render
関数を使ってコンポーネントをレンダリングした結果をテストしています。
実際には、ブラウザのDOM APIをシミュレートした環境でレンダリングを行っています。仮想DOMへのアクセスはcontainer
オブジェクトから行えます。
あくまでもシミュレートした環境なので、機能的な部分のテスト以外の目的では使わない方が良いです。
また、"@testing-library/jest-dom/extend-expect" をインポートしているのがわかると思います。これはアサーションのマッチ関数をjest-dom向けに拡張したもので、testing-library-react
を使ってコンポーネントのテストをする時には、これをインポートしないとTypeError: expect(...).toHaveTextContent is not a function
みたいなエラーが表示されると思います。
これでisValidEmail
関数とForm
コンポーネントが結合した状態でテストできました!
結合テストから単体テストにする
最後に、isValidEmail
関数をモックして、結合しないようにしてみます。
/* eslint-disable @typescript-eslint/no-var-requires */
import { fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { Form } from "../components/form";
// ../utils/validation.ts をモックする
jest.mock("../utils/validation");
const mockValidation = require("../utils/validation");
const setup = () => {
const utils = render(<Form />);
const input = screen.getByLabelText("email-input");
// FormのバリデーションがonChangeで実行されるので、適当に入力する
fireEvent.change(input, { target: { value: "a" } });
return utils;
};
it(`isValidEmail関数がtrueの場合はエラーが表示されない`, () => {
// isValidEmail が true を返すように返り値をモックする
mockValidation.isValidEmail.mockReturnValue(true);
const { container } = setup();
expect(container).not.toHaveTextContent("Invalid email address");
});
it(`isValidEmail関数がfalseの場合はエラーが表示される`, () => {
// isValidEmail が false を返すように返り値をモックする
mockValidation.isValidEmail.mockReturnValue(false);
const { container } = setup();
expect(container).toHaveTextContent("Invalid email address");
});
jest.mock
関数でモジュールをモック、モックしたモジュールをインポートします。
テストケース内でモックしたモジュールのisValidEmail
関数の返り値をモックすることで、isVlidEmail
関数に依存せず単体テストになります。今回の場合は単体テストとするより、結合テストでまとめてテストしてしまった方がコスパが良さそうとなんとなく感じていただけたかと思います。
最後に
ここまでで、自動テストを導入する上でどのような手法を取れば良いかがなんとなくわかるようになったら嬉しいです。
皆様のプロジェクトで自動テストが自動テストとして活躍できることを願います。
良い自動テストライフを🎉
Discussion