In my last two articles, I introduced pytest, a library for testing Python code (see "Testing Your Code with Python's pytest" Part I and Part II). pytest has become quite popular, in no small part because it's so easy to write tests and integrate those tests into your software development process. I've become a big fan, mostly because after years of saying I should get better about testing my software, pytest finally has made it possible.
So in this article, I review two features of pytest that I haven't had a chance to cover yet: fixtures and code coverage, which will (I hope) convince you that pytest is worth exploring and incorporating into your work.
When you're writing tests, you're rarely going to write just one or two. Rather, you're going to write an entire "test suite", with each test aiming to check a different path through your code. In many cases, this means you'll have a few tests with similar characteristics, something that pytest handles with "parametrized tests".
But in other cases, things are a bit more complex. You'll want to have some objects available to all of your tests. Those objects might contain data you want to share across tests, or they might involve the network or filesystem. These are often known as "fixtures" in the testing world, and they take a variety of different forms.
In pytest, you define fixtures using a combination of the pytest.fixture
decorator, along with a function definition. For example, say
you have a file that returns a list of lines from a file, in which each
line is reversed:
def reverse_lines(f):
return [one_line.rstrip()[::-1] + '\n'
for one_line in f]
Note that in order to avoid the newline character from being placed at
the start of the line, you remove it from the string before reversing and
then add a '\n'
in each returned string. Also note that although it
probably would be a good idea to use a generator expression rather than a list
comprehension, I'm trying to keep things relatively simple here.
If you're going to test this function, you'll need to pass it a
file-like object. In my last
article, I showed how you could use a StringIO
object
for such a thing, and that remains the case. But rather than defining
global variables in your test file, you can create a fixture that'll provide
your test with the appropriate object at the right time.
Here's how that looks in pytest:
@pytest.fixture
def simple_file():
return StringIO('\n'.join(['abc', 'def', 'ghi', 'jkl']))
On the face of it, this looks like a simple function—one that returns the value you'll want to use later. And in many ways, it's similar to what you'd get if you were to define a global variable by the name of "simple_file".
At the same time, fixtures are used differently from global variables. For example, let's say you want to include this fixture in one of your tests. You then can mention it in the test's parameter list. Then, inside the test, you can access the fixture by name. For example:
def test_reverse_lines(simple_file):
assert reverse_lines(simple_file) == ['cba\n', 'fed\n',
↪'ihg\n', 'lkj\n']
But it gets even better. Your fixture might act like data, in that you don't invoke it with parentheses. But it's actually a function under the hood, which means it executes every time you invoke a test using that fixture. This means that the fixture, in contrast with regular-old data, can make calculations and decisions.
You also can decide how often a fixture is run. For example, as it's
written now, this fixture will run once per test that mentions it.
That's great in this case, when you want to compare with a list or
file-like structure. But what if you want to set up an object and then
use it multiple times without creating it again? You can do that by
setting the fixture's "scope". For example, if you set the scope of the
fixture to be "module", it'll be available throughout your tests but
will execute only a single time. You can do this by passing the scope
parameter to the @pytest.fixture
decorator:
@pytest.fixture(scope='module')
def simple_file():
return StringIO('\n'.join(['abc', 'def', 'ghi', 'jkl']))
I should note that giving this particular fixture "module" scope is a
bad idea, since the second test will end up having a StringIO
whose
location pointer (checked with file.tell
) is already at the end.
These fixtures work quite differently from the traditional setup/teardown system that many other test systems use. However, the pytest people definitely have convinced me that this is a better way.
But wait—perhaps you can see where the "setup" functionality exists in these fixtures. And, where's the "teardown" functionality? The answer is both simple and elegant. If your fixture uses "yield" instead of "return", pytest understands that the post-yield code is for tearing down objects and connections. And yes, if your fixture has "module" scope, pytest will wait until all of the functions in the scope have finished executing before tearing it down.
This is all great, but if you've ever done any testing, you know there's always the question of how thoroughly you have tested your code. After all, let's say you've written five functions, and that you've written tests for all of them. Can you be sure you've actually tested all of the possible paths through those functions?
For example, let's assume you have a very strange function, only_odd_mul
,
which multiplies only odd numbers:
def only_odd_mul(x, y):
if x%2 and y%2:
return x * y
else:
raise NoEvenNumbersHereException(f'{x} and/or {y}
↪not odd')
Here's a test you can run on it:
def test_odd_numbers():
assert only_odd_mul(3, 5) == 15
Sure enough, the test passed. It works great! The software is terrific!
Oh, but wait—as you've probably noticed, that wasn't a very good job of testing it. There are ways in which the function could give a totally different result (for example, raise an exception) that the test didn't check.
Perhaps it's easy to see it in this example, but when software gets larger and more complex, it's not going to be so easy to eyeball it. That where you want to have "code coverage", checking that your tests have run all of the code.
Now, 100% code coverage doesn't mean that your code is perfect or that it lacks bugs. But it does give you a greater degree of confidence in the code and the fact that it has been run at least once.
So, how can you include code coverage with pytest? It turns out that
there's a package called pytest-cov on PyPI that you can download and
install. Once that's done, you can invoke pytest with the --cov
option. If you don't say anything more than that, you'll get a
coverage report for every part of the Python library that your program
used, so I strongly suggest you provide an argument to --cov
,
specifying which program(s) you want to test. And, you should indicate
the directory into which the report should be written. So in this case, you would
say:
pytest --cov=mymul .
Once you've done this, you'll need to turn the coverage report into something human-readable. I suggest using HTML, although other output formats are available:
coverage html
This creates a directory called htmlcov. Open the index.html file in this directory using your browser, and you'll get a web-based report showing (in red) where your program still lacks coverage. Sure enough, in this case, it showed that the even-number path wasn't covered. Let's add a test to do this:
def test_even_numbers():
with pytest.raises(NoEvenNumbersHereException):
only_odd_mul(2,4)
And as expected, coverage has now gone up to 100%! That's definitely something to appreciate and celebrate, but it doesn't mean you've reached optimal testing. You can and should cover different mixtures of arguments and what will happen when you pass them.
If you haven't guessed from my three-part focus on pytest, I've been bowled over by the way this testing system has been designed. After years of hanging my head in shame when talking about testing, I've started to incorporate it into my code, including in my online "Weekly Python Exercise" course. If I can get into testing, so can you. And although I haven't covered everything pytest offers, you now should have a good sense of what it is and how to start using it.