写作测试是程序员的关键技能。在这篇文章中,我们将走过所需的一切,以便成为熟练的测试作家。无论您是刚开始写作测试,还是希望加深您对主题的知识,我们将在这里为您提供一些东西。

虽然以下例子在Kotlin,但这些概念适用于整个行业。片段没有任何第三方图书馆,我将根据需要解释独特的kotlin关键字。您将能够接受您在此学习的内容并将其应用于您最爱的平台和语言。

在我们踏上我们的冒险之前,让我们了解我们为什么这样做。

为什么我们写测试

单元测试是一种工具,允许我们编写验证应用程序代码行为的代码。起初它看起来像是刚刚进入圈子,但是有很多很大的好处:

  1. 写作测试可以节省我们的时间从通过应用程序所需的手动努力。
  2. 我们的测试运行可以自动化,因此我们可以切换到不同的任务,而某些外部过程验证我们的代码。
  3. Having tests allows us to ship our code with much more confidence, as we've identified scenarios we want to validate and validated them already.
  4. Simply the act of writing tests can help you write more reliable code. Over time you'll get used to thinking of various constraints and flows that are important, and you'll end up thinking about them during the development process instead of retroactively when writing tests.

现在我们相信,让我们来看看我们想要测试的课程。

样本类

在此示例中,我们要测试获取和显示某些数据的ViewModel类。它应该有支持显示数据列表,无数据和全部,或获取数据错误。我们将使用我最喜欢的榜样,是口袋妖怪列表。

class pokemonlistviewmodel {private var state:pokemonliststate?= null val pokemon:list ?get()=(陈述?pokemonliststate.loaded)?data val sprolling:boolean get()= state是pokemonliststate.loading val showerror:boolean get()= state是pokemonliststate.error val showdata:boolean get()= state是pokemonliststate.loaded val showemptystate:boolean get()= state是pokemonliststate.empty init {fetchpokemomonlist()}私人有趣fetchpokemomonlist(){state = pokemonliststate.load trive try {val pokemonlist = pokemonservice()。getPokemon()状态=如果(pokemomonlist.isuspley()){pokemomonliststate.preasty} else {pokemonliststate.loaded(pokemonlist)}} catch(错误:throwable){state = pokemonliststate.error(错误)}}}

In addition to this file, let's look at two other relevant classes, just to understand what they look like:

/ * * * PokemonService类负责requesting all network data for us. */ class PokemonService { fun getPokemon(): List { // Fetch data from network } } /** * PokemonListState is a sealed class of each possible state our screen could be in. * If you're new to Kotlin, think of these like an enum type. */ sealed class PokemonListState { object Loading : PokemonListState() object Empty : PokemonListState() class Loaded(val data: List) : PokemonListState() class Error(val error: Throwable?) : PokemonListState() }

识别测试用例

在我们编写测试之前,我们应该准确了解我们想要测试的内容。保持那些测试用例的思想对于确保我们编写支持测试的好代码非常重要,这是我们谈论下一个。

测试一种方法是查看每一个公共方法和属性,并为每个公共方法和属性写一次测试用例以验证其行为。它还可能有助于在相关的用户体验方面考虑测试案例,这是我们在这里要做的事情。我们的ViewModel有三种可能的场景:

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

确定了我们想要测试的内容,让我们确保我们甚至可以这样做。

Writing Testable Code

令人惊讶的是,并非所有代码都可以是测试的。我给你的例子是一个无法准确测试的例子。

Avoid Hardcoded Dependencies

One of the problems we'll have when trying to test our viewmodel lies with this line right here:

val pokemonlist = pokemonservice()。getPokemon()

In the test cases we've identified, we want to validate three different scenarios for retrieving data. However, since our viewmodel has a direct dependency topokemonservice.,我们是怜悯那个班级的行为。我们无法告诉它成功返回或错误。

问题是我们内心的依赖PokemonListViewModel我们无法在测试期间控制。我们可以使用构造函数将服务传递给ViewModel类来解决这个问题。这通常被称为“依赖注入”。

Class PokemomonListViewModel(Private Val服务:Pokemonservice){// ...私人有趣fetchpokemomonlist(){state = pokemonliststate.load trive {val pokemonlist = service.getpokemon()// ...} catch(错误:throwable){状态= pokemonliststate.error(错误)}}}}}

使用接口定义预期的行为

While we've solved the problem of having an internal dependency, we have a new problem with ourpokemonservice.参数。让我们重新审视课程:

class PokemonService { fun getPokemon(): List { // Fetch data from network } }

Inside this class we have an already defined way of fetching data from a network. Which means from our testing code I can't modify this to fake a specific response. One option to support this is to create an interface. Interfaces are a way to define expected behavior, and leave the implementation details to classes.

通过创建界面,我们可以在我们的应用程序代码中有一个实现来与我们的网络交谈,并为我们的测试实现返回虚假响应的一个实现。

一旦我们创建了该界面,我们现在可以将该类型传递到我们的视图中:

接口PokemonRepository {Fun GetPokemon():list }类PokemonListViewModel(Private Val存储库:PokemonRepository){// ...}

Mocking Dependencies

The last thing we need to do before we can write our tests is to mock those data requests. This is important to create an isolated environment for our test to run in. While the actual application connects to the internet, we don't want to rely on this for the unit tests. We also don't want to bring the site down just to validate our error scenarios. ;)

现在,我们的课程是没有依赖的,并且它需要的行为由接口备份,我们可以为我们定义的每个测试用例创建一个实现:

//返回一个真正的pokemon类成功列表:pokemomonRepository {override fun getPokemon():list  {return listof(pokemon(name =“squirtle”)))} //返回一个空名单的口袋妖怪类emptyrepository:pokemonrepository{override fun getPokemon():列表 {return emptylist()}} //抛出一个例外以模拟网络错误类errorpository:pokemonRepository {override fun getPokemon():list  {drown throwable(“oblow throwable(”owlops))}}}

Writing Our Tests

我们采取了课堂并重新开始推荐它以支持测试。我们已经创建了必要的嘲笑数据。现在我们可以编写我们的第一个测试。让我们首先走过我们的成功案例:

  1. Create a viewmodel using our成功的延期
  2. Assert the list of pokemon exposed by the viewmodel matches what we expect from our fake repository.
  3. 验证showdata.是真的,而且淋浴道showloving.萨默塞特都是假的。

这是它看起来的。如果你是新的测试,assertequals.是一种采用两个参数的方法。第一个是你期待的价值,第二个是你正在检查的价值。如果这些不等于,它将抛出异常并使您的测试失败。

class pokemonlistviewmodeltest {@test fun成功= listof(pokemon(name =“squirtle”))val视图= pokemonlistviewmodel(appecticrepository())assertequals(truepokemon.pokemon)assertequals(true,ViewModel.showdata)assertequals(false,ViewModel.showloading)Assertequals(false,ViewModel.Showerror)Assertequals(False,ViewModel.showemptyState)}}}

负测试案例

写入单元测试时,也很重要的是考虑错误方案。崩溃是令人沮丧的用户体验。当某些错误发生时,确保我们不会崩溃更好,然后显示相关的错误消息。编写模拟这些流的测试单元测试确保避免了优雅地处理错误,并避免出乎意料的崩溃。

跟随我们已经了解到这一点的一切,我们可以从第一次测试中获取代码并为其他案例调整它。我们需要做的就是更改存储库,并修改相应的存储库assertequals.呼叫。

class PokemonListViewModelTest { @Test fun emptyFetch() { val viewModel = PokemonListViewModel(EmptyRepository()) assertEquals(null, viewModel.pokemon) assertEquals(false, viewModel.showData) assertEquals(false, viewModel.showLoading) assertEquals(false, viewModel.showError) assertEquals(true, viewModel.showEmptyState) } @Test fun errorFetch() { val viewModel = PokemonListViewModel(ErrorRepository()) assertEquals(null, viewModel.pokemon) assertEquals(false, viewModel.showData) assertEquals(false, viewModel.showLoading) assertEquals(true, viewModel.showError) assertEquals(false, viewModel.showEmptyState) } }

测试需要独立

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

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

这些测试的问题

让我们花点时间看看到目前为止我们已经完成了什么。我们拍摄了一些我们想要测试的代码,将其重组为分开问题并使您更容易测试,学会了如何模拟数据请求,然后验证了该行为。只有这三个测试是从我们所在的地方一大一步,我们可以随着我们之前提到的所有其他利益而继续发货。

As we move on to writing more tests for our codebase though, we're going to hit a couple of roadblocks.

  1. Those repetitiveassertequals.呼叫真的粗糙在眼睛上。当你滚动测试时,他们会有点流血,特别是因为他们在测试中看起来也一样。
  2. 每个测试紧密耦合到视图。我的意思是,每个测试都直接访问视图上的字段,如果我们要改变一些事情ViewModel.Pokemon.例如,我们必须更新引用它的每个测试。这最终可能会耗时。

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

测试机器人

回想起我们在删除依赖的开始时解决的问题pokemonservice.。除了测试目的之外,这可能对我们的视图典礼造成了问题,因为它需要对PokeMonservice的特定知识,以及我们的观点典礼。通过隐藏界面背后的行为,我们创建了一个系统,可以在其中改变实施细节pokemonservice.不必用它更新我们的视图。

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

Wouldn't it be nice if our test code read like this?

@Test乐趣successfulFetch(){VAL expectedPokemon = listOf(口袋妖怪(NAME = “小水龟”))PokemonListViewModelRobot().buildViewModel(库= SuccessfulRepository()).assertPokemonList(expectedPokemon).assertShowData(真).assertShowError(假).assertShowLoading(false).assertshowemptystate(false)}

创建测试机器人

要隐藏所有ViewModel从测试类中的实现细节,我们需要创建另一个类(我们的机器人类),我们将所有的行为包装。这意味着我们的机器人需要包含要测试的组件的引用,PokemonListViewModel

第一步是让我们的机器人定义它仿真的组件,在这种情况下,viewModel,并公开一种创建该组件的方法。

class pokemonlistviewmodelrobot {private lateinit var Viewmodel:pokemonlistviewmodel fun buildviewmodel(存储库:pokemonrepository):pokemonlistviewmodelrobot {this.viewmodel = pokemonlistviewmodel(存储库)返回此}}

在这个片段,胶林机是一个kotlin关键字,只是说我们定义了这个属性,但我们稍后会给它一个值,我们在里面做了一个值BuildViewModel.

另请注意,我们的方法的最后一行返回机器人的实例。允许我们做的事情是反复连锁调用,就像我们在最后一节中所做的那样。

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

Earlier we noted that our test doesn't need to know the implementation of a property. All it needs to do is assert the value. So for any information that our class makes available to read, we can create a correspondingassertThing()在我们的机器人中:

class pokemomllistviewmodelrobot {fun sermememon:list ?):pokemomonlistviewmodelrobot {val amantypokemon = ViewModel.Pokemon Assertequals返回此}有趣的assertshowloading(预期的show:boolean):pokemonlistviewmodelrobot {val ameralshowing = ViewModel.showloading assequals(预期的播出,实际播出)返回此} // ...}

Proxy Any Public Method Calls

在我们的示例中,我们没有任何我们可以在ViewModel上呼叫的公共方法。但是,如果我们这样做,机器人也将负责仅将信息传递给这些电话。例如,让我们说我们的视图表达了一个公共方法loadmorepokemon(count:int)。Our robot would implement it this way:

class PokemonListViewModelRobot { fun loadMorePokemon(countToLoad: Int): PokemonListViewModelRobot { viewModel.loadMorePokemon(countToLoad) return this } }

完成示例

现在我们的机器人已经涵盖了我们想要测试的每一个公共方法和财产,我们都可以重构我们所有的测试:

@Test乐趣successfulFetch(){VAL expectedPokemon = listOf(口袋妖怪(NAME = “小水龟”))PokemonListViewModelRobot().buildViewModel(库= SuccessfulRepository()).assertPokemonList(expectedPokemon).assertShowData(真).assertShowError(假).assertShowLoading(false)} @test fun emptyfetch(){pokemonlistviewmodelrobot().buildviewmodel(repository = emptyrepository()).assertpokemonlist(null).assertshowemptystate(true).assertshowerror(false).assertshowloading(false).assertshowdata(false).assertshowdata(false).assertshowdata(false)} @测试有趣errorfetch(){pokemonlistviewmodelrobot().buildviewmodel(repository = errorrupository()).assertpokemonlist(null).assertshowerror(true).assertshowloading(false).assertshowdata(false)}

机器人行动

The readability of these new tests and the ability to easily create tests by chaining calls together may be reason enough to adopt this pattern. To see another example where the power of test robots can shine, let's change up our viewmodel and see how quickly it would be to update our tests.

记得我们的showdata.property? It's a boolean property referenced by every one of our tests. Let's change it to an integer, just for example sake:

class pokemomonlistviewmodel {// ... val showdata:int get(){if(state是pokemonliststate.loaded){return 1} else {return 0}}}

Now all of our tests are failing, because they're expectingtrue代替1。想象一下,如果我们有几十个测试,而不是三个。

谢天谢地,我们重构了我们所有的测试,以使用机器人类,我可以在机器人内处理此调整。

class pokemomlistviewmodelrobot {fun sharertshowdata(pusinshows:boolean):pokemonlistviewmodelrobot {//将此布尔映射到viewmodel val的int暴露的int indectint = if(permanialshowing)1 else0 0 val ameralshowing = ViewModel.showdata Assertequals(ChecipantInt,ActualShowing)返回此}}}

随着两个线的变化,我们所有的测试都会再次传递。

搭档

哇!一个冗长的帖子,但你做到了。让我们简要回顾我们学到的一切:

  • 在我们可以为我们的代码编写单元测试之前,我们必须确保代码甚至可测试。
    • We do this by avoiding any internal dependencies and instead passing them in.
    • 我们可以在可能的情况下定义使用接口的行为,以便我们可以控制在测试用例中的实现。
    • 如果我们首先定义我们的测试用例,它可以帮助我们确定我们如何构建我们的代码以支持它们。
  • 嘲笑我们的依赖关系。这样做可确保我们的测试以可预测的方式运行,我们不依赖外部因素。
  • 利用测试机器人。这使得我们的测试易于阅读,并允许我们在应用程序代码更改时快速更新我们的测试。

If you have any further questions about testing, please reach out to me onTwitter

有兴趣在一个小团队上追求这样的伟大新做法吗?我们正在招聘!

资源

要从此博客中查看所有已完成的代码,我将每个文件放入一个GIST:https://gist.github.com/AdammC331/1362FEB28241EBFAC320C21F6E24F341