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

通过亚当麦克尼尔公司

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

虽然下面的例子来自Kotlin,但这些概念适用于整个行业。这些代码片段不需要任何第三方库,我将根据需要解释唯一的Kotlin关键字。你将能够把你在这里学到的东西应用到你最喜欢的平台和语言上。

在我们开始冒险之前,让我们先了解一下我们为什么要这样做。

我们为什么要编写测试

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

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

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

一个示例类

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

类PokemonListViewModel {
private var state: PokemonListState?=零

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

val showLoading:布尔
get()=状态是pokemonliststate.loading

val showError:布尔
get()= state是pokemonliststate.error

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()

确定测试用例

在编写测试之前,我们应该确切地了解要测试的内容。记住这些测试用例对于确保我们编写支持测试的良好代码非常重要,这是我们接下来要讨论的。

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

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

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

写可测试代码

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

避免硬编码依赖性

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

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“哎呀”)

写我们的测试

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

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

这是它的样子。如果你是测试新手,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())
assertequals(null,weivemodel.pokemon)
viewModel.showData assertequal(假)
viewModel.showLoading assertequal(假)
viewModel.showError assertequal(假)
assertequal(真的,viewModel.showEmptyState)


@Test
乐趣errorFetch () {
/ /口袋onlistviewmodel (ErrorRepository())
assertequals(null,weivemodel.pokemon)
viewModel.showData assertequal(假)
viewModel.showLoading assertequal(假)
assertequal(真的,viewModel.showError)
viewModel.showEmptyState assertequal(假)

测试需要独立

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

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

这些测试的问题

让我们花点时间看看我们目前已经完成了什么。我们取了一些想要测试的代码,重新构造它以分离关注点并使其更容易测试,学习如何模拟数据请求,然后验证行为。只有这三个测试是一个很大的进步,我们可以继续发行更多的信心和所有其他好处,我们提到了早些时候。

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

  1. 这些重复的assertequal电话打得太刺眼了。当你滚动测试时,它们会在一起流血,特别是因为它们在测试中看起来一样。
  2. 每个测试都与视图模型紧密耦合。我的意思是,每个测试都直接访问视图模型上的字段,如果我们要更改一些东西viewModel.pokemon例如,我们必须更新引用它的每个测试。这最终可能会非常耗时。

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

测试机器人

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

同样,我们可以为我们的测试创建一个系统,以这种方式也是如此。如果我们考虑我们的测试试图验证,他们真的只是说“这是预期的口袋妖怪列表。”该测试不需要知道该列表如何暴露的具体细节。

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

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

PokemonListViewModelRobot ()
.buildViewModel(库= SuccessfulRepository ())
.assertPokemonList (expectedPokemon)
.assertShowData(真正的)
.AstshtShowerror(假)
.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(真正的)
.AstshtShowerror(假)
.assertShowLoading(假)


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


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

机器人的行动

这些新测试的可读性和通过链接调用轻松创建测试的能力可能是足以采用这种模式的原因。要查看测试机器人能力可以发光的另一个例子,让我们更改我们的视图,并查看更新测试的速度。

还记得我们showData财产?这是我们测试中的每一个都引用的布尔属性。让我们将其更改为整数,例如sake:

类PokemonListViewModel {
/ /……

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


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

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

类PokemonListViewModelRobot {

funassertshowdata (expectedshows: Boolean): PokemonListViewModelRobot {
//将这个布尔值映射到视图模型所暴露的int
val期望= if(permanialshowing)1其他0
val actual显示= viewModel.showData
assertequal (expectedInt actualShowing)
返回这

随着这两条线的改变,我们所有的测试又通过了。

回顾

哇!一篇很长的文章,但你做到了。让我们简要回顾一下我们所学到的一切:

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

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

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

资源

要从此博客中查看所有已完成的代码,我将每个文件放入一个GIST: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金宝搏官网