Wevoxのフロントエンドエンジニアをしているタガミです。最近はmonorepo構成に移行中のWevoxフロントエンドのテストやデザインシステムなどをいい感じにしようとしています。
この記事では、WevoxというSaaSプロダクトのフロントエンドにおける自動テストの話をします。Wevoxはリリースから5年以上が経過し、チームのメンバーも増え、またソースコードも巨大化しています。そんな中でフロントエンドも"式年遷宮"をして、改善を繰り返しています。中にはソースコードをガラッと変えるようなリファクタもあり、担当するエンジニアにとってはデグレの心配が付き纏います。そんな日々変化するフロントエンドを支えるのが自動テストです。
Wevoxの開発チームは決して大人数ではありません。そんなチームでも品質の改善のために一歩ずつ改善しつつある経験をもとに、フロントエンドの自動テストポイントをいくつかお伝えします。
結論
まずはじめに結論から。これらは「ちょうどいい」自動テストのために必要なポイントです。
- 重要度をもとにアプリケーションごとに書くべきテストを決める
- テストの方針をドキュメントに起こす
- Presentation/Logicを分離して、テストしやすさをあらかじめ担保する
- 重要な機能のみテストを厚めに書く
- チームのエンジニアのテスト習熟度に応じてテストツールを利用する
ちなみに、ここでいう「ちょうどいい」とはROIがプラスになり、かつチームの成熟度に適したテスト運用・実装の度合いを指します。自動テストについては「テストカバレッジ100%派」や「お金を産む新規実装の方が大事派」などさまざまな考えがありますが、それらをひっくるめて適切な粒度・優先度で自動テストを書くことがチーム開発においては重要です。
この記事の内容は、例えばTDDを採用していないようなチーム、あるいは自動テストに慣れていないチームでも自動テストを適切に書き始める際の参考になることを目指しています。
テストバランスと重要度(Severity Level)
みなさん、テストは好きですか?
自信を持って「はい!」と答える方はあまり多くないかもしれません...。自動テストを書くということは、短期的には実装工数が増えることを意味します。もちろんテストコードを書くのに慣れていたり、すでにテストコードが充実しているプロダクトでは負荷は少ないでしょう。しかし、はじめのうちは負担になりがちなテストをちょうどいいバランスで書くためには、どこにどれくらいのテストを書くべきなのでしょうか。
テストのバランスについては"Testing Pyramid"や"Testing Trophy"などのベストプラクティクスが存在します。
一方で、各テストには別の指標、重要度(Severity Level)も存在すると考えています。ここでいう重要度とは、「テスト対象のアプリケーションにおける機能や画面ごとに存在する、ビジネス上あるいは技術上重要な度合い」を意味します。
例を挙げて考えます。例えば、SNSアプリケーションにおいて3つの機能のうちいずれが重要度が高いでしょうか?
1. ユーザー登録機能 2. いいね!機能 3. 課金機能
さまざまな観点があると思いますが、もしここにバグがあったときにサービスに致命的である度合いと考えると「3 > 1 > 2」の順番に重要と考えられます。もしも課金機能にバグがあり、クレジットカード情報の漏洩や課金ミスが起きた場合、サービス運営に致命的なダメージになり得ます。
ここでポイントは、重要度はテストそのものとは全く分けて考えるべきということです。例えば重要度は高いが、自動テストがしづらいという機能や画面があったとしても、それはテストをしない理由にはなり得ません。インシデントを防止することは新たな機能を実装することと同じくらい、サービス運営にとっては大事なことです。
自動テストにはテストのバランスと、重要度が存在し、それらをマトリクスに起こすとこのようになります。
重要度を高い順から3~1の3つにわけ、さらにTesting Pyramidを参考にUnit Test > Integration Test > E2E Testの順で重みづけをしました。そしてそれぞれに対応する機能テストが点数が高い順に対応優先度が高いとしています。これはあくまで一例なので、それぞれのポイントは各サービスごとに異なります。
各テストへのチームの温度感を探る
現在の自分たちのプロダクトと向き合ったときに「最低限やっておきたいライン」「チャレンジングだけどやりたいライン」「後回しでもいいライン」の3つがあると思います。これはチームメンバー、環境など様々な要因があります。これはテストに限らず、品質改善という広い視点で考えられます。
例えば、「最低限やっておきたいライン」でいえば
- eslintやprettierなどを使った静的解析(Static Test)
- TypeScriptをstrict: trueにして、厳密な型チェックを適用する
- Jestでの単体テスト
など。
また、「チャレンジングだけどやりたいライン」だと
- StorybookのUIカタログ
- Snapshotテスト
- VRT
などチームのナレッジ、経験を超えて「やってみたい」というモチベーションを含んだ選択肢が出てくると思います。
一方で、現状を鑑みて「後回しでもいいライン」というのも各チームには存在しています。例えば小規模のMVPプロダクトにおいて「テストカバレッジ100%」などは、プロダクト開発のアジリティを落とすことになりかねません。
チームを主体として考えたときに「自分達はどこまで手を伸ばすべきなのか?」を考えることは、今後テストに対してオーナーシップを持つための打一歩として有効です。
テストの方向性をチームで考える
どのテストを、どの機能に重点的に書くべきか?を考えることはチームのテストに対する考え方にも影響します。
MVPの小さいアプリケーションの場合や自動テストを専門に書くQAエンジニアがいる場合を除いて、開発者全員が自動テストを書けることが理想です。いま自分が書いているソースコードがアプリケーションに対してどれくらいの重要度なのか?はたまた変化度合いがどれくらいありそうなのか?は開発者にしか分かりません。プルリクエストのレビューでもチェックはできますが、仕様・背景を理解している当人がテストを書くべきか?の判断ができることは、スケールする自動テストの重要なファクターのひとつです。
Wevoxのフロントエンドチームで実際に使ったドキュメントです。
どのようなケースでテストを書くべきか、またなぜテストが必要なのか?の理由、そして具体的にどのような点をテストすべきか?の3点を重要度ごとに記載しています。
自動テストに関する方針は曖昧になりやすく、属人的なため、ドキュメンテーションが重要です。テストの必要性に関する背景は特に重要で、「なんでこんなことテスト書かないといけないんだ...」という不満を抱えないよう説明あるいは議論することが、テストの方向性をそろえる第一歩だと考えています。
では、このようなドキュメントは誰が書き始めるべきでしょうか?
自動テストに慣れていない、あるいはモチベーションが高くない方が書いても意味はないでしょう。一方でテストに成熟したプロが「俺が考える最強のテスト!あれもこれもテストするんだ!」のようにちょうど良くないドキュメンテーションは、チームによっては忌避される恐れもあります。チームの成熟度に応じて適切な方針が策定できるメンバーがドラフトを作成すべきではないでしょうか。
ただし、ドキュメント化しただけでは意味はありません。文字におこしたものをチームで議論・意見を出し合って、それが適切かどうか?を考えることが重要です。また実際の開発の際に、ドキュメントの内容が適切かどうか?を書いた本人含めて振り返ることも、ドキュメントが意味あるものになっているかどうかのポイントです。
テストしやすいコード
テストをチームで始める際のポイントについてお伝えしてきました。ここからは具体的にテストを書き始める際にWevoxチームの動きをいくつかご紹介します。
1. ロジックとの分離
まずは一般的にBetter Practiceといわれているロジックと見た目の分離です。保守性の高いコードのポイントとして「テストしやすさ」が挙げられます。WevoxのフロントエンドではReactを採用しており、ここではViewとそれに関わるロジック(以下ViewModel)を分離しています。ViewModelはCustom Hooksとして定義しており、Viewつまりコンポーネントはそれを呼び出し、Custom Hooksが提供するロジックをJSXにそのまま渡しています。
Viewコンポーネントの中にはロジック(ステートや関数)は持たせていません。Custom Hooksを呼び出し、その返り値を参照しています。
// View const UserPage = () => { const [userInfo, updateUserInfo, deleteUser] = useUserPageViewModel(); return ( // ViewModelが返す値を使ったView ); };
Custom Hooksの中身はこのようにステートや関数を持ち、ロジックを集約しています。
// ViewModel const useUserPageViewModel = () => { const [userInfo, setUser] = useState<User | null>(null); useEffect(() => { getUser(); }, []); const getUser = () => {} const updateUserInfo = () => {} const deleteUser = () => {} return [ userInfo, updateUserInfo, deleteUser, ] };
こうすることで、ComponentへのUnit Test、ViewModel(Custom Hooks)へのUnit Testが描きやすくなります。ViewModelをCustom Hooksにすることでテストも純粋な関数へのテストと同程度にシンプルに書くことができます。また、フロントエンドにおいてよくあるバグあるいはデグレのほとんどはロジック部分にあると感じており、そのロジックのみを狙い撃ちしてテストかけることは品質の担保にもなります。
ViewModelへのテストはこのようになります。
describe('useUserPageViewModel', () => { describe('when user is null', () => { result = renderHook(() => useUserPageViewModel() ).result; it('return null', async () => { await waitFor(() => { expect(result.current.getUser()).toBe(null); }); }); }); });
Custom HooksのテストにはrenderHook
を使用して、result.current.hoge()
のようなかたちでそのHooksが返す関数や値をテストしています。また、Hooksの返り値だけでなく、ViewModelの内部で別のHooksや関数を読んでいること自体をテストしたいケースは以下のようにテストを書いています。
describe('useUserPageViewModel()', () => { const showSnackbar = jest.fn(); (useSnackbar as jest.Mock).mockImplementation(() => ({ showSnackbar: showSnackbar, })); describe('when user is null', () => { result = renderHook(() => useUserPageViewModel() ).result; result.current.getUser(); it('call other hooks function', async () => { await waitFor(() => { expect(showSnackbar).toHaveBeenCalledWith('ユーザーが見つかりません'); }); }); }); });
ここでは、想定した文字列が画面に表示されること自体はテストしていません。その代わりに別のCustom Hooksが提供する関数に対して想定した文字列が引数に渡され呼ばれることだけをテストしています。これができる前提には、ViewModelのようなロジックとViewのみを扱うコンポーネントが疎結合になっていることが必要です。
もしこれらが結合していると、テストへのハードルがぐんとあがり、結果的に自動テスト書く文化が薄れてしまいます。テストしやすいコードを書くことは自分だけでなく、チームのテスト文化醸成にも役立ちます。
一方で、View単体のテストやロジックと結合した場合のテストも必要になってきます。では、それらについて説明します。
2. "Pure" View Componentを作っておく
WevoxではView Componentの単体テスト自体は積極的には書いておらず、代わりにStorybookでUIカタログを作っています。またTypeScriptでコンポーネントのPropsをある程度セキュアに保つことができているため、Atomic ComponentでいうところのAtomにあたるコンポーネントの単体テストは書いていません。StorybookでUIカタログを整理するメリットはカタログとしてのそれだけでなく、Storycapなどを使ってSnapshotテストやVRTを始めやすくなります。
ただ、Storybook自体も書くメリットがないとなかなかカバレッジを上げることが難しいです。
ただし上述のロジックとの分離をもう一歩進めることで純粋なView Componentを作って、Storybook駆動開発を促進することができます。先ほどのUserPageコンポーネントを例に挙げます。
const UserPage = () => { const [userInfo, updateUserInfo, deleteUser] = useUserPageViewModel(); return ( // ViewModelが返す値を使ったView ); };
このコンポーネントをStorybookで扱おうとすると、内部にCustom Hooksとの結合があるため少し記述が面倒です。
そこで、下記のように純粋に見た目だけを扱うコンポーネントを"同一ファイルに"記述することで、Storybookが目的とするViewのみをUIカタログに登録することができます。
// Containerから呼び出されるPresentational Component export const UserPageTemplate = ({ userInfo, updateUserInfo, deleteUser }) => ( // UserPageの中にあったView ); // Custom Hooksと接続するContainer Component export const UserPage = () => { const [userInfo, updateUserInfo, deleteUser] = useUserPageViewModel(); return ( <UserPageTemplate userInfo={userInfo} updateUserInfo={updateUserInfo} deleteUser={deleteUser} /> ); };
import { UserPageTemplate } from './UserPage'; export default { title: 'Pages/UserPage', component: UserPage, } as Meta; const Template: ComponentStory<typeof UserPage> = (args: React.ComponentProps<typeof UserPage>) => ( <UserPageTemplate {...args} /> );
Container/Presentational Componentの分離はコンポーネント単位では一般的に行われますが、Container Componentそれ自体もさらに2つに分離することで、Storybookへの登録がしやすくなります。さらに、Storybookに限らず、コンポーネントとしての単体テストもしやすくなるでしょう。
これらはテストを書く/書かないに限らず、実装時点で分けておくことで後からテストを追加しやすくなるという点ではどのようなチームでもできる工夫のひとつだと思います。
3. Integration TestとUnit Testを分けて考える
さて、ここまでコンポーネントやロジックの単体テストについてみてきました。Wevoxでもフロントエンドにおける結合テストは書いています。ただし、上述の通り単体 > 結合というバランスで書いているため、アプリケーションにおいて「絶対に落としてはいけないところ」を中心にテストを書いています。例えば機能Aがそれにあたり、機能Bや機能Cは比較的重要度が低い場合以下のようにテストの優先度を決めています。
重要度が高い機能Aに関しては、単体テストはもちろん、結合テスト、さらにE2Eテストも書いています。これは一見して"ちょうど良くない"過剰なテストと思われるかもしれませんが、アプリケーションにおけるテストの重要度を鑑みてこのように方針を決めています。
結合テストにもJestを利用しており、以下のようにテストを書いています。単体テストで担保しているロジックとViewを横断してチェックするようなイメージです。
describe('Important Form', () => { it('should show user info update form', async () => { renderComponent({ userInfo: { ...mockUserInfo, text: 'test' }, }); const textInput = screen.getByTestId('input-form'); expect(textInput).toHaveTextContent('test'); }); it('should be onChange input form', async () => { renderComponent(); const textInput = screen.getByTestId('input-form'); userEvent.type(textInput, 'my user'); expect(onChangeInfo).toBeCalled(); }); });
これとは別にView Component、ViewModelの単体テストを書いています。
もしかすると上記のような「ボタンを押したらonChangeHoge
が動く」のようなフロントエンドの流れのみをテストする場合があるかもしれません。ただ、それだけだとonChange
の中身で何が起きているか?だったり、ボタンがどう動くか?などのケースまではテストでは担保できません。View/ロジック/ユーザー操作それぞれをテストすることで各レイヤーの仕様をテストで保証しています。
「自分達のサービスではどこを手厚くテスト書くべきだろうか?」をエンジニア、その他の職種のメンバー含めて議論することをおすすめします。そして、手厚く書くべき機能に関しては結合/単体をそれぞれ担保するように自動テストを徐々に増やすことを目指します。
WevoxではさらにE2Eテストも利用しています。ただ、これにはcypressやseleniumなどのコードベースのものではなく、ブラウザ上でテストを設定するAutifyというサービスを利用しています。これについて説明します。
4. チームのテスト成熟度に応じてツールを利用する
バックエンド含めて元々単体テストはいくつかあったものの、ブラウザ上での一貫した挙動確認は人力でテストしていました。ただし、開発メンバーが増え、リリース頻度が高くなるなかで、アジリティを落とすことなく、バグ・デグレを減らす方法としてE2Eテストの導入を検討していました。
ただし、Wevoxのサービスの特性上、日にちをまたぐユーザー操作などのテストができることが求められ、またE2Eテストの実装経験があるメンバーがほとんどいないという状況でした。そんな中で国内外のE2Eテストツールを検討し、最終的にAutifyに決定し、1年以上利用しています。
ここではAutifyの素晴らしさは詳しくは述べませんが(また別の記事で書きます!)、チームのテスト成熟度次第ではこのような「ショートカット」の利用は有効です。
テスト経験があまりないチームで無理にテストを書いたり、その環境構築をして、メンバーからテストに対する嫌悪感を抱かれるケースをよく聞きます...。その結果テスト環境はあるものの、誰も自動テストを書かない、結果としてかたちだけの「雰囲気自動テストが回っている」というような状況になっていきます。
そのような事態を避けるためにも、Autifyのようなツールを利用してテストへのハードルを下げていくほうがいいでしょう。さらにAutifyの場合、クロスブラウザテストやモバイルアプリのE2Eテストなどにも対応しています。クロスプラットフォーム前提のアプリケーションの場合にはなおさら有効な手段だと思います。
最後に
ここまでWevoxのケースを交えながら"ちょうどいい"自動テストのはじめかたについてお伝えしました。
「はじめかた」と言っても、ほとんどの方はすでに各々の方法で自動テストを書いていることと思います。ただ、それを「チームの文化」として浸透させられているケースは少ないと思います。TDDのような手法をルール化したり、ペアプロするなど様々な方法はあると思いますが、最終的にはモチベーションが高いエンジニアが旗振りをする、それに限ると思います。多少なりとも手間になる自動テストを、いかに「プロダクトを改善するためにやるべきこと」として浸透させられるかは、チームのためにアクションできる方、またそれを応援してくれるメンバーがいるからこそだと思います。
弊社アトラエではエンジニア誰しもがそのように「チームづくり」に日々コミットしています。またプロダクトをより良くするために、自分がやるべきと思ったことを手を上げて進められる文化があります。テスト大好きな方も、そうではない方も、カジュアル面談などでお話ししましょう!
こちらのスライドではより詳しくアトラエについて知ることができます!