フロントエンドのテストアーキテクチャ
近年 testing/library をはじめとしたフロントエンド向けのテストツールが普及し, フロントエンドでのテストが一般的になってきました.
また手動テストとE2Eテストを中心としたアイスクリームコーン型のテスト構成から, Testing Trophy の普及によってフロントエンドでも適切なテストを考える機会が増えてきています.
Testing Trophy
The Testing Trophy and Testing Classifications より
Testing Trophy は Kent C. Dodds が提唱したテスト比率に関する考え方です[1]. Testing Trophy はその名の通りのテスト比率に関する図が象徴的ですが, Testing Trophy が主張しているのは以下のようなことです(筆者の解釈です).
- 実行コスト・時間は有限である
- テストの信頼性を上げることが最重要
- ユーザーの使い方に近いテストが信頼性が高い
このことから Testing Trophy では静的解析と統合テストを重視しています.
フロントエンドのテストにおける新たな課題
フロントエンドでテストが普及した一方で, 以下のような課題が出てきました[2][3].
- スローテスト
- 信頼不能テスト (flaky test)
- 脆いテスト (fragile test)
Testing Trophy の問題点
Testing Trophy を忠実に守っていたとしても上述の課題は解決できません. その問題は統合テストを中心としたテスト構成にあります. そもそも統合テストとは一体なんでしょうか. フォームは統合テストでしょうか. それとも統合テストはページだけ? ページのテストを書けばページで使っているコンポーネントのテストは不要でしょうか.
そもそも flaky test や fragile test といった問題は単体テストや統合テストといった分類とは関係なく, モックや副作用によるものです. Testing Trophy ではこれらに対して効果的な指標をもたらしません.
テストアーキテクチャのためのテスト分類
ここまでで Testing Trophy だけでは上述の問題点を解決できないことがわかりました. そこで新たな考え方を導入しましょう. それが test sizes とテスト手法です[4][5].
Test sizes
Test sizes はテストが必要とするリソースによって分類したものです.
- Small test: 実行プロセスは単一.
- Medium test: 単一のマシン. 複数プロセス.
- Large test: 複数のマシン. 複数プロセス.
『Googleのソフトウェアエンジニアリング』[6]より
リソースによって機械的に分類することでテストの区別に恣意性がなくなります. また信頼不能テストとなってしまう範囲をコントロールできます. Test sizes では small test を増やすことを目指します.
Feature | Small | Medium | Large |
---|---|---|---|
Network access | No | localhost only | Yes |
Database | No | Yes | Yes |
File system access | No | Yes | Yes |
Use external systems | No | Discouraged | Yes |
Multiple threads | No | Yes | Yes |
Sleep statements | No | Yes | Yes |
System properties | No | Yes | Yes |
Time limit (seconds) | 60 | 300 | 900+ |
3つのテスト手法
『単体テストの考え方/使い方』で紹介されているテスト手法の分類はテストの検証方法によって分類します.
出力値ベース・テスト: 戻り値を確認するテスト
状態ベース・テスト: 状態を確認するテスト
コミュニケーションベース・テスト: オブジェクト間のやり取りを確認するテスト
『単体テストの考え方/使い方』より
テストをこのように分類することで依存関係や変更容易性をコントロールすることができます. 例えばコミュニケーションテストでは必ずモックが必要です. そのため『単体テストの考え方/使い方』出力値ベース・テストが優れていると主張しています.
フロントエンドではE2EテストとUIコンポーネントに関するテスト, それ以外の関数に対するテストに分類することもできます. これらを出力値/状態/コミュニケーションベース・テストに割り当ててみましょう. 当然出力値ベース・テストとして検証できるのは関数のテストだけです. UIコンポーネントに関するテストの多くは状態を検証していると言えるでしょう. 外部依存性によりコミュニケーションベース・テストが必要になる場合もありそうです. E2Eテストも同様に状態ベース・テストとコミュニケーション・ベーステストとして検証されます. このことからフロントエンドにおける効果的なテストとは以下のように考えられます.
出力値 | 状態 | コミュニケーション | |
---|---|---|---|
関数 | GOOD | OK | NG |
UI | - | GOOD | OK |
E2E | - | GOOD | OK |
Test sizes とテスト手法
Test sizes とテスト手法からフロントエンドでは以下のように分類できます. UIコンポーネントに関するテストは可能な限り small test とし, 状態ベース・テストとして検証します. コミュニケーションベース・テストは避け, medium test で状態ベース・テストの方が望ましいでしょう. 出力値ベース・テストとなる関数に対するテストも積極的に採用しましょう. 関数に対するテストではテストダブルとなるような依存関係は避けましょう.
関数 | UI | E2E | |
---|---|---|---|
Small | GOOD | GOOD | - |
Medium | NG | OK | GOOD |
Large | NG | NG | OK |
このようにテストを構成することで関数に対するテストでは速さと決定性を, UIコンポーネントに関するテストでは速さと忠実性のバランスを, E2Eテストでは忠実性を目的としてテストを作ることになります. 当然これらの分類は機械的に決まるため, テストの目的に対して共通認識を持ちながら実装することができます.
テストダブルについて
これまでの議論では単体テストでよく用いられる偽の実装:テストダブルについて言及しませんでした. ここではテストダブルを機能によって3つに分類します[7].
- モック: テスト対象システムが利用し隠れた出力を担うオブジェクト
- スタブ: テスト対象システムが利用し隠れた入力を担うオブジェクト
- フェイク: 本物の実装同様に振る舞う軽量な実装
これらのテストダブルを使うことで以下のようなトレードオフが発生します.
- モック:コミュニケーションベース・テストになる
- スタブ:テストの忠実度が下がる(脆いテスト、偽陰性)
- フェイク:テストの決定性が下がる(信頼不能テスト)
この記事では深く追求しませんが test sizes とテスト手法ではこれらのトレードオフを理解しながら実装することが求められます.
叫ぶテストアーキテクチャ
最上位レベルのディレクトリ構造と最上位レベルのパッケージのソースファイルは、「ヘルスケアシステム」「会計システム」「在庫管理システム」と叫んでいるだろうか?[8]
『Clean Architecture』より
これまででフロントエンドのテストに関する新しい分類を取り入れました. しかし分類をしただけでは, そのテスト構成が忠実に適用されるとは限りません. そこで明示的なアーキテクチャ, つまり叫ぶテストアーキテクチャとして構築します.
ここでは test sizes にしたがってテストに名前付けをしていくことにします. 関数やUIコンポーネント, E2Eテストは元々ファイルを分けているでしょうから, test sizes によって名前づけをすることで以下のような構成となります.
./
├── e2e
│ └── login
│ └── login.large.test.ts
└── app
└── login
├── LoginForm.tsx
├── LoginForm.small.test.ts
├── LoginForm.medium.test.ts
├── page.tsx
├── validateUser.ts
└── validateUser.small.test.ts
テスト実行も分離します. 例えば Vitest なら vite.small.config.ts
として, test sizes ごとに設定ファイルを追加することで個別に実行することができます.
/// <reference types="vitest" />
import { defineConfig } from "vite";
export default defineConfig({
test: {
include: ["**/*.small.{test,spec}.?(c|m)[jt]s?(x)"],
},
});
{
"scripts": {
"test": "vitest run --silent --config=vite.config.ts",
"test:small": "vitest run --silent --config=vite.small.config.ts"
}
}
このようにテストの決定性をコントロールできる test sizes に従ってテスト実行そのものを分離することで, テストが失敗する範囲を制限し隔離することになるため, 結果的に実行コストを下げることになります.
この記事ではフロントエンドにおけるテストに現れた課題が Testing Trophy では解決できないことを明らかにしました. そこで test sizes とテスト手法という新たな分類を導入することで, 機械的にテストを分類し, その分類ごとにテストの目的を絞ることにしました. さらに test sizes ごとにテストファイルを分けることで, テストの目的ごとにテスト実行を分けることができ, 柔軟なテスト実行計画にすることができます. このように明示的なテスト構成:叫ぶテストアーキテクチャを採用することで, テストの目的に合わせたより柔軟で効率的なテストを実行できます.
-
単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略, Vladimir Khorikov, 須田 智之, マイナビ出版, 2022 http://book.mynavi.jp/ec/products/detail/id=134252 ↩︎
-
Googleのソフトウェアエンジニアリング―持続可能なプログラミングを支える技術、文化、プロセス, Titus Winters, Tom Manshreck, Hyrum Wright, 竹辺 靖昭, 久富木 隆一, オライリー・ジャパン, 2021 https://www.oreilly.co.jp/books/9784873119656/ ↩︎
-
Clean Architecture 達人に学ぶソフトウェアの構造と設計, Robert C.Martin, 角 征典, 高木 正弘, ドワンゴ, 2018 https://asciidwango.jp/post/176293765750/clean-architecture ↩︎
Discussion