okuzawatsの日記

Android / Kotlin / GitHub Actions Enthusiast 🤖

[Android] OkHttpのMockWebServerを用いた、OkHttp + Retrofitのユニットテスト

目次

OkHttpのMockWebServerを用いて、OkHttpとRetrofitを用いたRESTful APIアクセス部分のユニットテストを書いていきます。Android Developersに掲載されているアプリアーキテクチャガイドにおける、Data layerのRemoteDataSourceにあたる部分です。

以下のサンプルコードのOkHttpとMockWebServerのバージョンは4.9.1、Retrofitのバージョンは2.9.0です(バージョンが少し古いのは、このサンプルコードを書いたのがしばらく前だったからです)。MockWebServerは testImplementation としています。

dependencies {
  implementation("com.squareup.okhttp3:okhttp:4.9.1")
  implementation("com.squareup.retrofit2:retrofit:2.9.0")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.0")
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0")
  testImplementation("com.squareup.okhttp3:mockwebserver:4.9.1")
}

MockWebServerを用いた、OkHttpとRetrofitを用いたRESTful APIアクセス部分のユニットテスト

はじめにテストクラスの最終型を示します。サンプルコードの元になったのが自分の個人プロジェクトなので、趣味に走っているところがあります。それについては後段で説明を加えていきます。ポイントは、 @Before@After でMockWebServerの起動と停止をおこなっていること、各テストケースでMockWebServerのレスポンスコードとレスポンスボディを enqueue していることになるでしょうか。

class RemoteDataSourceImplTest {

  private val mockWebServer = MockWebServer()

  private lateinit var target: RemoteDataSource

  @ExperimentalSerializationApi
  @Before
  fun setUp() {
    mockWebServer.start()

    target = RemoteDataSourceImpl(
      apiClient = TestApiClientProvider().provideWith(mockWebServer),
    )
  }

  @After
  fun tearDown() {
    mockWebServer.shutdown()
  }

  @Test
  fun testGetAwesomeData_returnRightIfSuccess() = runBlocking {
    mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(dummyJson()))

    val actual = target.getAwesomeData()

    assert(actual is Either.Right && actual.value.items.isNotEmpty())
  }

  @Test
  fun testGetAwesomeData_returnLeftIfFailure() = runBlocking {
    mockWebServer.enqueue(MockResponse().setResponseCode(500))

    val actual = target.getAwesomeData()

    assert(actual is Either.Left)
  }
}

private fun dummyJson(): String =
"""
{
    "items": [
        {
            "id": 1,
            "name": "Starbucks",
            "created_at": "2022-01-02T05:48:15.209Z",
            "updated_at": "2022-01-02T05:48:15.209Z"
        },
        {
            "id": 2,
            "name": "Komeda Coffee",
            "created_at": "2022-01-02T05:48:46.648Z",
            "updated_at": "2022-01-02T05:48:46.648Z"
        },
        {
            "id": 3,
            "name": "Mr.Donuts",
            "created_at": "2022-01-02T05:49:16.757Z",
            "updated_at": "2022-01-02T05:49:16.757Z"
        }
    ]
}""".trimIndent()

コードの説明を以下に示します

MockWebServerの起動と停止

MockWebServerの起動と停止は以下の箇所で行っています。テストケースごとにMockWebServerの起動と停止を行うため、 @BeforeMockWebServer#start を、 @AfterMockWebServer#shutdown を呼んでいます。

  private val mockWebServer = MockWebServer()

  @ExperimentalSerializationApi
  @Before
  fun setUp() {
    mockWebServer.start()

    // 省略
  }

  @After
  fun tearDown() {
    mockWebServer.shutdown()
  }

MockWebServerへのレスポンスのenqueue

以下の箇所では、MockWebServerにモックレスポンスをenqueueしています。MockWebServerにリクエストを行った時に、queueからdequeueされたモックレスポンスを返してくれます。テストケースに合わせてモックレスポンスを作ってあげることで、RESTful APIアクセス部分のユニットテストを書くことができます。

  @Test
  fun testGetAwesomeData_returnRightIfSuccess() = runBlocking {
    mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(dummyJson()))

    // 略
  }

  @Test
  fun testGetAwesomeData_returnLeftIfFailure() = runBlocking {
    mockWebServer.enqueue(MockResponse().setResponseCode(500))

    // 略
  }

上記の1個目のテストケースでは、リクエストに成功した場合(レスポンスコードが200番台)の時のテストを行っています。レスポンスボディーには、後述するJsonをrawStringで定義した値を渡しています。

上記の2個目のテストケースでは、リクエストに失敗した場合(レスポンスコードが200番台でない)のテストを行っています。レスポンスコードを適宜設定することで、レスポンスコードに応じた挙動のテストを記述できます。また、1個目の例と同様に setBody を呼び出すことで、エラーレスポンスを定義することもできます。

Json

レスポンスのJsonはKotlinのrawStringで定義しました。Jsonのファイルをプロジェクト内に置き、ファイルからJsonの文字列を読み込む方法もあると思います。

private fun dummyJson(): String =
"""
{
    "items": [
        {
            "id": 1,
            "name": "Starbucks",
            "created_at": "2022-01-02T05:48:15.209Z",
            "updated_at": "2022-01-02T05:48:15.209Z"
        },
        {
            "id": 2,
            "name": "Komeda Coffee",
            "created_at": "2022-01-02T05:48:46.648Z",
            "updated_at": "2022-01-02T05:48:46.648Z"
        },
        {
            "id": 3,
            "name": "Mr.Donuts",
            "created_at": "2022-01-02T05:49:16.757Z",
            "updated_at": "2022-01-02T05:49:16.757Z"
        }
    ]
}""".trimIndent()

Retrofit

Retrofitを用いた、RESTful APIのクライアントは以下のように作っています。クライアントのinterfaceはApiClientという名前にしました。APIアクセスを行う関数については、データをRetrofitのResponseに包んで返すsuspend関数として定義しています。Kotlin Coroutinesではなく、例えばRxJavaのSingleを使うような場合でも、全体的な実装の流れは変わらないと思います。

interface ApiClient {
  @GET("/")
  suspend fun getAwesomeData(): Response<AwesomeData>
}

ApiClientの注入

テスト対象にApiClientを注入していますが、そのインスタンスは以下のような処理で生成するようにしています。このサンプルコードではJsonのパーサとしてKotlin Serializationを使っていますが、例えばMoshiを使う時なども同様のインスタンス生成のコードを書けると思います。

@ExperimentalSerializationApi
internal class TestApiClientProvider {
  fun provideWith(mockWebServer: MockWebServer): ApiClient =
    Retrofit.Builder()
      .baseUrl(mockWebServer.url(""))
      .client(OkHttpClient())
      .addConverterFactory(converterFactory())
      .build()
      .create(ApiClient::class.java)

  private val json = Json { ignoreUnknownKeys = true }

  private fun converterFactory(): Converter.Factory =
    json.asConverterFactory(contentType = "application/json".toMediaType())
}

テスト対象のクラス

参考のために、テスト対象のクラスの定義も記載しておきます。Either型を返すsuspend funのみを定義したinterfaceです。Either型については、 Either / Option in Kotlin に書きました。簡単に言うと、成功をRight型、失敗をLeft型で表すsealed classです。

interface RemoteDataSource {
  suspend fun getAwesomeData(): Either<AwesomeException, Awesomedata>
}

その実装クラスは以下です。前述のApiClientをコンストラクタで注入しています。RetrofitのResponse型をEither型に変換して返しています(Retrofitへの依存をRemoteDataSourceImplの外に持ち出さないようにするためです)。

class RemoteDataSourceImpl @Inject constructor(
  private val apiClient: ApiClient,
) : RemoteDataSource {
  override suspend fun getAwesomeData(): Either<AwesomeException, Awesomedata> =
    apiClient.getAwesomeData().toEither()
}

RetrofitのResponse型は、レスポンスコードが200番台の時に isSuccessful がtrueを返します。このサンプルコードでは、単純に、 isSuccessful がtrueの時はEither.Right、falseの時はEither.Leftを返すようにしています。

private fun <T : Any> Response<T>.toEither(): Either<AwesomeException, T> =
  if (isSuccessful) {
    Either.Right(requireNotNull(body())
  } else {
    Either.Left(AwesomeException())
  }

Reference

  1. Data layer | Android Developers(最終アクセス日:2022年1月7日)
  2. Either / Option in Kotlin(最終アクセス日:2022年1月10日)
  3. [Kotlin] sealed classに親しむ(最終アクセス日:2022年1月20日)

#Android #Test

書いている人 😎

profile

茨城県つくば市在住のモバイルアプリケーションアーキテクト(Androidが得意です)。モバイルアプリのアーキテクチャ、自動テスト、CI/CDに興味があります。いわゆる「レガシーコード」のリファクタリング・リアーキテクチャが好きです。

Androidプロジェクトの開発速度低下にお悩みで、お手伝いが必要でしたら、メールフォームよりお気軽にお問い合わせください。

👉 もっと詳しく

著書 ✍

Android MVVMアーキテクチャ入門 🛠

Androidアプリ開発の初学者に向けた、MVVM(Model-View-ViewModel)アーキテクチャの入門書を書きました。初学者の方を確実にネクストレベルに引き上げる技術書です。NextPublishingより出版されています。

販売サイトへ 🏃

Android ユニットテスト ヒッチハイク・ガイド 🧳

Androidアプリのユニットテストに入門するためのガイダンスです。初学者が混乱せずにAndroidアプリのユニットテストを書き始めることができる、ということを目的としています。

販売サイトへ 🏃

関連記事 👀

お問い合わせ 📨

お名前

メールアドレス

お問い合わせ内容