PyBerlin is a new meetup community in Berlin, Germany that strives to be open and inclusive, with talks from a broad range of topics for anyone who is interested in Python.
The PyBerlin organizers invited me to give the opening talk at the first event, which took place last month in February, and asked me to speak about automated testing with pytest. I was super excited and I also felt very honored to be the first speaker for the community. I wanted to prepare something special! π¨π»βπ»
New concepts stick much better with me when applied to a real problem rather than when explained with a bunch of trivial examples. So instead of using a slide deck packed with short code snippets to explain the core concepts of writing tests in Python with pytest, I decided to do some live coding on earth, a small example project, and work on a patch for an open GitHub issue.
earth is a library for organizing and running events: You can invite adventurers from around the world, see how they pack their bags and travel by airplane to the event location, and finally greet them and make introductions. π
The GitHub issue reads:
Earth π is such an important project, but Iβm concerned that without adequate test coverage we may be leaving its users vulnerable. Letβs identify the features that are not yet tested and ensure we can maintain excellent coverage in the future.
This blog post is the written form of my PyBerlin talk βCustomizing your pytest test suiteβ If you find it useful, please share it with your colleagues and Python friends! ππ
Another version of this talk was live streamed on YouTube and the recording is now available on our Mozilla YouTube channel, if you would rather watch my talk than read this rather lengthy blog post. πΊ
Picture taken on Feb 21, 2019 by Tammo Behrends at PyBerlin π·
About this blog post
Iβve structured this blog post as a tutorial. π
We will learn how toβ¦
- get started with writing automated tests with pytest
- use pytest fixtures and markers to organize our tests
- use a number of different CLI options for test selection
- use powerful features of several awesome pytest plugins
- and write our own plugin to customize our test suite
As we go over these topics, I encourage you to try out the code yourself and follow the links to the documentation if you want to learn more about specific features.
Good tutorials are difficult to write. Iβll try my best to give you enough information to complete the steps without going into too much detail. If you spot any typos, find bugs in the code examples, or have any questions, please open a new issue on GitHub. π»
I will commit code changes to the earth repository on a separate branch named increase-test-coverage and create commits for the steps in this tutorial. Should you get stuck at any point, please check out the commits on that branch and continue from there.
Letβs get started! π
Our task
We will work on identifying blind spots in the earth project and increasing its code coverage by developing a series of automated tests. π
We need a local copy of the earth GitHub repository as well as a new Python 3.7 virtual environment with attrs installed. π
Test requirements
Please also install the following Python packages into your activated virtual environment as we will be using these plugins in this tutorial:
pytest
pytest-cov
pytest-emoji
pytest-html
pytest-md
pytest-repeat
pytest-variables
Explore the project
There are a number of tests in the tests directory of the earth project. Have a look around and open a few files in your text editor to make yourself familiar with what the tests are doing. π΅οΈββοΈ
tests/
βββ __init__.py
βββ adventurers
β βββ __init__.py
β βββ test_adventurers_01.py
β βββ test_adventurers_02.py
β βββ test_adventurers_03.py
βββ earth
β βββ __init__.py
β βββ test_earth_01.py
β βββ test_earth_02.py
βββ events
β βββ __init__.py
β βββ test_events_01.py
β βββ test_events_02.py
β βββ test_events_03.py
β βββ test_events_04.py
βββ old
β βββ __init__.py
β βββ stuff
β βββ __init__.py
β βββ test_stuff_01.py
β βββ test_stuff_02.py
β βββ test_stuff_03.py
βββ travel
β βββ __init__.py
β βββ test_travel_01.py
β βββ test_travel_02.py
β βββ test_travel_03.py
βββ year
βββ __init__.py
βββ test_year_01.py
βββ test_year_02.py
7 directories, 25 files
You will find that only tests/year/test_year_02.py
imports the earth
library, which indicates that the majority of the existing tests do not use the
earth library and canβt possibly generate code coverage.
Run the tests
Letβs start with running the existing tests as they are:
pytest --verbose
E File "earth/tests/old/stuff/test_stuff_03.py", line 6
E print "hello world"
E ^
E SyntaxError: Missing parentheses in call to 'print'. Did you mean
print("hello world")?
Hmmβ¦ a SyntaxError before pytest could run any of the tests? π
Skip a single test
When pytest tried to collect the tests, it failed to import the following file which contains a test that uses the Python 2 print statement. Thatβs not supported under Python 3, which caused a SyntaxError! π
tests/old/stuff/test_stuff_03.py
def test_numbers():
assert 1234 == 1234
def test_hello_world():
print "hello world"
def test_foobar():
assert True
Letβs comment out the invalid code, add a comment so that we donβt forget to look into this later and skip the test.
tests/old/stuff/test_stuff_03.py
import pytest
def test_numbers():
assert 1234 == 1234
@pytest.mark.skip(reason="Outdated Python syntax")
def test_hello_world():
# TODO: Can we remove this test?
# print "hello world"
pass
def test_foobar():
assert True
Run the tests (again)
Run pytest again and watch closely what happens:
pytest --verbose
You will find that we donβt see the error anymore, but the 4 tests in
tests/old/stuff/test_stuff_02.py
take a fairly long time to complete. π΄
Skip entire test module
Letβs generate a test coverage report using pytest-cov to see if the test module with the slow tests generates test coverage:
pytest --cov earth/
Name Stmts Miss Cover
------------------------------------------
earth/__init__.py 3 0 100%
earth/adventurers.py 71 41 42%
earth/events.py 24 12 50%
earth/travel.py 31 14 55%
earth/year.py 14 0 100%
------------------------------------------
TOTAL 143 67 53%
Then skip the entire test module:
tests/old/stuff/test_stuff_02.py
import time
import unittest
import pytest
pytest.skip("This does not generate code coverage", allow_module_level=True)
class TestStringMethods(unittest.TestCase):
def setUp(self):
# A long time ago in a galaxy far, far away...
time.sleep(2)
def test_upper(self):
self.assertEqual("foo".upper(), "FOO")
def test_upper_bar(self):
self.assertEqual("foo".upper(), "BAR")
def test_isupper(self):
self.assertTrue("FOO".isupper())
self.assertFalse("Foo".isupper())
def test_split(self):
s = "hello world"
self.assertEqual(s.split(), ["hello", "world"])
# check that s.split fails when the separator is not a string
with self.assertRaises(TypeError):
s.split(2)
Letβs generate a new test coverage report. π»
You will see that the test file was skipped and that the test coverage remained at 53%. This means that the test does not generate any code coverage and is safe to skip.
Run the example
The README file in the earth repo contains an example for how the library can
be used and the very same example is also copied to the example1.py
script.
example1.py
from earth import adventurers, Event, Months
def main():
print("Hello adventurers! π")
print("-" * 40)
friends = [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
event = Event("PyCon US", "North America", Months.MAY)
for adventurer in friends:
event.invite(adventurer)
print("-" * 40)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
print("-" * 40)
event.start()
if __name__ == "__main__":
main()
Letβs run the example and see what it does:
python example1.py
Hello adventurers! π
----------------------------------------
Bruno accepted our invite! π
Michael accepted our invite! π
Brianna accepted our invite! π
Julia accepted our invite! π
----------------------------------------
πΈ Bruno is packing π
πΈ Bruno is travelling: South America βοΈ North America
π¦ Michael is packing π
π¦ Michael is travelling: Africa βοΈ North America
π¨ Brianna is packing π
π¨ Brianna is travelling: Australia βοΈ North America
π― Julia is travelling: Asia βοΈ North America
----------------------------------------
Welcome to PyCon US in North America! π
Let's start with introductions...π¬
πΈ Hello, my name is Bruno!
π¦ Hello, my name is Michael!
π¨ Hello, my name is Brianna!
π― Hello, my name is Julia!
Write a happy path test
The example from the README is super important for the adoption of our earth project. If this code snippet doesnβt work out of the box, chances are users will be frustrated and will look for alternative libraries. We definitely want to have test coverage for that. β οΈ
Letβs create a new test file and write a test based on the example. Note that we wonβt perform any assertions in our test, but only test for unhandled exceptions.
I also recommend to add custom pytest markers to tests that we
currently work on as it makes selecting them that much easier. Weβll use
happy
because our new test will be a happy path test and wip
to
indicate that this test is a work in progress.
tests/test_earth.py
import pytest
from earth import adventurers, Event, Months
@pytest.mark.wip
@pytest.mark.happy
def test_earth():
print("Hello adventurers! π")
print("-" * 40)
friends = [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
event = Event("PyCon US", "North America", Months.MAY)
for adventurer in friends:
event.invite(adventurer)
print("-" * 40)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
print("-" * 40)
event.start()
Run only our test
We can now use the markers to select our test and see if it passes:
pytest -m wip
============================ test session starts =============================
collected 50 items / 49 deselected / 1 skipped
tests/test_earth.py .
============= 1 passed, 1 skipped, 49 deselected in 0.11 seconds =============
Yay it works! π
Start using pytest fixtures
Now that we know that the test passes, letβs refactor our code and separate out test dependencies from the test implementation using pytest fixtures and also remove the prints as they are not required for the test.
tests/test_earth.py
import pytest
from earth import adventurers, Event, Months
@pytest.fixture
def event():
return Event("PyCon US", "North America", Months.MAY)
@pytest.fixture
def friends():
return [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
@pytest.mark.wip
@pytest.mark.happy
def test_earth(event, friends):
for adventurer in friends:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
Generate an HTML coverage report
Itβs a good idea to check whether adding this test changed the code coverage. This time we will run the tests again and generate an HTML coverage report to find out not only the percentage of lines covered by tests, but also which lines are not executed when we run the tests.
pytest --cov earth/ --cov-report html
This creates a new file at htmlcov/index.html
π
When you open that coverage report in your web browser, you will see that our test coverage increased quite drastically. It is now at 84% - woohoo! π
Missing coverage
The HTML coverage report also shows code coverage for individual files. We can
use this information to work on increasing the code coverage for earth even
more. The first file in our report adventurers.py
has a coverage of 80%. If
you click on the link in the HTML, you will see a number of red lines, which
means we donβt have coverage for them.
The following functions are not called in any of our tests:
new_panda()
new_bear()
new_fox()
Letβs do something about that! πΌπ»π¦
Write another test
Weβll leave the test for the example from the README as is and write a new test
for a larger group of adventurers. Rename the fixture friends
to
small_group
and rename the test from test_earth
to
test_small_group
.
Create a new fixture large_group
based on small_group
but return a
complete list of adventurers and copy test_small_group
to a new
test_large_group
test.
tests/test_earth.py
import pytest
from earth import adventurers, Event, Months
@pytest.fixture
def event():
return Event("PyCon US", "North America", Months.MAY)
@pytest.fixture
def small_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
@pytest.fixture
def large_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_panda("Po"),
adventurers.new_fox("Dave"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
adventurers.new_fox("Raphael"),
adventurers.new_fox("Caro"),
adventurers.new_bear("Chris"),
# Bears in warm climates don't hibernate π»
adventurers.new_bear("Danny", availability=[*Months]),
adventurers.new_bear("Audrey", availability=[*Months]),
]
@pytest.mark.wip
@pytest.mark.happy
def test_small_group(event, small_group):
for adventurer in small_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
@pytest.mark.wip
@pytest.mark.happy
def test_large_group(event, large_group):
for adventurer in small_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
Run the tests (again)
Letβs run our work in progress tests again and see if the new test passes:
pytest -m wip
============================ test session starts =============================
collected 51 items / 49 deselected / 1 skipped / 1 selected
tests/test_earth.py .F [100%]
================================== FAILURES ==================================
______________________________ test_large_group ______________________________
@pytest.mark.wip
@pytest.mark.happy
def test_large_group(event, large_group):
> for adventurer in small_group:
E TypeError: 'function' object is not iterable
tests/test_earth.py:55: TypeError
======== 1 failed, 1 passed, 1 skipped, 49 deselected in 0.12 seconds ========
Wait, what? Our test fails with a TypeError?! π€
Specify fixture names
We forgot to rename small_group
in the for loop in test_large_group
π€¦ββ
However because we define a function with name small_group
in the module
scope and everything in Python is an object, Python tries to iterate over our
fixture function instead of raising a NameError.
I recommend overwriting the auto-generated fixture name to avoid this problem.
tests/test_earth.py
import pytest
from earth import adventurers, Event, Months
@pytest.fixture(name="event")
def fixture_event():
return Event("PyCon US", "North America", Months.MAY)
@pytest.fixture(name="small_group")
def fixture_small_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
@pytest.fixture(name="large_group")
def fixture_large_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_panda("Po"),
adventurers.new_fox("Dave"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
adventurers.new_fox("Raphael"),
adventurers.new_fox("Caro"),
adventurers.new_bear("Chris"),
# Bears in warm climates don't hibernate π»
adventurers.new_bear("Danny", availability=[*Months]),
adventurers.new_bear("Audrey", availability=[*Months]),
]
@pytest.mark.wip
@pytest.mark.happy
def test_small_group(event, small_group):
for adventurer in small_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
@pytest.mark.wip
@pytest.mark.happy
def test_large_group(event, large_group):
for adventurer in small_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
Now if we run the tests again, we will see a much more helpful error message. I canβt stress enough how important this is. With Pythonβs duck-typing capabilities, you might not even realize that your tests accidentally pass only because you forgot to add your fixture to your testβs argument list. β οΈ
pytest -m wip
============================ test session starts =============================
collected 51 items / 49 deselected / 1 skipped / 1 selected
tests/test_earth.py .F [100%]
================================== FAILURES ==================================
______________________________ test_large_group ______________________________
@pytest.mark.wip
@pytest.mark.happy
def test_large_group(event, large_group):
> for adventurer in small_group:
E NameError: name 'small_group' is not defined
tests/test_earth.py:55: NameError
======== 1 failed, 1 passed, 1 skipped, 49 deselected in 0.13 seconds ========
Letβs fix that and update the variable name in test_large_group
, before we
run the tests again. π»
Show slow running tests
If you run the tests, you will notice that the suite now takes a long time to complete. pytest comes with a really neat CLI flag to run the tests and show a sorted list of slow tests. Letβs show only the top 2 slowest tests β±
pytest -m wip --durations 2
============================ test session starts =============================
collected 51 items / 49 deselected / 1 skipped / 1 selected
tests/test_earth.py .F [100%]
================================== FAILURES ==================================
______________________________ test_large_group ______________________________
@pytest.mark.wip
@pytest.mark.happy
def test_large_group(event, large_group):
for adventurer in large_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
> event.start()
tests/test_earth.py:62:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
earth/events.py:34: MissingAttendee
--------------------------- Captured stdout call ----------------------------
Bruno accepted our invite! π
Po accepted our invite! π
Dave accepted our invite! π
Michael accepted our invite! π
Brianna accepted our invite! π
Julia accepted our invite! π
Raphael accepted our invite! π
Caro accepted our invite! π
Chris is not available in May! π’
Danny accepted our invite! π
Audrey accepted our invite! π
πΈ Bruno is packing π
πΈ Bruno is travelling: South America βοΈ North America
πΌ Po is eating... π±
πΌ Po is eating... π±
πΌ Po is eating... π±
πΌ Po is eating... π±
πΌ Po is packing π
πΌ Po is travelling: Asia βοΈ North America
π¦ Dave is packing π
π¦ Dave's flight was cancelled π Problems at Berlin Airport π§
π¦ Michael is packing π
π¦ Michael is travelling: Africa βοΈ North America
π¨ Brianna is packing π
π¨ Brianna is travelling: Australia βοΈ North America
π― Julia is travelling: Asia βοΈ North America
π¦ Raphael is packing π
π¦ Raphael's flight was cancelled π Problems at Berlin Airport π§
π¦ Caro is packing π
π¦ Caro is travelling: Europe βοΈ North America
π» Danny is packing π
π» Audrey is packing π
Welcome to PyCon US in North America! π
Let's start with introductions...π¬
πΈ Hello, my name is Bruno!
πΌ Hello, my name is Po!
========================== slowest 2 test durations ==========================
20.01s call tests/test_earth.py::test_large_group
(0.00 durations hidden. Use -vv to show these durations.)
======= 1 failed, 1 passed, 1 skipped, 49 deselected in 20.16 seconds ========
The new test eventually failed after taking more than 20 seconds to complete. β±
While the --durations
CLI flag might not be super helpful with only two
tests and we already knew that the first new test passed in less than one
second in a previous run, itβs super useful for larger test suites.
Add marker documentation
Letβs add a new custom pytest marker slow
to test_large_group
. Itβs
good practice to write documentation for custom markers and provide a short
description for every marker, that explains the characteristics of tests with
that marker. π
Add a pytest configuration file with a markers section:
pytest.ini
[pytest]
markers =
slow: tests that take a long time to complete.
Now if you run pytest --markers
you will see the information about pytestβs
markers and also our custom one.
Show output
We know which test is slow, but we donβt really know why that is. By default pytest captures any output sent to stdout and stderr. Letβs run the slow test again with capturing disabled, so that we see prints while the test runs.
pytest -s -m slow
============================ test session starts =============================
collected 51 items / 50 deselected / 1 skipped
tests/test_earth.py Bruno accepted our invite! π
Po accepted our invite! π
Dave accepted our invite! π
Michael accepted our invite! π
Brianna accepted our invite! π
Julia accepted our invite! π
Raphael accepted our invite! π
Caro accepted our invite! π
Chris is not available in May! π’
Danny accepted our invite! π
Audrey accepted our invite! π
πΈ Bruno is packing π
πΈ Bruno is travelling: South America βοΈ North America
πΌ Po is eating... π±
πΌ Po is eating... π±
πΌ Po is eating... π±
πΌ Po is eating... π±
πΌ Po is packing π
πΌ Po is travelling: Asia βοΈ North America
π¦ Dave is packing π
π¦ Dave's flight was cancelled π Problems at Berlin Airport π§
π¦ Michael is packing π
π¦ Michael is travelling: Africa βοΈ North America
π¨ Brianna is packing π
π¨ Brianna is travelling: Australia βοΈ North America
π― Julia is travelling: Asia βοΈ North America
π¦ Raphael is packing π
π¦ Raphael's flight was cancelled π Problems at Berlin Airport π§
π¦ Caro is packing π
π¦ Caro is travelling: Europe βοΈ North America
π» Danny is packing π
π» Audrey is packing π
Welcome to PyCon US in North America! π
Let's start with introductions...π¬
πΈ Hello, my name is Bruno!
πΌ Hello, my name is Po!
F
================================== FAILURES ==================================
______________________________ test_large_group ______________________________
@pytest.mark.wip
@pytest.mark.slow
@pytest.mark.happy
def test_large_group(event, large_group):
for adventurer in large_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
> event.start()
tests/test_earth.py:63:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
def start(self):
print(f"Welcome to {self.name} in {self.location}! π")
print(f"Let's start with introductions...π¬")
for attendee in self.attendees:
if attendee.location != self.location:
> raise MissingAttendee(f"Oh no! {attendee.name} is not here! π")
E earth.events.MissingAttendee: Oh no! Dave is not here! π
earth/events.py:34: MissingAttendee
============ 1 failed, 1 skipped, 50 deselected in 20.17 seconds =============
Our slow test failed, but letβs focus on the test duration for now. If you
tried this out for yourself, you could see that πΌ Po is eating... π±
for
several seconds multiple times!
Random fact from the nature documentary Earth: One Amazing Day π
Pandas are very fussy when it comes to their diet. Bamboo is almost the only thing they eat. But because bamboo is not very nutritious, pandas need to eat up to 14 hours a day to get enough energy to feed themselves. πΌ
Back to our testsβ¦
Write another test
We have one test that does not use all of the adventurers.py
functions, but
is really fast. We have one test that uses all of the adventurers.py
functions, but is rather slow. The ideal test would be one that uses as many
adventurers.py
functions as possible and is really fast. π¨
Letβs write a new pytest fixture no_pandas_group
and another test named
test_no_pandas_group
:
tests/test_earth.py
import pytest
from earth import adventurers, Event, Months
@pytest.fixture(name="event")
def fixture_event():
return Event("PyCon US", "North America", Months.MAY)
@pytest.fixture(name="small_group")
def fixture_small_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
@pytest.fixture(name="large_group")
def fixture_large_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_panda("Po"),
adventurers.new_fox("Dave"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
adventurers.new_fox("Raphael"),
adventurers.new_fox("Caro"),
adventurers.new_bear("Chris"),
# Bears in warm climates don't hibernate π»
adventurers.new_bear("Danny", availability=[*Months]),
adventurers.new_bear("Audrey", availability=[*Months]),
]
@pytest.fixture(name="no_pandas_group")
def fixture_no_pandas_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_fox("Dave"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
adventurers.new_fox("Raphael"),
adventurers.new_fox("Caro"),
adventurers.new_bear("Chris"),
# Bears in warm climates don't hibernate π»
adventurers.new_bear("Danny", availability=[*Months]),
adventurers.new_bear("Audrey", availability=[*Months]),
]
@pytest.mark.wip
@pytest.mark.happy
def test_small_group(event, small_group):
for adventurer in small_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
@pytest.mark.wip
@pytest.mark.slow
@pytest.mark.happy
def test_large_group(event, large_group):
for adventurer in large_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
@pytest.mark.wip
@pytest.mark.happy
def test_no_pandas_group(event, no_pandas_group):
for adventurer in no_pandas_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
Test selection via marker expression
We now have three different tests and added custom markers to them. Letβs run all wip tests that are not slow. π»
pytest -m "wip and not slow"
============================ test session starts =============================
collected 52 items / 50 deselected / 1 skipped / 1 selected
tests/test_earth.py .. [100%]
============= 2 passed, 1 skipped, 50 deselected in 0.19 seconds =============
Hmmβ¦ both tests passed. π€
Repeating tests
Remember in our previous test run the slow test failed due to an unhandled exception. According to the error our fox adventurer βDaveβ didnβt make it to PyCon. π¦
earth.events.MissingAttendee: Oh no! Dave is not here! π
Itβs rather suspicious that the test_no_pandas_group
passed, when the only
difference to the test_large_group
test is that it doesnβt have any pandas.
This might be because tests with foxes are flaky and only fail under certain
conditions.
We can check this by running our tests multiple times with pytest-repeat. Letβs run our fast, wip tests 10 times.
pytest -v -m "wip and not slow" --count 10
============================ test session starts =============================
collecting ... collected 520 items / 500 deselected / 1 skipped / 19 selected
tests/test_earth.py::test_small_group[1/10] PASSED [ 5%]
tests/test_earth.py::test_small_group[2/10] PASSED [ 10%]
tests/test_earth.py::test_small_group[3/10] PASSED [ 15%]
tests/test_earth.py::test_small_group[4/10] PASSED [ 20%]
tests/test_earth.py::test_small_group[5/10] PASSED [ 25%]
tests/test_earth.py::test_small_group[6/10] PASSED [ 30%]
tests/test_earth.py::test_small_group[7/10] PASSED [ 35%]
tests/test_earth.py::test_small_group[8/10] PASSED [ 40%]
tests/test_earth.py::test_small_group[9/10] PASSED [ 45%]
tests/test_earth.py::test_small_group[10/10] PASSED [ 50%]
tests/test_earth.py::test_no_pandas_group[1/10] FAILED [ 55%]
tests/test_earth.py::test_no_pandas_group[2/10] FAILED [ 60%]
tests/test_earth.py::test_no_pandas_group[3/10] FAILED [ 65%]
tests/test_earth.py::test_no_pandas_group[4/10] FAILED [ 70%]
tests/test_earth.py::test_no_pandas_group[5/10] FAILED [ 75%]
tests/test_earth.py::test_no_pandas_group[6/10] PASSED [ 80%]
tests/test_earth.py::test_no_pandas_group[7/10] FAILED [ 85%]
tests/test_earth.py::test_no_pandas_group[8/10] FAILED [ 90%]
tests/test_earth.py::test_no_pandas_group[9/10] FAILED [ 95%]
tests/test_earth.py::test_no_pandas_group[10/10] FAILED [100%]
======= 9 failed, 11 passed, 1 skipped, 500 deselected in 0.30 seconds =======
As we can see from the CLI report, the test_small_group
test passed in 10
out of 10 runs, whereas test_no_pandas_group
passed only on 1 out of 10
runs. We might not know the exact reason for why that is, but we can tell from
the stdout in the CLI report that there appear to be problems at TXL
airportβ¦sounds familiar? π€·ββ
Marking flaky tests
We can use the built-in xfail marker from pytest for our flaky test: Tests marked with xfail are expected to fail due to the specified reason. If they fail, they will be reported as XFAIL, however if they unexpectedly pass they will be reported as XPASS.
tests/test_earth.py
import pytest
from earth import adventurers, Event, Months
@pytest.fixture(name="event")
def fixture_event():
return Event("PyCon US", "North America", Months.MAY)
@pytest.fixture(name="small_group")
def fixture_small_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
]
@pytest.fixture(name="large_group")
def fixture_large_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_panda("Po"),
adventurers.new_fox("Dave"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
adventurers.new_fox("Raphael"),
adventurers.new_fox("Caro"),
adventurers.new_bear("Chris"),
# Bears in warm climates don't hibernate π»
adventurers.new_bear("Danny", availability=[*Months]),
adventurers.new_bear("Audrey", availability=[*Months]),
]
@pytest.fixture(name="no_pandas_group")
def fixture_no_pandas_group():
return [
adventurers.new_frog("Bruno"),
adventurers.new_fox("Dave"),
adventurers.new_lion("Michael"),
adventurers.new_koala("Brianna"),
adventurers.new_tiger("Julia"),
adventurers.new_fox("Raphael"),
adventurers.new_fox("Caro"),
adventurers.new_bear("Chris"),
# Bears in warm climates don't hibernate π»
adventurers.new_bear("Danny", availability=[*Months]),
adventurers.new_bear("Audrey", availability=[*Months]),
]
@pytest.mark.wip
@pytest.mark.happy
def test_small_group(event, small_group):
for adventurer in small_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
@pytest.mark.wip
@pytest.mark.slow
@pytest.mark.happy
@pytest.mark.xfail(reason="Problems with TXL airport")
def test_large_group(event, large_group):
for adventurer in large_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
@pytest.mark.wip
@pytest.mark.happy
@pytest.mark.xfail(reason="Problems with TXL airport")
def test_no_pandas_group(event, no_pandas_group):
for adventurer in no_pandas_group:
event.invite(adventurer)
for attendee in event.attendees:
attendee.get_ready()
attendee.travel_to(event)
event.start()
Run the tests again:
pytest -v -m "wip and not slow" --count 10
============================ test session starts =============================
collecting ... collected 520 items / 500 deselected / 1 skipped / 19 selected
tests/test_earth.py::test_small_group[1/10] PASSED [ 5%]
tests/test_earth.py::test_small_group[2/10] PASSED [ 10%]
tests/test_earth.py::test_small_group[3/10] PASSED [ 15%]
tests/test_earth.py::test_small_group[4/10] PASSED [ 20%]
tests/test_earth.py::test_small_group[5/10] PASSED [ 25%]
tests/test_earth.py::test_small_group[6/10] PASSED [ 30%]
tests/test_earth.py::test_small_group[7/10] PASSED [ 35%]
tests/test_earth.py::test_small_group[8/10] PASSED [ 40%]
tests/test_earth.py::test_small_group[9/10] PASSED [ 45%]
tests/test_earth.py::test_small_group[10/10] PASSED [ 50%]
tests/test_earth.py::test_no_pandas_group[1/10] XFAIL [ 55%]
tests/test_earth.py::test_no_pandas_group[2/10] XFAIL [ 60%]
tests/test_earth.py::test_no_pandas_group[3/10] XPASS [ 65%]
tests/test_earth.py::test_no_pandas_group[4/10] XFAIL [ 70%]
tests/test_earth.py::test_no_pandas_group[5/10] XFAIL [ 75%]
tests/test_earth.py::test_no_pandas_group[6/10] XPASS [ 80%]
tests/test_earth.py::test_no_pandas_group[7/10] XFAIL [ 85%]
tests/test_earth.py::test_no_pandas_group[8/10] XFAIL [ 90%]
tests/test_earth.py::test_no_pandas_group[9/10] XFAIL [ 95%]
tests/test_earth.py::test_no_pandas_group[10/10] XFAIL [100%]
= 10 passed, 1 skipped, 500 deselected, 8 xfailed, 2 xpassed in 0.24 seconds =
Check code coverage
Now letβs run all of our tests and check the current code coverage. π
pytest --cov earth/
Name Stmts Miss Cover
------------------------------------------
earth/__init__.py 3 0 100%
earth/adventurers.py 71 0 100%
earth/events.py 24 0 100%
earth/travel.py 31 3 90%
earth/year.py 14 0 100%
------------------------------------------
TOTAL 143 3 98%
Yay, weβre at 98% code coverage for the earth project! Thatβs fantastic! π
Next steps
Weβve completed our task of increasing the code coverage through automated tests, but we have some more work to do! π§
Running all of the tests including the slow test isnβt great for developer productivity. It makes sense to write a custom pytest plugin that skips tests marked as slow by default and only includes slow tests if we run pytest with a custom CLI option.
Also, did you notice that we have three test cases, but apart from the markers and the different fixtures the test functions itself are identical? Thatβs a lot of redundancy. π
The good news is that pytest comes with a marker which solves this exact problem. Parametrized tests and custom plugins will be our topics in the next part of this tutorial!
I hope youβre enjoying this tutorial as much as I enjoyed giving my talk and writing the first part of this blog post. Weβll do some really cool stuff with pytest in the next part of this tutorial. As usual, I will post a link on my Twitter. Stay tuned! π
Update: Customizing your pytest test suite (part 2) is now online! π¨π»βπ»