Python Test Driven Development Basics with PyTest¶
Introduction
Not long ago I was chatting with someone about some code he was working
on that did something some might consider 'obscure';, and how to be sure
it worked correctly. You can of course put in print statements to trace
what is going on, then remove them later, but this also sounds like
a case for writing a test that confirms the behavior, then coding up
the function, mkaing adjustments until the test passes. This roughly,
is what is called test-driven development (TDD).
When we chatted about how to do that, the person said they had trouble
working with the Python unittest module, and I pointed out I don't
much care for it either. One reason is because it forces you to use
classes even when it may not feel all that natural to write a class for
a particular problem. I wrote up some notes for him on using PyTest,
and then dedicded to modify those for somewhat wider sharing.
So here's a vaguely practical example of applying the pattern that
led to our discussion, that is how to effectively run a test function
several different ways, rather than writing up a function for each
permutation of your test.
First, and immediately violating the principles of TDD which say to
write the test first, let's write the function to test. Our candidate
function tries to return the reverse of its argument, to keep it simple,
we will assume the argument is something that can be iterated over,
so we can use fancy list slicing (thus we can't reverse a dictionary -
but that has no meaning anyway since a dictionary has no order). To show
it's working there is also code to try it out if it is called as a program
(as opposed to a module). This is the way people used to write tests for
a Python module, before test harnesses became widely used: just code up
a few checks and stuff them in the "main" part.
def slicerev(collection):
return collection[::-1]
if __name__ == "__main__":
print slicerev([1,2,3,4])
print slicerev((1,2,3,4))
print slicerev('abcd')
If we run that, we see that all of list, tuple and string did indeed
get reversed as we expected:
[4, 3, 2, 1]
(4, 3, 2, 1)
dcba
Using PyTest
Let's take this basic test code and turn it into a separate test using
PyTest. Unit tests for a particular function are often named (this is
convention) test_{funcname}.py. If it's named this way pytest can find
it automatically - runing py.test without arguments lets it hunt for
files that begin with 'test'. It's not mandatory to use this naming,
you can give the name of the test file as an argument, or use other
methods to describe exactly where the tests should be picked up from.
The code can be really simple since this is a contrived example - we're
not really systematically "unit testing", we're spot checking. All we
have to do is import the function we are going to test (even this is
not needed if the test is in the same file as the code being tested,
as opposed to a separate file), and then write out our three tests cases,
which do nothing but call the function with a known argument, then
compare the return with what we expect the result to be.
from reverser import slicerev
def test_slicerev_list():
output = slicerev([1,2,3,4])
assert output == [4,3,2,1]
def test_slicerev_tuple():
output = slicerev((1,2,3,4))
assert output == (4,3,2,1)
def test_slicerev_string():
output = slicerev('abcd')
assert output == 'edcba'
That's really all there is to it.
To make things a little more interesting, I have introduced an error in
the test itself: the function checking the reversed string claims it
expects 'edcba' instead of 'dcba'. This is done to show what it looks
like when PyTest reports a failure.
Let's run it:
$ py.test test_slicerev.py
============================= test session starts ==============================
platform linux2 -- Python 2.7.13, pytest-2.9.2, py-1.4.33, pluggy-0.3.1
rootdir: /home/mats/PyBlog/pytester.d, inifile:
collected 3 items
test_slicerev.py ..F
=================================== FAILURES ===================================
_____________________________ test_slicerev_string _____________________________
def test_slicerev_string():
output = slicerev('abcd')
> assert output == 'edcba'
E assert 'dcba' == 'edcba'
E - dcba
E + edcba
E ? +
test_slicerev.py:13: AssertionError
====================== 1 failed, 2 passed in 0.01 seconds ======================
A run with that problem fixed is considerably quieter:
$ py.test test_slicerev.py
============================= test session starts ==============================
platform linux2 -- Python 2.7.13, pytest-2.9.2, py-1.4.33, pluggy-0.3.1
rootdir: /home/mats/PyBlog/pytester.d, inifile:
collected 3 items
test_slicerev.py ...
=========================== 3 passed in 0.00 seconds ==========================
PyTest Fixtures
If you think about this for a bit, you notice that the same code is
run three times, only the data in the three test functions differs.
As mentioned above, this is a very common situation in testing, where
you want to try different cases to see how a unit behaves - test the
boundary conditions, test invalid data or data types, etc.
PyTest provides a mechanism called a "fixture" - a fixed baseline that
can be executed repeatedly, which helps with this situation.
In the first iteration of our tests, we did not need to import "pytest"
for it to work when the test is run by PyTest - PyTest wraps the code and
the code itself never uses anything from PyTest. However, in our second
iteration, we do want something from PyTest namespace - the definition
of the decorator we need to turn something into a PyTest fixture, so
the import is needed.
Since what we're factoring here is supplying different sets of data, the
fixture function 'slicedata' itself is extremely simple: all it does is
return the data. The test function has the same two functional statements
that each of the test functions had before - call the function under test,
then use an assertion to check the result was as expected. In addition
to that, the takes the fixture function as an argument, which would not
make much sense by itself, but once it is turned into a fixture it does.
We use a decorator to turn 'slicedata' into a fixture - remember Python
decorators are a piece of special syntax that helps alter the behavor
of a function. The PyTest fixture decorator can take a "params" parameter,
which should be something that can be iterated over, the fixture function
can then receive the data one at a time. In this case we are going
to pass a list of tuples, the first element of each tuple being the
data we are going to apply to the test, the second element being the
expected value.
We now know the other change we need to make to the test function:
the "fixture object" returned by the fixture will be a tuple, so
we should unpack the tuple into the pieces we want.
The new code looks like this:
import pytest
from reverser import slicerev
@pytest.fixture(params=[
([1,2,3,4], [4,3,2,1]),
((1,2,3,4), (4,3,2,1)),
('abcd', 'dcba')
])
def slicedata(request):
return request.param
def test_slicerev(slicedata):
input, expected = slicedata
output = slicerev(input)
assert output == expected
Run these tests and we'll see the results are the same as before:
$ py.test test_slicerev_fix.py
============================= test session starts ==============================
platform linux2 -- Python 2.7.13, pytest-2.9.2, py-1.4.33, pluggy-0.3.1
rootdir: /home/mats/PyBlog/pytester.d, inifile:
collected 3 items
test_slicerev_fix.py ...
=========================== 3 passed in 0.00 seconds ===========================