フロントエンド開発におけるテスト自動化について
#フロントエンド
はじめに
フロントエンド開発において、どこに/いつ/どうやってテストを書いたらよいのかまとめていきます
なぜテストを書くのか?
TypeScriptによる型チェックやリンター (ESLintなど)だけでは捕捉し切ることが困難なバグはたくさん存在すること
予期せぬリグレッションを防止または検出する (あるコードを変更した際に、まったく関係ないと思われていた箇所でバグなどが生じてしまう可能性がある)
テストコードがありその実行がCIなどで自動化されていることで、バグ修正や新機能の追加などにおいて既存のコードにバグを埋め込んでしまうリスクを軽減できる。また、リファクタリングや依存パッケージのアップデートなどのリスクも軽減できる。
また、上記のメリットにより心理的な負担の軽減も期待されます
テスト対象について
関数やクラス
コンポーネント (UIコンポーネントのテストパターン)
リグレッションテスト
型定義 (TypeScriptの型をテストする)
その他
テストの種別/分類
テストには色々な分類の仕方があると思います
テスト対象の粒度に応じた分類: ユニットテスト/インテグレーションテスト/E2Eテスト
実装の詳細度に応じた分類: ホワイトボックステスト/ブラックボックステスト
その他: リグレッションテスト/ストレステストなど
これらは人やチームによって分類や呼び方などが異なることも多いと思うため、あくまで参考程度に留めておくくらいで良いのかなと思います
ユニットテスト
実装した関数やクラスなどに対して他の要素とは独立してテストを行う
副作用のない関数(純粋関数)やクラス(例: Value Object)などはモックやスタブなどを必要とせず容易に独立してユニットテストが記述できます
例えば、Reduxにおけるreducerは副作用のない純粋関数として実装されるため、容易にユニットテストが書けます
code:javascript
describe('sum', () => {
it('returns sum of numbers', () => {
expect(sum(1, 2, 3)).toBe(6);
});
it('should return zero if no arguments are given', () => {
expect(sum()).toBe(0);
});
});
SOLID原則などに従い、独立して効果的にユニットテスト可能な関数やクラスの比重を増やせると理想的ではあると思います
ただし、実際に開発を行っているとなかなかそれが難しい場合も多いとは思う(例: テストコードがまだ存在ないプロジェクトにおいて、後から少しずつテストを追加するケースなど)ので、あまり拘りすぎず、まずはインテグレーションテストを中心に少しずつテストを増やしていくのも現実的なのではないかと思います
インテグレーションテスト
複数の要素(関数, クラス, ミドルウェア, 外部サービスなど)などを組み合わせてテストし、それらがきちんと意図した通りに連携されていることを確認します
フロントエンド開発においていくつか例を挙げると、以下のようなテストなどがインテグレーションテストと呼べるのではないかと思います
複数の関数やクラス、モジュールなどを組み合わせて、それらがきちんと連携されていることをテストする
Testing Libraryを使ってDOMとの連携も含めたコンポーネントの振る舞いをテストする (UIコンポーネントのテストパターン)
Firebaseに依存した関数やクラスなどを実際にFirebaseエミュレーターなどに接続させてテストをし、きちんと永続化などが行われていることを検証する
自動テストがまだ整備されていないコードベースに対してテストを導入したい場合は、ユニットテストよりもまずはインテグレーションテストを優先して整備していくと効果的な場面も多いのではないかと思います
インテグレーションテストが増えてきたら、カバレッジなどを計測しつつ、ユニットテストがしやすい構造にコードを少しずつ置き換えていくと安全だと思います
もし置き換えていく段階でカバレッジが低下した場合は、どこかにバグなどが埋め込まれてしまっている可能性があるので注意するとよさそうです
E2Eテスト
インテグレーションテストとの分類がやや曖昧かもしれませんが、概ね以下のようなテストはE2Eテストとして分類できるのではないかと思います
ビジュアルリグレッションテスト
ヘッドレスブラウザーを活用して、アプリケーションの振る舞いをテストする (例: Cypress/Playwrightなど)
APIサーバーを実際に起動してHTTPリクエストを送信し、レスポンスなどを検証する
一見、E2Eテストは万能な解決策のようにも見えますが、デメリットも多いので、適宜、他のテスト手法と組み合わせるのが良いと思います
メリット
実際の本番アプリケーションと近い状況を再現してテストができるため、E2Eテストが通ればその機能に関してはきちんと動作していることが期待できる
デメリット
メンテナンスや記述コストが高い
E2Eテストを用意したとしてもメンテナンスにコストがかかりすぎてしまい、形骸化してしまうリスクがある
実行に時間がかかる
インフラの構築や整備などのコストがかかる
ユニットテストやインテグレーションテストなどと比較すると、不安定になるがち (Flaky Test)
ホワイトボックステスト/ブラックボックステスト
個人的な意見としては、可能な限りブラックボックステストとして記述するのがよいと思います
テストコードが実装の詳細に強く依存していると、テストコードが意図せずして壊れてしまい、テストの信頼性が低下してしまう可能性があるためです
後述するjest.mockやEnzymeなどのパッケージについては、注意深く使用しないと実装の詳細に強く依存したテストになってしまうので注意
リグレッションテスト
導入の方法としては、以下のようにすると効果的だと思います
1. バグが発生した際に、まずそのバグを再現するための失敗するテストコードを書きます。
2. 次にそのテストコードを実行してみて、実際にテストが失敗することを確認します。
3. 最後にその失敗しているテストコードがパスするようにバグを修正します。
こうすることで、テストコードの実行がCIなどで自動化されていれば、同じ原因でバグが再発することを防止できます
ただし、実際には常にこの方法でテストをうまく書けるとは限らない場面も多いと思うため、必要に応じて後からテストを導入することなども検討するとよいと思います
実際の例として、Denoの開発では基本的にバグを修正する際にリグレッションテストも一緒に追加されています
fix(ext/node): panic on 'worker_threads.receiveMessageOnPort' (#23386)
例えば、このPRでは#23362が修正されていることを保証するために、リグレッションテストが追加されています
tests/unit_node/worker_threads_test.ts#L418-L438
また、このテストがあることにより、バグの再発が防止されます
テストダブルの使用について
テストダブルとはモック, スタブ, スパイ, フェイクなどのテストにおいて本物のオブジェクトの代替として機能するオブジェクトなどを指します
どれを使うべきか?
Googleのソフトウェアエンジニアリングという書籍ではテストダブルの使用に関して解説されていて、基本的にはフェイクを使用することが推奨されています
フェイクとは、外から見た際にあたかも本物のオブジェクトであるかのように振る舞うオブジェクトのこと
フェイクを実装する際は、契約による設計における事前条件・事後条件・不変条件が本物のオブジェクトとフェイクとで一致していると理想的だと思います
例としては、バックエンド開発における永続化レイヤー(Repository, Data Access Objectなど)のインメモリ実装などがそれにあたります
上記の書籍では、フェイクに対してもテストコードを記述することが推奨されています
モック・スタブの乱用について
Mocking is a Code Smell.という記事では、テストを書く際にモックを乱用しなければならない場合、コード品質に問題がある可能性が高いということが指摘されています
パッケージのスタブについて
JestやVitestなどでは特定のモジュールのスタブ機能があります (jest.mock, vi.mock)
個人的な意見としては、これらの機能は非常に便利ではありますが、どうしてもそれを使わないとテストが難しいというケースを除いて使用を避けるのがよいと考えています
これらの機能を使うと実装の詳細に強く依存した信頼性の低いテストに陥ってしまうというのが理由です (jest.mock/vi.mockの使用は極力避けた方が良い理由について)
HTTPリクエストのスタブ
ユニットテストやインテグレーションテストを記述する際に、実際のAPIサーバーに対してHTTPリクエストを送信したくない、というケースは頻繁に発生するのではないかと思います
こういった目的を解決するために様々なパッケージが存在します
msw
nock
fetch-mock
moxios
この中では、個人的にはmswやnockなどをおすすめします
理由はfetch-mockやmoxiosなどは特定のHTTPクライアント(例: fetch, axios)と強く結合していることが挙げられます
例えば、moxiosを採用していた場合、もし「HTTPクライアントをaxiosからfetch()へ移行したい」というようなケースが生じた場合、テストコードの大幅な書き換えが発生する恐れがあります
mswやnockなどのように特定のHTTPクライアントに依存しない方法であれば、そういったケースにおいても問題なく機能し続けてくれます
また、外部のAPIとのやり取りを抽象化したインターフェースを用意しておくことで、HTTPリクエストをスタブせずともテストすることもできます (TypeScriptを使ってDIについて説明する)
結合度などの観点からすると、この手法に従いスタブの使用をできるだけ抑えられると理想的ではあると思います
結合度の低下や凝集度を高めるのに役立つ手法について
他のモジュールからは独立して効果的にユニットテストを記述するためには、結合度が低く凝集度の高いコードを記述すると効果的です
こういったコードを書く方法についてはSOLID原則やその他の手法などについて学ぶと理解が深まるのではないかと思います
DI (TypeScriptを使ってDIについて説明する)
Container/Presentationalコンポーネント
Composition over inheritance
関数型プログラミングやOOPなどにおける様々な手法
どういった場面でテストを書くとよいか
リグレッションテストはフロントエンドでもとても有用だと思います