Testing in android

给app编写测试用例,很多时候我们只是听说过,没写的原因有很多:

  • 没时间,业务功能都写不完
  • 没心思,Bug都还没修完
  • 不知测试用例有啥用
  • 不会写App测试用例
  • 领导没这个要求

这篇文章我会从测试用例是什么,为什么要写,怎么写来描述一下这些概念

什么是测试用例

测试用例也是代码,业务代码用来实现功能需求;而测试用例用来验证业务代码逻辑是否正确,边缘情况是否考虑周全,新的代码有没有引入Bug,已修复的Bug会不会因为新加的逻辑又复现了。

一般写测试会遵循Given, When, Then三步

  • Given: 创建测试需要的环境和状态,以及要测的任务列表.
  • When: 运行测试用例,这会运行需要被覆盖的代码.
  • Then: 和预期结果比对测试的返回,输出是通过还是失败.

测试用例的好处

Google提倡写测试用例,认为这应该是应用开发过程中必要的一部分。通过持续对应用运行测试,可以在发布应用之前验证其正确性、功能行为和易用性。

测试还会为带来以下好处:

  • 快速获得故障反馈
  • 在开发周期中尽早进行故障检测
  • 更安全的代码重构,让你可以优化代码而不用担心bug回归。
  • 稳定的开发速度,帮助您最大限度地减轻技术债

Android 测试用例

策略

一个测试用例是否合理,符合预期?一般会从三个维度来衡量:

  • 覆盖率:测试用例可以覆盖多少行代码,覆盖多少种边缘Case
  • 速度:运行这个测试用例需要花费多少计算资源和持续多少时间,可能是几毫秒到几分钟
  • 拟真度:运行过程中的环境和数据是否真实产生还是mock,一般拟真度越高,速度越慢,这需要一个权衡

分类

测逻辑的用例我们希望它能快速运行;测环境因素的,会希望它尽量符合真实环境等。根据测试用例的侧重点不同一般会把测试用例分为下面三种:

  • 单元测试(Unit test):速度快,拟真度低,测试单个类的单个方法的一种Case
  • 集成测试(Integration test):速度稍慢,可以验证几个类之间的逻辑
  • 终端测试(E2e):拟真度高,但一般需要在终端上运行所以速度比较慢

分别对应Android测试代码里的@SmallTest,@MediumTest,@LargeTest,一般推荐的比例是7:2:1。@SmallTest 在android 工程的test目录下,其它两种需要依赖Android环境,放在androidTest目录下。

写一个SmallTest

我们需要测试覆盖的大部分场景都是业务逻辑代码,这些代码我们会内聚在一个个类里,比如XXViewModel或者mvp的present。写个测试用例来验证ViewModel逻辑和LiveData。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
@RunWith(AndroidJUnit4::class)
class TasksViewModelTest {

// 这是一个JUnit Rule, 它能确保Task按顺序可以重复的同步执行
@get:Rule
var instantExecutorRule = InstantTaskExecutorRule()

private lateinit var tasksViewModel: TasksViewModel
private lateinit var tasksRepository: FakeTasksRepository

@Before
fun setupTaskViewModel() {
Log.d("TaskViewModel", "-----0000 setupTaskViewModel")
LogUtil.logDebug("TaskViewModel", "-----1111 setupTaskViewModel")
tasksRepository = FakeTasksRepository()
val task1 = Task("Title1", "Description1")
val task2 = Task("Title2", "Description2", true)
val task3 = Task("Title3", "Description3", true)
tasksRepository.addTasks(task1, task2, task3)
tasksViewModel = TasksViewModel(tasksRepository)
}

@Test
fun addNewTask_setNewTaskEvent() {

// Create observer - no need for it to do anything!
val observer = Observer<Event<Unit>> {}
try {

// Observe the LiveData forever
tasksViewModel.newTaskEvent.observeForever(observer)

// When adding a new task
tasksViewModel.addNewTask()

// Then the new task event is triggered
val value = tasksViewModel.newTaskEvent.value
assertThat(value?.getContentIfNotHandled(), (not(nullValue())))

} finally {
// Whatever happens, don't forget to remove the observer!
tasksViewModel.newTaskEvent.removeObserver(observer)
}
}

@Test
fun addNewTask_setNewTaskEventNew() {
// Given a fresh ViewModel

// When adding a new task
tasksViewModel.addNewTask()

// Then the new task event is triggered
val value = tasksViewModel.newTaskEvent.getOrAwaitValue()

assertThat(value.getContentIfNotHandled(), not(nullValue()))
}

@Test
fun setFilterAllTasks_tasksAddVisible() {
tasksViewModel.setFiltering(TasksFilterType.ALL_TASKS)

val value = tasksViewModel.tasksAddViewVisible.getOrAwaitValue()

assertThat(value, `is`(true))
}
}

写一个MediumTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@ExperimentalCoroutinesApi
@MediumTest
@RunWith(AndroidJUnit4::class)
class TaskDetailFragmentTest {

private lateinit var repository: TasksRepository

@Before
fun initRepository() {
repository = FakeAndroidTasksRepository()
ServiceLocator.tasksRepository = repository
}

@After
fun cleanupDb() = runBlockingTest {
ServiceLocator.resetRepository()
}

@Test
fun activeTaskDetails_DisplayedInUi() = runBlockingTest {
// GIVEN - Add active (incomplete) task to the DB
val activeTask = Task("Active Task", "AndroidX Rocks", false)
repository.saveTask(activeTask)

// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(activeTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)
Thread.sleep(2000)
onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_title_text)).check(matches(withText("Active Task")))
onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
// and make sure the "active" checkbox is shown unchecked
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(not(isChecked())))

}

@Test
fun completedTaskDetails_DisplayedInUi() = runBlockingTest{
// GIVEN - Add completed task to the DB
val completedTask = Task("Completed Task", "AndroidX Rocks", true)
repository.saveTask(completedTask)

// WHEN - Details fragment launched to display task
val bundle = TaskDetailFragmentArgs(completedTask.id).toBundle()
launchFragmentInContainer<TaskDetailFragment>(bundle, R.style.AppTheme)

// THEN - Task details are displayed on the screen
// make sure that the title/description are both shown and correct
onView(withId(R.id.task_detail_title_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_title_text)).check(matches(withText("Completed Task")))
onView(withId(R.id.task_detail_description_text)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_description_text)).check(matches(withText("AndroidX Rocks")))
// and make sure the "active" checkbox is shown unchecked
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isDisplayed()))
onView(withId(R.id.task_detail_complete_checkbox)).check(matches(isChecked()))
}
}

依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Dependencies for local unit tests
testImplementation "junit:junit:$junitVersion"
testImplementation "org.hamcrest:hamcrest-all:$hamcrestVersion"
testImplementation "androidx.test.ext:junit-ktx:$androidXTestExtKotlinRunnerVersion"
testImplementation "androidx.test:core-ktx:$androidXTestCoreVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"
testImplementation "androidx.arch.core:core-testing:$archTestingVersion"
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

// AndroidX Test - Instrumented testing
androidTestImplementation "androidx.test.ext:junit:$androidXTestExtKotlinRunnerVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "junit:junit:$junitVersion"
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion"

总结

现实中我们总是沉迷于业务代码。测试用例的重要性很多时候只在理论中。通过两周的实践,有以下体验:

  • 写合格的测试用例很有挑战:你需要需要快速学习Test工具怎么用,发散的逆向思维来Mock边界,类似周伯通双手互搏术。
  • 测试用例很有价值:比如登录模块,支付模块,当你把所有异常情况都用测试用例覆盖了,你会觉得很稳,代码重构也会很有底气。监控出一个遗漏Case,补一个测试用例,会觉得更稳了。
  • 测试用例对代码思维提升:写几个测试用例后,你
    会不自觉开始审视自己写的代码逻辑,对架构也会开始有想法。因为烂代码是特别难写测试用例的。这样良性循环,代码越来越科学,测试用例也越来越健壮。

参考

https://codelabs.developers.google.com/codelabs/advanced-android-kotlin-training-testing-basics/index.html?index=..%2F..index#8