Kotlin Unit Testing guide — part 1 — introduction and basics

Kacper Wojciechowski
8 min readJul 23, 2024

--

Unit Testing is a core skill of every developer. At least it should be. Yet still, I’ve stumbled into multiple codebases that had literally zero unit tests. The reasons for this usually were:
1. Lack of knowledge on how to write unit tests
2. Lack of knowledge on how to make your code easier to test or testable at all
3. Lack of knowledge on how to write unit tests for complex cases
4. Lack of knowledge on how to write maintainable unit tests
5. Writing poor quality code
6. Laziness

This series of articles is aimed at helping you solve the first four reasons.

Let’s start with some theory

What is Unit Testing?

Let’s start with a definition:

Unit testing — a method of testing developed software by performing tests that verify the correctness of operation of individual elements (units) of the program.

For easier understanding, you can think of it as black-box testing. We prepare some testing data, then let the unit perform some action with that data, and then we verify that the output is what was expected.

How to define “Unit”?

Understanding the word “Unit” in Unit Testing is crucial for writing good tests. A unit should be the smallest possible individual piece of code that can be isolated from the rest of the code. Let’s start with the smallest element of code and step by step find what can be unit tested.

  • Can we test a single line of code in isolation? No, because the lines that come before and after our line of code can have huge impact on our test subject.
  • Can we test a single function of the class in isolation? No, because we cannot separate the function from the class as it can interact with multiple elements of its parent.
  • Can we test a single class? Yes, as the class can only interact with the properties and functions that are a part of it, and its dependencies (f.e. constructor parameters) can be faked or mocked. We can achieve isolation.
  • Can we test a static/top level function? Yes, as such function can only interact with its parameters, that can be faked or mocked. We can achieve isolation.

Why Unit Tests?

Thanks to isolation, we can check if this chunk of code is doing its part correctly. Splitting the code into the smallest possible chunks allows you to verify, piece by piece, if the code you wrote is doing what is expected. If testing bigger chunks of code, in case of failure it is much harder to spot the actual issue. Unit tests also help you verify that the code changes you’ve made don’t break something you did not intend to break. We’re all human.

As an example, let’s think about how to spot the issue with a car. You turn the key in the keyhole, and it just doesn’t turn on. You can deliberate what the issue might be, but you can never be sure, as a car is a very complex system. Now imagine you could split the car into the smallest possible pieces, put them into a fake car, feed them some fake input, and check if the output is what is expected to happen. Now imagine that this process is automated and can verify all cases and parameters seamlessly. This way you could spot the faulty part pretty quickly!

Of course, testing bigger chunks of code (integration tests) is also important. You may be sure that the unit is working correctly, but maybe those units don’t match each other as expected. Everything has its pros and cons, and its purpose.

How to write unit tests?

To start, you usually need to select a testing framework. The most popular ones are:
1. JUnit 4
2. JUnit 5
3. Kotest
4. Spek

In this series of articles, I am going to use the classic that always works and is the easiest to work with — JUnit 4. Let’s implement the dependencies:

[versions]
test-junit = "4.13.2"
[libraries]
test-junit = { module = "junit:junit", version.ref = "test-junit" }
testImplementation(libs.test.junit)

As you can see, we are implementing the dependency inside the test codebase. This is because tests should never be a part of the main codebase. Test classes should never be a part of the output binary. Test helpers and dependencies should never be accessible in the main codebase.

Now we need to create a directory for the test codebase. The package structure should also reflect the package structure of the main codebase of the module. It is a good practice to place test classes in the same package as the test subject so they are easier to locate.

IntelliJ IDEA highlights these directories with a green background. It also provides some functionality, like running all tests in the directory.

This will be our test subject:

The class contains a single function named doSomeMagic that takes integer input, multiplies it by 2, ensures it doesn't exceed 4, and returns the result. Let's create a Unit Test for this class. Start by creating a class in the test directory in the same package as our test subject. The general good practice for naming convention of such classes is to append the name of the test subject with "Test." In our case, the name for the test class will be ExampleClassTest. Inside the body of this test class, we will need an instance of our test subject, usually as a private property of the test class. The naming convention of the property for the test subject is usually agreed upon with the team. For me, it's usually just the name of the class of the test subject. Your test class should now look like this:

Creating test cases

Now we need to create our test cases in the body of the test class. How do we determine what our test cases should be? The general rule is that the Unit Test should contain test cases for every possible meaningful input and output for all the operations that the test subject can perform. Our test subject has one function that will generate two test cases:
1. Multiplication that does not exceed 4
2. Multiplication that does exceed 4

In JUnit 4, creating test cases is done by creating a function annotated with @Test. The name of the test function should be verbose so it is easier to understand what the test case is about. The name of the test function can be wrapped with the ` character (GRAVE ACCENT in Unicode), which on Mac can be found near the left shift button. This way, we can use whitespaces in its name.

package com.example

import org.junit.Test

class ExampleClassTest {

@Test
fun `doSomeMagic returns multiplication result when it does not exceed 4`() {
// TODO
}
}

IntelliJ IDEA should show 2 new buttons on the sidebar. The one next to the class definition will run all test cases in this class, while the one next to the test case definition will run only this one test case.

Now lets talk about the test case body

The most popular and go-to rule for the test body is — GIVEN, WHEN, THEN. This might sound a bit confusing, so let’s break it down:
- GIVEN — test case setup, creating the test data, setting up mocks, setting up listeners, and test helpers
-WHEN — performing the operations that you want to test, getting the result of the operation for further verification
-THEN —test result verification, resulting values assertions, calls verifications

These 3 blocks of the test case body should be somehow visibly separated for better readability. Some devs add comments containing the block name to make it obvious. For me, if someone knows the rule, just leaving an empty line will make it obvious enough.

@Test
fun `doSomeMagic returns multiplication result when it does not exceed 4`() {
// TODO GIVEN

// TODO WHEN

// TODO THEN
}

Our test subject is currently so simple that the GIVEN block is not needed as we don’t have any setup needed. The WHEN block should execute the corresponding function in the test subject and get the reference to the resulting object:

@Test
fun `doSomeMagic returns multiplication result when it does not exceed 4`() {
val result = exampleClass.doSomeMagic(input = 1)

// TODO THEN
}

In the THEN block, we need to assert that the result is what we expected. To do so, we should use assertion functions that will throw AssertionError in case of a failure. You can pick from multiple frameworks for assertions:
1. JUnit 4 built in assertions inside org.junit.Assert
2. Kotlin assertions kotlin.test
3. Truth

I am going to use the second option, as the Kotlin assertion functions have the best compatibility with Kotlin language features. Add this to the gradle file:

testImplementation(kotlin("test"))

Now we can start asserting stuff. In our test, we pass 1 as input. We expect the class to return 2:

@Test
fun `doSomeMagic returns multiplication result when it does not exceed 4`() {
val result = exampleClass.doSomeMagic(input = 1)

assertEquals(2, result)
}

And that’s all, you have a fully working, readable Unit Test! You can run it with the green button on the sidebar. You can play with it and change the expected value to 3 to check what would happen in case of error.

Now we can also create our second test that will cover the case where the resulting value exceeds 4. The full test class code should look like this:

package com.example

import org.junit.Test
import kotlin.test.assertEquals

class ExampleClassTest {

private val exampleClass = ExampleClass()

@Test
fun `doSomeMagic returns multiplication result when it does not exceed 4`() {
val result = exampleClass.doSomeMagic(input = 1)

assertEquals(2, result)
}

@Test
fun `doSomeMagic returns 4 when multiplication result exceeds 4`() {
val result = exampleClass.doSomeMagic(input = 3)

assertEquals(4, result)
}
}

That’s it, you’ve learned some basic concepts and now know how to write very basic unit tests. However, to test real classes that are used in modern applications, you will need to use more advanced techniques.

In the next episode, I’m covering the topic of mocking the class dependencies:

--

--