Tabelog Tech Blog

食べログの開発者による技術ブログです

MockWebServerを使ったAndroidアプリのUIテスト

この記事は 食べログアドベントカレンダー2023 の21日目の記事です。

こんにちは。食べログAndroidアプリの保守を担当している米山です。
最近はテスト工数の削減に向け、UIテストの自動化を進めています。
今回の記事ではMockWebServerを使ったUIテストの実装方法についてご紹介します。

目次

はじめに

Androidアプリ開発でUIテストを実装する方法は様々ありますが、MockWebServerを使いインストルメンテーション テストでUIテストを実装することも可能です。

MockWebServerはSquare社が開発しているHTTP Clientライブラリ「OkHttp」のテスト用ライブラリです。

MockWebServerを使えば、ローカル環境でAPIのレスポンスをダミーデータに差し替えることができます。そのため、比較的低コストでUIテストを実装できます。また、安定したレスポンスを常に返すことができるのでスクリーンショットテストにも役立つでしょう。

MockWebServerの導入方法

今回はインストルメンテーション テストでのみMockWebServerを使用するので、build.gradleにandroidTestImplementationでテスト用ライブラリを追加します。

dependencies {
    val okhttpVersion = 4.12.0 // 2023/11時点での最新バージョン
    implementation("com.squareup.okhttp3:logging-interceptor:$okhttpVersion")
    androidTestImplementation("com.squareup.okhttp3:mockwebserver:$okhttpVersion")
}

実装方法について

ここからは例を交えて実装方法についてご紹介します。

ラッパークラスの作成

以下のようなMockWebServerのラッパークラスを作成すると便利です。

class MockServerService {

    private val mockServer = MockWebServer()

    fun setup(dispatcher: Dispatcher) {
        mockServer.dispatcher = dispatcher
    }

    fun start() {
        Thread {
            // 今回の例は、API_DOMAIN_URLがグローバル変数の場合
            // プロダクトコード側で Retrofit を使っているなら以下のように参照されている想定
            // Retrofit.Builder().baseUrl(API_DOMAIN_URL)...
            API_DOMAIN_URL = mockServer.url("/").toString()
        }.start()
        // MockWebServerが立ち上がるまで、余裕を持って1000ms待機する
        // 実際には、PINGのような疎通確認用の処理を追加するのが良い
        Thread.sleep(1000L)
    }

    fun finish() {
        mockServer.shutdown()
    }
}
  • setupメソッドでは、後述するモックサーバーのリクエストハンドラであるDispacherを設定しています。
  • startメソッドでは、プロダクトコード側で参照しているURLをモックサーバーのURL(localhost:自動採番されるポート番号)に変更しています。これにより、テスト実行時にモックサーバーへAPIリクエストが送信されるようになります。上の例は、プロダクトコード側のAPI通信処理で参照しているURLがグローバル変数で定義されている場合です。URLをグローバル変数で定義していない場合もあるので、URLの差し替え方法は、プロジェクトの環境に適した方法で行ってください。

ダミーAPIの作成

以下のようなインターフェースを作成すると、リクエストのハンドリング処理をスッキリ書けます。

interface MockApi {

    fun url(): String

    fun response(): MockResponse
}

そして、差し替えが必要なAPIの分だけこのインターフェースを実装したクラスを作成します。
例えば、https://example.com/newsというようなAPIをダミーに差し替える場合は以下のように実装します。
※ここでのhttps://example.com/は、上述のMockServerServiceクラスのstartメソッド内でモックサーバーのURLに差し変わります。

class News(type: NewsType) : MockApi {

    enum NewsType {
        MORNING,
        MIDDAY,
        EVENING,
    }

    override fun url(): String {
        return "/news"
    }

    override fun response(): MockResponse {
        return MockResponse().also {
            it.setResponseCode(200)
            when(type) {
                MORNING -> it.setBody(MorningNewsResult.response)
                MIDDAY -> it.setBody(MiddayNewsResult.response)
                EVENING -> it.setBody(EveningNewsResult.response)
            }
        }
    }
}

responseメソッドでは、以下のようなダミーデータを返します。今回の例では、APIのデータがJson形式であることを前提としています。(ダミーデータを簡単に作る方法は後述します)

object MorningNewsResult {
    val response = """
        {
            "date": "2023-12-21",
            "topic": [
                ...
            ],
            "topic_image_url": [
                "https://example.com/sport/img/2023-12-21.png", 
                "https://example.com/tech/img/2023-12-21.png",
                ...
            ],
            ....
        }
    """.trimIndent()
}

Dispacherの作成

APIのリクエストをハンドリングするために、Dispatcherを使いますが、以下のような継承可能なクラスを作成すると便利です。

open class BaseDispatcher : Dispatcher() {

    // mockApiListにダミーのAPIを追加する
    open val mockApiList: MutableMap<APIName, MockApi> =
        mutableMapOf(
            NEWS to News(MORNING),
            WEATHER_INFO to WeatherInfo(),
            USER_INFO to UserInfo(),
            ...
        )

    override fun dispatch(request: RecordedRequest): MockResponse {
        println("Mock Server received : ${request.path}")
        val path = request.path ?: return createFailureResponse()
        for (api in mockApiList.values) {
            if (path.contains(api.url())) return api.response()
        }
        return createFailureResponse()
    }

    private fun createFailureResponse(): MockResponse {
        return MockResponse().also {
            it.setResponseCode(404)
        }
    }
}
enum APIName {
    NEWS,
    WEATHER_INFO,
    USER_INFO,
    ...
}

継承可能なクラスにすることで、APIのレスポンスをテストパターンに応じて容易に変えることができます。
例えば、上述の例だとnewsというAPIのレスポンスを変えたテストを実装したい場合、以下のようなクラスを作成して利用するだけです。

class EveningNewsDispatcher : BaseDispatcher() {

    init {
        mockApiList[APIName.NEWS] = News(EVENING)
    }
}

テストケースの作成

MockWebServerは、テスト対象のActivityが立ち上がる前にセットアップを完了しておく必要があるため、以下のような基底クラスを作っておくと便利です。

open class BaseUITest {
    val mockServer: MockServerService = MockServerService()

    open fun setupServer() {
        mockServer.setup(BaseDispatcher())
        mockServer.start()
    }

    inner class CustomTestRule : BaseTestRule() {
        override fun beforeActivityLaunchedAction() {
            setupServer()
        }
    }
}
abstract class BaseTestRule : TestRule {
   
    abstract fun beforeActivityLaunchedAction()

    override fun apply(base: Statement, description: Description): Statement {
        return object : Statement() {
            override fun evaluate() {
                beforeActivityLaunchedAction()
                base.evaluate()
            }
        }
    }
}

これを使うとテストを以下のように実装できます。

class MorningNewsTest : BaseUITest() {
    private val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)

    @get:Rule
    val testRule: RuleChain = RuleChain.outerRule(CustomTestRule()).around(activityScenarioRule)

    @After
    fun clear() {
        mockServer.finish()
    }

    @Test
    fun showNewsTest() {
        // Espresso などのテストライブラリを使ってテストを書く
    }
}

また、Dispatherを変えたい場合は、以下のように実装します。

class EveningNewsTest : BaseUITest() {
    private val activityScenarioRule = ActivityScenarioRule(MainActivity::class.java)

    @get:Rule
    val testRule: RuleChain = RuleChain.outerRule(CustomTestRule()).around(activityScenarioRule)

    @After
    fun clear() {
        mockServer.finish()
    }

    @Test
    fun showNewsTest() {
        // Espressoなどのテストライブラリを使ってテストを書く
    }

    override fun setupServer() {
        mockServer.setup(EveningNewsDispatcher())
        mockServer.start()
    }

    inner class CustomTestRule : BaseTestRule() {
        override fun beforeActivityLaunchedAction() {
            setupServer()
            // 他にもActivityが立ち上がる前に行いたい処理があればこの中に書く
        }
    }
}

ダミーレスポンスを簡単に作成する方法

APIのレスポンスデータのサイズが大きい場合、一行ずつ手作業で記述することは非常に困難です。 そこで、Talend API Tester等のAPIのテストツールを使えば、Jsonデータを楽に作成することができます。

これを使ってAPIをリクエストすると、レスポンスをJsonデータとしてダウンロードできます。ダウンロードしたJsonデータをコピーし貼り付ければ、一行づつ手作業でダミーレスポンスを記述する必要はなくなります。
また、APIのレスポンスに、画面に表示させる画像のURLが記述されている場合があります。このURLを使って画像を表示するには、サーバーにアクセスする必要あるので、スクリーンショットテストを行う場合、結果が不安定になる可能性があります。そこで、Drawableフォルダにダミー画像を置いて、以下のようにダミーレスポンスの画像のURLをDrawableフォルダのダミー画像のURLに差し替えることで、画像の表示を安定させることができます。

object MorningNewsResult {
    // テストツールを使ってダウンロードしたJsonデータを貼り付ける。
    // 画像のURLはDrawableフォルダのものに変更する。
    val response = """
        {
            "date": "2023-12-21",
            "topic": [
                ...
            ],
            "topic_image_url": [
                "${MockImage.sport}",
                "${MockImage.tech}",
                ...
            ],
            ....
        }
    """.trimIndent()
}
object MockImage {
    val sport = R.drawable.sport_150x150.toStringUrl()
    val tech = R.drawable.tech_150x150.toStringUrl()
    ...

    // baseURLは android.resource://package_name/ とする。
    private const val baseURL = "android.resource://com.example.myapp/"

    private fun Int.toStringUrl(): String {
        return baseURL + this
    }
}

最後に

MockWebServerを使ったUIテストの実装方法について紹介しました。AndroidアプリのUIテストを実装する際の一つの手段として参考にしていただければ幸いです。

明日は osawa-yoshinori さんの「食べログiOSアプリでユニットテストを書けるようにしていく話」です。お楽しみに!