在软件开发中,很多时候我们都会忘记编写测试代码,甚至觉得没有必要,尤其是在APP开发中。经常会有人说:
实际上,随着业务扩展、团队的扩大,APP的逻辑也变得越来越复杂,甚至有不少公司根据业务拆分不同的团队,最后由负责构建的团队进行模块合并、打包上传。由于代码复杂性和团队协作的难度增加,可能会影响到APP的正常运行。编写良好的测试代码可以避免出现这种问题。
在APP开发中,测试主要分为以下三类:
在APP开发中,一个典型测试比例大概是,UI测试占10%,集成测试占20%,单元测试占70%,本文主要专注于单元测试。
注意:阅读本文需要有使用Kotlin开发Android的经验
在本文中,你将学会将一个简单的APP重构为Model-View-Presenter构架,并使用Mockito为它编写单元测试。
下面通过一个示例项目功能包括搜索课程,收藏课程,展示课程列表等功能。项目主要包括以下几个源文件:
文件 | 大小 | 修改时间 |
---|---|---|
app | 2021年12月14日 | |
build.gradle | 526 B | 2021年12月14日 |
gradle/wrapper | 2021年12月14日 | |
gradle.properties | 1 kB | 2021年12月14日 |
gradlew | 6 kB | 2021年12月14日 |
gradlew.bat | 3 kB | 2021年12月14日 |
local.properties | 437 B | 2021年12月14日 |
settings.gradle | 290 B | 2021年12月14日 |
在开始集成之前,我们先思考一个非常重要的问题,项目目前的结构是否适合添加单元测试?首先我们看看Activity中的源代码
1class SearchActivity : ChildActivity() {
2
3 override fun onCreate(savedInstanceState: Bundle?) {
4 ...
5 searchButton.setOnClickListener {
6 // 1
7 val query = ingredients.text.toString().trim()
8 // 2
9 if (query.isBlank()) {
10 // 3
11 Snackbar.make(searchButton, getString(R.string.search_query_required), Snackbar
12 .LENGTH_LONG).show()
13 } else {
14 // 4
15 startActivity(searchResultsIntent(query))
16 }
17 }
18 }
19}
当用户点击搜索按钮时:
1class SearchResultsActivity : ChildActivity() {
2
3 private fun search(query: String) {
4 // 1
5 showLoadingView()
6 // 2
7 repository.getRecipes(query, object : RecipeRepository.RepositoryCallback<List<Course>> {
8 override fun onSuccess(courses: List<Course>?) {
9 if (courses != null && courses.isNotEmpty()) {
10 // 3
11 showRecipes(courses)
12 } else {
13 // 4
14 showEmptyRecipes()
15 }
16 }
17
18 override fun onError() {
19 // 5
20 showErrorView()
21 }
22 })
23 }
24}
搜索流程如下:
在SearchResultsActivity.kt的adapter中处理添加收藏的逻辑。
1list.adapter = RecipeAdapter(recipes, object : RecipeAdapter.Listener {
2 override fun onAddFavorite(item: Recipe) {
3 // 1
4 item.isFavorited = true
5 // 2
6 repository.addFavorite(item)
7 // 3
8 list.adapter.notifyItemChanged(recipes.indexOf(item))
9 }
10 ...
11})
可以看出,项目中很多业务逻辑都放在了Activity和Adapter中,这样的结构比较乱,而且不利于单元测试。下面我们将项目改为Model-View-Presenter结构,这样会让项目结构更加清晰,也利于集成单元测试。
首先创建SearchPresenter.kt,把搜索相关的业务逻辑抽离到SearchPresenter中。
1class SearchPresenter {
2 // 1
3 private var view: View? = null
4
5 // 2
6 fun attachView(view: View) {
7 this.view = view
8 }
9
10 // 3
11 fun detachView() {
12 this.view = null
13 }
14
15 // 4
16 fun search(query: String) {
17 // 5
18 if (query.trim().isBlank()) {
19 view?.showQueryRequiredMessage()
20 } else {
21 view?.showSearchResults(query)
22 }
23 }
24
25 // 6
26 interface View {
27 fun showQueryRequiredMessage()
28 fun showSearchResults(query: String)
29 }
30}
然后修改SearchActivity,并实现SearchPresenter.View接口定义的方法。
1//1
2class SearchActivity : ChildActivity(), SearchPresenter.View {
3
4 private lateinit var binding: ActivitySearchBinding
5 private val presenter: SearchPresenter = SearchPresenter()
6
7 override fun onCreate(savedInstanceState: Bundle?) {
8 super.onCreate(savedInstanceState)
9 binding = ActivitySearchBinding.inflate(layoutInflater)
10 setContentView(binding.root)
11
12 //2
13 presenter.attachView(this)
14
15 binding.searchButton.setOnClickListener {
16 val query = binding.ingredients.text.toString()
17 // 3
18 presenter.search(query)
19 }
20 }
21
22 override fun onDestroy() {
23 // 4
24 presenter.detachView()
25 super.onDestroy()
26 }
27
28 // 5
29 override fun showQueryRequiredMessage() {
30 // Hide keyboard
31 val view = this.currentFocus
32 if (view != null) {
33 val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
34 imm.hideSoftInputFromWindow(view.windowToken, 0)
35 }
36
37 Snackbar.make(binding.searchButton, getString(R.string.search_query_required), Snackbar
38 .LENGTH_LONG).show()
39 }
40
41 // 6
42 override fun showSearchResults(query: String) {
43 startActivity(searchResultsIntent(query))
44 }
45}
创建SearchResultsPresenter.kt,定义接口。
1// 1
2class SearchResultsPresenter(val repository: RecipeRepository) {
3 private var view: SearchResultsPresenter.View? = null
4 private var recipes: List<Course>? = null
5
6 // 2
7 fun attachView(view: SearchResultsPresenter.View) {
8 this.view = view
9 }
10
11 fun detachView() {
12 this.view = null
13 }
14
15 // 3
16 interface View {
17 fun showLoading()
18 fun showRecipes(recipes: List<Course>)
19 fun showEmptyRecipes()
20 fun showError()
21 fun refreshFavoriteStatus(recipeIndex: Int)
22 }
23}
这里的Presenter也定义了attachView/detachView方法,我们可以抽象一个基类,把公共方法定义在基类中。
1abstract class BasePresenter<V> {
2 protected var view: V? = null
3
4 fun attachView(view: V) {
5 this.view = view
6 }
7
8 fun detachView() {
9 this.view = null
10 }
11}
然后让SearchResultsPresenter继承BasePresenter。
1class SearchResultsPresenter(val repository: RecipeRepository) :
2 BasePresenter<SearchResultsPresenter.View>() {
3
4 private var recipes: List<Course>? = null
5
6 interface View {
7 fun showLoading()
8 fun showRecipes(recipes: List<Course>)
9 fun showEmptyRecipes()
10 fun showError()
11 fun refreshFavoriteStatus(recipeIndex: Int)
12 }
13}
为SearchResultsPresenter添加search方法。
1 // 1
2fun search(query: String) {
3 view?.showLoading()
4 // 2
5 repository.getRecipes(query, object : RecipeRepository.RepositoryCallback<List<Course>> {
6 // 3
7 override fun onSuccess(recipes: List<Course>?) {
8 this@SearchResultsPresenter.recipes = recipes
9 if (recipes != null && recipes.isNotEmpty()) {
10 view?.showRecipes(recipes)
11 } else {
12 view?.showEmptyRecipes()
13 }
14 }
15
16 // 4
17 override fun onError() {
18 view?.showError()
19 }
20 })
21}
添加收藏相关的方法。
1// 1
2fun addFavorite(recipe: Course) {
3 // 2
4 recipe.isFavorite = true
5 // 3
6 repository.addFavorite(recipe)
7 // 4
8 val recipeIndex = recipes?.indexOf(recipe)
9 if (recipeIndex != null) {
10 view?.refreshFavoriteStatus(recipeIndex)
11 }
12}
13
14// 5
15fun removeFavorite(recipe: Course) {
16 repository.removeFavorite(recipe)
17 recipe.isFavorite = false
18 val recipeIndex = recipes?.indexOf(recipe)
19 if (recipeIndex != null) {
20 view?.refreshFavoriteStatus(recipeIndex)
21 }
22}
然后修改SearchResultsActivity,并实现SearchResultsPresenter.View接口定义的方法。
1// 1
2class SearchResultsActivity : ChildActivity(), SearchResultsPresenter.View {
3
4 private val presenter: SearchResultsPresenter by lazy {SearchResultsPresenter(RecipeRepository.getRepository(this))}
5
6 override fun onCreate(savedInstanceState: Bundle?) {
7 super.onCreate(savedInstanceState)
8 setContentView(R.layout.activity_list)
9
10 val query = intent.getStringExtra(EXTRA_QUERY)
11 supportActionBar?.subtitle = query
12
13 // 2
14 presenter.attachView(this)
15 // 3
16 presenter.search(query)
17 retry.setOnClickListener { presenter.search(query) }
18 }
19}
实现其它SearchResultsPresenter.View接口定义的方法。
1override fun showEmptyRecipes() {
2 binding.loading.container.visibility = View.GONE
3 binding.error.container.visibility = View.GONE
4 binding.list.visibility = View.VISIBLE
5 binding.empty.container.visibility = View.VISIBLE
6}
7
8override fun showError() {
9 binding.loading.container.visibility = View.GONE
10 binding.error.container.visibility = View.VISIBLE
11 binding.list.visibility = View.GONE
12 binding.empty.container.visibility = View.GONE
13}
14
15override fun refreshFavoriteStatus(recipeIndex: Int) {
16 binding.list.adapter?.notifyItemChanged(recipeIndex)
17}
18
19override fun showLoading() {
20 binding.loading.container.visibility = View.VISIBLE
21 binding.error.container.visibility = View.GONE
22 binding.list.visibility = View.GONE
23 binding.empty.container.visibility = View.GONE
24}
25
26override fun showRecipes(recipes: List<Course>) {
27 binding.loading.container.visibility = View.GONE
28 binding.error.container.visibility = View.GONE
29 binding.list.visibility = View.VISIBLE
30 binding.empty.container.visibility = View.GONE
31
32 setupRecipeList(recipes)
33}
34
35private fun setupRecipeList(recipes: List<Course>) {
36 binding.list.layoutManager = LinearLayoutManager(this)
37 binding.list.adapter = CourseAdapter(recipes, object : CourseAdapter.Listener {
38 override fun onClickItem(recipe: Course) {
39 startActivity(recipeIntent(recipe.sourceUrl))
40 }
41
42 override fun onAddFavorite(recipe: Course) {
43 // 1
44 presenter.addFavorite(recipe)
45 }
46
47 override fun onRemoveFavorite(recipe: Course) {
48 // 2
49 presenter.removeFavorite(recipe)
50 }
51 })
52}
到目前为止,项目已经调整为MVP的结构了,我们可以开始集成单元测试了,以下是调整完成的项目。
文件 | 大小 | 修改时间 |
---|---|---|
app | 2022年01月13日 | |
build.gradle | 526 B | 2021年12月14日 |
gradle/wrapper | 2021年12月14日 | |
gradle.properties | 1 kB | 2021年12月14日 |
gradlew | 6 kB | 2021年12月14日 |
gradlew.bat | 3 kB | 2021年12月14日 |
local.properties | 437 B | 2021年12月14日 |
settings.gradle | 290 B | 2021年12月14日 |
首先在gradle中添加Mockito的依赖
dependencies {
...
testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0'
...
}
Mockito-Kotlin是基于Mockito封装的,除了使用起来更加顺手外,还修复了一些Mockito库在kotlin中使用的一些问题。
首先,我们需要明白基于状态验证和基于行为验证的区别:
Mockito的主要功能有Mock和Stubbing
我们创建一个单元测试来验证当调用search方法时,如果传入的参数是空,则会调用showQueryRequiredMessage方法提示用户,首先我们删除ExampleUnitTest.kt,然后创建SearchTests.kt,代码如下:
1package com.example.mock
2
3import org.junit.Test
4import org.mockito.kotlin.mock
5import org.mockito.kotlin.verify
6
7class SearchTests {
8 // 1
9 @Test
10 fun search_withEmptyQuery_callsShowQueryRequiredMessage() {
11 // 2
12 val presenter = SearchPresenter()
13 // 3
14 val view: SearchPresenter.View = mock()
15 presenter.attachView(view)
16
17 // 4
18 presenter.search("")
19
20 // 5
21 verify(view).showQueryRequiredMessage()
22 }
23}
运行单元测试,得到结果。
搜索模块有很多单元测试方法,我们每一个测试方法都需要用到SearchPresenter和View对象,所以我们可以把一些重复的代码统一封装到一个方法中,并使用@Before注解标记,这样每次执行单元测试方法之前都会先运行该方法,如下
1class SearchTests {
2
3 private lateinit var presenter: SearchPresenter
4 private lateinit var view: SearchPresenter.View
5
6 @Before
7 fun setup() {
8 presenter = SearchPresenter()
9 view = mock()
10 presenter.attachView(view)
11 }
12
13 @Test
14 fun search_withEmptyQuery_callsShowQueryRequiredMessage() {
15 presenter.search("")
16 verify(view).showQueryRequiredMessage()
17 }
18
19 @Test
20 fun search_withEmptyQuery_doesNotCallsShowSearchResults() {
21 presenter.search("")
22 verify(view, never()).showSearchResults(anyString())
23 }
24}
有一点需要注意的是Mockito不能mock被final修饰的类和方法,而kotlin中定义的类和方法,默认都是final修饰的,我们可以通过以下几种方式来解决:
下面开始编写搜索结果页的单元测试,首先定义接口RecipeRepository和RepositoryCallback,修改Repository.kt。
1interface RecipeRepository {
2 fun addFavorite(item: Course)
3 fun removeFavorite(item: Course)
4 fun getFavoriteRecipes(): List<Course>
5 fun getRecipes(query: String, callback: RepositoryCallback<List<Course>>)
6}
7
8interface RepositoryCallback<in T> {
9 fun onSuccess(t: T?)
10 fun onError()
11}
12
13
14class RecipeRepositoryImpl(private val sharedPreferences: SharedPreferences) : RecipeRepository {
15
16 private val gson = Gson()
17
18 override fun addFavorite(item: Course) {
19 val favorites = getFavoriteRecipes() + item
20 saveFavorites(favorites)
21 }
22
23 override fun removeFavorite(item: Course) {
24 val favorites = getFavoriteRecipes() - item
25 saveFavorites(favorites)
26 }
27
28 private fun saveFavorites(favorites: List<Course>) {
29 val editor = sharedPreferences.edit()
30 editor.putString(FAVORITES_KEY, gson.toJson(favorites))
31 editor.apply()
32 }
33
34 private inline fun <reified T> Gson.fromJson(json: String): T =
35 this.fromJson(json, object : TypeToken<T>() {}.type)
36
37 override fun getFavoriteRecipes(): List<Course> {
38 val favoritesString = sharedPreferences.getString(FAVORITES_KEY, null)
39 if (favoritesString != null) {
40 return gson.fromJson(favoritesString)
41 }
42
43 return emptyList()
44 }
45
46 override fun getRecipes(query: String, callback: RepositoryCallback<List<Course>>) {
47 val call = ApiService.create().search(query)
48 call.enqueue(object : Callback<CourseListResponse> {
49 override fun onResponse(
50 call: Call<CourseListResponse>?,
51 response: Response<CourseListResponse>?
52 ) {
53 if (response != null && response.isSuccessful) {
54 val recipesContainer = response.body()
55 markFavorites(recipesContainer)
56 callback.onSuccess(recipesContainer?.courses)
57 } else {
58 callback.onError()
59 }
60 }
61
62 override fun onFailure(call: Call<CourseListResponse>?, t: Throwable?) {
63 callback.onError()
64 }
65 })
66 }
67
68 private fun markFavorites(courseListResponse: CourseListResponse?) {
69 if (courseListResponse != null) {
70 val favoriteRecipes = getFavoriteRecipes()
71 if (favoriteRecipes.isNotEmpty()) {
72 for (item in courseListResponse.courses) {
73 item.isFavorite = favoriteRecipes.map { it.id }.contains(item.id)
74 }
75 }
76 }
77 }
78
79 companion object {
80 fun getRepository(context: Context): RecipeRepository {
81 return RecipeRepositoryImpl(context.getSharedPreferences("Favorites", Context.MODE_PRIVATE))
82 }
83 }
84}
创建SearchResultsTests.kt编写单元测试方法
1class SearchResultsTests {
2
3 private lateinit var repository: RecipeRepository
4 private lateinit var presenter: SearchResultsPresenter
5 private lateinit var view: SearchResultsPresenter.View
6
7 @Before
8 fun setup() {
9 repository = mock()
10 view = mock()
11 presenter = SearchResultsPresenter(repository)
12 presenter.attachView(view)
13 }
14
15 // 1
16 @Test
17 fun search_callsShowLoading() {
18 presenter.search("eggs")
19 verify(view).showLoading()
20 }
21
22 // 2
23 @Test
24 fun search_callsGetRecipes() {
25 presenter.search("eggs")
26 verify(repository).getRecipes(eq("eggs"), any())
27 }
28}
Mock功能前面已经使用了,下面看看Stubbing如何使用。
1@Test
2fun search_withRepositoryHavingRecipes_callsShowRecipes() {
3 // 1
4 val recipe = Course("id", "title", "imageUrl", "sourceUrl", false)
5 val recipes = listOf(recipe)
6
7 // 2
8 doAnswer {
9 val callback: RepositoryCallback<List<Course>> = it.getArgument(1)
10 callback.onSuccess(recipes)
11 }.whenever(repository).getRecipes(eq("eggs"), any())
12
13 // 3
14 presenter.search("eggs")
15
16 // 4
17 verify(view).showRecipes(eq(recipes))
18}
下面是基于状态验证的单元测试
1@Test
2fun addFavorite_shouldUpdateRecipeStatus() {
3 // 1
4 val recipe = Course("id", "title", "imageUrl", "sourceUrl", false)
5
6 // 2
7 presenter.addFavorite(recipe)
8
9 // 3
10 Assert.assertTrue(recipe.isFavorite)
11}
下面开始未Repository编写单元测试方法,首先我们看看RecipeRepositoryImpl的addFavorite方法。
1override fun addFavorite(item: Course) {
2 //1
3 val favorites = getFavoriteRecipes() + item
4 saveFavorites(favorites)
5}
6//2
7override fun getFavoriteRecipes(): List<Course> {
8 val favoritesString = sharedPreferences.getString(FAVORITES_KEY, null)
9 if (favoritesString != null) {
10 return gson.fromJson(favoritesString)
11 }
12 return emptyList()
13}
下面我们为它编写单元测试方法,首先我们需要mock一个RecipeRepository对象,然后设置getFavoriteRecipes的返回值,但是我们又需要调用RecipeRepository的addFavorite方法,而调用mock对象的方法是没有用的,所以我们需要使用spy功能,对一个真实的对象方法进行stubbing。前面提到了mockito不能mock被final修饰的对象和方法,但是可以通过mock-maker-inline来解除限制,下面我们就来配置mock-maker-inline。
我们在app/src/test/resources/mockito-extensions目录下创建一个名为org.mockito.plugins.MockMaker的文件,内容如下:
mock-maker-inline
这样就可以了,下面创建RepositoryTests.kt,开始编写单元测试代码。
1class RepositoryTests {
2 private lateinit var spyRepository: RecipeRepository
3 private lateinit var sharedPreferences: SharedPreferences
4 private lateinit var sharedPreferencesEditor: SharedPreferences.Editor
5
6 @Before
7 fun setup() {
8 // 1
9 sharedPreferences = mock()
10 sharedPreferencesEditor = mock()
11 whenever(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor)
12
13 // 2
14 spyRepository = spy(RecipeRepositoryImpl(sharedPreferences))
15 }
16}
然后为addFavorite方法创建单元测试。
1@Test
2fun addFavorite_withEmptyRecipes_savesJsonRecipe() {
3 // 1
4 doReturn(emptyList<Course>()).whenever(spyRepository).getFavoriteRecipes()
5
6 // 2
7 val recipe = Course("id", "title", "imageUrl", "sourceUrl", false)
8 spyRepository.addFavorite(recipe)
9
10 // 3
11 inOrder(sharedPreferencesEditor) {
12 // 4
13 val jsonString = Gson().toJson(listOf(recipe))
14 verify(sharedPreferencesEditor).putString(any(), eq(jsonString))
15 verify(sharedPreferencesEditor).apply()
16 }
17}
下面是添加单元测试后的完整项目。
文件 | 大小 | 修改时间 |
---|---|---|
app | 2022年01月17日 | |
build.gradle | 526 B | 2021年12月14日 |
gradle/wrapper | 2021年12月14日 | |
gradle.properties | 1 kB | 2021年12月14日 |
gradlew | 6 kB | 2021年12月14日 |
gradlew.bat | 3 kB | 2021年12月14日 |
local.properties | 437 B | 2021年12月14日 |
settings.gradle | 290 B | 2021年12月14日 |