NewsPicks iOSアプリエンジニアの金子です。
NewsPicksアプリはニュース記事やコメントの投稿・閲覧といった基本的な機能に加えて、動画コンテンツや広告の表示、有料会員機能、法人向け機能など、様々な機能を持った規模の大きなアプリです。
このため社内から上がってくる機能開発要求の数も非常に多く、ほぼ毎週、多いときは週に2、3回アップデートをリリースすることもあります。
NewsPicksではデータドリブンで機能開発・改善を行っており、これのベースとなるのがアプリから送信されるログです。信頼できるログがあって初めて、データドリブンな開発が成立します。
また、NewsPicksでは広告事業を行っており、広告クライアントに対して広告のインプレッション数やCTR等のレポーティングをする必要があるのですが、これらもアプリが送信するログに基づいているので、ログに間違いがあってはいけません。
このように、NewsPicksというサービスではログが非常に重要な役割を果たしています。にもかかわらず、これまではログの正しさを担保する仕組みがなかったため、機能改善の影響でログのデグレが発生しても気づきにくい状態になっていました。
これから益々成長していくNewsPicksにおいて、ログの正しさを担保する仕組みの構築は必要不可欠です。
本記事では、最近実装したiOSアプリのログの正しさを担保する仕組みについてご紹介します。
ログの正しさを担保する仕組みをどう作るか?
方法としては3つ考えられるかなと思います。
- 手動でテストを行う
- ユニットテストを実装する
- E2Eテストを実装する
ログの数は膨大なので、リリースのたびに毎回手動でテストするというのは現実的ではありません(これまでも、リリース対象の機能のログ以外はあまりテストできていませんでした)。
ユニットテストを実装するという方法もありますが、年季の入った巨大なアプリに今からユニットテストを導入するのは工数的にもかなり大変というのと、後述するログの仕様的にそもそもユニットテストが適していないという事情もあるため、こちらも採用しませんでした。
最終的に、E2Eテストを実装することにしました。E2Eテストであれば既存のコードの複雑さに影響されずに実装ができるのと、例えば以下のような仕様のログのテストも実装しやすいというメリットがあります。
- ニュースフィードで記事が画面に表示されたら送信する。ただし、フィードを更新するまでは同じ記事に対してログは一度のみ送信する。
- 記事ページが表示されたら送信する。ただし、記事ページを何度表示してもログは一度のみ送信する。
複数画面をまたいで送信管理をしているログのテストをユニットテストで実装するのは結構大変です。E2Eテストなら比較的容易に実装が可能です。
※以降、「ログE2Eテスト」と呼称します
ログE2Eテストの大まかな仕組み
図にするとこんな感じです。
iOSのUIテストでは、テストを実行するとシミュレーターもしくは実機にアプリ本体とテストランナーアプリというものがインストールされ、テストランナーアプリはアプリ本体に対して画面操作などの指示を出すという構成になっています。
テストコードにはテスト対象のログが送信されるときの画面操作を実装しています。例えば、ニュースフィードで記事を表示する、記事をタップする、といった操作です。
ログ送信のエンドポイントはスタブ化していて、テスト時のログが本番環境に送信されないようにしています。さらに、ログリクエストの内容をあとで検証できるように、スタブ化する過程でリクエスト内容をアプリのメモリに保存しています。
画面操作が一通り終わったら、ログが想定通り送信されたかどうかを検証します。このあたりの実装方法については後述します。
ちなみに、Androidでも同じような仕組みでログE2Eテストを実装中なので、完成したらAndroidエンジニアのメンバーに記事にしてもらう予定です。
ログE2Eテストの実装の詳細
大まかな仕組みについては説明したので、ここからは具体的にどのように実装したかを説明していきます。
ログ送信エンドポイントのスタブ化とリクエストの保存
スタブライブラリはOHHTTPStubsを使用しています。
スタブ化処理は一つのクラスにまとめていて、LogApiStub.stubLogs()
を本体アプリ側で呼び出すことでスタブ化できます。
class LogApiStub { private typealias LogParams = [String: String] // [1] private static var logRequests = [String: [LogParams]]() static func stubLogs() { // [2] stub(condition: isNPApi && pathMatches("/logs/.*")) { req in // [3] let event = req.url!.lastPathComponent let components = URLComponents(string: req.url!.absoluteString)! let params = components.queryItems?.dictionary ?? [:] if self.logRequests[event] == nil { self.logRequests[event] = [params] } else { self.logRequests[event]?.append(params) } return HTTPStubsResponse(data: "{}".data(using: .utf8)!, statusCode: 200, headers: nil) } } ...
[1]
logRequests
は送信されたログのリクエスト内容を保持しておくためのプロパティです。後述しますが、最終的にはJSON化してテストコード側から参照します。
[2]
stub()
はOHHTTPSTubsが提供するメソッドです。APIのURLを指定することで、そのURLがリクエストされたときだけHTTP通信がインターセプトされ、クロージャが実行されます。
[3]
クロージャ内ではreq(URLRequest)
からリクエストの内容を取り出して、logRequests
プロパティに追加しています。
スタブ化処理はAppDelegateで呼び出しています。リリース用のビルドでスタブ化されてしまうとまずいので、#if DEBUG
でデバッグビルド時のみスタブ化されるようにしています(実際には他にも条件がありますが、簡易化して記載しています)。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { #if DEBUG LogApiStub.stubApis() #endif ...
ニュースフィードAPIのスタブ化
ログ送信エンドポイントに加えて、ニュースフィードAPIのスタブ化もしています。
ニュースフィードの内容はアクセスするたびに変わるので、これだとテストのたびに送信されるログの内容も変わってしまい検証ができません。
そこで、ニュースフィードのテスト用のレスポンスのJSONデータをテスト時に受け取り、これをスタブのレスポンスとして設定します。
// テストコード // [1] let feedsHomeStubJson = FileHelper.fileTextInTestBundle(fileName: "feeds_home.json") app.launchEnvironment["feedsHomeStubJson"] = feedsHomeStubJson // アプリ本体コード private static func stubFeeds() { // [2] if let feedsHomeStubJson = ProcessInfo.processInfo.environment["feedsHomeStubJson"] { stub(condition: isNPApi && pathMatches("/feeds")) { req in // [3] let data = feedsHomeStubJson.data(using: .utf8)! return HTTPStubsResponse(data: data, statusCode: 200, headers: nil) } } ...
[1]
テストバンドルからJSONファイルをロードし、テスト実行時環境変数(app.launchEnvironment
)としてJSON文字列を追加します。
[2]
環境変数はProcessInfo
というクラスを通じて受け取ることができます。feedsHomeStubJson
が設定されていたらスタブ化します。
[3]
受け取ったデータは文字列なので、Data化してスタブレスポンスとして設定します。
テストフィクスチャの準備
テスト対象となるログの数は膨大で、かつ同じログイベントでもパラメタが違うケースなどもあり、これらの期待値データをテストコードに直接書いてしまうと可読性も悪くメンテナンスも大変になります。
さらに、Androidアプリでも同様のログテストを実装しているので、アプリのリポジトリとは別のテストフィクスチャ用リポジトリを用意し、このリポジトリをサブモジュールとして参照する方法を採ることにしました。
サブモジュールのフォルダをXcodeのプロジェクトに追加するときは、Create folder references
を選択します。
UIテストターゲットのBuild PhasesのCopy Bundle Resoucesでこのフォルダが指定されていることを確認します。
フォルダ参照の形をとることで、サブモジュールをpullしてファイルが追加されたときでも、Xcode側では何もせずとも追加されたファイルがテストバンドルに含まれるようになります。
(ちなみに、iOSとAndroidで共通で参照するファイル群を集めたリポジトリはtokyo-olympic
と命名されていますw)
ログリクエストの検証方法
アプリ本体に保存されたログリクエストデータをテストコード側でどう参照するかが一つ大きな課題でした。
iOSのファイルシステムでは基本的にアプリ同士でファイルを参照し合うことはできません。
どうしようか悩んでいたときにこの記事を見つけ、シミュレーターで実行したときにファイル共有する方法はうまくいきました。しかし、App Groupを使って実機でファイル共有する方法は色々試行錯誤してみたのですが、署名周りでうまくいきませんでした。この記事が書かれたのは2017年なので、その間に仕組みが変わったのかもしれません。もしくは自分のやり方が悪いのかもしれないです。
ログE2EテストはFirebase Test Labを使って実機で行うため、このままでは前に進めません。
そこで、あまり格好良くはないですが、アプリ本体側でログリクエストデータを表示するだけの画面を用意し、その画面のラベルの値をテストコード側で読み取って検証するようにしました。
画面にログリクエストデータのJSONが表示されたら、テストコード側でラベルの値を読み取ってJSONオブジェクトとして取り出します。最後に期待値と比較して内容が一致していればテストにパスします。
let jsonString = app.staticTexts[ID.logRequestsLabel].label let logRequests = try! JSONSerialization.jsonObject(with: jsonString.data(using: .utf8)!, options: []) as! [String: [LogParams]] expect(logRequests).to(equal(expected))
Firebase Test Labでログテストを実行する
NewsPicksアプリではCI/CD環境としてBitriseを使用しています。Bitriseではまだβ版ではありますが、Firebase Test Labでテストを実行する仕組みが用意されています。
基本的には上記のリンクに記載の通りステップを追加していけば簡単に設定することができます。β版ではありますが、今のところ安定して動作しているので問題なく使用できると思います。
Firebase Test Labを使ったログE2Eテストは、Pull Requestが作成・更新されるたびに実行されるようにしています。ただし、今後テスト対象となるログが増えていくとテストの実行時間も長くなっていくので、トリガーについては検討の余地がありそうです。
おわりに
データドリブンな開発を進めていく上で欠かせないアプリログ、その正しさを担保する仕組みについてご紹介しました。
まだ運用を始めたばかりなので課題は出てきていませんが、今後ログが増えていったときにテストの実行時間の長さが課題になったり、アプリ本体の機能追加・改善によってテストが壊れたりする可能性が考えられます。このような課題に対応できるよう、改善を進めていきます。
NewsPicksではiOSアプリエンジニアも積極採用中です。本記事を読んで「自分ならもっと良いものにできる!」と思った方、ぜひぜひご応募お待ちしております!