テスト戦略のアンチパターンにハマっていたけど、学びは多かった話
こんにちは。
PharmaXのエンジニアの古家(@enzerubank)です!
普段PharmaXではスクラムマスターをやったり、Webフロントエンドのテックリードをやったりしています。
今日は自分がフロントエンドのテスト戦略を考えた時にアンチパターンにハマった話をしたいと思います。
また最初に考えたテスト戦略はこちらです!
こちらも合わせて読んでもらえるとより前提が理解しやすいかと思います。
では早速、書いていきます!
過去の成功体験によるバイアス
数年前、自分はRailsエンジニアとしてバックエンドをガリガリ書いていたのですが、その当時はRails wayなモノシリックなアプリケーションを開発することがメインでした。
その時はsystem spec(RailsでE2Eテストを書くための仕組み)で主要機能に対してのE2Eテストを作って品質を守っていた経験があり、フロント(Next.js)とバックエンド(Rails API)で分かれている弊社のようなサービスであっても、同様のアプローチでいけるのではないかという甘い意識がありました。
今になって思うと、モノシリックアプリを作っていた時は、規模も数人規模の小さい会社でCTOや社長が隣にいて不明点があればエンジニアがすぐに聞くことができるので、仕様書が特に無くても主要機能の認識もズレにくい状態でした。
またsystem specはRailsエンジニアであれば、そこまで学習コストをかけずに書き始められるということで、無理なく運用できていたという理由もあり、上手くいっていたのだなと思います。
では実際にフロントでテストを書いていく際に思考したことを書いていきます。
テストの種類が多すぎる問題
実際にフロント(Next.js/React)でテストを書こうとなった時に、「フロントのテストの種類、多すぎん?」「結局何を書いたらよくわからん」といった状態になりました。そこで冒頭の記事でも紹介しているとおり、テスト用語を定義して、全体感を理解するというのはまずやりました。
ただここで踏んだアンチパターンとしては、フロントのテスト用語をきれいに定義できると思い込んだことです。
ユニットテスト、インテグレーションテスト、E2Eテストは完全に分けられるものではなく、E2Eテスト寄りのインテグレーションテストだったり、インテグレーションテスト寄りのユニットテストがあったりとグラデーションがかかるものでもあります。
そのチームによって何をどのテストで担保するのかは異なるため、きちんと共通認識をとって定義することが大事です。
また、フロントのテストを考える際にTesting Trophyを参考にしたのですが、このトロフィー型の考えも、登場してきた経緯や何を解決するためのものなのかを理解せずに、表面上だけ取り入れたことも失敗でした。
コストパフォーマンスを考えるとインテグレーションテストを書くのがよいか?と安直に考えてましたが、Testing Trophyで言っていることはそれぞれのテストのトレードオフを考えて、全てのテストの比率を意識しましょうということでユニットテスト書かなくていいよみたいなことは言っていません。
そんな勘違いもあり、次の話に繋がっていきます。
インテグレーショテストってどう書くの?何をテストするの?
Testing Trophyを参考にして、まずはインテグレーションテストを書こう!となり、書き方を調べていきました。
どうやらreact-testing-libraryを使えば書けそうということで、サンプルを書いてたら何かアクションをして、見た目の変更を見ることはテストできそうだなということは分かりました。
describe('sum module', () => {
test('テストサンプル', () => {
const testMessage = 'Test Message'
render(
<div>
<label htmlFor="example">Example</label>
<h1>{testMessage}</h1>
<input id="example"/>
</div>,
)
expect(screen.queryByText(testMessage)).not.toBeNull()
expect(screen.getByText(testMessage))
})
});
ただ実際にテストを書いていると、処理後のページ遷移も含めてテストしたい!という気持ちが高まってきました。
調べた当時、next-page-testerというライブラリを使えば書けそうだなとわかりましたが、既にdeprecatedされており、作者もブラウザテストを推奨しますと言っていて、やっぱりE2Eテスト書いた方がやりたいこと網羅できて良さそうなんじゃない?という気持ちになってきました。
今思うと、何をテストで見たいのか方針を合意できてれば、他の方法も見えていたのかなと思います。単純にパスが切り替わったことだけをテストしたいなら useRouter を jestでモックしてtoHaveBeenCalledWithで確認することはできます。
その辺もテストはグラデーションであるという意識が抜けていて、品質確保はE2Eテストでしかできないと思ってしまった罠だったなと思いました。
ということで、次からはE2Eテストの書き方を調べていきました。
cypressでE2Eテスト、結構いい感じ?
フロントでE2Eテスト書く際によく使われているcypressを使ってみようということで書き始めました。
まずは、一番画面遷移が複雑な問診画面の遷移を書くことから始めました。以下はかなり簡略化したサンプルです。
describe('Navigation', () => {
it('編集したら戻る', () => {
cy.visit('http://localhost:3336/last');
cy.get("[data-testid='hoge']").click();
cy.location().should((loc) => {
expect(loc.pathname).to.eq('/hoge');
cy.contains('項目A').click();
expect(loc.pathname).to.eq('/last');
});
});
}
かなり実際のユーザー体験に近く、サイトを訪れて操作するということは見えそうだなということが分かりました。
ただそんな甘いものではなく、E2Eテストを本格運用するには考えるべき論点はたくさんありました。
例えば、毎回のプッシュ時にCIを実行していたら時間がかなりかかり、開発スピードを損なうため、週1とかの定期実行にした方がいいのでは?とか、CIは導入せずにまずは開発時の動作確認用として割り切って、ローカルだけ実行する運用にするのはどうか?などです。
他にも色々ありましたが、一番は次の問題でした。
その仕様本当に合っているの?問題
E2Eテストを書いてみて、いざPRが上がってきた時に、そもそもこの仕様で合っているんだっけ?と疑問が出てきました。
E2Eテストを運用するのは変更コストが大きいため、「重要な主要機能だけ書きたい。でも、(恥ずかしいことですが)その当時、開発した機能の仕様書がなく、Figma上のデザインしかない状態で、正しい仕様が何なのかすぐに判断できない」といった状態でした。
仕様も機能も複雑で、エンジニアが自己判断で書くのはコストパフォーマンスが合わないなということで、一旦E2Eテストは停止し、仕様書をまずはちゃんと書こうという方向へシフトしていきました。
シフトレフトだ!手動テスト前倒しムーブ
E2Eテスト導入は断念したものの、依然プロダクトの品質保証は問題意識があり、シフトレフトやってみるのはどうだろう?という話が出てきました。
従来、最後の方にやっていたテスト工程を、より左側の上流工程で組み込むという考え方ですが、自動化テストは実質0%という状態のため、前倒しできるテストは手動テストになります。
QAシナリオをプロジェクトの初期段階で作るようにしたり、全体を通したシナリオテスト(手動)を前半のスプリントのタイミングで挟んだりなど、工夫をしていきました。
結果、プロジェクトの終盤でQAをやんなきゃ!と焦ることはなくなり、何度も手動テストを繰り返したことにより、事前にバグが発見できるようになり、終盤に発覚するバグの件数は以前よりは減ったため、品質の向上は一定得られたかなと思います。
手動テストで品質を守る誘惑に負けた問題
実はこれ、アイスクリームコーン型テストと呼ばれる、あるあるなテストのアンチパターンです。
テストコードを書かなくても何とか品質を守ることができるため、現状維持してしまうという甘い誘惑があります。
しかし、短期的な目線で手動テストばかり選んでいると、機能追加が続くほどにテストにかかる労力と工数は比例して大きくなります。
そうすると、いずれは全パターンを網羅することなどできなくなり、どんどん品質を保証できなくなっていきます。
そのため、どこかでこの甘い誘惑を断ち切り、自動テストの比率を増やしていくという意思決定をしていかなくてはいけません。
テストピラミッドを順番に登っていこう
ありがたいことに、自分がE2Eテストと格闘している間に、チームではリファクタリングとユニットテストを書いていくムーブが起こりました。
FatになっていたReactコンポーネントをロジック分解して小さい所からでいいので改善して、そこに小さくユニットテストを書いていこうという意識が生まれてきました。
この意識はとても大切で、ユニットテストの比率を増やしていくべく今は動いています。
アンチパターンにハマって得られた学び
テスト観点でいうとアイスクリームコーン型というアンチパターンにハマってはいましたが、そのおかげで改善が進み、多くの学びが得られました。
学び① 正しい仕様を判断できるように仕様書を残す
手動でE2Eテストを行う場合でも、正しい仕様が分からなければ必要十分なテストケースを書くことはできません。今回QAシナリオを書いたプロダクトはスピード重視で作ってきた経緯があり、デザインのみで仕様書は残っていませんでした。
その後、新しく作った機能に関しては、詳細な仕様書を書いたことで必要十分なテストケースを書けるようになりました。
これは、cypressやAutifyのようなツールで自動化する際にも大前提の条件のため、整えることができ良かったです。
学び② 要求を表すため、ユーザーストーリーでチケットを書く
いままでチケット起票には特にルールが整備されておらず、洗い出したタスクが必要十分になっているか判断できず、後でタスク漏れが発生しており、受け入れ条件が不明確で、十分にテストすることもできていませんでした。
そのため、誰にどんな利益があるのかを明示する「ユーザーストーリー形式」で基本的にチケットを書くようにしたことで、スプリントレビュー時に利用者に触ってもらえるレベルの変更を出せるようになりました。
(フォーマットはいわゆるINVEST形式を元にしています)
また合わせてバックログリファインメント時に受け入れ条件を記入することを徹底することで、エンジニアが自分で動作確認を自信をもって出来るようになりました。
学び③ フロントとバックエンドで分断していたチームを誰でもユーザーストーリーを担当できるよう、スキル交換して両方できる体制を作る
今まではフロントとバックエンドでチームが分断されており、プロジェクトの中盤以降にマージしたら、バグが出るということが頻発していました。
そこでユーザーストーリー単位で担当者を割り当てるようにし、そのユーザーストーリーの動作確認をフロント〜バックエンドまで一気通貫で行うことを徹底したことで、スキル交換が進みチームメンバーのフルスタック化が推進されました。
学び④ スプリントレビューでステークホルダーやエンジニアが動作確認を行い、フィードバックする体制を作る
これは当たり前のことですが、以前はスプリントレビューで皆でプロダクトを触ってみてフィードバックを受ける習慣がなかったので、これも体制を作りました。
このおかげで、スプリントレビューまでに検証環境に実装者が責任をもって自分の作った機能を反映するという意識が芽生え、以前よりも遥かに品質への意識が向上しました。
学び⑤ プロダクトオーナーとステークホルダーとの横連携を強化する
以前はコミュニケーションが不足しており、ステークホルダーへ要求を確認するためのリードタイムが長かったり、新たな要求がプロジェクト中盤以降になって発覚したりというようなことが起きていました。
これも当たり前のことではありますが、プロダクトオーナーとステークホルダーの横のコミュニケーションを活性化できるように体制を改善しました。
これにより、開発者からの要件や仕様についての疑問が解消されるスピードが早くなり、品質向上に寄与しました。
以上のように、アンチパターンにハマったことで、E2Eテストで品質を担保するための体制は改善がかなり進んできました。もっと整備が進めば、cypressやAutifyなどのツールでの自動化にも入っていけそうです。
ただ、単体レベルでのテスト自動化がまだまだなので、テストピラミッドを順番に登りながら、自動テストの比率を増やしていこうと思います。
今後の展望
ユニットテストは70%が理想とされていますが、その比率を闇雲に追うことは自分たちのフェーズでは最適ではないかなと考えています。
スピードと品質を両立しながら自動テストを増やしていくために、自分たちはどの部分に注力してテストを書いていくべきなのか、E2Eテストとユニットテストの比率などチームとも認識を合わせながら改めて考えていきます。
またバグ件数・変更失敗率・ベロシティなどの指標が計測できると、これらの改善の評価もしやすくなるため、そういったDevOps的な活動も合わせてやっていければと思います。
最後に
PharmaXではエンジニアを募集しております!採用情報についてはこちらをご覧ください。
また、毎月エンジニアイベントを開催中です!
エンジリングマネージャーやテックリードの方、プロダクト開発に携わる方、DX推進するスタートアップに興味がある方へ向けて、登壇各社とあらゆるテーマでLT&パネルディスカッションを行っています。
ご参加お待ちしております!