こんにちは。Yahoo!広告SDKチームの高木です。
みなさんが開発するプロダクトでは、しっかりとテストを書いているでしょうか?自信を持ってYESと言えるプロダクトもあれば、そうではないプロダクトもあるかと思います。私が所属する広告SDKチームでは、自動テストを書くモチベーションは強くある一方で、実装はあまりされていないという状況でした。
本記事では、そのようなチームにテストを書く文化が根付いていった過程についてお話しします。
Yahoo!広告SDKとは
Yahoo!広告SDKとは、Yahoo!広告のディスプレイ広告をネイティブアプリへ簡単に表示できる、Yahoo!サービス内およびパートナーネットワークのアプリへ配布しているSDKです。広告表示に必要なデータをリクエスト〜取得したり、広告枠のUIを提供する機能を持っています。
Yahoo!広告SDKは事故発生時の影響金額が大きい性質上、安定稼働が強く求められます。自動テストを充実させて品質を担保することが大切ですが、実際は社内QAチームによる手動テストに頼っているところが多々ありました。
Unitテストは書きづらい
私たちが開発する広告SDKでは、Unitテストはあるもののあまり充実しているとは言えませんでした。
度々Unitテストを拡充しようという話が上がっていましたが、既存コードの依存関係がめちゃくちゃだったり、アーキテクチャがイマイチだったりなどの理由でテストが非常に書きづらい状況でした。Unitテストを拡充するには、既存コードをテスタブルな形にリファクタリングしていくことが必要だったため、今回は見送りました。
E2Eテストだと運用コスト大
Unitテストが拡充しづらいなら、いっそのことE2Eテストで全ての機能を確認すればいいのでは?(なんかイケてそうだし)と考えました。ネイティブアプリでは、AppiumというOSSを使うことでiOSとAndroidの両OSでE2Eテストを実装できます。
しかしE2Eテストはテストの守備範囲が広大で実行に数時間かかってしまうことに加え、UIが変化し続けることによってテストの運用コストがとても大きくなってしまいます。またチームへの浸透を考えると、Appiumの学習コストも発生するため、E2Eテストを定着させることは難しいと判断しました。
現実的な落としどころ:機能単位でのテスト
そこで私たちは広告SDKの機能単位でのテストを導入しました。
機能とは、外部と関係しSDKに責務のある機能のことを指します。具体的には次のようなものです。
- サーバーリクエスト(レスポンスはサーバー側の責務なので範囲外)
- アプリに提供するインターフェース
- アプリに返すデータやView
- イベントログ収集サーバーへのリクエスト
図で表すと赤で示した箇所が広告SDKの機能です。
このような機能単位のテストを実装することで、品質を担保しながらリファクタリングを行うことができるようになり、結果的にUnitテストを拡充する足がかりになります。また動く仕様書としての機能を兼ねることもできます。
実装前準備
実装を始める前に広告SDKの機能を全て書き出し、仕様書を兼ねたテストケースを作成しました。かなり大変な作業ですが、テストケースは自動テストの指針になるだけでなく、PMやデザイナーの確認用としての役割も兼ねることができます。
実装
実装の詳細については割愛しますが、おおまかに以下のような実装を行いました。
- 通信やアプリとのインターフェースなどのUIが関係しないテスト → XCTest やインストゥルメント化単体テストを使ったテスト
- アプリにViewを返す機能のうち、表示だけを確認するテスト → スナップショットテスト(Visual Regression Test)
- ビューアブルインプレッションなどのUI操作を必要とするテスト → XCUITest や Espresso によるインストゥルメント化中規模テストを使ったテスト
実装時の注意点
3つのことに注意してテストを実装しました。
- 仕様書とテストコードの対応を明確にする
- 1テスト1検証
- できるだけ共通化は行わない
それぞれについて説明します。
1. 仕様書とテストコードの対応を明確にする
例えば「サーバーリクエスト・AAAな条件の場合・ヘッダーにBBBが付与されること」という仕様がある場合は以下のように対応を明確にしたテスト名にします。
以下iOSのXCTestの例です。
func test_サーバーリクエスト_AAAな条件の場合_ヘッダーにBBBが付与されること() throws {
// ...
}
このようにすることで仕様書が変更された時にどの自動テストを直すべきか明確になります。当たり前のように思えますが、仕様書とテストの関連はテストを増やしていく上でとても大切になっていきます。
2. 1テスト1検証
1テストに複数の検証項目がある場合、テストが失敗した際に原因が見づらくなることがあります。仮に同じ条件であっても実行時間を短縮しようと1つのテストにまとめるのではなく、テストを分けてそれぞれの検証項目を1つにした方がシンプルで守備範囲が明確なテストになります。
以下iOSのXCUITestの例です。
Bad
func test_ViewCCC_タップ() throws {
app.otherElements["ViewCCC"].tap()
// DDDという文字列が表示されていること
XCTAssertTrue(app.staticTexts["DDD"].waitForExistence(timeout: 1))
// EEEというViewが表示されていること
XCTAssertTrue(app.otherElements["EEE"].waitForExistence(timeout: 1))
}
Good
func test_ViewCCC_タップ_文字列DDDが表示されていること() throws {
app.otherElements["ViewCCC"].tap()
XCTAssertTrue(app.staticTexts["DDD"].waitForExistence(timeout: 1))
}
func test_ViewCCC_タップ_ViewEEEが表示されていること() throws {
app.otherElements["ViewCCC"].tap()
XCTAssertTrue(app.staticTexts["EEE"].waitForExistence(timeout: 1))
}
3. できるだけ共通化は行わない
テストフレームワークには各テストケース実行前後に共通処理を書くことができるメソッドが用意されていると思います。これらのメソッドは確かに便利なのですが、テストを増やしていく際に共通処理が邪魔になることがあります。明らかに必要な共通処理以外は、各テストの中で実装する方が他テストに影響しないテストを書くことができます。
以下AndroidのJUnitの例です。
Bad
@Before
// FFF.jsonというローカル定義のjsonを返すサーバーを起動
fun setup() {
val server = MockServer()
server.setDefaultDispatcher("FFF.json")
server.start()
}
@Test
fun test1() {
// ...
assertThat().isEqualTo()
}
Good
@Test
fun test1() {
val server = MockServer()
server.setDefaultDispatcher("FFF.json")
server.start()
// ...
assertThat().isEqualTo()
}
// 後々違うレスポンスのjsonでテストしたくなるかもしれない
@Test
fun test2() {
val server = MockServer()
server.setDefaultDispatcher("GGG.json")
server.start()
// ...
assertThat().isEqualTo()
}
Tips: テスト実装に便利なOSSや拡張機能
最後にテスト実装で役に立ったOSSや拡張機能を紹介します。(各リンク先は外部サイト)
iOS
- Swifter
- Swift製ローカルサーバーを起動するためのOSSです。
- OHHTTPStubs
- 通信リクエストをスタブ化するためのOSSです。Swifterは localhost でサーバーを起動しますが、OHHTTPStubsは localhost ではないリクエストもキャプチャしてstubに変換できます。
- SnapshotTesting
- スナップショットテスト(Visual Regression Test)を可能にするOSSです。ViewControllerやView、画像などさまざまなフォーマットを対象にテストが可能です。
Android
- MockWebServer
- ローカルサーバーを起動するためのOSSです。
- Awaitility
- 非同期処理を含むテストを簡潔で読みやすい方法で表現できるOSSです。「ある要素が非同期処理によって変化するまで待つ」といったコードを簡単に書くことができます。
- Espresso-Intents
- テスト対象のアプリから送信されるインテントの検証とスタブ化をサポートする拡張機能です。例えばGoogle ChromeでWebページを開くかどうかをテストしたい時、ChromeでWebページを開くこと自体をスタブしてテストができます。
運用してみて
機能単位のテスト導入前は、そもそもテストを書くかどうか、書くならどのタイミングで書くのかが曖昧になっていました。導入後は仕様更新のタイミングで仕様書とテストコードも更新するという明確な基準を持つことができるようになりました。また、できる限り独立したテストを書くように設計したことで、拡張性の高いテストにすることができました。これらによってチームメンバーが容易にテストを書くことができるようになり、テストを書く文化がチームに定着したように感じます。
一方で課題もあります。
機能単位のテストは、Unitテストとは異なり先にテストを実装することが難しいため、開発が一区切りしたタイミングでテストを実装する必要があります。今後はテストの実装を踏まえた見積りをすることになるため、以前よりも工数が少し増えてしまいます。また現状では仕様書とテストに強制的な関連付けがないため、片方を修正してもう片方を修正し忘れた、という状況が起こり得ると思います。こちらは今のところ運用でカバーするしかなさそうですが、改善策を検討中です。
こちらの記事のご感想を聞かせください。
- 学びがある
- わかりやすい
- 新しい視点
ご感想ありがとうございました
- 高木 健三朗
- Yahoo!広告SDKエンジニア
- Yahoo!広告のiOS / Android SDKの開発を担当しています。 埼玉県が好きです。