Skip to main content

Unit Testing

Introduction

Jigsaw Icon

Unit testing is the most commonly used testing technique in software development. It is a software testing method by which individual units of source code are tested in isolation from the rest of the program. The goal of unit testing is to validate that each unit of the software performs as designed.

These test should be the fastest to run and written by the developers themselves. Their primary purpose is not to find bugs, but to prevent them. They are used to validate your assumptions and understanding of the code as you write it. By writing tests throughout development, you are forced to think about how the code should be designed and function. This can help you catch bugs early and make the code easier to maintain.

What is a Unit Test?

There are a lot of contradicting definitions of what a unit test is. Some people say that a unit test should test a single function, while others say that it should test a single module or class. The dividing line between a unit test and an integration test is rarely clear.

Martin Fowler has one of the most useful approaches to unit testing. He splits unit tests into two categories: solitary tests and sociable tests. Solitary tests are tests that do not rely on any other components. They using test doubles to replace dependencies from your system that are not under test. Sociable tests are tests that do rely on other components and are used to test the interaction between the various components in your system. They mock any external dependencies and may test multiple components at once. Some people say that solitary tests are unit tests, while sociable tests are better called component or integration tests.

I'm going to choose a fairly relaxed definition for unit tests. For the purposes of this site a test is a unit test if all of the following are true:

  • It is an automated test written by a developer.
  • It runs in less than 100ms, preferably less than 10ms.
  • Does not rely on any infrastructure, such as a database, network, or even the file system.

We want to create and run as many useful tests as possible, with minimal barriers to running them. This means that they should run quickly. And we want the tests to be as reliable as possible, which means they should be isolated from any potentially flaky infrastructure.

Use the definition common to your team

I've chosen the definition that works best for me, but it may not match what your team or organization uses. The most common difference is that many teams split unit tests into solitary and sociable tests. These might be called unit and component tests, or unit and integration tests.

Make sure to use the terminology common to your team when writing tests.

Focus on Behavior

When writing unit tests, you should focus on the behavior of the code, not the implementation. This means that you should test what the code does, not how it does it. This is important because the implementation of the code may change, but the behavior should remain the same.

Sometimes you'll hear people say that unit tests should test a single function, and that just isn't generally true. If you are properly encapsulating your classes, then it should be rare for a single function to be accessed atomically. Instead, you should be testing the behavior described by the public interface for the class. Don't change the access permissions of your functions just to make them testable.

The interface for your class defines a contract that the class should adhere to. Given some input, it agrees to produce some output. This is what you should be testing. If you are testing a single internal function, then you are testing the implementation, not the behavior.

Side effects complicate this considerably. If your function has side effects, then you need to test those side effects. These are usually harder to test than the return value of a function. You may need to use test doubles to isolate the code under test from its dependencies.

Achievement Icon

Purpose of Unit Testing

Unit testing is a valuable step in the software development process. It helps to ensure that the code works as expected and more importantly, that it is maintainable. During development unit tests help you to:

  • Verify your assumptions, giving you confidence that the code works as intended. Having a suite of tests that pass gives you confidence that you haven't made a mistake.
  • They encourage you to think about modularity and edge cases when writing code. By concentrating on writing testable code, you naturally write code that is easier to understand and maintain.
  • They provide an easy way to proof out ideas. If you are unsure how a piece of code should work, you can write a test to verify your understanding.

What they don't do is find a lot of bugs. Sometimes you might uncover a defect or misunderstanding of the requirements, but most of the time, unit tests won't find bugs in your code. This is because you are writing the tests at the same time as you are writing the code, so you are less likely to make mistakes. They are more about preventing bugs than finding them.

Main Advantages

  • Future-Proofing: Unit tests provide a safety net that allows you to make changes to the code with confidence. They automatically protect against regressions. You can think about a suite of tests as a vice that holds the functionality in place while you make changes.
  • Documentation: Unit tests can serve as documentation for the code. They show how the code is supposed to work and can help new developers understand the codebase.
  • Encourage Modular Thinking: Unit tests encourage you to write modular code. Simply by trying to make it easy to test, you will naturally make it easier to understand and maintain.
  • Catching Silly Mistakes: Everyone makes little mistakes when writing code. By writing tests throughout the development process, you can catch these mistakes early.

When to Write Unit Tests?

If we accept that unit tests are valuable, then the question becomes when to write them. There are three main approaches to writing unit tests:

  • Writing Tests After Code: This is the most common approach. You write the code first and then write the tests. This allows you to focus on the code and then write tests to verify that it works.
  • Writing Tests Before Code: This is known as Test-Driven Development (TDD). You write the tests before you write the code. This forces you to think about the design of the code before you write it.
  • Hybrid Approach: This is a combination of the two approaches. You write some tests before you write the code and some tests after you write the code.

Clock Icon

Writing Tests After Code

While the most common approach, especially for new developers, is to write tests after the code, it is not the best approach. Taking this tack leads to worse code, slower feedback, and bad design.

When you write the code first, you are focused on getting the code to work. You are not thinking about how to test the code. This usually leads to design decisions that make the code harder to test simply because they made it easier to write. It also sometimes leads to skipping tests because you run out of time, or because you've written the code in a way that is hard to test.

If you spend two days writing code and only then start to write tests, you are going to have a lot of code to test. Let's say that you wrote 1000 lines of code and you've made a mistake somewhere causing the code not to work. You now have to go back and figure out what is wrong. Your error could be anywhere in those 1000 lines of code. If you had been testing as you went along, you would have narrowed down the problem to a much smaller area and it would be much easier to fix.

TDD (Test-Driven Development)

The extreme alternative to writing tests after code is Test-Driven Development (TDD). In TDD, you write the tests before you write the code. This forces you to think about the design of the code before you write it. You have to think about how you are going to test the code before you write it.

You'll find developers arguing that TDD is the only way to write code, and you'll find developers arguing that TDD is a waste of time. The truth is probably somewhere in the middle. TDD is a valuable tool, but it is not the only tool. It is a good way to ensure that you are writing testable code, but it is not always the best way to write code.

The basic process of TDD is called the Red-Green-Refactor cycle. You start by writing a failing test (Red), then you write the code to make the test pass (Green), and finally you refactor the code to make it better (Refactor). As long as you take the refactoring step seriously, TDD can be a very effective way to write code.

Hybrid Approach

In a hybrid approach, you intersperse writing tests and writing code. If someone asked you if you spent your morning writing tests or writing code, you wouldn't be able to answer. You were doing both at the same time.

Taking this approach keeps the feedback loop tight. You are constantly getting feedback on the code you are writing. You are constantly thinking about how to test the code you are writing.

This approach is more flexible than TDD. You aren't as tied to a specific process, but it also requires more discipline. It is easy to get caught up in writing code and forget to write tests. It is easy to write tests that are too tightly coupled to the implementation.

Choose the Right Approach for You

The best approach to writing tests is the one that works best for you. Some people love TDD and swear by it. Some people hate TDD and refuse to use it. Some people use a hybrid approach. The important thing is to write tests and to write code that is easy to test.

As long as your process results in a suite of tests that you trust, you are doing it right.

Example

Here is an extremely simple example of my process when writing tests for a simple class with a single method. This is a contrived example, but it should give you an idea of how I approach most development. I'm going to use Kotlin and JUnit for this example, but the basic approach is the same for any language or tool. The simple class is a utility for determining a given string is a palindrome.

Step 1: Basic Implementation

I often begin by writing the simplest implementation that seems reasonable based on my understanding of the requirements.

class PalindromeUtils {
fun isPalindrome(candidate: String): Boolean {
return candidate == candidate.reversed()
}
}

Step 2: Write Some Tests

My initial tests are usually the happy path and some edge cases. I try to think of the simplest possible test cases that will exercise the code. I don't worry too much about the edge cases at this point. I just want to make sure that the code works in the most basic cases.

class PalindromeUtilsTest {
private val systemUnderTest = PalindromeUtils()

@Test
fun `should return true when given a single letter`() {
assertTrue(systemUnderTest.isPalindrome("z"))
}

@Test
fun `should return false when given a non-palindrome`() {
assertFalse(systemUnderTest.isPalindrome("test"))
}

@Test
fun `should return true when given an empty string`() {
assertTrue(systemUnderTest.isPalindrome(""))
}
}

Step 3: Add More Tests Based on the Specification

If things look good so far, I'll add more tests based on the requirements that I've been given. These tests are created based on the feature or project specification. I avoid referencing the implementation details while writing these tests. This is the time to think about edge cases and other scenarios that might not be covered by the initial tests.

As more tests are added I'll often group them into a nested test class or a parameterized test. During this step I often reorganize the tests to make them more useful as documentation.

class PalindromeUtilsTest {
private val systemUnderTest = PalindromeUtils()

@Test
fun `should return true when given an empty string`() {
assertTrue(systemUnderTest.isPalindrome(""))
}

@ParameterizedTest
@ValueSource(strings = ["z", "racecar", "madam", "level"])
fun `should return true when given a palindrome`(input: String) {
assertTrue(systemUnderTest.isPalindrome(input))
}

@ParameterizedTest
@ValueSource(strings = ["test", "hello", "world"])
fun `should return false when given a non-palindrome`(input: String) {
assertFalse(systemUnderTest.isPalindrome(input))
}

@ParameterizedTest
@ValueSource(strings = ["Z", "Racecar", "Madam", "Level"])
fun `should return true when given a palindrome with mixed cases`(input: String) {
assertTrue(systemUnderTest.isPalindrome(input: String))
}

@Test
fun `should return true when given a palindrome with punctuation`() {
assertTrue(systemUnderTest.isPalindrome("Don't nod!))
}
}

Step 4: Fix Issues and Refactor

Those last two tests exposed an issue in the code. My initial implementation doesn't handle mixed cases or punctuation. I had made an assumption about the requirements that was incorrect. I'll fix the code and then rerun the tests to validate my changes.

class PalindromeUtils {
fun isPalindrome(candidate: String): Boolean {
val lowercase = candidate.lowercase()
val cleaned = lowercase.filter { it.isLetterOrDigit() }
return cleaned == cleaned.reversed()
}
}

Summary

This is a very simple example, but it should give you an idea of how I approach writing tests. I start with a simple implementation and then write some tests. I add more tests based on the requirements and then fix any issues that I find. I continue this process until I am happy with the code.

Conclusion

Unit testing is a valuable step in the software development process. It helps to ensure that the code works as expected and that it is maintainable. There are many benefits to writing unit tests, including future-proofing, documentation, and catching silly mistakes.

When writing unit tests, you should focus on the behavior of the code, not the implementation. You should test what the code does, not how it does it. Each test should run quickly and validate a specific piece of functionality.

You can write unit tests before you write the code, after you write the code, or at the same time as you write the code. The important thing is to write tests and to write code that is easy to test.

Image Credits