Android上的单元测试与UI测试(一)

发表于:2020-12-02 11:17

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:大大纸飞机    来源:掘金

  对大多数人而言,工作的绝大部分时间都用来开会对需求,对完需求写业务,然后测试上线。在这种情况下,单元测试成了看起来不太重要,但又略显神秘的存在,你可能总想着试试但总也没有机会实施。然而作为一名严谨的工程师,都应该对这一必要但不紧急的知识有一定的理解,从而拥有更完整的技术栈。
  单元测试的意义就像它字面上那样,对一个可以运行的最小单元进行测试,保证它的稳定。只要每个最小单元都是正确的,就可以保证上层逻辑的正确性。因此单元测试应该从那些最基础的部分写起,到覆盖核心逻辑为止。而建立在核心逻辑之上的是和用户互动的UI界面,这部分属于UI测试的范畴,但是由于UI变动通常比核心逻辑频繁得多,因此UI测试的意义也就远低于单元测试。
  本文以我的 玩儿Android 项目 v1.1 版本为例,介绍单元测试常用的 MockWebServer、Mockito,以及UI测试常用的 Espresso 等框架的使用。
  在这个项目中我们使用Room作为本地数据的缓存,使用Retrofit进行网络请求,又通过Repository模型完成了MVX三层架构中的M层构建。M层基本涵盖了全部的核心业务逻辑,也就是说我们的单元测试只要覆盖了M层即可。Room和Retrofit是Repository的基础,所以第一步是对DAO和ApiService进行单元测试。
  API的单元测试
  首先API是后端同学开发的,因此其正确性应该由他们来保证,这听起来好像和我们没什么关系,而且我们也的确不需要对每个API都书写测试代码。真正需要我们做的是:确保网络模块能够正确解析数据。
  通常API开发都会遵守一定的规范,例如将返回数据定义为类似这样的结构:
{
    "data": ...,
    "errorCode": 0, // 0表示成功,其它表示失败
    "errorMsg": ""
}
  我们需要确保的就是能够按照约定正确地解析数据。也许有人会说,这么简单的事情写什么测试?这句话有一定的道理,但也有点浮躁。首先我们一定不是为了证明 1+1 确实等于 2 而写测试代码的,那样的确没什么意义。做API测试的目的在于:一是确保能够正确解析任何情况下的数据,二是掌握模拟网络请求方式这一测试技术本身,三是应该本着谨慎一点会更好的理念来写测试代码。
  要模拟网络请求使用 mockwebserver 库,首先添加依赖:
testImplementation 'com.squareup.okhttp3:mockwebserver:4.8.1'
  然后基于 MockWebServer 类构建一个 Retrofit 实例:
// 和正常使用Retrofit一样,只是把url换成了mockwebserver url随便写
Retrofit.Builder()
    .baseUrl(mockWebServer.url("/"))
    .addConverterFactory(serializationConverterFactory)
    .build()
  因为MockWebServer不会真正发送请求,所以它也不知道应该返回什么数据。要模拟请求,就需要我们自己定义返回内容,这时使用的类是 MockResponse。现在让我们模拟一次网络错误的请求:

class NetworkTest {
    @Rule
    @JvmField
    val instantExecutorRule = InstantTaskExecutorRule()

    lateinit var service: TestService

    lateinit var mockWebServer: MockWebServer

    // 测试前初始化代码
    @ExperimentalSerializationApi
    @Before
    fun setUp() {
        mockWebServer = MockWebServer()

        val contentType = "application/json".toMediaType()
        val serializationConverterFactory = Json {
            ignoreUnknownKeys = true
        }.asConverterFactory(contentType)

        service = Retrofit.Builder()
            .baseUrl(mockWebServer.url("/"))
            .addConverterFactory(serializationConverterFactory)
            .build()
            .create(TestService::class.java)
    }

    // 测试完成后关闭
    @After
    fun clearUp() {
        mockWebServer.shutdown()
    }

    @Test
    fun networkError() = runBlocking {
        // 构造一个401的返回
        val mockResponse = MockResponse()
            .addHeader("Content-Type", "application/json; charset=utf-8")
            .setResponseCode(401)

        // 放到队列,调用时会取出
        mockWebServer.enqueue(mockResponse)

        // 请求数据
        val resource = safeCall {
            val response: ApiResponse<FakeUser> = service.login("fake_username", "fake_password")
            response.toResource { if (it.data == null) Resource.empty() else Resource.success(it.data) }
        }
        // 判断返回结果
        assertEquals(resource, Resource.error<FakeUser>(401, "Client Error"))
    }
}
  可以看到,和平时开发最大的区别就在于需要自己创造返回值,并对结果进行断言,后续其它单元测试也会常常使用这个套路。按照这种方式,让我们补全正常返回数据和服务器返回错误时的测试代码:
@Test
fun getFakeUserSuccess() = runBlocking {
    val mockResponse = MockResponse().apply {
        setBody("""{"data": {"name": "LiHua","gender": "male"},"errorCode": 0,"errorMsg": "请求成功"}""".trimIndent())
    }
    mockWebServer.enqueue(mockResponse)

    val response: ApiResponse<FakeUser> = service.login("fake_username", "fake_password")
    assertTrue(response.isSuccess())
    assertEquals(response.data, FakeUser("LiHua", "male"))
}
  以上我们将json直接写在测试代码里,可读性变得很差,可以通过文件的方式来处理,在 test 目录下创建 resources 目录,再创建一个 response 目录专门存储json,以上代码就可以改为:
val inputStream = javaClass.classLoader!!.getResourceAsStream("response/get_user.json")
val bufferedSource = inputStream.source().buffer()
val mockResponse = MockResponse()
mockWebServer.enqueue(mockResponse.setBody(bufferedSource.readString(Charsets.UTF_8)))

mockWebServer.enqueue(mockResponse)
// ...
  DAO的单元测试
  测试的第一步是添加依赖:
androidTestImplementation 'androidx.test:core:1.2.0'
androidTestImplementation 'androidx.arch.core:core-testing:2.1.0'
androidTestImplementation 'androidx.room:room-testing:2.2.5'
  Room的测试分为两部分:升级测试与DAO测试,目前该项目并没有升级,因此暂时只能写DAO测试,后续有升级时会在项目中跟进。
  接下来看UserDao的实现:
@Dao
interface UserDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(user: User)

    @Query("SELECT * FROM user LIMIT 1")
    suspend fun getUser(): User?

    @Query("SELECT * FROM user LIMIT 1")
    fun getUserLiveData(): LiveData<User>

    @Query("DELETE FROM user")
    suspend fun clearAllUsers()
}
  getUser和getUserLiveData虽然执行了同样的SQL语句,但却适用于不同的场景,一方面我们希望借助LiveData的特性当数据变化时自动更新页面,另一方面也希望随时可以获取到用户的信息,例如判断用户是否登录。
  回到正题,我们应该写些什么测试呢?最严谨的做法是全方位测试,测试每个方法在正确、异常等各种场景下的表现。但是这样一来需要耗费极大的精力,二来有许多场景的测试是无意义的。例如我们已经十分明确1+1=2,就不需要为这种显而易见的事情写测试。还有对于“换汤不换药”的行为,例如Insert+Select+Delete操作,无论对UserDao还是对其它的Dao而言都是等效的,这样的测试只要一次跑通,也没有必要每增加一个Dao就书写一次相似的测试代码。因此在我看来,只需要对自己怀疑的代码书写测试。怀疑是一种主观行为,有的人谨小慎微,也有的人大大咧咧,因此我建议你自信一点但不要自负,因为出现问题最后买单的还是你自己(==!)。当然,如果时间充分,全方位测试自然是最好的。
  第一个测试
  接下来让我们开始写第一个测试,例如测试OnConflictStrategy.REPLACE的效果,需要先插入一条数据,取出来看一下,再插入另一条数据(主键的值相同),再取出来,如果每次取出后的结果和预期一致,就说明功能运行正常。在写测试之前,必然需要先连接好数据库,那么Room的inMemoryDatabaseBuilder就派上用场了。
open class DbTest {
    protected val db: WanAndroidDb
        get() = _db

    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    private lateinit var _db: WanAndroidDb

    @Before
    fun initDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        _db = Room.inMemoryDatabaseBuilder(context, WanAndroidDb::class.java).build()
    }

    @After
    fun closeDb() {
        _db.close()
    }
}
  使用inMemoryDatabaseBuilder获取测试使用的Db对象,这里 @Before 和 @After 分别表示在测试开始前和结束后将执行的代码。还有一处 @get:Rule = InstantTaskExecutorRule() 的作用是将异步任务转为同步执行,在developer.android上有对InstantTaskExecutorRule的详细说明:

A JUnit Test Rule that swaps the background executor used by the Architecture Components with a different one which executes each task synchronously.

You can use this rule for your host side tests that use Architecture Components.

  有了Db,终于可以测试了,测试代码需要使用 @Test 标注,接下来按照我们的设想就可以很快地写出如下的代码:
class UserDaoTest : DbTest() {
    @Test
    fun insertAndReplace() = runBlocking {
        val userDao = db.userDao()
        // 创建第一个实例,uid是主键
        val user = createUser(uid = 100, nickname = "nick1")
        userDao.insert(user)
        // 从数据库获取,并确认和插入的数据一致
        val getFromDb = userDao.getUser()
        assertThat(getFromDb, notNullValue())
        assertThat(getFromDb!!.id, `is`(100))
        assertThat(getFromDb.nickname, `is`("nick1"))

        // 只有主键值保持一致时,才能替换
        val replaceUser = createUser(uid = 100, nickname = "nick2")
        userDao.insert(replaceUser)
        // 从数据库获取,并确认数据已经更新
        val getReplaceUserFromDb = userDao.getUser()
        assertThat(getReplaceUserFromDb, notNullValue())
        assertThat(getReplaceUserFromDb!!.id, `is`(100))
        assertThat(getReplaceUserFromDb.nickname, `is`("nick2"))
    }
}
  接下来点击方法左侧的运行按钮,就可以看到测试结果了:
  测试LiveData
  UserDao中会返回一个LiveData,我们想确认当数据库内容发生变化时LiveData会自动更新,不过LiveData需要Observer才能激活,这一点可以通过observeForever解决。
// 自动取消订阅
fun <T> LiveData<T>.getOrAwaitValue(
    time: Long = 2,
    timeUnit: TimeUnit = TimeUnit.SECONDS,
    afterObserve: () -> Unit = {}
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }
    this.observeForever(observer)

    afterObserve.invoke()

    // Don't wait indefinitely if the LiveData is not set.
    if (!latch.await(time, timeUnit)) {
        this.removeObserver(observer)
        throw TimeoutException("LiveData value was never set.")
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

@Test
fun notifyLiveData() = runBlocking {
    val userDao = db.userDao()

    val user = createUser(uid = 100, nickname = "nick1")
    userDao.insert(user)

    val liveData = userDao.getUserLiveData()

    val user1 = liveData.getOrAwaitValue()
    assertEquals(user1, user)

    val replaceUser = createUser(uid = 100, nickname = "nick2")
    userDao.insert(replaceUser)

    val user2 = liveData.getOrAwaitValue()
    assertEquals(user2, replaceUser)
}
  关于Room是如何做到通知LiveData的,可以通过自动生成的实现类,定位到源码 RoomTrackingLiveData 类中,关键的一行代码就是下面这句了:
@Override
protected void onActive() {
    super.onActive();
    mContainer.onActive(this);
    getQueryExecutor().execute(mRefreshRunnable);
}

  本文内容不用于商业目的,如涉及知识产权问题,请权利人联系博为峰小编(021-64471599-8017),我们将立即处理
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号