经得起时间的考验:可维护单元测试指南
通过亚当麦克尼尔公司

编写测试是程序员的一项重要技能。在这篇文章中,我们将介绍成为一名熟练的测试编写者所需要知道的一切。无论你是刚刚开始写测试,还是想加深你对这门学科的知识,我们这里都有东西给你。
虽然下面的例子来自Kotlin,但这些概念适用于整个行业。这些代码片段不需要任何第三方库,我将根据需要解释唯一的Kotlin关键字。你将能够把你在这里学到的东西应用到你最喜欢的平台和语言上。
在我们开始冒险之前,让我们先了解一下我们为什么要这样做。
我们为什么要编写测试
单元测试是一种工具,它允许我们编写验证应用程序代码行为的代码。一开始你可能会觉得你只是在兜圈子,但这有很多好处:
- 编写测试为我们节省了运行应用程序所需的手动工作时间。
- 我们的测试可以自动运行,因此我们可以切换到不同的任务,同时一些外部进程验证我们的代码。
- 有了测试,我们可以更有信心地发布代码,因为我们已经确定了想要验证的场景,并且已经验证了它们。
- 简单地编写测试可以帮助您编写更可靠的代码。随着时间的推移,您将习惯于思考各种重要的约束和流程,最终您将在开发过程中考虑它们,而不是在编写测试时回溯考虑它们。
现在我们已经确信了,让我们看看我们想要测试的类。
一个示例类
在这个例子中,我们想要测试一个获取和显示一些数据的视图模型类。它应该支持显示数据列表,无数据和全部,或一个错误抓取数据。我们将使用我最喜欢的例子,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 fetchpokemon onlist () {
= PokemonListState状态。加载
尝试{
val pokemonList = PokemonService().getPokemon()
state = if (pokemonList.isEmpty()){/ /口袋里的内容
PokemonListState。空
其他}{
PokemonListState.Loaded (pokemonList)
}
} catch (error: Throwable) {
状态= PokemonListState.Error(错误)
}
}
}
除了这个文件,让我们看看另外两个相关的类,只是为了了解它们是什么样子的:
/**
PokemonService是负责为我们请求所有网络数据的类。
*/
类PokemonService {
getPokemon(): List {
//从网络中获取数据
}
}
/**
PokemonListState是一个封闭的类,包含了我们的屏幕可能处于的每一种状态。
*如果您是Kotlin的新手,可以将其视为enum类型。
*/
密封类PokemonListState {
对象加载:PokemonListState()
对象空:PokemonListState()
class Loaded(val data: List): PokemonListState()
类错误(val错误:Throwable?): PokemonListState()
}
确定测试用例
在编写测试之前,我们应该确切地了解要测试的内容。记住这些测试用例对于确保我们编写支持测试的良好代码非常重要,这是我们接下来要讨论的。
一种测试方法是查看每个公共方法和属性,并为每个方法和属性编写测试用例,以验证它们的行为。考虑与相关用户体验相关的测试用例也会有帮助,这就是我们在这里要做的。上面的视图模型有三种可能的场景:
- 当页面加载时,用户会看到一个口袋妖怪列表。
- 当页面加载时,用户会看到一个空的状态,因为他们没有口袋妖怪。
- 当页面加载时,用户会看到一条错误消息,因为应用程序无法请求该信息。
确定了要测试的内容后,让我们确保我们能够做到这一点。
编写测试代码
令人惊讶的是,并不是所有的代码都可以进行单元测试。上面我给你的例子是一个无法准确测试的例子。
避免硬编码依赖关系
当我们尝试测试我们的视图模型时,其中一个问题就在这一行:
val pokemonList = PokemonService().getPokemon()
在我们已经确定的测试用例中,我们希望验证检索数据的三个不同场景。然而,由于我们的视图模型直接依赖于PokemonService
在美国,我们受那个班学生行为的支配。我们没有办法告诉它返回成功或返回错误。
问题是我们内部有一个依赖性PokemonListViewModel
我们在测试时无法控制的我们可以通过使用构造函数将服务传递给视图模型类来解决这个问题。这通常被称为“依赖项注入”。
类PokemonListViewModel (
私人val服务:PokemonService
){
/ /……
private fun fetchpokemon onlist () {
= PokemonListState状态。加载
尝试{
val pokemonList = service.getPokemon()
/ /……
} catch (error: Throwable) {
状态= PokemonListState.Error(错误)
}
}
}
定义接口的预期行为
虽然我们已经解决了内在依赖的问题,但我们的PokemonService
参数。让我们回顾一下这门课:
类PokemonService {
getPokemon(): List {
//从网络中获取数据
}
}
在这个类中,我们已经定义了从网络获取数据的方法。这意味着从我们的测试代码中,我不能修改它来伪造一个特定的响应。支持此功能的一种选择是创建接口。接口是定义预期行为的一种方法,而将实现细节留给类。
通过创建一个接口,我们可以在我们的应用程序代码中有一个实现来与我们的网络对话,还有一个实现用于我们的测试,它会返回虚假的响应。
一旦我们创建了那个接口,我们就可以把那个类型传递到我们的视图模型中了:
接口PokemonRepository {
乐趣getPokemon():列表<口袋妖怪>
}
类PokemonListViewModel (
私有val存储库:PokemonRepository
){
/ /……
}
嘲笑的依赖性
在编写测试之前,我们需要做的最后一件事是模拟这些数据请求。这对于创建一个独立的环境来运行我们的测试非常重要。当实际的应用程序连接到互联网时,我们不希望在单元测试中依赖它。我们也不想仅仅为了验证错误场景而关闭站点。;)
既然我们的类没有依赖关系,并且它需要的行为有接口支持,我们可以为我们定义的每个测试用例创建一个实现:
//返回一个真实的口袋妖怪列表
类SuccessfulRepository: PokemonRepository {
getPokemon(): List {
(口袋妖怪(name = "Squirtle"))
}
}
//返回一个空的pokemon列表
类EmptyRepository: PokemonRepository {
getPokemon(): List {
返回emptyList ()
}
}
//抛出异常来模拟网络错误
类ErrorRepository: PokemonRepository {
getPokemon(): List {
(把Throwable“哎呀”)
}
}
写我们的测试
我们对我们的类进行了重构,以支持测试。我们已经创建了必要的模拟数据。现在我们可以编写第一个测试了。让我们从成功案例开始:
- 创建一个视图模型使用
SuccessfulRepository
. - 断言视图模型所暴露的pokemon列表与我们从假仓库中所期望的相符。
- 验证
showData
是真的,showError
,showLoading
,showEmptyState
都是假的。
这是它的样子。如果你是测试新手,assertequal
是一个接受两个参数的方法。第一个是你期望的值第二个是你检查的值。如果它们不相等,它将抛出异常并失败测试。
类PokemonListViewModelTest {
@Test
乐趣successfulFetch () {
val expectedPokemon = listOf(Pokemon(name = "Squirtle"))
/ /口袋onlistviewmodel (successrepository ())
assertequal (expectedPokemon viewModel.pokemon)
assertequal(真的,viewModel.showData)
viewModel.showLoading assertequal(假)
viewModel.showError assertequal(假)
viewModel.showEmptyState assertequal(假)
}
}
负面测试用例
在编写单元测试时,考虑错误场景也很重要。崩溃是令人沮丧的用户体验。当某些错误发生时,最好确保我们不会崩溃,而不是显示相关的错误消息。编写模拟这些流的单元测试可以确保适当地处理错误并避免意外的崩溃。
根据到目前为止我们所学到的一切,我们可以从第一次测试中获取代码,并针对其他情况进行调整。我们需要做的只是更改存储库,并修改相应的内容assertequal
调用。
类PokemonListViewModelTest {
@Test
乐趣emptyFetch () {
/ /口袋onlistviewmodel =口袋onlistviewmodel (EmptyRepository())
assertequal (null, viewModel.pokemon)
viewModel.showData assertequal(假)
viewModel.showLoading assertequal(假)
viewModel.showError assertequal(假)
assertequal(真的,viewModel.showEmptyState)
}
@Test
乐趣errorFetch () {
/ /口袋onlistviewmodel (ErrorRepository())
assertequal (null, viewModel.pokemon)
viewModel.showData assertequal(假)
viewModel.showLoading assertequal(假)
assertequal(真的,viewModel.showError)
viewModel.showEmptyState assertequal(假)
}
}
测试需要独立
注意,在上面的例子中,每个测试创建它自己的视图模型。这实际上是故意的。在编写测试时,我们应该确保每个测试都是独立运行的。这是因为类的所有测试一起运行,如果有任何属性跨多个测试运行,我们可能会产生意想不到的结果。
通过确保每个测试都有它自己的视图模型,我们可以确信没有外部因素可能导致失败。
这些测试的问题
让我们花点时间看看我们目前已经完成了什么。我们取了一些想要测试的代码,重新构造它以分离关注点并使其更容易测试,学习如何模拟数据请求,然后验证行为。只有这三个测试是一个很大的进步,我们可以继续发行更多的信心和所有其他好处,我们提到了早些时候。
但是,当我们继续为代码库编写更多测试时,我们将遇到一些障碍。
- 这些重复的
assertequal
电话打得太刺眼了。当你滚动测试时,它们会在一起流血,特别是因为它们在测试中看起来一样。 - 每个测试都与视图模型紧密耦合。我的意思是,每个测试都直接访问视图模型上的字段,如果我们要更改一些东西
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
funbuildviewmodel (repository: PokemonRepository): PokemonListViewModelRobot {
这一点。viewModel = PokemonListViewModel(库)
返回这
}
}
在这个片段中,lateinit
是一个Kotlin关键字,它表示我们正在定义属性,但我们稍后会给它一个值,我们在里面做什么buildViewModel
.
另外,注意方法的最后一行返回机器人的实例。它允许我们做的是像上一节中所做的那样重复地将调用串在一起。
断言任何公共属性或Getter方法
前面我们注意到,我们的测试不需要知道属性的实现。它所需要做的就是断言值。因此,对于类提供的任何信息,我们可以创建相应的assertThing ()
在我们的机器人:
类PokemonListViewModelRobot {
fun assertPokemonList(expectedPokemon: List?): PokemonListViewModelRobot {
val actualPokemon = viewModel.pokemon
assertequal (expectedPokemon actualPokemon)
返回这
}
funassertshowloading (expectedshows: Boolean): PokemonListViewModelRobot {
val actual显示= viewModel.showLoading
assertequal (expectedShowing actualShowing)
返回这
}
/ /……
}
代理任何公共方法调用
在我们的示例中,我们没有任何可以在视图模型上调用的公共方法。但是,如果我们这样做了,机器人也将负责向这些调用传递信息。例如,假设我们的视图模型有一个被调用的公共方法loadMorePokemon(数:Int)
.我们的机器人会这样实现:
类PokemonListViewModelRobot {
funloadmorepokemon (countToLoad: Int): pokemon onlistviewmodelrobot {
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 () {
如果(state is PokemonListState.Loaded) {
返回1
其他}{
返回0
}
}
}
现在我们所有的测试都失败了,因为他们在期待真正的
而不是1
.想象一下,如果我们有几十个测试,而不是只有三个。
幸运的是,我们已经重构了所有测试以使用一个robot类,我可以在robot内部处理这个调整。
类PokemonListViewModelRobot {
funassertshowdata (expectedshows: Boolean): PokemonListViewModelRobot {
//将这个布尔值映射到视图模型所暴露的int
val expectedInt = if (expectedInt) 1 else 0
val actual显示= viewModel.showData
assertequal (expectedInt actualShowing)
返回这
}
}
随着这两条线的改变,我们所有的测试又通过了。
回顾
哇!一篇很长的文章,但你做到了。让我们简要回顾一下我们所学到的一切:
- 在我们可以为代码编写单元测试之前,我们必须确保代码是可测试的。
- 我们通过避免任何内部依赖,而是将它们传递进来。
- 在可能的情况下,我们使用接口定义行为,这样我们就可以在测试用例中控制实现。
- 如果我们先定义测试用例,它将帮助我们确定如何构建代码来支持它们。
- 嘲笑我们的依赖性。这样做可以确保我们的测试以可预测的方式运行,并且我们不依赖于外部因素。
- 利用测试机器人。这使我们的测试易于阅读,并允许我们在应用程序代码更改时快速更新测试。
如果你有任何关于测试的问题,请联系我推特.
有兴趣在一个小团队中工作,追求许多这样的伟大的新实践吗?我们招聘!
资源
为了查看这篇博客中所有完整的代码,我将每个文件放入一个gist中:https://gist.github.com/AdamMc331/1362feb28241ebfac320c21f6e24f341
最初发表在https://tech.188bet金宝搏官网okcupid.com2019年11月26日。