Stand The Test Of Time: A Guide To Maintainable Unit Testing

ByAdam McNeilly

Writing tests is a crucial skill for programmers. In this post we’re going to walk through everything you need to know to become a skilled test writer. Whether you’re just getting started writing tests, or looking to deepen your knowledge of the subject, we’ll have something for you here.

尽管以下示例在Kotlin中,但这些概念在整个行业中都适用。这些摘要没有任何第三方库,我将根据需要说明独特的Kotlin关键字。您将能够将自己学到的知识应用于最喜欢的平台和语言。

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

为什么我们写测试

Unit testing is a tool that allows us to write code that validates the behavior of our application code. At first it may seem like you’re just going in circles, but there’s a number of great benefits:

  1. Writing tests saves us time from the manual effort required to run through the app.
  2. The running of our tests can be automated, so we can switch to a different task while some external process validates our code.
  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. 简单地编写测试的行为可以帮助您编写更多可靠的代码。随着时间的流逝,您会习惯于考虑重要的各种限制和流动,最终您会在开发过程中考虑它们,而不是在编写测​​试时追溯。

Now that we’re convinced, let’s look at a class we want to test.

A Sample Class

在此示例中,我们想测试一个查看和显示一些数据的ViewModel类。它应该支持显示数据列表,无数据和所有数据,或一个错误获取数据。我们将使用我最喜欢的示例,即口袋妖怪列表。

类PokemonListViewModel {
private var state: PokemonListState? = null

val pokemon: List?
get()=(状态为?pokemonliststate.poaded)?

val showLoading: Boolean
get() = state is PokemonListState.Loading

val showError: Boolean
get() = state is PokemonListState.Error

val showData: Boolean
get()=状态为pokemonliststate.poaded

val showEmptyState: Boolean
get() = state is PokemonListState.Empty

init {
fetchPokemonList()
}

private fun fetchPokemonList() {
state = PokemonListState.Loading

try {
Val PokemonList = PokeMonservice()。getPokemon()

state = if (pokemonList.isEmpty()) {
PokemonListState.Empty
} else {
PokemonListState.Loaded(PokemonList)
}
} catch (error: Throwable) {
state = PokemonListState.Error(error)
}
}
}

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

/**
* PokemonService is the class responsible for requesting all network data for us.
*/
class PokemonService {
Fun GetPokemon():列表 {
// Fetch data from network
}
}

/**
* PokemonListState is a sealed class of each possible state our screen could be in.
*如果您是Kotlin的新手,请将这些视为枚举类型。
*/
sealed class PokemonListState {
object Loading : PokemonListState()
object Empty : PokemonListState()
class Loaded(val data: List) : PokemonListState()
类错误(Val错误:可投掷?):PokemonListState()
}

识别测试用例

在我们编写测试之前,我们应该准确理解要测试的内容。牢记这些测试案例对于确保我们编写支持测试的良好代码很重要,我们接下来会谈论。

One approach to testing is to look at every public method and property and write a test case for each of them to validate their behavior. It may also help to think of test cases with respect to the relevant user experiences, which is what we’re going to do here. There are three possible scenarios for our viewmodel above:

  1. When the page loads, the user sees a list of pokemon.
  2. 当页面加载时,用户看到一个空状态,因为他们没有口袋妖怪。
  3. When the page loads, the user sees an error message because the application was unable to request the information.

Having identified what we want to test, let’s make sure we can even do that.

Writing Testable Code

Surprisingly, not all code can be unit tested. The example I gave you above is one that cannot be tested accurately.

Avoid Hardcoded Dependencies

试图测试我们的ViewModel时,我们将遇到的问题之一在于此行:

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, we are at the mercy of that class's behavior. We have no way of telling it to return successfully or with an error.

The problem is that we have a dependency insidePokemonListViewModel在测试期间我们无法控制。我们可以通过使用构造函数将服务传递到ViewModel类来解决。这通常称为“依赖注入”。

class PokemonListViewModel(
私人瓦尔服务:PokeMonservice
) {
// ...

private fun fetchPokemonList() {
state = PokemonListState.Loading

try {
val pokemonList = service.getPokemon()
// ...
} catch (error: Throwable) {
state = PokemonListState.Error(error)
}
}
}

Define Expected Behavior With Interfaces

While we’ve solved the problem of having an internal dependency, we have a new problem with ourPokemonServiceparameter. Let's revisit the class:

class PokemonService {
Fun GetPokemon():列表 {
// 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.

通过创建一个接口,我们can have one implementation inside our app code to talk to our network, and one implementation for our tests that returns fake responses.

Once we’ve created that interface, we can pass that type into our viewmodel now:

interface PokemonRepository {
fun getPokemon(): List
}

class PokemonListViewModel(
private val repository: PokemonRepository
) {
// ...
}

Mocking Dependencies

我们要编写测试之前需要做的最后一件事是模拟这些数据请求。这对于创建一个孤立的环境很重要。我们的测试要进行。尽管实际应用程序连接到Internet,但我们不想将其依赖于单元测试。我们也不想将网站放下来只是为了验证我们的错误方案。)

既然我们的课程没有依赖关系,并且其要求的行为得到了接口的支持,那么我们可以为我们定义的每个测试用例创建一个实现:

//返回神奇宝贝的真实列表
class SuccessfulRepository : PokemonRepository {
覆盖娱乐getPokemon():list {
return listOf(Pokemon(name = "Squirtle"))
}
}

// Returns an empty list of pokemon
class EmptyRepository : PokemonRepository {
覆盖娱乐getPokemon():list {
return emptyList()
}
}

// Throws an exception to simulate a network error
类errorrepository:pokemonrepository {
覆盖娱乐getPokemon():list {
投掷(“ whops”)
}
}

Writing Our Tests

我们参加了课程,并重新分配了它以支持测试。我们创建了必要的模拟数据。现在我们可以编写我们的第一个测试。让我们开始浏览我们的成功案例:

  1. Create a viewmodel using ourSuccessfulRepository
  2. 主张ViewModel揭露的口袋妖怪列表与我们对假存储库的期望相匹配。
  3. Verify thatshowDatais true, andshowError,showLoading,showEmptyStateare all false.

Here is what that looks like. If you’re new to testing,assertEqualsis a method that takes in two parameters. The first one is the value you're expecting and the second one is the value you're checking against. If these are not equal, it will throw an exception and fail your test.

类PokemonListViewModeltest {

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

val viewModel = PokemonListViewModel(SuccessfulRepository())
assertEquals(expectedPokemon, viewModel.pokemon)
assertEquals(true, viewModel.showData)
assertEquals(false, viewModel.showLoading)
assertEquals(false, viewModel.showError)
assertEquals(false, viewModel.showEmptyState)
}
}

Negative Test Cases

When writing unit tests, it’s important to consider the error scenarios too. Crashes are frustrating user experiences. When certain errors occur, it’s much better to make sure we don’t crash, and instead show a relevant error message. Writing unit tests that mimic these flows ensure that errors are handled gracefully and unexpected crashes are avoided.

Following everything we’ve learned up to this point, we can take the code from the first test and tweak it for the other cases. All we need to do is change the repository, and modify the correspondingassertEqualscall.

类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)
}
}

Tests Need To Be Independent

注意,在上面的例子中,每个测试创建its own viewmodel. This is actually done intentionally. When writing tests, we should make sure that each test runs independently of one another. This is because all of the tests for the class run together, and if there are any properties that carry over across multiple tests, we could have unintended consequences.

By making sure each test has its own viewmodel, we can be confident that no external factors could be causing failures.

The Problem With These Tests

让我们花点时间看看我们成事实shed so far. We took some code we wanted to test, restructured it to separate concerns and make it easier to test, learned how to mock data requests, and then validated the behavior. Having just these three tests is a big step up from where we were, and we can go on to ship with more confidence and with all the other benefits we mentioned earlier.

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

  1. 那些重复的assertEqualscalls are really rough on the eyes. As you scroll through tests they'll kinda just bleed together, especially because they look they same across tests.
  2. Each test is tightly coupled to the viewmodel. What I mean by this is that each test is directly accessing fields on the viewmodel, and if we were to change something aboutviewModel.pokemonfor example, we would have to update each and every test that references it. This could end up being really time consuming.

One solution we have is the robot pattern.

Test Robots

Think back to the problem we addressed in the beginning where we removed the dependency toPokemonService。除了测试目的之外,这可能会引起我们的ViewModel问题,因为它需要对PokeMonservice的特定知识,如果它更改了,我们的ViewModel也会更改。通过将这种行为隐藏在界面后面,我们创建了一个系统,我们可以在其中更改的实现细节PokemonServicewithout having to update our viewmodel with it.

Similarly, we can create a system for our tests to behave this way as well. If we think about what our tests are trying to validate, they’re really just saying “this is the list of Pokemon that is expected.” The test doesn’t need to know the specific details for how that list is exposed.

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

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

PokemonListViewModelrobot()
.buildViewModel(repository = succesrepository())
.ASSERTPOKEMOMELIST(ExpectionPokeMon)
.assertShowData(真正的)
。assertShowError(false)
。assertShowLoading(false)
。assertShowEmptyState(false)
}

Creating A Test Robot

To hide all of the viewmodel’s implementation details from the test class, we need to create another class (our robot class) that we wrap all of that behavior with. This means our robot needs to contain a reference to the component we’re testing,PokemonListViewModel

The first step is to have our robot define the component it’s emulating, in this case the viewmodel, and expose a method to create that component.

class PokemonListViewModelRobot {
私人Lateinit var ViewModel:PokemonListViewModel

fun buildViewModel(repository: PokemonRepository): PokemonListViewModelRobot {
this.viewModel = PokemonListViewModel(repository)
return this
}
}

In this snippet,lateinitis a Kotlin keyword that just says we're defining the property, but we're going to give it a value later, which we do insidebuildViewModel

Also, note that the last line of our method returns the instance of the robot. What that allows us to do is repeatedly chain calls together like we did in the last section.

Assert Any Public Properties Or Getter Methods

我们早些时候注意到,我们的测试不需要知道财产的实施。它要做的就是断言价值。因此,对于我们的班级可以阅读的任何信息,我们可以创建相应的assertThing()in our robot:

class PokemonListViewModelRobot {

Fun AssertPokeMonList(ExpectionPokemon:List ?):PokemonListViewModelrobot {
val actualPokemon = viewModel.pokemon
assertEquals(expectedPokemon, actualPokemon)
return this
}

fun assertShowLoading(expectedShowing: Boolean): PokemonListViewModelRobot {
val actualShowing = viewModel.showLoading
assertEquals(expectedShowing, actualShowing)
return this
}

// ...
}

代理任何公共方法调用

在我们的示例中,我们没有任何可以在ViewModel上调用的公共方法。但是,如果我们这样做了,机器人也将仅将信息传递给这些电话。例如,假设我们的ViewModel有一种公共方法称为loadMorePokemon(count: Int)。Our robot would implement it this way:

class PokemonListViewModelRobot {

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

Completed Example

Now that our robot has covered every public method and property we wanted to test, we can refactor all of our tests over to this:

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

PokemonListViewModelrobot()
.buildViewModel(repository = succesrepository())
.ASSERTPOKEMOMELIST(ExpectionPokeMon)
.assertShowData(真正的)
。assertShowError(false)
。assertShowLoading(false)
}

@Test
fun emptyFetch() {
PokemonListViewModelrobot()
。buildViewModel(repository = EmptyRepository())
。assertPokemonList(null)
。assertShowEmptyState(true)
。assertShowError(false)
。assertShowLoading(false)
。assertShowData(false)
}

@Test
fun errorFetch() {
PokemonListViewModelrobot()
。buildViewModel(repository = ErrorRepository())
。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.

Remember ourshowDataproperty? It's a boolean property referenced by every one of our tests. Let's change it to an integer, just for example sake:

类PokemonListViewModel {
// ...

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

Now all of our tests are failing, because they’re expectingtrue代替1。Imagine if we had dozens of tests, instead of just three.

Thankfully we’ve refactored all of our tests to use a robot class, and I can just handle this tweak inside the robot.

class PokemonListViewModelRobot {

fun assertShowData(expectedShowing: Boolean): PokemonListViewModelRobot {
//将此布尔映射到ViewModel暴露的INT
val expectedInt = if (expectedShowing) 1 else 0
val actuthow = viewModel.showdata
assertEquals(expectedInt, actualShowing)
return this
}
}

And with that two line change, all of our tests are passing again.

Recap

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

  • Before we can write unit tests for our code, we have to make sure that code is even testable.
  • 我们通过避免任何内部依赖性,而是将其传递给他们来做到这一点。
  • We define behavior using interfaces when possible so that we can control the implementation in our test cases.
  • If we define our test cases first, it helps us identify how we can structure our code to support them.
  • 嘲笑我们的依赖性。这样做确保我们的测试以可预测的方式进行,并且我们不依赖外部因素。
  • Utilize test robots. This makes our tests easy to read and allows us to quickly update our tests when application code changes.

如果您还有关于测试的其他问题,请与我联系推特

Interested in working on a small team pursuing a number of great new practices like this?我们正在招聘!

Resources

To see all of the completed code from this blog, I’ve placed each file into a gist:https://gist.github.com/AdamMc331/1362feb28241ebfac320c21f6e24f341

Originally published at//www.bizviewz.comon November 26, 2019.

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

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

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

OkCupid’s Engineering team is responsible for matching millions of people daily. Read their stories on the OkCupid tech blog

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

OkCupid’s Engineering team is responsible for matching millions of people daily. Read their stories on the OkCupid tech blog