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

发表于:2020-12-03 09:25

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

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

分享:
  Repository测试
  完成了对Dao的测试,就保证了在基本单元上程序是正确的,但是这还不够。要对代码有足够的信心,至少要完成M层的测试,也就是接下来的Repository的测试。如果你对Repository不太熟悉,可以参考《也谈Android应用架构》里的描述。下面我们摘取一个简单的Repository,看下为其写测试代码时应该注意什么。
interface ClassifyRepository {
    fun getClassifies(forceUpdate: Boolean): LiveData<Resource<List<ClassifyModel>>>
}

class DefaultClassifyRepository(
    private val classifyDao: ClassifyDao,
    private val classifyService: ClassifyService
) : BaseRepository(), ClassifyRepository {

    override fun getClassifies(forceUpdate: Boolean): LiveData<Resource<List<ClassifyModel>>> {
        return liveData {
            if (forceUpdate) {
                emit(fetchFromNetwork())
            } else {
                val dbResult = classifyDao.getClassifies()
                if (dbResult.isNotEmpty()) {
                    emit(Resource.success(dbResult))
                } else {
                    emit(fetchFromNetwork())
                }
            }
        }
    }

    private suspend fun fetchFromNetwork(): Resource<List<ClassifyModel>> {
        return safeCall {
            val classifies = classifyService.getClassifies()
            classifies.data?.let {
                classifyDao.insertClassifies(it)
            }
            classifies.toResource {
                if (it.data.isNullOrEmpty()) {
                    Resource.empty()
                } else {
                    Resource.success(it.data)
                }
            }
        }
    }
}
  非常简单,就是从本地获取数据,获取不到再从网络获取,而从网络获取后会保存到本地,从而实现优先取缓存数据的操作。如果要为它写测试代码,要考虑哪些情况呢?首先对于forceUpdate=true应该确保直接从网络获取数据,其他情况下要确保的是如果缓存有数据则不会触发网络请求,缓存没数据则会触发网络请求,以及当网络请求也失败时,返回Error。也许你会说,这段代码看起来就会按照设想的那样执行,但是当你想要检查它是否正确时只能运行起来,等待网络请求返回数据,这样从编译运行再到等待网络的过程会耗费很多的精力和时间,更何况当网络出错时你可能根本看不到预期的页面。而单元测试则没有以上问题,这也是单元测试的优点之一。
  与Dao不同的是,对于逻辑测试我们使用mockito。通过它,我们可以模拟一个对象,而不是使用真实的实例,这样不仅可以校验逻辑的正确性,还省去了真实对象的方法执行所需的时间,真是一举两得。因为逻辑测试是平台无关的,因此可以写在 test 目录下。第一步依然是添加依赖:
testImplementation 'junit:junit:4.13'
testImplementation 'org.mockito:mockito-core:2.25.0'
testImplementation 'androidx.arch.core:core-testing:2.1.0'
  得益于mockito的优秀设计,我们为测试写的代码就像自然语言一样流畅,因此以下代码可以轻易被理解,也就不需要我这里赘述:
class ClassifyRepositoryTest {
    @get:Rule
    var instantTaskExecutorRule = InstantTaskExecutorRule()

    // 模拟一个对象
    private val classifyDao = mock(ClassifyDao::class.java)
    private val classifyService = mock(ClassifyService::class.java)

    private val classifyRepository = DefaultClassifyRepository(classifyDao, classifyService)

    @Test
    fun getClassifiesForceUpdate() = runBlocking {
        val models = listOf(
            ClassifyModel(classifyId = 0, name = "", courseId = 0, parentChapterId = 0)
        )
        val response = ApiResponse(data = models)
        // 调用模拟对象的方法前,要定义好怎么执行这个方法
        `when`(classifyService.getClassifies()).thenReturn(response)

        val result = classifyRepository.getClassifies(true)

        val observer = mock<Observer<Resource<List<ClassifyModel>>>>()
        result.observeForever(observer)

        // 确认方法被调用了
        verify(classifyService).getClassifies()
        verify(classifyDao).insertClassifies(anyList())
        verifyNoMoreInteractions(classifyService)
        verifyNoMoreInteractions(classifyDao)
        verify(observer).onChanged(Resource.success(models))
    }
}
  基于 when 和 verify 可以对任何逻辑进行测试,只要通过合理的设计,我们就可以对程序可能出现的任何情况进行测试而不需要依赖外部资源。
  完成了Repository层测试,也就完成了M层的覆盖,对于核心业务现在你可以自信地说,我写的代码几乎没有Bug(为什么是几乎?总会有一些让你意想不到的事情吧~)!现在让我们把目光聚焦在最后一层,也就是UI层面,虽然UI测试因为变动频繁显得没有M层重要,但在时间充足的情况下还是很有必要写一下的,它会在后期节省你大量的时间。
  UI测试
  1. 了解Espresso和IdlingResource
  在编码时,我们总会写好一部分代码就运行起来看下效果,其实这本身就是UI测试,不过它是人为驱动的,且非常依赖外部环境,假如运行后发现服务器挂了,你的心情一定有些波动吧?还有些时候你就是想看下出错后的样子,就只好改下代码,再运行一次…,写好之后美滋滋地把代码推送到远端,结果发现改过的代码没有改回来,这回你的心情波动的应该明显许多了吧?如果你不想老这么波动,那就真的需要了解一下UI测试了。
  我们希望的是,写好的代码不需要删,写好代码之后点击开始就自动执行,还想不管网络好不好都能完成测试,并且能同时测试到各种情况,所幸的是这一切 Espresso 都可以做到。
  根据我们的设计,ViewModel像是“傀儡”一样只做了很少的事情,因此其单元测试的意义十分微薄,当然如果有必要测试,只需要按照Repository的测试方式进行即可,这里不再赘述。接下来我们通过一段简单的代码,先了解一下Espresso的基本结构:
// 添加依赖
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'

class IdlingResourceActivityTest {
    @Test
    fun testEspresso() {
        val scenario = ActivityScenario.launch(IdlingResourceActivity::class.java)

        onView(withId(R.id.et_name)).perform(typeText("wanandroid"), closeSoftKeyboard())
        onView(withId(R.id.et_pwd)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())
    }
}
  如果执行这条Test,你会看到手机自动打开了页面,自动填充内容并点击登录按钮。但这等于什么都没有做,我们需要的是进行一定的操作后能够对结果进行确认。现在让我们加入一句确认语句:
onView(withId(R.id.tv_login_success)).check(matches(ViewMatchers.withText(R.string.text_login_success)))
  我们的意图是点击按钮之后,tv_login_success会被赋值文字,但这条测试失败了。其原因在于点击按钮之后我们模拟了一个耗时操作,它需要一点时间才能返回结果,但检查的语句是即时执行的。在UI测试中这种耗时操作比比皆是,所幸Espresso有足够的能力应对,不过需要进行一点点准备工作。
  首先添加依赖,注意是生产依赖而不是测试依赖:
implementation 'androidx.test.espresso:espresso-idling-resource:3.3.0'
  Espresso提供了 IdlingResource 接口,并默认有一个 CountingIdlingResource 实现,Espresso通过 IdlingResource#isIdleNow 知道是否空闲以继续执行测试。因此在一个耗时操作开始前,需要我们告诉Espresso现在开始“忙碌”了,耗时操作结束后又需要我们告诉它我们“忙完”了。这个动作需要在生产项目中操作,因此算是Espresso的一点“额外”的准备。现在让我们对 IdlingResourceActivity 进行小小的改造:
// 使用单一的CountingIdlingResource对象即可
object EspressoIdlingResource {
    private const val RESOURCE = "GLOBAL"
    var countingIdlingResource = CountingIdlingResource(RESOURCE, true)
        private set

    fun increment() {
        countingIdlingResource.increment()
    }

    fun decrement() {
        if (!countingIdlingResource.isIdleNow) {
            countingIdlingResource.decrement()
        }
    }
}

class IdlingResourceActivity : BaseActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        //...
        btn_login.setOnClickListener {
            lifecycleScope.launch {
                fetchFakeData {
                    tv_login_success.text = it
                }
            }
        }
    }

    private suspend fun fetchFakeData(callback: (text: String) -> Unit) {
        withContext(Dispatchers.IO) {
            // 耗时任务启动
            EspressoIdlingResource.increment()
            delay(3000)
            withContext(Dispatchers.Main) {
                // 耗时任务执行完毕
                EspressoIdlingResource.decrement()
                callback.invoke(getString(R.string.text_login_success))
            }
        }
    }

    @VisibleForTesting
    fun getIdlingResource(): IdlingResource {
        return EspressoIdlingResource.countingIdlingResource
    }
}
  接下来让我们完善一下测试代码,加入IdlingResource:
class IdlingResourceActivityTest {
    private lateinit var idlingResource: IdlingResource

    @Test
    fun testEspresso() {
        val scenario = ActivityScenario.launch(IdlingResourceActivity::class.java)
        scenario.onActivity {
            idlingResource = it.getIdlingResource()
            IdlingRegistry.getInstance().register(idlingResource)
        }

        onView(withId(R.id.et_name)).perform(typeText("wanandroid"), closeSoftKeyboard())
        onView(withId(R.id.et_pwd)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())

        onView(withId(R.id.tv_login_success)).check(matches(ViewMatchers.withText(R.string.text_login_success)))
    }

    @After
    fun release() {
        IdlingRegistry.getInstance().unregister(idlingResource)
    }
}
  这样Espresso就可以敏锐地感知耗时任务了,但是问题在于:一是我们要改造所有耗时任务以进行通知,二是这样的测试依赖真实环境,会因网络等原因出现意外,且无法测试所有边界。因此这种方式仅适用于明确希望按照实际代码执行的情况,并不适用于大规模使用。
  2. 更好的测试方式
  要覆盖问题的所有边界,就不能依赖真实的网络请求,而是使用mockito(没错,就是测试Repository时用的mocktio)。有了mockito,我们就可以假装成功和假装失败,并且没什么耗时。要使用mockito,耗时操作就不能直接写在Activity中,因此需要我们进行小小的改造,这里我们使用了ViewModel:
class IdlingResourceActivity : BaseActivity() {
    private val viewModelFactory: ViewModelProvider.Factory =
        IdlingResourceInjection.provideViewModelFactory()
    private val viewModel: IdlingResourceViewModel by viewModels { viewModelFactory }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.idling_resource_activity)
        btn_login.setOnClickListener {
            viewModel.fetchFakeData()
        }

        viewModel.fakeData.observe(this, {
            tv_login_success.text = getString(it)
        })
    }
}
  最终只要mock一个ViewModel对象,就可以实现我们的目的了:
class IdlingResourceActivityTest {
    private val viewModel = mock(IdlingResourceViewModel::class.java)

    @Test
    fun fakeTest() {
        val fakeData = MutableLiveData<Int>()
        doReturn(fakeData).`when`(viewModel).fakeData
        `when`(viewModel.fetchFakeData()).then {
            fakeData.postValue(R.string.text_login_success)
        }
        IdlingResourceInjection.viewModel = viewModel

        val scenario = ActivityScenario.launch(IdlingResourceActivity::class.java)
        onView(withId(R.id.et_name)).perform(typeText("wanandroid"), closeSoftKeyboard())
        onView(withId(R.id.et_pwd)).perform(typeText("123456"), closeSoftKeyboard())
        onView(withId(R.id.btn_login)).perform(click())

        onView(withId(R.id.tv_login_success)).check(matches(ViewMatchers.withText(R.string.text_login_success)))
    }
  总结
  经过上述各种操作,我们对Android单元测试有了一个大体的印象。首先可以肯定单元测试十分有用,可以大大加强我们对代码的信心,但写测试是一个把用手操作的过程变成自动化的过程,不仅繁琐而且枯燥,还极其考验耐心和细心程度,因此在人力或时间不足的情况下应该优先书写核心业务的测试,确保投入和产出拥有最高“性价比”。

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

关注51Testing

联系我们

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

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

沪ICP备05003035号

沪公网安备 31010102002173号