Brenton Cleeland

Types of Testing You Should Care About: Unit Testing

Published on

The most traditional of all tests, unit tests execute a single "unit" of code and assert on the expected result. Unit tests can be used to verify that your code does what you expect, to test the boundaries and behaviour on the edges, and to help you write better code.

The breadth of a unit test is difficult to define. A single unit tests may span across multiple classes, functions or areas of code, but it should do so in order to test isolated behaviour.

Unit tests are low-level tests that should be written at about the same time as the code that is being tested by the same group of developers.

When you're using Test Driven Development you will be writing unit tests before your write your implementation. We use the tests to drive the design and behaviour of our code.

If you're writing tests after you write your code you'll notice that unit tests not only test your code but help ensure that your design is easy to test. When you encounter a scenario where creating a unit test is difficult you should take some time to think about your design.

Here is a simple function that takes a value and "clamps" it to a range. This comes directly from a small Django project that I was working on last week.

def clamp(value, min_value, max_value):
    return max(min(value, max_value), min_value)

Simple, enough? Right? Let's write a simple test case that adds unit tests for this function.

from unittest import TestCase

class ClampUnitTestCase(TestCase):
    def test_returns_value_in_range(self):
        x = clamp(10, 0, 100)
        self.assertEqual(x, 10)

    def test_returns_boundary_value_if_value_out_of_range(self):
        x = clamp(-1, 0, 100)
        self.assertEqual(x, 0)

        x = clamp(300, 0, 100)
        self.assertEqual(x, 100)

    def test_returns_value_in_negative_range(self):
        x = clamp(-12, -15, -8)
        self.assertEqual(x, -12)

Great, we've tested the function with a variety of different example cases. I like to make sure that the tests are named appropriately for each different type of behaviour being tested.

It's okay to have multiple asserts in a single test (see: test_returns_boundary_value_if_value_out_of_range) if all asserts are testing the same behaviour. When thinking about tests in the "Arrange, Act, Assert" structure I avoid having multiple "Arrange" steps, but consider it okay to have multiple "Act, Assert" steps if the action is the same. If you find yourself re-arranging things that's likely a sign that you should split your test in two.

At this point we might consider this done, but I'd like to introduce another test that will trigger an update to our implementation.

class ClampUnitTestCase(TestCase):
    # ...
    def test_raises_when_range_incorrect(self):
        with self.assertRaises(ValueError):
            x = clamp(1, 10, 1)

Our clamp function doesn't correctly handle the situation where our min and max values are reversed!

There's multiple ways that we could handle this case. I've imagined a scenario where we raise an exception but it's equally valid to handle this in other ways. The key is to document the behaviour with both a comment and a unit test.

def clamp(value, min_value, max_value):
    """Returns value clamped to the range min_value >= value <= max_value. Raises if min value is >= max value."""
    if min_value >= max_value:
        raise ValueError(f"Minimum value ({min_value}) is bigger than maximum value ({max_value})")

    return max(min(value, max_value), min_value)

Unit tests are the fundamental building block of your test suite. You should be writing them as you write new code, and using them to verify the changes you are making.