Do you write unit tests for your code? I hope so. Have you ever wondered how good your unit tests are? If so, what techniques did you use to check this?
There are several characteristics that good unit tests should have:
and the question is...
Take a look at the code snippet below:
def fibobacci(n: int) -> int:
"""
Get the n-th Fibonacci number
"""
a, b = 0, 1
for _ in range(n-1):
a, b = b, a + b
return b # BUG! Should be `a`!
This code contains a bug that makes the function return incorrect results. Let's write a test for our function:
def test_fibonacci():
fibonacci()
Can you already see why test coverage is not always the best metric? Let's run our test with pytest-cov
:
> pytest --cov --cov-branch
Name Stmts Miss Branch BrPart Cover
-----------------------------------------------
__init__.py 0 0 0 0 100%
fib.py 5 0 2 0 100%
test_fib.py 3 0 0 0 100%
-----------------------------------------------
TOTAL 8 0 2 0 100%
Great, we have 100% coverage!
...but our test doesn't check anything. There's no single assert
statement, it just calls the function. We have a bug, but none of our tests detected it. If only there was a way to prevent it ...
Oh, wait...
There is!
The idea behind mutation testing is to see how your test suite will behave after you make a change to the tested code. If the test suite is good, at least one of the tests should fail after you make a change. In other words: it verifies that your tests check the behavior of the code, not just run it.
In this post, I will demonstrate how mutation testing works using the mutmut tool, but it is not the only option available (see also: MutPy, mutatest).
Firstly, let's install the tool:
> pip install mutmut
What mutmut
does to your code? It makes subtle modifications that change the behavior of the code. Available mutations include:
+
to -
, >
to >=
and
to or
and the other way aroundTrue
/False
, in
/not in
copy
and deepcopy
""
with None
"xx"
"xx"
Let's try it out on our previous example:
> mutmut run --paths-to-mutate=.
- Mutation testing starting -
These are the steps:
1. A full test suite run will be made to make sure we
can run the tests successfully and we know how long
it takes (to detect infinite loops for example)
2. Mutants will be generated and checked
Results are stored in .mutmut-cache.
Print found mutants with `mutmut results`.
Legend for output:
🎉 Killed mutants. The goal is for everything to end up in this bucket.
⏰ Timeout. Test suite took 10 times as long as the baseline so were killed.
🤔 Suspicious. Tests took a long time, but not long enough to be fatal.
🙁 Survived. This means your tests need to be expanded.
🔇 Skipped. Skipped.
1. Using cached time for baseline tests, to run baseline again delete the cache file
2. Checking mutants
⠏ 8/8 🎉 2 ⏰ 0 🤔 0 🙁 6 🔇 0
The output from the above command indicates that mutmut
managed to perform 8 mutations of our short function. Two of them were detected by our tests and "killed," but six of them survived.
To see the details of the mutants that survived, we can take a look at the results summary:
> mutmut results
To apply a mutant on disk:
mutmut apply <id>
To show a mutant:
mutmut show <id>
Survived 🙁 (6)
---- ./fib.py (5) ----
1-2, 4-6
---- ./test_fib.py (1) ----
8
Now we can see that mutations with id 1, 2, 4, 5, 6 and 8 survived. Then, using the id of the mutation, we can display its details:
> mutmut show 1
--- ./fib.py
+++ ./fib.py
@@ -1,6 +1,6 @@
def fibonacci(n: int) -> int:
"""Get the n-th Fibonacci number"""
- a, b = 0, 1
+ a, b = 1, 1
for _ in range(n-1):
a, b = b, a + b
return b # BUG! should be a!
The form of presentation is very developer-friendly, it looks like a diff view from git. We can see that mutmut
has changed the value of a
variable from 0
to 1
. What can we do to detect this change? I would start with adding a proper assert
statement to the test_fibonacci
function:
def test_fibonacci():
assert fibonacci(10) == 34
Adding the correct assert
will make our tests fail - remember the comment with the error note? :)
So let's fix the actual function:
def fibonacci(n: int) -> int:
"""Get the n-th Fibonacci number"""
a, b = 0, 1
for _ in range(n-1):
a, b = b, a + b
return a
The coverage hasn't changed:
> pytest --cov --cov-branch
Name Stmts Miss Branch BrPart Cover
-----------------------------------------------
__init__.py 0 0 0 0 100%
fib.py 5 0 2 0 100%
test_fib.py 3 0 0 0 100%
-----------------------------------------------
TOTAL 8 0 2 0 100%
But we've killed almost all of our mutants:
> mutmut run --paths-to-mutate=.
[...]
2. Checking mutants
⠇ 10/10 🎉 9 ⏰ 0 🤔 0 🙁 1 🔇 0
So let's check out what's left to be tested:
> mutmut results
To apply a mutant on disk:
mutmut apply <id>
To show a mutant:
mutmut show <id>
Survived 🙁 (1)
---- ./fib.py (1) ----
6
> mutmut show 6
--- ./fib.py
+++ ./fib.py
@@ -2,6 +2,6 @@
"""Get the n-th Fibonacci number, starting with 0 and 1."""
a, b = 0, 1
for _ in range(n-1):
- a, b = b, a + b
+ a, b = b, a - b
return a
It turns out that for an even argument, it does not matter whether there is a +
or -
sign between the variables a
and b
. So let's add another assert
, this time for an odd argument:
def test_fibonacci():
assert fibonacci(3) == 1
assert fibonacci(10) == 35
And run mutmut
once again:
> mutmut run --paths-to-mutate=.
[...]
2. Checking mutants
⠏ 13/13 🎉 13 ⏰ 0 🤔 0 🙁 0 🔇 0
We did it! No mutant survived the meeting with our test suite.
You might have noticed that the number of mutants generated by mutmut
varied over time. This is due to the fact that the test file was also mutated, and therefore, with the increase in the number of "mutable" lines of code (addition of ==
characters and numbers), the number of potential mutants to kill also increased.
To make more sense of the mutations, we can configure the package accordingly. The available options can be found under the mutmut run --help
command, we can use all of them in the setup.cfg
or pyproject.toml
file. An example configuration in your pyproject.toml
might look like this:
[tool.mutmut]
paths_to_mutate = "src/"
tests_dir = "tests/"
After a slight modification of the project structure:
.
├── __init__.py
├── pyproject.toml
├── src
│ ├── fib.py
│ ├── __init__.py
└── tests
├── __init__.py
└── test_fib.py
we can stop using the --paths-to-mutate
parameter in the mutmut run
command call, and the number of mutants produced has decreased (the test file is no longer mutated).
> mutmut run
[...]
2. Checking mutants
⠦ 7/7 🎉 7 ⏰ 0 🤔 0 🙁 0 🔇 0
The purpose of this post was to introduce the idea of mutation tests and to trigger a reflection on whether the tests you write are any good. The example shown here was written in Python, but other languages also have corresponding tools, such as Stryker (JavaScript) or Mutant (Ruby). If you have any questions about this post or would like to discuss mutation testing, feel free to contact me: lukasz.chojnacki [at] deployed.pl.
May good tests always protect you from bad changes!