测试网络请求

1. 准备工作

在之前的 Codelab 中,您已了解了如何使用 Retrofit 发出网络请求。在本 Codelab 中,您将学习如何为网络代码编写单元测试。

前提条件

  • 已在 Android Studio 中创建过测试目录。
  • 已在 Android Studio 中编写过单元测试和插桩测试。
  • 已在 Android 项目中添加过 Gradle 依赖项。

学习内容

  • 如何存储测试资源。
  • 如何模拟 API 响应以进行测试。
  • 如何测试 Retrofit API 服务。

所需条件

  • 一台安装了 Android Studio 的计算机。
  • Mars Photos 应用的解决方案代码。

下载此 Codelab 的起始代码

在本 Codelab 中,您需要将插桩测试从之前的解决方案代码添加到 Mars Photos 应用中。

如需获取此 Codelab 的代码并在 Android Studio 中打开它,请执行以下操作。

获取代码

  1. 点击提供的网址。此时,项目的 GitHub 页面会在浏览器中打开。
  2. 在项目的 GitHub 页面上,点击 Code 按钮,这时会出现一个对话框。

5b0a76c50478a73f.png

  1. 在对话框中,点击 Download ZIP 按钮,将项目保存到计算机上。等待下载完成。
  2. 在计算机上找到该文件(很可能在 Downloads 文件夹中)。
  3. 双击 ZIP 文件进行解压缩。系统将创建一个包含项目文件的新文件夹。

在 Android Studio 中打开项目

  1. 启动 Android Studio。
  2. Welcome to Android Studio 窗口中,点击 Open an existing Android Studio project

36cc44fcf0f89a1d.png

注意:如果 Android Studio 已经打开,请依次选择 File > New > Import Project 菜单选项。

21f3eec988dcfbe9.png

  1. Import Project 对话框中,前往解压缩的项目文件夹所在的位置(很可能在 Downloads 文件夹中)。
  2. 双击该项目文件夹。
  3. 等待 Android Studio 打开项目。
  4. 点击 Run 按钮 11c34fc5e516fb1c.png 以构建并运行应用。请确保该应用按预期构建。
  5. Project 工具窗口中浏览项目文件,了解应用的设置方式。

2. 起始应用概览

Mars Photos 应用仅包含一个屏幕,其中显示了一系列使用网络请求检索的照片。

此起始代码有一些额外的与测试相关的细微差异。我们不会深入探讨这些差异,因为它们不在本 Codelab 的讨论范围之内,但还是有必要说明一下。

在测试应用如何处理从 API 检索数据的过程时,最好提供我们自己的数据,因为这样我们可以确知数据应该是什么样的。从 API 检索到的数据可能会发生变化,而这可能会破坏测试,导致测试意外失败。此外,依赖真实的网络调用可能会导致测试因为网络连接或网速的原因失败,这可能会造成测试的不一致。因此,我们将在测试中生成一些数据,并将其作为 JSON 文件存储在 test/res 目录中。这是一个资源目录,类似于主应用代码中的资源目录,不同之处在于我们的测试资源存储在 test/res 中。

  1. 添加 test/res 目录。右键点击项目视图中的 src 目录,然后从下拉列表中依次选择 New -> Directory

95e789a6a8d34185.png

  1. 在随即显示的弹出式窗口中,向下滚动并选择 test/res

eeb4ef7904e60846.png

  1. 在项目视图中,右键点击 test/res 目录,然后依次选择 New -> File

6ea89527d6534c1a.png

  1. 将文件命名为 mars_photos.json

c4f463255956ce33.png

  1. 将以下代码复制到 mars_photos.json 文件中。
[
 {
   "id":"424905",
   "img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000MR0044631300503690E01_DXXX.jpg"
 },
 {
   "id":"424906",
   "img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000ML0044631300305227E03_DXXX.jpg"
 }
]
  1. 为了能够访问此资源目录中的文件,必须在 build 文件中将该测试资源目录明确指定为“源”目录。添加以下代码行:

app/build.gradle

android {
    ...
    sourceSets {
       test.resources.srcDirs += 'src/test/res'
    }
}

这样我们就可以直接访问资源文件,而不必每次在测试中访问这些文件时,都需要在代码中输入文件的完整路径。输入完整路径的做法对于测试而言非常不便,因为文件路径可能会因机器和操作系统的不同而发生变化。

  1. 在同一文件中,添加以下依赖项并同步 Gradle。

app/build.gradle

dependencies {
    ...
    testImplementation 'junit:junit:4.12'
    testImplementation "androidx.arch.core:core-testing:2.1.0"
    testImplementation "com.squareup.okhttp3:mockwebserver:4.9.1"
}
  1. 现在创建一个常规测试目录。

22137bac57bd2d77.png

  1. 在该测试目录中,创建一个新的软件包 com.example.android.marsphotos
  2. 在该软件包中,创建一个名为 BaseTest.kt 的新 Kotlin 文件。

481f7a26f0935093.png

  1. 复制以下代码并将其粘贴到 BaseTest.kt 文件中。如果对此代码不熟悉,也没有关系!“Mock”(模拟)一词会在下面的代码中反复出现,并会在整个 Codelab 中经常提到。此类中的 enqueue() 函数会解析您创建的 mars_photos.json 文件中的数据,以便在后续编写的测试中使用。

BaseTest.kt

import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.buffer
import okio.source

open class BaseTest {

   val mockWebServer = MockWebServer()

   fun enqueue(fileName: String) {
       val inputStream = javaClass.classLoader!!.getResourceAsStream(fileName)
       val buffer = inputStream.source().buffer()

       mockWebServer.enqueue(
           MockResponse()
               .setResponseCode(200)
               .setBody(buffer.readString(Charsets.UTF_8))
       )
   }
}

3. 模拟数据

在本 Codelab 中,我们高度依赖模拟数据。在测试环境中,模拟是指模拟一段代码的返回值。在上一个 Codelab 中,我们模拟了一个类。我们还可以模拟函数,使它们返回我们指定的值,或者模拟 API 以返回我们指定的一段数据。这对于测试非常有用,因为它可以帮助我们隔离出一段代码来进行测试。在本 Codelab 中,我们主要介绍如何模拟 API 响应。我们将在另一个 Codelab 中介绍如何模拟函数。

4. 创建单元测试类

在此测试中,我们将测试 API 服务。首先,创建一个名为 MarsApiServiceTests.kt 的新类。

5. 依赖项

本 Codelab 的起始代码包含您需要的依赖项。不过,还有一个重要的依赖项还没有介绍。

testImplementation "com.squareup.okhttp3:mockwebserver:4.9.1"

此依赖项可帮助我们创建模拟服务器。从本质上讲,模拟服务器的作用是拦截网络请求并重新路由,以返回我们定义的模拟数据。我们稍后会在此 Codelab 中讨论模拟的含义。

6. 最佳做法

请注意,我们的测试是用 Kotlin 编写的,Kotlin 是一种面向对象的编程语言。这意味着我们可以为测试编写面向对象的代码。由于我们要为本测试编写的代码量很大,因此实际上不需要采用面向对象的编写方法。不过,为了说明这个概念,我们还是会采用这种编写方法。

您可能已经注意到,起始代码中已经包含了一个单元测试目录,该目录中有一个名为 BaseTest 的类。如果有某段代码可用于多项测试,则可以创建一个 open class,并让我们的测试类继承自该类。请注意,此 BaseTest 类中只有一个名为 enqueue() 的方法,并且我们在本 Codelab 中将只编写一个测试类。在编写自己的测试时,请记住,您可以利用面向对象编程的优势。不过,切勿在不必要时过度使用。

7. 编写网络请求测试

首先,请确保您的测试类继承自 BaseTest

我们将直接测试 MarsApiService,因此需要它的一个实例。我们将按照应用代码中的相同方式进行设置,但这项新服务的网址会有所不同。

  1. 在您的测试类中,为 MarsApiService 创建一个 lateinit 变量。
private lateinit var service: MarsApiService

现在,我们需要在每次测试前创建一个定义 service 变量的函数。

  1. 创建一个名为 setup() 的函数,并为其添加 @Before 注解。
@Before
fun setup() {}

对于此测试,我们不会为网络请求使用网址。这就是 MockWebServer 的用武之地。

模拟数据

BaseTest 类中有一个名为 mockWebServer, 的属性,该属性只是 MockWebServer 对象的一个实例。此对象将拦截我们的网络请求,但我们首先需要将网络请求定向到将被拦截的网址。

MockWebServer 具有一个名为 url() 的函数,用于指定我们想要拦截的网址。请注意,我们不想发出真实的网络请求,而只想发出模拟请求,以便我们可以使用我们能够在测试中控制的数据来测试网络代码。url() 函数会接受一个表示该虚构网址的字符串,并返回 HttpUrl 对象。在 setup() 函数中,输入以下代码行:

val url = mockWebServer.url("/")

我们已经设置了要拦截的网址端点,并捕获了返回的 HttpUrl 对象。

在此函数内,按照 MarsApiServiceMarsApiClass 中的相同方式创建 MarsApiService 实例(但不包括 lazy 变量)。但是,现在不要添加基准网址。代码应如下所示:

service = Retrofit.Builder()
   .addConverterFactory(MoshiConverterFactory.create(
       Moshi.Builder()
           .add(KotlinJsonAdapterFactory())
           .build()
   ))
   .build()
   .create(MarsApiService::class.java)

现在,可以设置基准网址了。将以下代码添加到 Retrofit.Builder() 的函数链中:

.baseUrl(url)

这会向 API 服务指明我们想将请求路由到 MockWebServer

service = Retrofit.Builder()
   .baseUrl(url)
   .addConverterFactory(MoshiConverterFactory.create(
       Moshi.Builder()
           .add(KotlinJsonAdapterFactory())
           .build()
   ))
   .build()
   .create(MarsApiService::class.java)

同样,MockWebServer 的作用是避免向真实的 API 发出真实的网络请求。这里的基本考虑是,如果发出真实的网络请求,那么一旦 API 失败,测试就会失败。使用真实的 API 会测试 API 本身,但我们只是想测试 Android 项目中的代码。

您可以将 MockWebServer 视为会返回我们创建的数据的虚假 API,因此我们需要在发出请求之前明确地告知 MockWebServer 返回什么内容。这正是 BaseTestenqueue() 函数的作用。请勿太在意此函数中的代码,因为它超出了本 Codelab 的讨论范围。只需要知道该函数会从我们的测试资源中获取一个文件,并将其变为虚假的 API 响应。

  1. 创建一个名为 api_service() 的测试函数。在该测试函数中,调用 enqueue 方法,如下所示:
enqueue("mars_photos.json")
  1. 接下来,我们会直接从 MarsApiService 调用 getPhotos() 函数。请注意,getPhotos() 是一个挂起函数,必须从协程作用域中调用。为此,我们将调用封装到 runBlocking 中的方法,如下所示:
runBlocking {
    val apiResponse = service.getPhotos()
}
  1. 现在,我们需要确认 getPhotos() 响应不是 null。还记得我们在 runBlocking 内定义了 apiResponse,因此也必须在 runBlocking 内对其进行访问。
runBlocking {
    val apiResponse = service.getPhotos()
    assertNotNull(apiResponse)
}

getPhotos() 会返回包含 MarsPhoto 对象的列表,而我们需要确认列表的大小符合预期。

  1. 查看 test/res 中的 mars_photos.json 文件。创建另一个断言,以确保列表不为空。我们还需要创建一个断言来检查某些数据是否正确。转到 test/res/mars_photos.json 并复制其中一个 ID。断言该 ID 的值等于相应列表项的 ID 值。
runBlocking {
   val apiResponse = service.getPhotos()

   assertNotNull(apiResponse)
   assertTrue("The list was empty", apiResponse.isNotEmpty())
   assertEquals("The IDs did not match", "424905", apiResponse[0].id)
}

现在,您的代码应如下所示:4000883c0d6d47bb.png

8. 解决方案代码

9. 恭喜

测试网络请求可能会非常复杂,此 Codelab 仅触及了该主题的浅层内容。您编写的每个网络测试对于您的应用从中获取数据的 API 都是唯一的。随着您积累更多的 API 使用经验,您就可以扩展测试来模拟网络故障和不同的 API 响应。这可能是一个极具挑战性的测试领域,要想成为专家,就必须不断地尝试和犯错,所以一定要坚持练习。

在此 Codelab 中,我们了解了:

  • 如何将面向对象的编程应用于测试。
  • 如何将资源存储在测试目录中。
  • 如何在测试中调用挂起函数。
  • 如何在测试中模拟 API 响应。