Introduction to unit testing with Python
Testing is an essential part of software development. The main purpose is twofold:
- Good tests explicitly describe what the thing they're testing does; and
- Good tests make sure that the thing they're testing adheres to that description over time.
For example, imagine you have a function and you want to make sure it works the way you think it should. Something like this function that reverses a list (from one of our previous blog posts):
def reverse_list(original: List):
return original[::-1]
Although this is a simple function, there are a number of questions you could ask of it:
- Does it reverse a list with a non-zero number of elements?
- Does it reverse a list with different types of elements?
- Does it work with an empty list?
- Does it work with strings and other sequences, or only lists?
- Does it modify the list in place or does it give you a new list?
- Does it work for lists with duplicate entries?
You may know the answer to all these questions already (or you can easily find out just by launching a REPL and trying it out). But remember, the purpose of tests is to describe, and to make code resilient to change.
If someone comes around later on and modifies the function, the answers to some of these questions may change. You need to know if that happens, for a change to some of these could break your entire code!
I'll note now that it's extremely common to think of more questions as you write tests. We'll be writing tests for all questions we have!
Writing Python tests with unittest
Let's start off by writing our first test. Usually my first test will be a positive test, which means "does this function do what I expect it to?".
from unittest import TestCase
from typing import List
def reverse_list(original: List) -> List:
return original[::-1]
class ReverseListTest(TestCase):
def test_reverses_nonempty_list(self):
original = [3, 7, 1, 10]
expected = [1, 3, 7, 10]
self.assertEqual(reverse_list(original), expected)
We've written our first test!
It's important to write simple tests where possible. Usually in my tests I define the argument(s) to the thing I'm testing (the original
variable), the expected
result, and then I compare the two.
Running tests with unittest
By default when working with unittest, your file should be called test_*.py
. For now, call your file test_reverse.py
for example.
The naming of the test methods should also follow this naming pattern by default. Notice that my test method is called test_reverses_nonempty_list
.
Once you've saved your file, you can run it by either:
- If using PyCharm, right-click and press "Run tests with unittest...".
- If using a terminal, type
python -m unittest test_reverse.py
.
python -m unittest test_reverse.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
The .
you see there is your test succeeding!
Answering questions with tests
Let's answer our other questions with some more tests:
- Does it reverse a list with different types of elements?
- Does it work with an empty list?
- Does it work with strings and other sequences, or only lists?
- Does it modify the list in place or does it give you a new list?
- Does it work for lists with duplicate entries?
Does it reverse a list with different types of elements?
The answer to this question may seem irrelevant. You may think: "I'm never going to do this, so why should I care?".
If you're never going to do this, maybe what you want is to not do it by accident. Should our function check that elements are all the same type? It's up to you to decide what should happen in your codebase. For the purposes of this example, let's make it so we can't have mixed types.
from unittest import TestCase
from typing import List
def has_mixed_types(list_: List):
first = type(list_[0])
return any(not isinstance(t, first) for t in list_)
def reverse_list(original: List) -> List:
if has_mixed_types(original):
raise ValueError("The list to be reversed should have homogeneous types.")
return original[::-1]
class ReverseListTest(TestCase):
def test_reverses_nonempty_list(self):
original = [3, 7, 1, 10]
expected = [10, 1, 7, 3]
self.assertEqual(reverse_list(original), expected)
def test_reverses_varying_elements_raises(self):
original = [3, 7, "a"]
with self.assertRaises(ValueError):
reverse_list(original)
Note that you don't need an explicit test for "it doesn't raise an error when elements are all the same type" because that is inferred since our first test passes.
Does it work with an empty list?
This question is another that you may want to decide whether to support or not.
If you'd rather notify users when they're trying to reverse an empty list, you can raise an error if the length of the original list is 0. For now, we'll allow it.
This test is here because if in the future we decide to raise an error if an empty list is passed, this test should fail. Then we would come back and fix it, happy in the knowledge that our function works in the way we expect.
def test_reverses_empty_list(self):
original = []
expected = []
self.assertEqual(reverse_list(original), expected)
Does it work with strings and other sequences, or only lists?
This question occurred to me while writing this post!
Reversing with slices will work on strings and other sequences as well as lists. Maybe this is something you'd like to allow—but at the moment it is not allowed.
Because our type hinting specifies List
as the argument, lists should be passed or a warning will be given to the developer if they are using type hinting tools (which they should be!).
If you want to allow other sequences, you could use Sequence
instead of List
from the typing
module.
Does it modify the list in place or does it give you a new list?
This is a very important one! If you call a function and you're not sure whether it will modify the list in place or give you a new one, you're bound to encounter problems.
List reversal using slices will give you a new list, but if you use a different method for reversal it could modify in place.
That's why writing a test is important: if someone comes along later and modifies the function, we should have a failing test if the new function modifies the list in place.
def test_reversal_gives_new_list(self):
original = [3, 5, 7]
expected = [3, 5, 7]
reverse_list(original)
self.assertEqual(original, expected)
By checking the original
and the expected
are the same, we know that original
was left unchanged by reverse_list()
.
Does it work for lists with duplicate entries?
Some ways of reversing a list may not work well when the list has duplicate entries, so it's worth adding another test for this—just make sure that the reversal gives you the expected value even if there are duplicate elements.
def test_reversal_with_duplicates(self):
original = [3, 4, 3, 3, 9]
expected = [9, 3, 3, 4, 3]
self.assertEqual(reverse_list(original), expected)
More questions
As you write code and you use the reverse_list
function, you may come across more questions you'd like answered.
Maybe you'll write a bug related to this function, or maybe you'll just happen to think of something you'd like to make sure of.
Write more tests! Tests are like living documentation, and they help you have confidence that your code works the way you think it does, even as code evolves and changes.
Wrapping Up
The final complete code can be seen here.
Thank you for reading. I hope you've learnt something in this post!
Got any questions? Tweet at us. Consider joining our Complete Python Course for more Python goodness, or our Automated Software Testing with Python course for a deeper focus on testing Python and web applications.
If you'd like a discount code for either of those courses, sign up to our mailing list below. Every month we share discount codes with our subscribers!
See you on Monday with another Python Snippet!