この記事はiOS2 Advent Calendar 2017の8日目の記事です。
私事で恐縮ですが数ヶ月前に株式会社Globeeという会社のCTOに就任しまして、今はabceed analyticsという教育系アプリを開発しています。前職ではHadoop系を活用したログ収集基盤やログ解析基盤を担当していたので分野的には割と大きく変わりました。
さて、弊社のような小規模なスタートアップでは開発速度が重視されるため、自動テストがどうしても疎かになりがちです。
しかし個人的には小規模なスタートアップであっても、いけると思ったプロダクトならテストコードは書くべきだと考えています。理由はシンプルで、テストコードを書いた方が長期的に見て開発速度が上がるからです。
というわけで今回は弊社開発のアプリに自動テストを導入した時の考え方について話します。「うちはこうしている」などのアドバイス・ツッコミがありましたら是非コメントで教えてください!
テストコードを書く事で開発速度が上がる理由
私はテストコードを書く事で開発速度が上がる理由は大きく分けて以下の3つだと思っています。
コードを改修した時にバグが起きにくくなるので、バグの原因を調査する時間が少なくなり開発速度が上がる
テストコードがあることで新規メンバーが既存コードの仕様を理解しやすくなるので、チーム開発の速度が上がる
テスト可能なコードを意識する事でプロジェクト全体のコード品質が上がるので、長期的に見た時に開発速度が上がる
この辺りについては先日以下のような記事も上がっていましたので、既に認識済みの方も多いかと思います。
フロント開発における自動テスト
フロント開発はUI部分に頻繁に改修が入るため、バックエンド開発に比べてテストコードが形骸化しやすいです。ですので、個人的な意見ですがフロント開発ではテストカバレッジをそこまで追い求める必要は無いと思っています。
その辺を踏まえて、弊社では現在BDD(振る舞い駆動開発)の思想を採用しています。
BDD(振る舞い駆動開発)とは
BDDはTDDの亜種のようなもので、概要は以下です。
テストを「振る舞い」(機能的な外部仕様)の記述に特化させる、つまりユーザー目線でのテストとなる。
テストを実行可能なドキュメントとして扱い、テストの可読性を重視する。また、テストはユースケースの粒度で書かれる。つまり、テストコード=詳細設計書のような扱いとなる。
振る舞いをテストするのであってコードをテストするのではないので、カバレッジはそこまで重要視しない。
詳しく知りたい人は以下のサイトも参考にしてください。
特に重要だと思っているのが、テストを実行可能なドキュメントとして扱うという部分です。新規メンバーでもテストコードを読めば大体何をしているアプリなのか分かる、というのが理想かと思います。
SwiftではQuickがBDDテストフレームワークの代表格なので今回はそちらを採用しました。では実際にQuickを使った自動テストがどのようになるのか見てみましょう。
テストの前に
最初にも言いましたが、自動テストを導入するならコードがテスト可能な設計になっている必要があります。 超ざっくり言ってしまうと、
- ネストが深すぎるコード
- 長すぎるメソッド
- でかすぎるViewController
- 状態を持つシングルトン
があると自動テストが書きづらいです。
この辺はプロジェクトで使う設計手法を固めてしまえばある程度大丈夫かなと思います。 私はClean Architectureが一番しっくりきたのでそれを使っています。
Clean Architectureについて詳細は
などをご参照下さい。
Quickを使ったシンプルな自動テストの例
前置きが長くなってしまいましたが、Quickを使って自動テストを書いてみます。
例えば特定の問題集がお気に入り登録されているかどうかを判定する以下のようなユースケースクラスがあったとします。(簡易化のためRepository層を切らずにRealmにアクセスしています)
class IsBookFavoritedUseCase { func execute(_ id:String) -> Bool { let realm = try! Realm() let predicate = NSPredicate(format: "id_book == %@",id) if let _ = realm.objects(MyBook.self).filter(predicate).first { return true } else { return false } } }
このクラスに対してテストを書くと、以下のようになります。
class IsBookFavoriteUseCaseSpec : QuickSpec { override func spec() { let isBookFavoritedUseCase = IsBookFavoritedUseCase() describe("特定の問題集がお気に入り登録されているかどうかを判定できる") { let favoritedId = "book_favorited" let unfavoritedId = "book_unfavorited" beforeEach { //テストデータの準備 } it("指定されたIDの問題集がお気に入り登録されていた場合はtrueを返す") { let isFavorited = isBookFavoritedUseCase.execute(favoritedId) expect(isFavorited).to(equal(true)) } it("指定されたIDの問題集がお気に入り登録されていない場合はfalseを返す") { let isFavorited = isBookFavoritedUseCase.execute(unfavoritedId) expect(isFavorited).to(equal(false)) } } } }
中々可読性が高く、テストコードを読むだけでクラスの仕様が分かると思うのですがどうでしょうか?(テスト名に日本語を使うのが嫌いな人もいるかと思いますが)
ポイントはテスト名にメソッド名ではなく要求される振る舞いを記述しているという事です。これはBDDではコードをテストしているのではなく振る舞いをテストしているからです。
依存性を持つクラスの自動テスト
さて、次は現在地の緯度経度を元にユーザーが日本にいるかどうかを判定する以下のようなクラスを考えてみます。
class IsUserInJapanUseCase { func execute() -> Bool { let location = LocationGetter.shared.getUserLocation() return isInJapan(location) } private fun isInJapan(location:CLLocation?) -> Bool { //do some check process } }
このクラスは位置情報取得処理がLocationGetter
(架空のクラスです)に依存しているため、このままでは単体テストを行うことができません。
このような状態を実装に依存していると呼びます。実装への依存を避けるためのデザインパターンがDI(依存性注入)です。
DIで実装依存を避ける
サービスクラスをインタフェース経由で使うようにし、実体を外部から渡せるようにするのがDIです。 詳しく知りたい方は以下の記事が良いかもしれません。
今回の例だと、LocationGetterは現在地を取得し返すという振る舞いを持つクラスです。なので、まずはprotocolとしてその振る舞いを定義します。
protocol LocationAccessable { func getUserLocation() -> CLLocation? }
次にLocationGetter
はLocationAccessable
を継承するようにし、IsUserInJapanUseCase
もLocationAccessable
経由で位置情報の取得を行うようにします。
class LocationGetter: LocationAccessable { func getUserLocation() -> CLLocation? { //CLLocationManagerを用いた実際の位置情報取得処理を実装 } }
class IsUserInJapanUseCase { var locationAccessor:LocationAccessable func execute() -> Bool { let location = locationAccessor.getUserLocation() return isInJapan(location) } private fun isInJapan(location:CLLocation?) -> Bool { //do some check process } }
以上のようにする事でIsUserInJapanUseCase
は位置情報取得の実装クラスを外部から渡せる実装に依存しないクラスとなり、単体でテスト可能となりました。では実際にテストを行ってみましょう。
テストのためにLocationAccessable
プロトコルを継承したモッククラスを作成します。
class FakeLocationGetter: LocationAccessable { var fakeLocation = CLLocation(latitude: 35.666401, longitude: 139.754207) func getUserLocation() -> CLLocation? { return fakeLocation } }
このモッククラスは、fakeLocation
の値を変える事で簡単に返却値を切り替える事ができます。このクラスをテスト実行時にIsUserInJapanUseCase
に以下のように注入します。
class IsUserInJapanUseCaseSpec : QuickSpec { override func spec() { let isUserInJapanUseCase = IsUserInJapanUseCase() let fakeAccessor = FakeLocationGetter() describe("現在地の緯度経度を元にユーザーが日本にいるかどうかを判定できる") { beforeEach { isUserInJapanUseCase.locationAccessor = fakeAccessor } it("日本の緯度経度にはtrueを返す") { fakeAccessor.fakeLocation = CLLocation(latitude: 35.666401, longitude: 139.754207) let isInJapan = isUserInJapanUseCase.execute() expect(isInJapan).to(equal(true)) } it("日本以外の緯度経度にはfalseを返す") { fakeAccessor.fakeLocation = CLLocation(latitude: 46.532219, longitude: 116.460937) let isInJapan = isUserInJapanUseCase.execute() expect(isInJapan).to(equal(false)) } } } }
このようにDIとモッククラスを活用することで、依存性を持つクラスでもテストを行うことができます。
今回は位置情報取得部分を例としましたが、API通信・ポップアップ表示・ローカル通知などもこの方法でテストを行うことができます。
詳しくは以下が参考になるかと思います。
まとめ
自動テストの導入には若干のコストがかかりますが、長期的に見れば絶対にペイすると思います。小規模なスタートアップであっても、このプロダクトはいけると思ったら積極的に自動テストを導入しましょう!
なお、弊社では現在エンジニアを募集中です。こんな長い記事を最後まで読んでくださった方は多分技術が好きな方だと思います。もし弊社に興味ありましたら是非一度お話しさせてください!