概要
こんにちは。ZOZOでiOSエンジニアをしている加藤です。
私が開発に携わるFAANS iOS は MVP アーキテクチャで開発されており、Presenter に対してユニットテストを作成しています。しかし、画面数の増加に伴い、十分にテストを書けていないケースが増えてきたことに課題感を持っていました。そこで今回、チーム内で「テストに対する考え方」を改めて擦り合わせ、FAANS iOS チームとしてどのような方針でテストを書いていくかを言語化しました。
また、これまで XCTest を使っていたテスト環境を Swift Testing へ移行したため、本記事では Swift Testing を用いた具体的な実装例についても紹介します。
テストに対する認識合わせ
まず、新卒 3 年目の私は「どのテストが必要なのか、そもそも書くべきなのか」を明確に判断できていませんでした。テストを書いたほうが良いことは理解しているものの、実装工数に対して本当に価値があるのか疑問に思うこともありました。
もちろん、「品質を担保するために必要」という意見はもっともですが、FAANS iOS ではリリース前に QA 工程が存在し、網羅的に品質チェックが行われています。そのため「では、開発側でどこまでテストを書くべきなのか?」という疑問が生まれていました。
この点についてチームで議論した結果、以下の方針が明確になりました。
-
テストは必要である。
特に QA に渡す前に開発側で仕様を満たしていることは最低限保証すべき。 -
テストを書くべきケースを明確にする。
モンキーテストでは担保しにくいケース(例:ユーザー権限による UI 変化、複雑な分岐など)は積極的にテストすべき。 - 機能開発の完了にはテスト実装も含まれると考える。
これらを踏まえると、仕様を満たしているかの確認を開発段階で担保することや、アカウント切り替えを伴う挙動など手動で再現しづらいケースを自動テストで保証することの重要性が理解できました。さらに、「テストを書くところまでが機能開発である」という考え方は、自分自身にとっても大切なマインドセットでした。
次節では、この考え方をもとに、Swift Testing を用いたテストの実装例を紹介します。
Swift Testing の実装例
Swift Testing には Suite や Tag をはじめとした多くの機能がありますが、本記事では FAANS iOS の開発で最低限使用している要素に絞って紹介します。ここでは動画トリミング画面のテストを例に取り上げます。
閾値を超えたら false にするテスト
@Test
func トリミング幅が閾値を超えたらisVideoLengthValidがfalseになる() throws {
// NOTE: 閾値は60.5
let presenter = KnowhowVideoTrimmingPresenter(
videoState: .init(video: .init(duration: 60.5, path: .stub(), timeRange: 0...60.5))
)
#expect(!presenter.isVideoLengthValid.value)
presenter.timeRangeDidChange(range: 0...60.4)
#expect(presenter.isVideoLengthValid.value)
presenter.timeRangeDidChange(range: 0...60.5)
#expect(!presenter.isVideoLengthValid.value)
}
このテストでは、動画のトリミング後の長さが許容範囲を超えていないかを判定するロジックを検証しています。トリミング可能な最大長は 60.5 秒に設定されており、この値を基準に isVideoLengthValid が正しく更新されるかを確認します。
まず、初期状態として 0...60.5 の範囲を渡した場合、トリミング後の長さが閾値と同じであるため isVideoLengthValid が false になることを確認します。次に、範囲を 0...60.4 に変更すると、閾値より短いため true に変わることを期待します。最後に、再び 0...60.5 に戻した場合には、再度 false となることを確かめています。
このように、トリミング範囲が変更された際に Presenter が正しくバリデーションを行っていることを保証できます。
再生位置が終点に達したら始点に戻るテスト
@Test(.timeLimit(.minutes(1)))
func 再生位置がトリミング範囲の終点に達したら再生位置がトリミング範囲の始点に戻る() async throws {
let presenter = KnowhowVideoTrimmingPresenter(
videoState: .init(video: .init(duration: 60.0, path: .stub(), timeRange: 1...60.0))
)
var cancellables = Set<AnyCancellable>()
await withCheckedContinuation { continuation in
presenter.onRestartedVideo
.sink { _ in
#expect(presenter.playbackTime.value == 1)
continuation.resume()
}
.store(in: &cancellables)
presenter.playbackTimeDidChange(currentTime: 60.0)
}
}
このテストは、動画の再生位置がトリミング範囲の終点に達した際、再生位置を自動的に始点へ戻す機能が正しく動作するかを確認しています。
トリミング範囲は 1...60 に設定されており、再生位置が終点に到達したタイミングで Presenter 内部の処理によって再生位置が 1 秒に戻ることが期待されます。また、このタイミングで onRestartedVideo がイベントとして通知されます。
Combine を使ってこのイベントを購読し、通知を受けた瞬間の playbackTime が 1 秒であるかどうかを #expect で検証しています。非同期イベントを扱うために withCheckedContinuation を使用し、イベントが発火するまで安全に待ち合わせています。
非同期の挙動やリアルタイム性のあるロジックは手動テストでは確認が難しく、このようにテストで明確に保証できるのが Swift Testing と Combine を組み合わせる大きなメリットです。
初回利用時にチュートリアルが表示されるテスト
@Test
func トリミングの初回利用時にチュートリアルが表示される() async throws {
UserDefaults.standard.removeObject(forKey: .userDefaultKeyHasVisitedTrimmingVC)
let presenter = KnowhowVideoTrimmingPresenter(
videoState: .init(video: .init(duration: .stub(), path: .stub(), timeRange: .stub()))
)
var cancellables = Set<AnyCancellable>()
await confirmation("トリミング画面初回表示時にチュートリアルが表示される") { fulfill in
var results: [Bool] = []
presenter.onTutorialVisibilityChange
.sink { visible in
results.append(visible)
if results == [false, true] {
fulfill()
}
}
.store(in: &cancellables)
presenter.viewDidAppear()
presenter.viewDidAppear()
}
}
このテストでは、ユーザーがトリミング機能を初めて利用した際にチュートリアルが表示されるかを検証しています。初回利用かどうかは UserDefaults のフラグで管理されているため、テストではこのフラグを削除して初回利用の状態を再現しています。
onTutorialVisibilityChange はチュートリアルの表示状態を通知するストリームであり、テストではこの通知を購読して状態の変化を追跡します。初回表示時にはまず false(非表示)が通知され、その後 true(表示)が通知されるため、通知の履歴が [false, true] になった時点でテストが成功します。
画面表示を再現するために viewDidAppear() を 2 回呼び出していますが、1 回目は初回表示としてチュートリアルが表示され、2 回目は表示されない仕様となっています。この一連の挙動をテストで保証することで、初回利用時の体験を崩さないようにしています。
まとめ
いかがだったでしょうか?
本記事では、FAANS iOS チームにおけるテストへの考え方と、Swift Testing を使った実際のテスト実装例を紹介しました。今回の内容が、皆さんのテスト設計や Swift Testing の活用に少しでも役立てば幸いです。