Guidelines for writing better tests

In theory, tests are just software. So if you are already a skillful software engineer, you can apply the same principles you use to write good software to tests, right? Well, not quite.

Writing tests in the same way you are writing code might result in tests that are hard to maintain and may be hard to debug. The "values" that define tests as "good" are not the same as for regular code. This post aims to summarize some important guidelines that I discovered throughout the years and gathered them into a single place. This is by no means an exhaustive list, but hopefully a list you can share with someone who is just starting to save them some time :)    

Readability is priority number one

A test should be easy to parse and understand foremost. This means that you are allowed to "violate" some best practices that apply to regular code, such as DRY (don't repeat yourself). A practical example is to set up each test scenario in the test, even if that means repeating the setup steps each time. When a test case fails and someone (probably not you) debug that test, they should be able to get all the "preparation" of the test case in a single place, rather than scattered around.

fun testCase1() {
    val item1 = makeItem("item1")
    service.setItems(item1)
    
    [...]
}

fun testCase2() {
    val item1 = makeItem("item1")
    service.setItems(item1)
    
    [...]
}

Always Arrange-Act-Assert

Following this simple yet powerful pattern without exceptions gives some uniformity to all your tests. Similar to why we agree to a common code style in a codebase, this uniformity removes some mental cycles when interpreting a test. Ideally, the 3 phases of each test are separated by blank lines for the test to be easily skimmable and parsable at first glance.

fun testCase1() {
    val item1 = makeItem("item1")
    service.setItems(item1)
    
    service.callAction1()
    
    assertThat(storage).isEqualTo(STATUS_1)
}

fun testCase2() {
    val item1 = makeItem("item1")
    val item2 = makeItem("item2")
    service.setItems(item1, item2)
    
    service.callAction2()
    
    assertThat(storage).isEqualTo(STATUS_2)
}

Test a single behavior

Follow up on the previous point, you should aim for testing only a single behavior in each test case. Checking for too many things in a test makes it difficult to figure out where something is going wrong when a test fails. This does not mean that you should have a single assert statement. The behavior might be verified with multiple assert statements. But be careful to split a test into multiple test cases if more than one behavior is being tested.

fun testCreate() {
    service.init()
    
    val response = service.createItem("item1")
    
    assertThat(storage.read()).contains("item1")
    assertThat(response).isEqualTo("item1")
}

Be careful of external dependencies

It's quite common having external dependencies, such as services or APIs not owned by you, in a software project. What happens when you need to test your piece of software that uses these external dependencies? If the author of the external service or API does not offer a fake implementation, prefer to wrap the calls to the external dependency and then mock your wrapper. This is to protect your tests from breaking when the dependency is replaced with something else. Of course, there's no silver bullet here since this approach means that when there's a bug in the external dependency code your test won't detect it. Just be aware of the limitation.

fun testCase1() {
    service.init(mockedWrapper)
    
    service.callAction1()
    
    verify(mockedWrapper).wasCalled()
}

Hopefully, this quick and short list of guidelines is a good starting point for writing better tests.

Happy coding!