实用 AI

可在线运行 AI 集合,涵盖 AI 文案生成、写作辅助、AI 绘图与照片修复、AI 配音、字幕生成、语音转录以及 AI 视频创作和数字人等多种 AI 服务

查看详情

使用Mockito进行Android单元测试

Android面试技术要点汇总
2021-05-17 14:29 · 阅读时长39分钟
小课
一、为什么要集成单元测试

在软件开发中,很多时候我们都会忘记编写测试代码,甚至觉得没有必要,尤其是在APP开发中。经常会有人说:

  • APP是前端应用,真正的逻辑在服务端,所以服务端更应该编写测试代码。
  • APP开发中很难进行单元测试,因为大部分都是UI交互,所以最多也就需要写UI测试。
  • APP的逻辑相对服务端更加轻量,所以精力应该放在开发需求上,而不是编写测试代码。

实际上,随着业务扩展、团队的扩大,APP的逻辑也变得越来越复杂,甚至有不少公司根据业务拆分不同的团队,最后由负责构建的团队进行模块合并、打包上传。由于代码复杂性和团队协作的难度增加,可能会影响到APP的正常运行。编写良好的测试代码可以避免出现这种问题。

在APP开发中,测试主要分为以下三类:

  • UI测试,主要是通过模拟用户行为来与APP进行交互,然后根据需求来校验交互的结果是否正确,编写UI测试一般比较麻烦,而且需要通过模拟器来运行,执行比较耗时,在Android开发中,一般会使用EspressoUI Automator编写UI测试代码。
  • 集成测试,当需要多个模块或者依赖配合时,检查相互之间是否能够正确协作运行,在Android开发中最常用的集成测试框架是Roboelectric
  • 单元测试,在进行单元测试时,我们只需要关心被测系统,而不需要关心其依赖的其它系统或库,对于这些依赖,我们一般会使用一些框架来进行模拟。单元测试相对而言比较简单,而且无需依赖模拟器运行,可以快速执行,在Android开发中,最常用的单元测试框架有JUnitMockito

在APP开发中,一个典型测试比例大概是,UI测试占10%,集成测试占20%,单元测试占70%,本文主要专注于单元测试。

注意:阅读本文需要有使用Kotlin开发Android的经验
二、调整项目为MVP构架

在本文中,你将学会将一个简单的APP重构为Model-View-Presenter构架,并使用Mockito为它编写单元测试。

下面通过一个示例项目功能包括搜索课程,收藏课程,展示课程列表等功能。项目主要包括以下几个源文件:

  • MainActivity.kt: 主界面
  • SearchActivity.kt: 搜索界面
  • SearchResultsActivity.kt: 搜索结果页
  • CourseActivity.kt: 课程详情页
  • FavoritesActivity.kt: 课程收藏列表
  • CourseRepository.kt: 数据请求处理
  • CourseAdapter.kt: 课程列表适配器
文件大小修改时间
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}

当用户点击搜索按钮时:

  1. 处理用户输入的文本,清除字符串两端的空格。
  2. 判断文本是否为空。
  3. 如果为空则提示错误。
  4. 如果不为空则跳转下一个页面。
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}

搜索流程如下:

  1. 因为请求是移步的,所以在请求之前先展示一个Loading窗口。
  2. 调用接口获取数据。
  3. 如果成功获取到了数据则展示给用户。
  4. 如果没有获取到数据,则提示用户没有相关数据。
  5. 如果获取过程出现错误,则提示出错。

在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})
  1. 修改model的属性。
  2. 将改动保存起来。
  3. 更新界面显示。

可以看出,项目中很多业务逻辑都放在了Activity和Adapter中,这样的结构比较乱,而且不利于单元测试。下面我们将项目改为Model-View-Presenter结构,这样会让项目结构更加清晰,也利于集成单元测试。

使用Mockito进行Android单元测试

首先创建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}
  1. Presenter持有View的引用。
  2. 当View创建时,通过Presenter.attachView方法把View的引用传递给Presenter。
  3. 当View销毁时,通过Presenter.detachView方法将View的引用置空,防止内存泄漏。
  4. Presenter暴露search方法给View。
  5. 当搜索的文本是空时,提示用户,当文本不为空,则展示搜索结果。
  6. 定义View的需要暴露给Presenter的接口。

然后修改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}
  1. 实现SearchPresenter.View接口。
  2. 创建SearchPresenter对象,在onCreate中调用presenter.attachView(),将当前引用传递给Presenter。
  3. 修改搜索按钮的点击事件,通过调用presenter.search方法处理后续的逻辑。
  4. 当Activity销毁时,在onDestory回调中调用presenter.detachView方法。
  5. 实现showQueryRequiredMessage和showSearchResults方法。

创建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}
  1. SearchResultsPresenter依赖RecipeRepository,通过构造参数传入。
  2. 统一需要实现attachView和detachView。
  3. 同样实现SearchResultsPresenter.View接口。

这里的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. 暴露search方法。
  2. 异步请求接口获取数据。
  3. 如果成功获取到接口数据,则展示请求结果。
  4. 如果请求出错,则展示错误提示。

添加收藏相关的方法。

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}
  1. 暴露addFavorite方法。
  2. 修改model属性。
  3. 调用repository将改动保存起来。
  4. 通知UI更新。
  5. 同样的暴露removeFavorite方法。

然后修改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}
  1. 实现SearchResultsPresenter.View接口。
  2. 实例化Presenter,并且调用attachView方法。
  3. 通过Presenter来实现搜索功能。
  4. 当View销毁时,通过Presenter.detachView方法将View的引用置空,防止内存泄漏。

实现其它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}
  1. 现在添加收藏,只需要调Presenter.addFavorite方法。
  2. 删除收藏,只需要调Presenter.removeFavorite方法。

到目前为止,项目已经调整为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中使用的一些问题。

首先,我们需要明白基于状态验证和基于行为验证的区别:

  • 基于状态验证,是验证结果是否符合预期,比如常用的框架Junit就是基于状态验证。
  • 基于行为验证,不是验证结果是否正确,而是验证方法调用是否符合预期,Mockito就是基于行为验证的测试框架。

Mockito的主要功能有MockStubbing

  • 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}
  1. 单元测试方法都需要用@Test注解标记。
  2. 创建一个SearchPresenter对象。
  3. 我们需要验证SearchPresenter.View中的showQueryRequiredMessage是否调用,所以我们mock一个SearchPresenter.View对象,并且调用presenter.attachView让他们关联起来。
  4. 然后调用presenter.search("")。
  5. 最后验证SearchPresenter.View的showQueryRequiredMessage方法是否被调用。

运行单元测试,得到结果。

使用Mockito进行Android单元测试
使用Mockito进行Android单元测试
优化单元测试代码

搜索模块有很多单元测试方法,我们每一个测试方法都需要用到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修饰的,我们可以通过以下几种方式来解决:

  • 使用open关键字来修饰需要mock的类和方法。
  • 为需要mock的类和方法定义接口,让类实现接口,通过接口mock。
  • 使用mock-maker-inline(下面会介绍)。

下面开始编写搜索结果页的单元测试,首先定义接口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}
  1. 当调用presenter的search方法时,验证是否弹出loading,即调用view的showLoading方法。
  2. 当调用presenter的search方法时,验证是否会调用repository的getRecipes方法去请求数据,并且第一个参数是“eggs”,第二个参数可以时任意值。

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. 创建一个课程列表。
  2. 设置当调用repository.getRecipes方法时,拿到方法里面的第二个参数,也就是callback,然后调用他的onSuccess方法。
  3. 调用presenter的search方法。
  4. 验证view是否调用了showRecipes方法,并且参数就是上面定义的课程列表。

下面是基于状态验证的单元测试

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}
  1. 创建一个Course对象
  2. 调用presenter的addFavorite方法。
  3. 验证对象的isFavorite属性是否修改成功。

下面开始未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}
  1. 在addFavorite中调用了getFavoriteRecipes方法获取原来已经收藏的列表,然后添加当前的内容。
  2. 最后保存收藏列表到SharedPreferences中。

下面我们为它编写单元测试方法,首先我们需要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}
  1. mock一个SharedPreferences和SharedPreferences.Editor对象,并且进行配置stubbing。
  2. 因为我们既需要调用真实的addFavorite方法,而且还需要stubbing,所以我们使用spy功能包装一下真实创建的对象。

然后为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}
  1. 配置getFavoriteRecipes方法,当调用spyRepository.getFavoriteRecipes时返回一个空列表,注意:spyRepository是通过spy包装的真实创建的对象,调用方式和直接mock的不太一样。
  2. 调用spyRepository.addFavorite方法。
  3. 验证后续调用是否按照顺序执行。
  4. 验证保存的收藏列表是否和我们预期的一致。

下面是添加单元测试后的完整项目。

文件大小修改时间
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日
Mockito单元测试mockstubbingspy