テストを書くことについての考察(フロントエンド)
ここ1ヶ月のプロジェクト開発を通じて、テストを書くことに対する考え方が完全に変わった。
フロントエンドにおいて、過去の僕もテストを書くのがとても好きだった。ユニットテスト(Unit Test)だけでなく、Reactで開発していたこともあって、クリックのシミュレートやユーザーインタラクションなど、コンポーネントの機能テストも一緒に書いていた。もしReduxで副作用(side-effect)を管理しているなら、Reduxのロジックに関するテストもあわせて書いていた。Reduxの世界では純粋(pure)さを保てるため、テストを書くのも比較的簡単だった。
例えば、記事を取得する機能を FETCH_ARTICLE、FETCH_SUCCESS、FETCH_FAILED という3つのActionに分割し、ユーザーがボタンをクリックしたときにトリガーするとしよう。コードはおそらく次のようになる:
const Article = ({
fetchArticle,
status,
article,
}) => {
if (status === 'idle') {
return <button onClick={fetchArticle}>click me</button>
} else if (status === 'loading') {
return <Loading />
}
if (status === 'error') {
return <Error />
}
return isLoading ? null : <article>{article}</article>
};
const mapStateToProps = state => ({
article: state.article,
status: state.article.status,
});
const mapDispatchToProps = {
fetchArticle,
}
export default connect(mapStateToProps, mapDispatchToProps)(Article);
テスト部分は、いくつか分けて見てみよう:
describe('<Article/>', () => {
it('should render button when isLoading', () => {
expect(...); // button exists
});
it('should call fetchArticle if button is clicked', () => {
find(button).simulate('click');
expect(...) // fetchArticle should be triggered
})
it('should render article isLoading == false', () => {
expect(...); // button doesn't exists and article get rendered
});
});
Reduxの部分はさらにシンプルだ:
describe('actions', () => {
it('should return correct type', () => {
expect(fetchArticle()).toBe({
type: 'FETCH_ARTICLE',
});
});
it('should return correct type and response', () => {
expect(fetchSuccess(content)).toBe({
type: 'FETCH_ARTICLE_SUCCESS',
payload: content,
});
});
it('should return correct type', () => {
expect(fetchSuccess(err)).toBe({
type: 'FETCH_ARTICLE_FAILED',
payload: err,
});
});
});
describe('reducers', () => {
it('should return correct state', () => {
expect(reducer(fetchArticle(), initialState), {
status: 'loading',
article: null
});
});
it('should return correct state', () => {
expect(reducer(fetchArticleSuccess(content), initialState), {
status: 'loaded',
article: content
});
});
it('should return correct state', () => {
expect(reducer(fetchArticleFailed(err), initialState), {
status: 'error',
error: err,
})
});
});
これを見る限り、なかなか悪くないテストに見えるし、実際かなり理想的な部類に入るだろう。しかし最近になって徐々に気づいたのは、どれほど頑張ってこれらのテストを網羅しようとしても、考慮漏れのケースが常に、しかもかなり頻繁に出てくるということだ。そして、それが僕にテストというものについて考え直すきっかけを与えた。
上記の単純なシナリオから見ても、いくつかの疑問を考えることができる:
- articleの返り値が正しくない場合はどうするのか?その状態でarticleの特定のフィールドにアクセスしたらどうなるのか?
- loadingの状態で、ネットワーク不安定、タイムアウト、パラメータエラー、サーバーエラー、ブラウザのマウントクラッシュなどの予期せぬエラーが発生しないか?それらをすべて同じ
errorとして分類・処理するのは適切なのか? - ボタンの連打(重複クリック)問題はないか?
disabledなどを使ってユーザーの連打を防ぐべきではないか? - Articleに改行(折り返し)は必要か?折り返すならどう折り返すか?ユーザーに横スクロールさせるのか縦スクロールさせるのか?
- ボタンや各部分のワーディング(文言)は正しいか?
これらはすべて、上記のテストではカバーされていない部分だ。「じゃあ順次追加していけばいいじゃないか」と思うかもしれない。しかし、テストを追加すると同時にコンポーネントも修正することになる。修正すること自体はテストがある分少し安心感があるし、当時の僕もそう考えていた。だが、修正が終わった後にも必ず想定外のシナリオが現れ、結局QAのバグ修正がいつまでも終わらず、デプロイが遅れ、最終的には全員が疲弊しきってしまう状況に陥るのだ。
フロントエンドにはUIとのインタラクションが必要なシナリオが多すぎて、一般的なテストではそうした挙動を十分に担保するのは極めて難しい。
その後、僕はひとつのことに気づいた。単にテストケースを書くだけでは魔法のように、最初から考慮していなかったシナリオ(状態)が突然現れるわけではないということだ。しかもフロントエンドでは、ユーザーの実際の行動をシミュレートしなければ、多様なブラウザや各端末、現実世界で起こる様々な事象(ネットワークの不安定さなど)で次々と問題が見つかってしまう。
最近のプロジェクトの中で、僕を最も大きく変えたのはテストに対する考え方だった。誤解しないでほしいが、僕はいまでも可能な限りテストを書いている。ただ、痛くも痒くもないようなテストケースを書いて自己満足に浸るのではなく、もっと意味のあるテストに注力したいのだ。
だから、いまの僕にとってテストを書くことは必須であるものの、最初からがむしゃらにテストを書けばいいというわけではない。
最も重要な第一歩は、しつこいほどに各種要件を明確化することだ。本当にこれに尽きるし、他に方法はない。
QAの課題が噴出する原因の多くは、単に僕たちが最初から要件を徹底的に理解していなかったことや、双方の間で要件に対する理解にギャップがあったことに起因している。
例えば、先ほどのArticle取得であれば、どのタイミングでAPIを叩くのか、APIのレスポンスにはどのようなパターンがあるのか、様々なAPIの状況に対して個別の処理が必要か、UI上のコンテンツに特別な処理が必要か(前述の折り返しやその他のUI上の考慮など)、重複クリックの防止など、これらをすべて確認し終えてからテストを書き始めても決して遅くはない。
理由は簡単だ。ユーザーが見るUIは、彼らがこのアプリケーションとやりとりするための媒体だ。しかし実際には、僕たちはそうしたE2Eテストをあまりにもやってこなかった(幸いにも最近はこの認識が改められつつあるが)。その結果、どれだけテストを書いても、UIの部分で足元をすくわれることになる。完全なE2Eテストを実施するのは難易度が高く、時にはバックエンドの協力(シミュレーション用のデータベースや環境の用意など)を得て、テスト環境を可能な限り本番に近い状態にする必要があるからだ。
もうひとつのポイントは、テストを書くなら、ユニットテストなんかに時間をかけすぎないことだ。もちろんテストすべき部分はテストすべきだが、expect(1+1).toBe(2) のように自明すぎるものに固執する必要はない。
この点に関して、統合テスト(integration test)ツールとしてCypressとPuppeteerの2つを強くおすすめしたい(Puppeteerは他の用途にも使えるが)。機能が非常に充実しており、必要なものは何でも揃っている。これらのツールがあれば、「面倒だから」という理由はもはやテストを避ける言い訳にはならない。
留意在 useEffect 呼叫 API 的程式碼
useEffect は副作用を実行するために用意されているものだが、油断するとあっという間に悪夢へと変わる。もしAPIのレスポンス結果がコンポーネント内部の状態を変更するものであれば、アンマウント時にAPIリクエストをキャンセルすることを忘れてはならない。さもないと、潜在的なメモリリーク(memory leak)を引き起こす可能性がある。
なぜか?APIのリクエストが完了する前にコンポーネントがアンマウントされた場合、APIのレスポンスが返ってきてコールバックを実行する際にワーニングが吐き出される。なぜなら、そのコールバックはすでにアンマウントされたコンポーネントの状態を変更しようとしているからだ。
それから、useEffect の中で直接APIを呼ぶ(直接 fetch() するような)場合、本当にテストがしづらい……。ひとつのコンポーネントのテストを書くためだけに、膨大なモックを作ってようやく始められる有様だし、APIのレスポンスと整合しているかも慎重に突き合わせる必要がある。目先の便利さに釣られて火種を残すようなことは本当にやめるべきだ。
狀態爆爆樂
また、多くのQAバグの発生源は、状態管理の複雑さにある。開発の初期段階では状態がまだ多くないため、シンプルな useState だけで難なく乗り切れるかもしれない。しかし、状態が増えるにつれてif文があちこちに散乱し、新しい状態をひとつ追加するだけで一瞬で人生を疑いたくなる(絶望する)ような事態に陥る。特に次のようなコードを目にしたときだ:
if (isLoading && !isEditing && profile && isNotEmpty && isLoggedIn) {
// fuck my logic
}
1箇所だけならまだしも、コンポーネントのあちこちにこのようなコードが存在すれば、十中八九複数のQAバグが吹き出すことになる。どれだけテストを大量に書いたとしても、状態管理の複雑さを隠蔽することはできないし、クソコードを良質なコードに変えることもできない。たとえ現時点のテストがすべての状態を網羅していたとしても、新しい状態が追加されたときにはやはりバグが発生しうるのだ。
そのため、状態をいかに(エレガントに)管理するかは、フロントエンドにおける最大の課題と言える。Reduxを書くのは確かにうんざりさせられることも多いが、この手法はより保守しやすいコードを書く助けになり、何より複雑な状態をreducerによって一元管理できるという強みがある。
Reactでは、useReducer を使うことで状態の爆発を防ぐことができる。reducerは本質的にステートマシンのようなものであり、reducer関数を通じて現在のすべての状態遷移を確認できるからだ。もっとも、これだけでもまだ十分ではない。状態はenumのような単純なものではなく、ツリーやグラフに近いものであり、状態の遷移には依存関係があるからだ。例えば、idle状態からいきなりsuccess状態に遷移することはなく、必ずloadingを経てからsuccessに遷移する。あるいはfailed状態からidle状態には遷移せず、loading状態に戻ってからレスポンス結果に応じてどの状態に遷移するかを決定する、といった具合だ。
通常のreducerではこれを実現できない。状態の依存関係を検知する仕組みがないため、エンジニア自身の規律と良識に頼るほかないのだ。しかし、エンジニアの良識ほど信用できないものはないと僕たちはよく知っている。だから必ずミスが起こる。
より良い方法は、状態をより的確に表現できる言語(モデル)を使って状態をモデリングすることだ。最近注目を集めている xstate は一見の価値がある。状態管理におけるあらゆるシナリオがほぼカバーされているので、興味があればぜひ調べてみてほしい。
関連記事
- 測定が目標になるとき:窓税からPull Request数まで かつて僕は小さなツールを自作し、四半期で自分がどれだけPRに貢献したか、レビューコメントをどれだけ残したか、チケットをどれだけ消化したかを集計して、上司にアウトプットを証明しようとしたことがある。上司は淡々と、評価はアウトプットだけで見るものではないと言った。数年後、僕はようやく理解した――測定が目標になるとき、それはもはや良い測定ではなくなるのだ。英国の窓税、ハノイのネズミ駆除の報奨金から、現代のPR数による開発者評価に至るまで、そのメカニズムはまったく同じだ。
- Cloudflare Images を画像ストレージ・変換ソリューションとして使う ウェブページに画像を1枚置くのはフロントエンドにとって最も簡単なことだが、リサイズや各種フォーマットの生成、さらにはトラフィックの負荷に耐えることまで完璧にやろうとすると、実際には一つの包括的なソリューションが必要になる。僕はその後、すべて Cloudflare Images に任せるようになり、オリジナル画像1枚だけを渡すようにしている。
- もう AWS Access Key を使うのはやめよう Access Key は AWS において見落とされがちなセキュリティリスクだ。OIDC と IAM Role を組み合わせることで、GitHub Actions にシークレットを一切保持させることなく、安全に AWS リソースを操作できるようにする。
- データベース主キー:AUTO_INCREMENT、UUID、そしてUUIDv7 バックエンド開発で度々直面する主キーの決定。auto incrementを使うべきか、それともUUIDか?衝突への懸念は?UUIDv7とcreated_at + インデックスの性能差はどれほどか?実際に2,000万件のデータで検証したベンチマークと設計上の意思決定を解説する。