Android
test
Kotlin
Spoon

AndroidアプリのゆるいUIテストをSpoonで実現する

この記事は リクルートライフスタイル Advent Calendar 2017 16日目の記事です。

現在ホットペッパービューティーのAndroidアプリをKotlinで開発している@oxsoftです。
今回は、ホットペッパービューティーで実際に導入してみたばかりの、Spoonを使ったゆるいUIテストについて書いてみたいと思います。

そもそも「ゆるいUIテスト」って何?

テストは通常、Arrange Act Assertの3ステップを基本的に行うと思いますが、UIテストでそれをキッチリやるためには、以下の2つの課題があると思います。

  1. ボタンAをタップした後、テキストAが表示されることをassertして、ボタンBをタップした後…みたいなコードを書く
  2. UIが変更されるたびにそれをメンテナンスする

1については、Espresso Test Recorderを使うことで、ある程度容易にできると思います。
https://developer.android.com/studio/test/espresso-test-recorder.html

2については、再びEspresso Test Recorderを使うという方法もあると思いますが、テストの同一性を保証することが難しいことと、UIの変更は頻繁に行われるという事情から、中々コスパが悪くなってします。

また、自動テストである以上、「何か見た目が崩れている」みたいな問題の発見が検出しにくいという問題もあると思います。

以上の理由から、結局書かないという選択をするプロダクトも多いのではないでしょうか。

そこで今回は、「ゆるいUIテスト」ということで、Spoonを使って「事前条件をセットした後、画面を起動してスクショを撮る」ということだけをひたすら行うという風にしました。すなわち、assertionは最低限しか書かずに、生成された画像一覧を人間が見て、判断するという流れです。後述しますが、差分が発生したことだけは自動で検知できるようにして、人間の負荷を減らす工夫はしています。

Spoonとは?

https://github.com/square/spoon
http://square.github.io/spoon/

スクリーンショットの撮影とレポートの作成をやってくれるライブラリです。Spoon自体は結構前から存在していて、日本語の記事もちらほらありますが、今回の記事では、InstrumentationTestの書き方や、実際に導入する上で苦労した点や工夫した点を書いていきたいと思います :muscle:

導入方法

本記事執筆時点では、Android Studio 3.0(com.android.tools.build:gradle:3.0.0)だとSpoonがうまく動かなかったので、以下では2系の書き方で説明していきます :bow:

Spoon自体は、jarファイルをjavaコマンドで実行するものですが、Gradleプラグインがあるので、そちらも使います。
https://github.com/stanfy/spoon-gradle-plugin

これを導入することで、以下のコマンドでテストが実行できるようになります。

./gradlew spoon

gradleファイルの書き方は大体以下のようなものになると思います。

app/build.gradle
// ...

dependencies {
    // ...
    androidTestCompile 'com.android.support:support-annotations:27.0.2' // 本体と合わせる
    androidTestCompile 'com.android.support.test:runner:1.0.1'
    androidTestCompile 'com.android.support.test.espresso:espresso-core:3.0.1'
    androidTestCompile 'com.squareup.spoon:spoon-client:1.6.4'
}

buildscript {
    repositories {
        mavenCentral()
    }

    dependencies {
        classpath 'com.stanfy.spoon:spoon-gradle-plugin:1.2.2'
    }
}

apply plugin: 'spoon'

spoon {
    debug = true
    noAnimations = true
    grantAllPermissions = true
}

サポートアノテーションをわざわざ指定している理由は、テストランナーがサポートアノテーションに依存していて、本体コードとテストコードで違うとビルドに失敗してしまうからです。

+--- com.android.support.test:runner:1.0.1
|    +--- com.android.support:support-annotations:25.4.0

Conflict with dependency 'com.android.support:support-annotations' in project ':app'. Resolved versions for app (27.0.2) and test app (25.4.0) differ. See http://g.co/androidstudio/app-test-app-conflict for details.

また、Spoonは端末のストレージを一時的に使うため、パーミッションが必要になります。

AndroidManifest.xml
<manifest>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <application />
</manifest>

アプリ本体がこのパーミッションを必要としない場合は、テスト用のAndroidManifestに↑を書くようにしてください。

InstrumentationTestを書く

遠い昔はActivityInstrumentationTestCase2を継承して書いていたと思いますが、今は以下のようにActivityTestRuleを用いて書くことができます。

MainActivityTest.kt
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
    // 起動用のcontextは以下のようにして取得する
    pivate val context: Context = InstrumentationRegistry.getTargetContext()

    @Rule
    @JvmField // テストランナー(java)から正しく見えるように
    // 3つ目の引数をfalseにすることで、自動でActivityを起動しないようにする(※1)
    val activityRule = ActivityTestRule(MainActivity::class.java, false, false)

    @Test
    fun mainActivity() {
        // 事前条件を準備する

        // 対象のActivityを起動する(※2)
        activityRule.launchActivity(MainActivity.intent(context))
        // スクリーンショットを「launch」というタグで撮影する
        Spoon.screenshot(activityRule.activity, "launch")
    }
}

(※1)で、3つ目の引数(launchActivity)をfalseにすることで、Activityが自動で起動しないようになり、ActivityRule#launchActivityで明示的にActivityを起動させることができるようになります。
これによって、APIレスポンスやpreferenceの値などの事前条件を整えてから起動することが出来ます。

(※2)では、本体コードに以下のようなintent関数が用意されていることを想定しています。

MainActivity.kt
class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    companion object {
        fun intent(context: Context) = Intent(context, MainActivity::class.java)
    }
}

端末かエミュレータを接続した状態で、以下のコマンドを打ち込めば、スクリーンショットを撮影できるはずです。

./gradlew spoon

スクリーンショットは以下のパスに出力されます。(Flavorによって多少異なります)

open app/build/spoon/debug/index.html

以下のようなページを見ることができます。

Activityを起動した後、「操作してスクショ撮って」っていうのを繰り返していくともっと画像が表示されますが、今回はメンテナンスコストを考えて、「事前条件セット→起動→スクショ撮影」だけをするテストケースをたくさん用意することにしました。
(1つのActivityでほとんどの機能を提供するようなアプリの場合は、この方法だとキツイかもしれないです)

一部のクラスをモックする

実際にCI上で運用するにはテストを安定させる必要がありますが、このままだとAPIなどがモックされていません。そこで、アプリケーションクラスを差し替えるなどして、モックを注入する必要があります。アプリケーションクラスから、画面表示に関連する全てのモックを差し込めるかどうかは、本体コードがDagger2などを使ってちゃんとDIされていることが必要条件となります。

アプリケーションを差し替える

本体アプリケーションに差し替え用の関数を用意する方法もありますが、今回はアプリケーションごと差し替えて、継承による差し替えを行います。
差し替え用のアプリケーション(今回はAndroidTestApplication)を用意したら、アプリケーションを差し替えるために、自前のテストランナーを用意します。

CustomInstrumentationTestRunner.kt
class CustomInstrumentationTestRunner : AndroidJUnitRunner() {
    override fun newApplication(cl: ClassLoader?, className: String?, context: Context?): Application {
        return Instrumentation.newApplication(AndroidTestApplication::class.java, context)
    }
}

そうしたら、以下のようにbuild.gradleを書き換えます。パッケージ名などは適宜読み替えてください。

app/build.gradle
android {
    // ...
    defaultConfig {
        // ...
        testInstrumentationRunner "com.oxsoft.spoon.CustomInstrumentationTestRunner"
    }
}

APIをモックする

今回は、MockWebServerを使うことにしました。
https://github.com/square/okhttp

app/build.gradle
dependencies {
    // ...
    androidTestCompile 'com.squareup.okhttp3:mockwebserver:3.9.1'
}

MockWebServerは起動に多少の時間がかかるので、Applicationクラスで1つ起動するようにしました。

AndroidTestApplication.kt
class AndroidTestApplication : Application() {
    private val dispatcher = CustomDispatcher() // ※1

    override fun onCreate() {
        super.onCreate()

        // モックサーバのスタートはメインスレッドでできないため、バックグラウンドスレッドで行う
        Completable.fromAction {
            val server = MockWebServer()
            server.setDispatcher(dispatcher)
            server.start(SERVER_PORT)
        }.subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe()
    }

    companion object {
        private const val SERVER_PORT = 12345
    }
}

(※1)のCustomDispatcherは、okhttp3.mockwebserver.Dispatcherを継承したクラスで、リクエストに応じて、あらかじめ用意したレスポンス(例えばassetsに用意したファイル)を返すようにします。

また、ここではネットワークアクセスのパーミッションが必要です。
(APIをモックするようなアプリは当然書いていると思いますが)

AndroidManifest.xml
<manifest>
    <uses-permission android:name="android.permission.INTERNET" />
    <application />
</manifest>

http://localhost:12345/にアクセスすると、CustomDispatcherでレスポンスを返すことができるようになるので、RetrofitbaseUrlを差し替えるなどすれば、APIをモックすることができるようになります。

その他のモック

その他に、テストを安定させるために、時刻をモックする必要があるケースもあると思います。時刻をモックできるようにするためには、本体コードで時刻を提供するクラスをDIで用意しておき、テスト時は差し替えるなどの処理が必要ですが、本体コードの設計の問題なので、ここではこれ以上触れません。

また、データベースやpreferenceの値もテストケース間で共有してしまうため、Activityテストのベースクラスなどで、適宜初期化する必要があると思います。

BaseActivityTest.kt
abstract class BaseActivityTest {
    @Before
    fun setUp() {
        // preferenceやDBのクリア
    }
}

テスト結果を見やすくする

ここまで一通りSpoonを使ったテストができるようになりますが、運用していく過程で以下のような問題がありました。

  1. クラス名やメソッド名、タグに日本語を使うことが出来ない
  2. 毎回全部の画像を見るのが大変(差分だけ見たい)

そこで、index.htmlと一緒に出力されるファイルであるresult.jsonを使って、独自に整形しています。

Markdown化

この辺はただのマッピングであることと、出力結果の好みが分かれるところなので、詳細は省きますが、以下のようなタスクを追加しています。

app/spoon.gradle
import groovy.json.JsonSlurper

task convertSpoonResult doLast {
    def path = "..." // result.jsonのパス
    def text = file(path).text
    def root = new JsonSlurper().parseText(text)
    def output = "..." // 出力先のパス

    // jsonファイルをMarkdownなどに変換
    file(outout).text = ... 
}

この時に、画像ファイル名に入っているタイムスタンプを取り除き、GitHubに自動でコミット・プッシュするようにしておけば、GitHub上で差分が見られるようになります :tada:

ただし、少しでも差分が出てしまうとコミットされてしまうため、差分を極力減らす工夫が必要となります。

日本語化

日本語を使うための苦肉の策として、Androidのログを利用しています(力技)

具体的には、まず画面名とテストケース名のアノテーションを用意して、各画面に説明を書きます。

ScreenName.kt
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
annotation class ScreenName(val name: String)
CaseName.kt
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class CaseName(val name: String)
MainActivityTest.kt
@ScreenName("メインの画面")
class MainActivityTest : BaseActivityTest() {
    @Test
    @CaseName("通常起動")
    fun mainActivity() {
        // ...
    }
}

そしてそれを、TestWatcherを使ってログに出力します。

BaseActivityTest.kt
abstract class BaseActivityTest {
    @Rule
    @JvmField
    val watcher: TestRule = object : TestWatcher() {
        override fun starting(description: Description) {
            val caseName = description.getAnnotation(CaseName::class.java)?.name
            if (screenName != null && caseName != null) {
                Log.d("spoon description", "$screenName:::$caseName")
            }
        }
    }
}

こうすることで、result.jsonにこのログも出力されるので、上述の変換タスク内で、該当するログから画面名とテストケース名を引っ張ってくることができるようになります。

最終的にホットペッパービューティーでは大体以下のような感じになってます。
(画面は開発中のものです)

おわりに

まだまだ作ったばかりで手探りの状態ですが、一応出来ました。
もっと良い方法などをご存じの方は教えていただけるとありがたいです :bow:

それでは良いお年を :christmas_tree: :bamboo: