メインコンテンツまでスキップ

iOSアプリにテストを導入するための考え方

· 約15分
arasan01

この記事はarasan01 Advent Calendar 2022の2日目です。

https://qiita.com/advent-calendar/2022/arasan01

こんにちは、あらさん(@arasan01_me)です。ポケモンSVのシナリオがはちゃめちゃに良くてポケモントレーナーだったのでは,という思い込みが加速しています。

https://twitter.com/arasan01_me

iOSアプリのテスト概要

iOSアプリでは大きく分けて2つのテストの管理方法があります。Xcodeを使うこと,Swift CLIからtestコマンドを利用することです。これらのテストはXCTestというAppleが提供しているフレームワークのチカラにより実現されています。またXCUITestを利用することによりXcode上でテスト実行をするとアプリが自動で立ち上がりUIテストが起動します。

XCTestでは特定の処理をテストする用途に向いています。例えば関数単位として意図している処理になっているかを確認できます。

テストを書くときの観点

テストが書きやすい設計にすることは開発者がテストを実行する段階で制御できる要素を増やすことと同義です。Fat View Controllerが悪いと言われていた一因にテストが容易に書けなかったことがあるでしょう。これを解決するためにMVVM, VIPERの責務を分割したものを積極的に導入することになりました。また、クラウドサービスが提供するAPIに依存したテストを書く場合を考えてみます,このテストで確認したい内容について細分化するとどのように分割すれば良いのか分かりやすいです。Web APIを呼んだときに発生する何かしらの処理の流れをテストしたい,エラーが適切にハンドリングされているかテストしたい、などの場合にはWeb APIを直接呼ぶことは適していないでしょう,エラーを好きなタイミングで発生させることは困難ですし正常系と異常系のテストが記述できないこともあります。この場合にはWeb APIが発生させうる値とエラーの条件をテストコードで静的に記述してこれらの値を使って処理を実行させるとテストが記述できそうです。Web APIを呼んだときに特定のJSONが返ってくることを期待している場合には想定しているJSONの値を静的に定義することもデコードができるかを確認するために有効です。

静的にWeb APIが返す値をテストコード内で定義することは良い方法です。その一方でWeb APIなどの外部に依存したテストコードを書くことも良い方法です。テストが失敗するのか制御できなくなる点においてテストコードを書くべきではない,という見方もありますが静的に値を定義することは自作自演のテストであるとも言えます。最終的にテストを実行することで実装は有効であることがわかる,これがテストに求める期待です。Web APIから何かしらの認証キー不整合やキー名の不一致により常にエラーが返ってくるなどのミスが発生した場合,これを検知するためには実際にWeb APIを呼ぶことが確実です。外部に依存を伴ったテストは壊れやすく書かない方が良い,という考えもありますがアプリケーションは常に本番環境で動くものです。あまりにも外部の依存の影響でテストが壊れる場合はリリース後のアプリケーションに対する不安に繋がるため何かがおかしいのではないかと疑ったほうが良いです。

UIテストを書くときの観点

UIテストはE2Eテストの1つです。考え方として開発者がアプリをビルド・起動して挙動を確かめる作業を肩代わりするものです。自作自演のテストではなくユーザが触るもの状況と同等のものをテストで提供するべきでしょう、そのため本番APIや実際のDBに対するアクセスをすることが前提です。一言でUIテストと言っても細かい違いですが画面状態を確かめるだけのUIテストとアプリの振る舞いを確かめるUIテストでは考え方に違いがあります。画面状態を確かめるだけのUIテストであればすべてのデータと処理は必要でしょう。アプリの振る舞いを確かめるUIテストではすべてのデータと処理は本物であることが望ましいです。

UIテストは単体テストと比べて壊れやすいものですが,開発者がアプリを実行して挙動を確かめるプロセスを自動化しているという考え方を徹底するとテストに対する取り組み方も変わるかもしれません。例えばリリースのためのreleaseブランチと開発のためのdevelopブランチが存在しており,開発はfeatureブランチを切るような運用をしていることを考えます。この場合にfeatureブランチではUIテストを書かない選択があります。UIテストはユーザが触ったときに意図しない画面・処理が呼ばれていないかなどの実装を確かめられるものです。つまりreleaseブランチへdevelopブランチをマージするタイミングでのみUIテストを記述して動けばよいことにも繋がります。これにより壊れやすいテストというデメリットを軽減できます。またreleaseブランチに合わせてUIテストを記述することで開発側が意図している画面の動き方という暗黙的な了解が可視化されます。これは非常に大きなメリットです。

テストを書く上でテストを容易にする重要な要素

時間を制御する

テストを書く場合に悩む部分に制御が難しい要素に対して如何に対処するか,という問題があります。例えば例を上げると下記のコードに対するテストはどのようになるでしょうか。

func xxx() {
DispatchQueue.main.async { isExecute = true }
execute {
DispatchQueue.main.asyncAfter(deadline: .now() + 10) { isExecute = false }
}}

これは時間が関わるものでありテストの記述として10秒以上待つ,といったコードを書くのではないでしょうか。しかし10秒待つテストというのはかなり辛いです。単純になにもしない時間を待つことになりできれば避けたいです。これに対する簡単な対処は待つ時間をdeadlineへのハードコードから引数に待ち時間を追加するようなものでしょうか,これにより待ち時間は制御可能になります。

func xxx(wait: DispatchTime = .now + 10) {...}

これは対処として良さそうに見えます,しかしこのコードは何かしらの処理を経由してパラメータが設定できなくなると同じ問題を引き起こします。ではデフォルト値を方法ではどうでしょうか。これはこの機能を使っていることをかなり遠くの処理が知識として知ってしまうことに繋がります。結局どのように考えると上手く動くのでしょうか?

1つの解決策としてDispatchQueueを用いて待ち時間を制御するのではなく,待つこと自体を無視するような時間を取り扱う要素があると問題は解決しそうです。例えば下記の内容はSwift ConcurrencyとSwift 5.7で導入されたClockの機能を用いて理想的な処理を書いた場合です。

func xxx(clock: any Clock<Duration>) {
Task { @MainActor in isExecute = true }
execute {
Task { @MainActor in
try await clock.sleep(for: .second(10))
isExecute = false
}
}
}

時間に対する操作ができる機能を用いることで時間を定数にしつつ,どのような時間が流れるのかを制御できるようになります。つまり10秒待つという処理はclockの実装によって実時間を利用する場合,メソッドを呼び出して時間を指定時間だけ進められる場合,10倍の時間の流れる早さの場合など好きな時間の流れを作ることができるようになります。 よって時間を指定して待つことから時間をどのように動かすかを指定して待たない方法へ変わることができました。 これについて詳しい内容はPoint-Freeのビデオが参考になります。

実装を制御する

広く知れ渡っていることですがDependency Injectionとテストコードは非常に相性が良いです。テストコードを実行する際に興味のない処理が関わるのなら何もしない処理へ書き換えることができるためです。

さて,テストでDIとして実装を作った場合,どのような実装にすると良いのか考えてみましょう。何もしない処理へすべて書き換えることははじめの一歩として素晴らしいことです。ここから一歩進んでテストコードの場合は通常すべての処理を呼び出した場合にはエラーとなることを検討してください。何もしない処理への書き換えは興味のある処理だけに注目できますが,誤った処理を呼び出していることのテストはできません。テストでは動くがアプリでは動かない,という自体が起きうる可能性を持ってしまいます。では通常すべての処理はエラーとなることについて考えるとどうでしょうか,何故か動いてしまった処理を検知してテストは失敗するため実装ミスが見つけられる可能性を高められます。

これについて詳しい解説はPoint-FreeのTCAリポジトリ内にあるDependenciesを参考にすると良いです。

おわりに

テストを容易にする方法はまだまだありますが,実装の制御と時間の制御の2つを最初に意識すると効果的なテストを組むことができます。また外部に依存するテストを怖がらないでください。テストターゲットは1つだけしか作れないわけではありません。通常のUnitTestで動かすターゲット,外部依存を持つUnitTestのターゲット,リリース前に動かすためのUITestのターゲットなど柔軟にプロジェクトを構成して良きテストライフを満喫しましょう!