对大多数人而言,工作的绝大部分时间都用来开会对需求,对完需求写业务,然后测试上线。在这种情况下,单元测试成了看起来不太重要,但又略显神秘的存在,你可能总想着试试但总也没有机会实施。然而作为一名严谨的工程师,都应该对这一必要但不紧急的知识有一定的理解,从而拥有更完整的技术栈。
单元测试的意义就像它字面上那样,对一个可以运行的最小单元进行测试,保证它的稳定。只要每个最小单元都是正确的,就可以保证上层逻辑的正确性。因此单元测试应该从那些最基础的部分写起,到覆盖核心逻辑为止。而建立在核心逻辑之上的是和用户互动的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),我们将立即处理