
先日、Flutter Tokyo #6 で同タイトルの発表をさせてもらいました。10分ほどの発表でしたが、割と良い反応をいただけたので、少し内容を補足してブログとしても公開します。
発表時のスライドは以下です。
前提
一般的に、モバイルアプリは自動テストしづらい箇所が多いと言われます。たしかに、画面から素朴に実装していくと、自動テストでは確認が難しくなりやすいです。そうなってしまうと、アプリを起動して手動で動作確認するしかなくなってしまいます。
一方で、設計やツールを適切に使えば、モバイルアプリであっても広範囲が自動テストで検証可能になります。手動での動作確認を完全になくすことはできませんが、手動テストへの依存度は下げられます。
手動テストへの依存度が下がると、検証時間の短縮、手戻りの抑制など、さまざまなメリットが得られます。また、手動テストにも良い影響を与え、手動テストでなければ確認できないことに集中できるようになります。
なぜテストしづらくなるのか
テストしづらさは、以下の2つに分解できます。
- テストする条件の再現が難しい。
- 結果の検証が難しい。
原因は色々と考えられますが、最もよくある原因はテスト対象の役割が大きすぎることだと思います。モバイルアプリではデータとUIの両方を制御する必要がありますが、両者の役割が混在すると状況はより厄介になります。また、役割が大きくなると記述するテストケースも網羅しづらくなり、抜け漏れが出てテストの価値が下がりやすくなります。
役割を混ぜないこと(特にデータの制御とUIの制御)、役割を大きくしないことを守れば、あとは場所ごとに適切な手段を使えば十分テスト可能となります。
例を使って解説
例としてGitHub APIでリポジトリ検索をして、結果を表示する画面を考えます。

この画面はそれほど複雑ではありませんが、それでも一気にすべてをテストするのは得策ではありません。役割の種類を混ぜないこと、役割を大きくしすぎないことを守りながらクラスの境界を設計し、それぞれをテストしていきます。
データとUIの関係を疎にする
数ある責務分割の中でもトップクラスで効果的なのは、データとUIの関係を疎にすることです。これを守ると、先に挙げた「テストしづらさ」の要因の1つである「テストする条件の再現が難しい」は解消しやすくなります。
依存関係の向きは常にUI → データなので、データとUIの関係を疎にするには、データ側のロジックの詳細がUI側に漏れないように設計します。今回の例ではデータ側はGitHubと通信を行い、レスポンスのステータスコードを確認し、JSONをパースし、データが想定通りか検証する、といった処理が必要ですが、この一連の内部処理の制御にはUIは関わらないようにします。
具体的な実装としては、データ側は以下のようなインターフェースを提供します。

UI側の視点で見れば、データ側に入力を渡せば結果が得られる状態になっています。一連の内部処理について立ち入ることはできず、結果だけを受け取るようになっています。
出力を適切にレイアウトに反映するには、内部処理の中で何が起きたか知る必要がある場合もあります。そういった場合に備えて、データ側では実行結果に適切な情報を含めるようにします。上記の例では、検索の失敗時にGitHubExceptionをスローすることになっていますが、この例外に適切な情報が含まれていれば、UI側は利用者と適切にコミュニケーションができます。

データとUIの関係が疎になると、UIの役割は「データに入力を渡して、出力をレイアウトに反映するだけ」というシンプルなものになります。これはコード上にもあらわれ、実際にwidgetの実装は以下のようになります。

なお、ここではRiverpodを使っており、前掲のデータ側のインターフェースを呼び出すproviderを以下のように定義しています。

ここでもう1つ重要なのは、UIが依存するデータ側の実装が差し替え可能になっているということです。Riverpodにはproviderを差し替えるoverrideという機能があり、データ側の出力を任意のものに変えられます。これによって、先に挙げた「テストしづらさ」の要因の1つである「テストする条件の再現が難しい」は解消されるという訳です。
役割がシンプルになったUIをテストする
UIの役割は「データに入力を渡して、出力をレイアウトに反映するだけ」というシンプルなものになりました。テストでもこの役割を検証します。
「テストしづらさ」の要因として「テストする条件の再現が難しい」「結果の検証が難しい」の2つを挙げていましたが、これまで説明していなかった「結果の検証が難しい」は、UIのテストにおいてはgolden testを使って解消します。
実際のテストケースに入る前に、テストする条件が再現されるwidgetを返す関数を準備します。テストする条件のデータの出力を渡して、providerをorverrideしてwidgetを返します。

ここまで来れば、あとは条件ごとにテストケースを書くだけです。以下のテストコードは、データ側がリポジトリのリストを正常に返した時に、その結果がUIに正しく反映されるか検証しています。

左側の画像はテストに使用するgolden fileです。右側のテストコードを書いて、flutter testを--update-goldensオプションをつけて実行すれば生成されます。実装中はgolden fileが想定通りになるまで修正と再生成を繰り返します。一度完了したら、以降はテストのリファレンスとして使用します。
通信が失敗した場合のテストケースも同様に書けます。今度はresultにFuture.error(GitHubException.connectionFailed()を渡して、通信が失敗した場合を再現しています。

このように「テストしづらさ」の「テストする条件の再現が難しい」と「結果の検証が難しい」をそれぞれ解決すれば、UIのテストも簡単に書くことができます。このような形で開発を進めていけば、アプリを起動して動作確認する必要性は自然と下がっていきます。
品質と生産性
視点を品質と生産性に移すと、以下のことが言えます。
- 手動テストでは再現が難しい条件も含めて、あらゆる条件を簡単に再現できる。
- 自動テストがリファクタリング耐性を持っているので、変更がしやすくなる。
- 高速に反復実行できるため、開発中の修正サイクルを速く回せる。
- 頻繁な全件実行が現実的なので、マイナーケースのデグレも早く発見できる。
あらゆる条件を簡単に再現できる
手動テストでは再現が難しい条件も、今回の形なら簡単に再現できます。
例えば、GitHubで障害が発生して500エラーを返した時にUIがどうなるかは、手動では再現が難しいです。しかし、今回の形であれば_createWidget()のresultにGitHubException.unsuccessfulResponse(statusCode: 500)を渡せばそれが再現できます。
あらゆる条件の再現が簡単になり、自動テストが実装されるということは、実行コストが大幅に圧縮され、その条件の検証が行われる機会が増えるということになります。それが、最終的には品質の底上げに繋がります。
リファクタリング耐性があるので変更がしやすい
UIのgolden testは、描画結果のスクリーンショットを使って検証しているため、widgetの内部実装には依存していません。そのため、widgetの内部実装を大幅に変えたとしても、テストコードを変える必要はなく、リファクタリング耐性があると言えます。
検証内容を変えない限りはテストコードを固定できるため、その分内部実装は柔軟に変更しやすくなり、結果として変更を早く終えられるようになります。
高速な反復実行による開発中の修正サイクルの高速化
今回のテストはFlutterのwidget testであり、これは非常に高速です。実装の仕方にもよりますが、今回のような形であれば10件くらいのテストケースも実行は1秒ほどで終わります。
また、Flutterにはhot reloadがありますが、これと比べてもメリットがあります。今回の形では、再現が難しい条件や、テキスト入力などのUIの操作が必要な条件などであっても、確認と修正のサイクルの速度はあまり影響を受けません。
頻繁な全件実行による品質の底上げ
テストの実行速度が速いため、手元でもCI上でも全件の実行が現実的になります。これにより、アプリの大通りでないケースについても、常に検証を行うことができます。
最終的に手動テストが不可欠であることに変わりはありません。なぜなら、今回の形のテストは結合した状態でテストしている訳ではないため、結合に起因した不具合や、使い勝手という観点の検証ができないためです。しかし、それでも自動テストは手動テストの負担を下げたり、稀にしかテストされないマイナーケースも含めた全体の品質の底上げに役立ちます。
例えば、レイアウト崩れを早期発見するgolden testがあれば、レイアウト崩れによる手戻りの頻度は大幅に抑えられ、開発担当者とテスト担当者が手戻り対応に割く時間を抑えることができます。
UI以外のテスト
今回はUIのテストを中心に説明しましたが、アプリを起動せずに開発を進めるにはデータ側のテストも同様に重要です。また、UIに関しても画面遷移のテストや、分析ログのテストなど、まだまだテストすべきものがあります。
これらについては、また別の機会に解説したいと思います。
まとめ
- モバイルアプリ開発は手動テストに依存した部分が増えやすいと言われる。
- しかし、役割をちゃんと分解すれば、ほとんどのことが自動テストできる。
- それなりに頑張る必要はあるが、やる価値はある。
メンバー募集
10Xではソフトウェアエンジニアを募集しています(特にバックエンド)。
今回はモバイルアプリのテストの話を書きましたが、役割の分解のところはバックエンドでのテストにも通ずるものです。こういった考え方で開発をより上手くやっていきたいという方に是非来ていただきたいです。
興味があれば、ぜひ会社紹介をご覧ください!
open.talentio.com