经得起时间的考验:可维护的单元测试指南

通过亚当麦克尼尔公司

编写测试是程序员的一项关键技能。在这篇文章中,我们将介绍成为一个熟练的测试编写人员所需要知道的一切。无论你是刚刚开始写测试,还是想要加深你对这门学科的知识,我们都将在这里为你提供一些东西。

虽然下面的例子是在Kotlin中,但是这些概念在整个行业都是适用的。这些代码片段不包含任何第三方库,我将在必要时解释独特的Kotlin关键字。你可以把你在这里学到的东西应用到你最喜欢的平台和语言上。

在我们开始我们的冒险之前,让我们明白我们为什么要这么做。

我们为什么要写测试

单元测试是一种工具,它允许我们编写验证应用程序代码行为的代码。乍一看,你可能只是在兜圈子,但这有很多好处:

  1. 编写测试为我们节省了运行应用程序所需的手工工作的时间。
  2. 我们的测试可以自动运行,因此当某些外部流程验证我们的代码时,我们可以切换到不同的任务。
  3. 有了测试,我们可以更加自信地发布代码,因为我们已经确定了我们想要验证的场景,并且已经验证了它们。
  4. 简单地编写测试可以帮助您编写更可靠的代码。随着时间的推移,您将习惯于考虑各种重要的约束和流,并且您将最终在开发过程中考虑它们,而不是在编写测试时向后考虑。

现在我们已经确信了,让我们看看我们想要测试的类。

一个示例类

在这个例子中,我们想测试一个视图模型类,它获取和显示一些数据。它应该支持显示数据列表,无数据和全部数据,或错误获取数据。我们将以我最喜欢的Pokemon列表为例。

类PokemonListViewModel {
private var state: PokemonListState?=零

val口袋妖怪:列表<口袋妖怪> ?
Get () = (state as?PokemonListState.Loaded) ? . data

val showLoading:布尔
get() = state是PokemonListState。加载

val showError:布尔
get() = state是PokemonListState。错误

val showData:布尔
get() = state是PokemonListState。加载

val showEmptyState:布尔
get() = state是PokemonListState。空

init {
fetchPokemonList ()


private fun fetchPokemonList() {
= PokemonListState状态。加载

尝试{
val pokemonList = PokemonService().getPokemon()

state = if (pokemonList.isEmpty()) {
PokemonListState。空
其他}{
PokemonListState.Loaded (pokemonList)

} catch (error: Throwable) {
状态= PokemonListState.Error(错误)


除了这个文件,让我们看看其他两个相关的类,只是为了理解它们的样子:

/**
* PokemonService是负责为我们请求所有网络数据的班级。
*/
类PokemonService {
getPokemon(): List {
//从网络获取数据



/**
*口袋妖怪列表状态是一个封闭的类,包含我们的屏幕可能处于的各种状态。
*如果您是Kotlin新手,请将它们视为enum类型。
*/
密封类PokemonListState {
对象加载:PokemonListState()
对象为空:PokemonListState()
类加载(val data: List): PokemonListState()
类错误(val错误:Throwable?): PokemonListState()

确定测试用例

在编写测试之前,我们应该准确地理解我们想要测试什么。记住这些测试用例对于确保我们正在编写支持测试的好代码是很重要的,这是我们接下来要讨论的。

测试的一种方法是查看每个公共方法和属性,并为每个公共方法和属性编写测试用例来验证它们的行为。根据相关的用户体验来考虑测试用例也会有所帮助,这就是我们在这里要做的。上面的视图模型有三种可能的场景:

  1. 当页面加载时,用户将看到一个pokemon列表。
  2. 当页面加载时,用户看到的是空状态,因为他们没有pokemon。
  3. 当页面加载时,用户会看到一条错误消息,因为应用程序无法请求该信息。

确定了我们想要测试的内容后,让我们确保我们能够做到这一点。

编写测试代码

令人惊讶的是,并不是所有的代码都可以进行单元测试。我上面给你的例子是一个无法准确测试的例子。

避免硬编码依赖关系

当我们试图测试我们的视图模型时,其中一个问题就在于这一行:

val pokemonList = PokemonService().getPokemon()

在我们已经确定的测试用例中,我们想验证检索数据的三个不同场景。然而,由于我们的视图模型直接依赖于PokemonService在美国,我们要受那个班级行为的支配。我们没有办法告诉它返回成功或返回错误。

问题是我们内部有一个依赖关系PokemonListViewModel我们在测试中无法控制的。我们可以通过使用构造函数将服务传递给视图模型类来解决这个问题。这通常被称为“依赖注入”。

类PokemonListViewModel (
private val service: PokemonService
){
/ /……

private fun fetchPokemonList() {
= PokemonListState状态。加载

尝试{
val pokemonList = service.getPokemon()
/ /……
} catch (error: Throwable) {
状态= PokemonListState.Error(错误)


用接口定义预期行为

虽然我们已经解决了内部依赖的问题,但我们还有一个新的问题PokemonService参数。让我们再来看看这门课:

类PokemonService {
getPokemon(): List {
//从网络获取数据

在这个类中,我们已经定义了一种从网络获取数据的方法。这意味着从我们的测试代码中,我不能修改这个来伪造一个特定的响应。支持这一点的一个选项是创建一个接口。接口是一种定义预期行为的方式,将实现细节留给类。

通过创建一个接口,我们可以在应用程序代码中有一个实现来与我们的网络通信,还有一个实现用于返回假响应的测试。

一旦我们创建了那个接口,我们现在就可以把那个类型传递到我们的视图模型中:

接口PokemonRepository {
乐趣getPokemon():列表<口袋妖怪>


类PokemonListViewModel (
private val repository: PokemonRepository
){
/ /……

嘲笑的依赖性

在编写测试之前,我们需要做的最后一件事是模拟这些数据请求。这对于为我们的测试创建一个独立的运行环境非常重要。虽然实际的应用程序连接到internet,但我们不希望在单元测试中依赖于此。我们也不希望仅仅为了验证错误场景而关闭站点。;)

既然我们的类不再依赖,并且它需要的行为由一个接口支持,我们可以为我们定义的每个测试用例创建一个实现:

//返回一个真实的pokemon列表
类成功的存储库:PokemonRepository {
override fun getPokemon(): List {
return listOf(Pokemon(name = "Squirtle"))



//返回一个空的pokemon列表
类EmptyRepository:口袋库{
override fun getPokemon(): List {
返回emptyList ()



//抛出一个异常来模拟网络错误
类ErrorRepository: PokemonRepository {
override fun getPokemon(): List {
(把Throwable“哎呀”)

写我们的测试

我们已经将我们的类进行了重构,以支持测试。我们已经创建了必要的模拟数据。现在我们可以编写第一个测试了。让我们先来看看我们的成功案例:

  1. 创建一个视图模型SuccessfulRepository
  2. 断言视图模型所暴露的pokemon列表与我们所期望的假仓库相匹配。
  3. 验证showData是真的,showErrorshowLoadingshowEmptyState都是假的。

这是它的样子。如果你是测试新手,assertequal是一个具有两个参数的方法。第一个是您期望的值,第二个是您要检查的值。如果它们不相等,它将抛出异常并使您的测试失败。

类PokemonListViewModelTest {

@Test
乐趣successfulFetch () {
val expectedPokemon = listOf(Pokemon(name = "Squirtle"))

val viewModel = PokemonListViewModel(SuccessfulRepository())
assertequal (expectedPokemon viewModel.pokemon)
assertequal(真的,viewModel.showData)
viewModel.showLoading assertequal(假)
viewModel.showError assertequal(假)
viewModel.showEmptyState assertequal(假)

负面测试用例

在编写单元测试时,考虑错误场景也很重要。崩溃是令人沮丧的用户体验。当某些错误发生时,最好确保不会崩溃,而是显示相关的错误消息。编写模拟这些流的单元测试可以确保优雅地处理错误并避免意外的崩溃。

根据我们到目前为止学到的所有内容,我们可以从第一次测试中获取代码,并针对其他情况对其进行调整。我们需要做的只是更改存储库,并修改相应的存储库assertequal调用。

类PokemonListViewModelTest {

@Test
乐趣emptyFetch () {
val viewModel = PokemonListViewModel(EmptyRepository())
assertequal (null, viewModel.pokemon)
viewModel.showData assertequal(假)
viewModel.showLoading assertequal(假)
viewModel.showError assertequal(假)
assertequal(真的,viewModel.showEmptyState)


@Test
乐趣errorFetch () {
val viewModel = PokemonListViewModel(ErrorRepository())
assertequal (null, viewModel.pokemon)
viewModel.showData assertequal(假)
viewModel.showLoading assertequal(假)
assertequal(真的,viewModel.showError)
viewModel.showEmptyState assertequal(假)

测试需要独立

注意,在上面的例子中,每个测试创建它自己的视图模型。这实际上是故意的。在编写测试时,我们应该确保每个测试都独立运行。这是因为类的所有测试都在一起运行,如果有任何属性跨多个测试运行,我们可能会产生意想不到的后果。

通过确保每个测试都有自己的视图模型,我们可以确信没有外部因素会导致失败。

这些测试的问题

让我们花点时间来看看我们到目前为止所取得的成就。我们使用一些我们想要测试的代码,重新构造它以分离关注点并使其更容易测试,学习如何模拟数据请求,然后验证行为。拥有这3个测试是我们向前迈出的一大步,我们可以带着更大的信心和我们之前提到的所有其他好处继续发行游戏。

当我们继续为我们的代码库编写更多的测试时,我们会遇到一些障碍。

  1. 这些重复的assertequal打电话真的很伤人。当你浏览试卷时,它们会混在一起,特别是因为它们在试卷中看起来是一样的。
  2. 每个测试都与视图模型紧密耦合。我的意思是,每个测试都直接访问视图模型上的字段,如果我们要改变一些东西viewModel.pokemon例如,我们必须更新每一个引用它的测试。这可能会非常耗时。

我们有一个解决方案是机器人模式。

测试机器人

回想一下我们在一开始解决的问题,在那里我们消除了对的依赖PokemonService.除了测试目的,这可能会给我们的视图模型带来问题,因为它需要PokemonService的特定知识,如果它改变了,我们的视图模型也会改变。通过在接口后面隐藏该行为,我们创建了一个可以更改实现细节的系统PokemonService而不用用它来更新视图模型。

类似地,我们也可以为我们的测试创建一个系统来以这种方式运行。如果我们思考我们的测试想要验证的是什么,他们实际上只是在说“这是我们期待的Pokemon列表。”测试不需要知道该列表如何公开的具体细节。

如果我们的测试代码读起来像这样不是很好吗?

@Test
乐趣successfulFetch () {
val expectedPokemon = listOf(Pokemon(name = "Squirtle"))

PokemonListViewModelRobot ()
.buildViewModel(库= SuccessfulRepository ())
.assertPokemonList (expectedPokemon)
.assertShowData(真正的)
.assertShowError(假)
.assertShowLoading(假)
.assertShowEmptyState(假)

创建测试机器人

为了对测试类隐藏所有视图模型的实现细节,我们需要创建另一个类(我们的机器人类)来包装所有的行为。这意味着我们的机器人需要包含对我们正在测试的组件的引用,PokemonListViewModel

第一步是让我们的机器人定义它正在模拟的组件,在本例中是视图模型,并公开一个方法来创建该组件。

类PokemonListViewModelRobot {
private lateinit var viewModel: PokemonListViewModel

PokemonListViewModelRobot {PokemonListViewModelRobot {PokemonListViewModelRobot {PokemonListViewModelRobot}
这一点。viewModel = PokemonListViewModel(库)
返回这

在这个片段中,lateinit是一个Kotlin关键字,它只是说我们定义了属性,但我们稍后会给它一个值,我们在里面做什么buildViewModel

另外,请注意,方法的最后一行返回机器人的实例。这样我们就可以像上一节中那样将调用重复地串在一起。

断言任何公共属性或Getter方法

前面我们注意到我们的测试不需要知道属性的实现。它所需要做的就是断言这个值。因此,对于类提供的可供阅读的任何信息,我们可以创建相应的assertThing ()在我们的机器人:

类PokemonListViewModelRobot {

Pokemon: List?): PokemonListViewModelRobot {
val actualPokemon = viewModel.pokemon
assertequal (expectedPokemon actualPokemon)
返回这


assertShowLoading(expectedshow: Boolean): PokemonListViewModelRobot {
val actualshow = viewModel.showLoading
assertequal (expectedShowing actualShowing)
返回这


/ /……

代理任何公共方法调用

在我们的例子中,我们没有任何可以在视图模型上调用的公共方法。如果我们这样做,机器人也会负责将信息传递给这些呼叫。例如,假设我们的视图模型有一个公共方法被调用loadMorePokemon(数:Int).我们的机器人会这样做:

类PokemonListViewModelRobot {

pokemon (counttload: Int): PokemonListViewModelRobot {
viewModel.loadMorePokemon (countToLoad)
返回这

完成的例子

现在,我们的机器人已经覆盖了我们想要测试的所有公共方法和属性,我们可以将所有的测试重构为:

@Test
乐趣successfulFetch () {
val expectedPokemon = listOf(Pokemon(name = "Squirtle"))

PokemonListViewModelRobot ()
.buildViewModel(库= SuccessfulRepository ())
.assertPokemonList (expectedPokemon)
.assertShowData(真正的)
.assertShowError(假)
.assertShowLoading(假)


@Test
乐趣emptyFetch () {
PokemonListViewModelRobot ()
.buildViewModel(库= EmptyRepository ())
.assertPokemonList(空)
.assertShowEmptyState(真正的)
.assertShowError(假)
.assertShowLoading(假)
.assertShowData(假)


@Test
乐趣errorFetch () {
PokemonListViewModelRobot ()
.buildViewModel(库= ErrorRepository ())
.assertPokemonList(空)
.assertShowError(真正的)
.assertShowLoading(假)
.assertShowData(假)

机器人的行动

这些新测试的可读性以及通过将调用链接在一起来轻松创建测试的能力可能是采用这种模式的充分理由。为了看到测试机器人的力量可以发挥作用的另一个例子,让我们改变我们的视图模型,看看更新我们的测试会有多快。

还记得我们showData财产吗?它是一个布尔属性,每个测试都引用它。我们把它改成整数,举个例子:

类PokemonListViewModel {
/ /……

val showData: Int
get () {
if (state is PokemonListState.Loaded) {
返回1
其他}{
返回0


现在我们所有的测试都失败了,因为他们在期待真正的而不是1.想象一下,如果我们有几十个测试,而不是只有三个。

幸运的是,我们已经重构了所有的测试,以使用机器人类,我可以在机器人内部处理这个调整。

类PokemonListViewModelRobot {

assertShowData(expectedshow: Boolean): PokemonListViewModelRobot {
//将这个布尔值映射到一个由视图模型公开的int类型
val expectedInt = if (expectedshow) 1 else 0
val actualshow = viewModel.showData
assertequal (expectedInt actualShowing)
返回这

随着这两行代码的更改,我们所有的测试都再次通过了。

回顾

哇!这篇文章很长,但你成功了。让我们简要回顾一下我们所学到的内容:

  • 在为代码编写单元测试之前,我们必须确保代码是可测试的。
  • 我们通过避免任何内部依赖,而不是传递它们来做到这一点。
  • 在可能的情况下,我们使用接口来定义行为,这样我们就可以控制测试用例中的实现。
  • 如果我们首先定义我们的测试用例,它将帮助我们确定如何构造我们的代码来支持它们。
  • 嘲笑我们的依赖性。这样做可以确保我们的测试以可预测的方式运行,而不依赖于外部因素。
  • 利用测试机器人。这使得我们的测试易于阅读,并允许我们在应用程序代码更改时快速更新测试。

如果你有任何关于测试的问题,请联系我推特

有兴趣在一个小团队中工作,追求一些像这样的伟大的新实践?我们招聘!

资源

为了从这个博客中看到所有完整的代码,我把每个文件放入一个要点:https://gist.github.com/AdamMc331/1362feb28241ebfac320c21f6e24f341

最初发表在https://tech.188bet金宝搏官网okcupid.com2019年11月26日。

188bet金宝搏官网OkCupid科技博客

阅读OkCupid工程团队每天连接数百万人的188bet金宝搏官网故事

188bet金宝搏官网OkCupid科技博客

188bet金宝搏官网OkCupid的工程团队负责每天为数百万人配对。在OkCupid科技博客上阅读他们的故事188bet金宝搏官网

188bet金宝搏官网OkCupid科技博客

188bet金宝搏官网OkCupid的工程团队负责每天为数百万人配对。在OkCupid科技博客上阅读他们的故事188bet金宝搏官网