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.

While the examples below are in Kotlin, these concepts are applicable throughout the industry. The snippets are free of any third party libraries, and I'll explain unique Kotlin keywords as necessary. You'll be able to take what you learn here and apply it to the platform and language that you love most.

Before we embark on our adventure, let's understand why we're doing it.

Why We Write Tests

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. 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.

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

A Sample Class

In this example we want to test a viewmodel class that fetches and displays some data. It should have support for showing a list of data, no data and all, or an error fetching data. We'll use my favorite example, a list of Pokemon.

class PokemonListViewModel {
    private var state: PokemonListState? = null

    val pokemon: List<Pokemon>?
        get() = (state as? PokemonListState.Loaded)?.data

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

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

    val showData: Boolean
        get() = state is PokemonListState.Loaded

    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(): List<Pokemon> {
        // 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<Pokemon>) : PokemonListState()
    class Error(val error: Throwable?) : PokemonListState()
}

Identifying Test Cases

Before we can write our tests, we should understand exactly what we want to test. Keeping those test cases in mind are important for making sure we're writing good code that supports testing, which we talk about next.

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. When the page loads, the user sees an empty state because they have no pokemon.
  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

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 to PokemonService, 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 inside PokemonListViewModel that we cannot control during our tests. We can resolve that by passing the service to the viewmodel class using the constructor. This is often referred to as "dependency injection."

class PokemonListViewModel(
	private val service: 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 our PokemonService parameter. Let's revisit the class:

class PokemonService {
    fun getPokemon(): List<Pokemon> {
        // 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.

By creating an interface, we 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<Pokemon>
}

class PokemonListViewModel(
	private val repository: 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. ;)

Now that our class is free of dependencies, and the behavior it requires is backed by an interface, we can create one implementation for each of the test cases we defined:

// Returns a real list of pokemon
class SuccessfulRepository : PokemonRepository {
    override fun getPokemon(): List<Pokemon> {
        return listOf(Pokemon(name = "Squirtle"))
    }
}

// Returns an empty list of pokemon
class EmptyRepository : PokemonRepository {
    override fun getPokemon(): List<Pokemon> {
        return emptyList()
    }
}

// Throws an exception to simulate a network error
class ErrorRepository : PokemonRepository {
    override fun getPokemon(): List<Pokemon> {
        throw Throwable("Whoops")
    }
}

Writing Our Tests

We've taken our class and refactored it to support testing. We've created the necessary mocked data. Now we can write our first test. Let's start by walking through our success case:

  1. Create a viewmodel using our SuccessfulRepository.
  2. Assert the list of pokemon exposed by the viewmodel matches what we expect from our fake repository.
  3. Verify that showData is true, and showError, showLoading, showEmptyState are all false.

Here is what that looks like. If you're new to testing, assertEquals is 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.

class 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 corresponding assertEquals call.

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

Tests Need To Be Independent

Note that in the above examples, each test creates 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

Let's take a moment to look at what we've accomplished 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. Those repetitive assertEquals calls 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 about viewModel.pokemon for 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 to PokemonService. Beyond testing purposes, this could have caused issues for our viewmodel because it required specific knowledge of PokemonService, and if it changed so would our viewmodel. By hiding that behavior behind an interface, we created a system where we can change the implementation details of PokemonService without 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 = SuccessfulRepository())
        .assertPokemonList(expectedPokemon)
        .assertShowData(true)
        .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 {
    private lateinit var viewModel: PokemonListViewModel

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

In this snippet, lateinit is a Kotlin keyword that just says we're defining the property, but we're going to give it a value later, which we do inside buildViewModel.

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

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 corresponding assertThing() in our robot:

class PokemonListViewModelRobot {

    fun assertPokemonList(expectedPokemon: List<Pokemon>?): PokemonListViewModelRobot {
        val actualPokemon = viewModel.pokemon
        assertEquals(expectedPokemon, actualPokemon)
        return this
    }

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

    // ...
}

Proxy Any Public Method Calls

In our example, we didn't have any public methods that we can call on the viewmodel. If we did, though, the robot would also be responsible for just passing information through to those calls. For example, let's say our viewmodel had a public method called 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 = SuccessfulRepository())
        .assertPokemonList(expectedPokemon)
        .assertShowData(true)
        .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)
}

Robots In Action

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 our 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 PokemonListViewModel {
	// ...
	
    val showData: Int
        get() {
            if (state is PokemonListState.Loaded) {
                return 1
            } else {
                return 0
            }
        }
}

Now all of our tests are failing, because they're expecting true instead of 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 {
        // Map this boolean to an int exposed by viewmodel
        val expectedInt = if (expectedShowing) 1 else 0
        val actualShowing = viewModel.showData
        assertEquals(expectedInt, actualShowing)
        return this
    }
}

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

Recap

Wow! A lengthy post but you made it. Let's briefly recap everything we've learned:

  • Before we can write unit tests for our code, we have to make sure that code is even testable.
    • We do this by avoiding any internal dependencies and instead passing them in.
    • 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.
  • Mock our dependencies. Doing this ensures our tests run in a predictable manner and we're not relying on external factors.
  • Utilize test robots. This makes our tests easy to read and allows us to quickly update our tests when application code changes.

If you have any further questions about testing, please reach out to me on Twitter.

Interested in working on a small team pursuing a number of great new practices like this? We're hiring!

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