pytest
Simple, rapid and fun testing with Python
§ The-Compiler/pytest-basics
Florian Bruhin
July 14th, 2025
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 1
About me
§ The-Compiler/pytest-basics
2011: 2013, 2015: 2019, 2020: You:
Used pytest before?
Used unittest/nose/. . . before?
Used pytest fixtures?
Used Python decorators?
Used context managers?
Used “yield”?
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 Used virtualenv? 2
Course content (I)
• About testing: Why and how to write tests
• About pytest: Why pytest, popularity, history overview
• The basics: Fundamental pytest features, test discovery and plain asserts
• Configuration: Typical directory structure, configs, options
• Marks: Marking/grouping tests, skipping tests
• Parametrization: Running tests against sets of input/output data
• Fixtures: Providing test data, setting up objects, modularity
• Built-in fixtures: Capturing output, patching, temporary files, test information
• Fixtures advanced: Caching, cleanup/teardown, implicit fixtures, parametrizing
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 3
Course content (II)
• Debugging failing tests: Controlling output, selecting tests, tracing fixtures,
using breakpoints, showing durations, dealing with hanging and flaky tests
• Migrating to pytest: Running existing testsuites, incremental rewriting, tooling
• Mocking: Dealing with dependencies which are in our way, monkeypatch and
[Link], mocking libraries and helpers, alternatives
• Plugin tour: Coverage, distributed testing, test reporting, alternative test syntax,
testing C libraries, asyncio integration, plugin overview
• Property-based testing: Using hypothesis to generate test data
• Writing plugins: Extending pytest via custom hooks, domain-specific languages
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 4
About testing
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 5
Why automated testing?
✓ To raise confidence that code works
To allow for changes without fear
[ To specify and document behaviour
² Collaborative and faster development cycles
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 6
Testing terminology
Different sizes of tests
Unit tests Integration tests Functional tests
Units react well to input Multiple components Full code works in user
(micro-tests) co-operate nicely environments
(end-to-end tests,
system tests,
acceptance tests)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 7
[Link]/anatomy
Arrange, Act, Assert
A test function usually has three parts:
Arrange Set up objects/values needed for the test
(with pytest, often done via fixtures instead of in the test function)
Act Call the function/method to be tested
Assert Check the result (with pytest, using the Python assert statement)
Act/assert might be repeated, but it’s usually good practice to only test one thing per
test function.
Arrange Act Assert
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 8
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 9
Setup
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 10
Setup overview
Download slides and example code for exercises:
•
§ The-Compiler/pytest-basics
• We’ll use Python 3.9 or newer, with pytest 8.3 (≥ 7.0 is okay).
• Use python3 --version or py -3 --version (Windows) to check
your version.
• You can use whatever editor/IDE you’d like – if you don’t use one
yet, PyCharm (Community Edition) or VS Code are good choices.
• However, we’ll first start exploring pytest on the command line,
in order to see how it works “under the hood” and explore various
commandline arguments.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 11
Setup with PyCharm / VS Code
• Open code/ folder (not enclosing folder!) as project, open basic/test_calc.py
• Select “Configure • Ctrl-Shift-P to open command palette,
Python interpreter” run “Python: Create Environment. . . ”
when prompted • Select venv and [Link]
• Wait until “Install for installation
requirements” prompt • Click e in the sidebar, you should see
appears and accept a tree of tests (some will fail)
• Open PyCharm / VS Code terminal at the bottom
• You should be able to run pytest --version and see at least 7.0.0
• Also try next to a test function (not entire file)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 12
Virtual environments: Isolation of package installs
Virtual environments:
• Provide isolated environments for Python package installs
first-proj/.venv
• Isolate different app/package-install configurations
• pytest 8.3.0
• Are built into Python since 3.4
• pytest-cov
(but a separate virtualenv tool also exists)
• pytest-mock
• Are the building blocks for high-level tools like poetry/uv
With a virtual environment, we can avoid running second-proj/.venv
sudo pip install . . . , which can mess up your system • pytest 7.4.4
(on Linux/macOS).
• requests
Chris Warrick ([Link]): • pytest-recording
“Python Virtual Environments in Five Minutes”
[Link]/venv
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 13
Using virtual environments
Installing and creating
Install venv:
(Debian-based Linux distributions only, shipped with Python elsewhere)
apt install python3-venv
Create a local environment (once, can be reused):
python3 -m venv .venv
py -m venv venv
(or virtualenv instead of venv)
This will create a local Python installation in a venv or .venv folder.
Any dependencies installed with its pip will only be available in this environment/folder.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 14
Using virtual environments
Running commands and activating
Run commands “inside” the environment:
venv\Scripts\pip .venv/bin/pip
venv\Scripts\python .venv/bin/python
venv\Scripts\pytest .venv/bin/pytest
Alternatively, activate the environment:
(changes PATH temporarily, so that pip, python, pytest etc. use the binaries from the virtualenv)
venv\Scripts\[Link]
Set-ExecutionPolicy Unrestricted -Scope Process
venv\Scripts\Activate
source .venv/bin/activate
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 15
Installing pytest
Install pytest and other dependencies within the activated
environment:
pip install -r code/[Link]
(or just pip install pytest, which covers most but not all of the training)
Now let’s see if it works:
pytest --version
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 16
The basics
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 17
[Link]/survey
Fundamental features of pytest
Popularity
• Automatic test discovery,
no-boilerplate test code
(boilerplate: repeated code without any “real” use)
• Useful information when a test fails
• Test parametrization
• Modular setup/teardown via fixtures
• Customizable: Many options,
JetBrains Python Developers Survey 2023
> 1600 plugins (easy to write your own!) n > 25 000
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 18
[Link]/usage
Cross-project test tool
• Tests are run via pytest command line tool (called [Link] before v3.0)
• Testing starts from given files/directories, or the current directory
Examples:
• pytest • pytest test_file.py::test_func
• pytest path/to/tests • pytest test_file.py::TestClass
• pytest test_file.py • pytest test_file.py::TestClass::test_meth
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 19
[Link]/discovery
Automatic test discovery
pytest walks over the filesystem and:
• Discovers test_· · · .py and · · · _test.py test files
• Discovers test· · · functions and Test· · · classes
• Discovers classes deriving from [Link]
Automatic discovery avoids boilerplate
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 20
Calculator project
rpncalc/[Link]
def calc(a, b, op):
if op == "+":
return a + b
elif op == "-":
return a - b
elif op == "*":
return a * b
elif op == "/":
return a / b
raise ValueError("Invalid operator")
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 21
No boilerplate Python test code
rpncalc/[Link] $ pytest basic/test_calc.py
def calc(a, b, op): ========== test session starts =========
if op == "+": collected 1 item
return a + b
... basic/test_calc.py .
=========== 1 passed in . . . ============
basic/ test_calc.py
from [Link] import calc
def test_add ():
res = calc(1, 3, "+")
assert res == 4
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 22
No boilerplate Python test code
rpncalc/[Link]
import unittest
def calc(a, b, op): from [Link] import calc
if op == "+":
return a + b
... class TestCalc([Link]):
def test_add(self):
basic/ test_calc.py res = calc(1, 3, "+")
[Link](res, 4)
from [Link] import calc
def test_add (): if __name__ == "__main__":
res = calc(1, 3, "+") [Link]()
assert res == 4
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 22
[Link]/classes
Test classes
class TestCalc:
def test_add(self): class TestCalc:
assert calc(1, 3, "+") == 4
test_add
def test_subtract(self):
... test_subtract
With pytest:
• Test classes have a Test prefix, are autodiscovered
• There is no need to subclass anything, test functions don’t have to be in classes
• Test classes are for logical grouping of your tests
• Fixtures are typically used in place of unittest’s setUp() and tearDown()
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 23
[Link]/assert
Assert introspection
assert x [Link](x)
assert x == 1 [Link](x, 1)
assert x != 2 [Link](x, 2)
assert not x [Link](x)
assert x < 3 or y > 5 ?
Demo on failure reporting (basic/failure_demo.py)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 24
Test outcomes
Every test can have one of the following outcomes:
PASSED . All assertions passed, no exceptions occurred
FAILED F An assertion failed or an exception occurred
ERROR E An exception occurred outside of the test (e.g. in a fixture)
SKIPPED s The test was skipped, e.g. because of a missing optional dependency
XFAILED x An expected failure occurred
XPASS X An unexpected success occurred (expected to fail but passed)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 25
Tracebacks
rpncalc/[Link] basic/test_traceback.py
def calc(a, b, op): from [Link] import calc
if op == "+":
return a + b def test_divide():
elif op == "-": # This will raise ZeroDivisionError
return a - b assert calc(2, 0, "/") == 0
elif op == "*":
return a * b def test_good():
elif op == "/": pass
return a / b
raise ValueError("Invalid operator")
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 26
[Link]/cli
Some important options
-v / --verbose More verbose output (can be given multiple times)
-q / --quiet More quiet output (negates -v)
-k expression Run tests whose names contain the given keyword
See pytest -h (--help) for many more options.
Exercise: getting-started
• Add a test_subtract function to basic/test_calc.py ↑
Every exercise has a label
• Run pytest basic/test_calc.py to run tests
like this on the right, which
• Add -k to only run your new test, play with -v and -q helps with finding it in the
code and solutions: Search
• Take a first look at the --help output
for [getting-started].
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 27
[Link]/layout
Typical directory structure
tests/ outside of src/, file layout 1:1 mirroring (roughly) of src/mypackage/:
myproject/ tests/
[Link] __init__.py
[Link] [Link]
src/ myproject/ test_utils.py
__init__.py export/
[Link] __init__.py
export/ [Link]
__init__.py test_jsonexport.py
[Link] test_yamlexport.py
[Link]
tests/
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 28
[Link]/layout
Typical directory structure
[Link]: fixtures, plugin hooks, etc.
myproject/ tests/
[Link] __init__.py
[Link] [Link]
src/ myproject/ test_utils.py
__init__.py export/
[Link] __init__.py
export/ [Link]
__init__.py test_jsonexport.py
[Link] test_yamlexport.py
[Link]
tests/
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 28
[Link]/layout
Typical directory structure
[Link] or [Link]: (declarative) config
myproject/ tests/
[Link] __init__.py
[Link] [Link]
src/ myproject/ test_utils.py
__init__.py export/
[Link] __init__.py
export/ [Link]
__init__.py test_jsonexport.py
[Link] test_yamlexport.py
[Link]
tests/
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 28
[Link]/layout
Typical directory structure
__init__.py files both in src/myproject and tests/
myproject/ tests/
[Link] __init__.py
[Link] [Link]
src/ myproject/ test_utils.py
__init__.py export/
[Link] __init__.py
export/ [Link]
__init__.py test_jsonexport.py
[Link] test_yamlexport.py
[Link]
tests/
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 28
[Link]/layout
Typical directory structure
Based on the “src layout”:
myproject/
[Link] Ionel Cristian Mărieş ([Link]):
[Link] “Packaging a python library”
(also for applications!)
src/ myproject/
__init__.py
[Link]
export/
__init__.py
[Link] Hynek Schlawack ([Link]):
[Link] “Testing & Packaging”
tests/
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 28
[Link]/config
[Link] file
You can create a [Link] file:
myproject/ [pytest]
[Link] option = value
[Link] ...
src/ myproject/
pytest also can read a [pytest] section in
[Link]
[Link] or [[Link].ini_options]
...
from [Link], in case you prefer
tests/
having multiple tools configured in one file.
[Link]
test_utils.py
Available options are listed in the pytest -h
...
output, or in the reference documentation:
[Link] → Reference guides →
API Reference → Configuration Options
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 29
[Link]/raises
Asserting expected exceptions
rpncalc/[Link] basic/test_raises.py
def calc(a, b, op): def test_zero_division():
... with [Link](ZeroDivisionError):
elif op == "/": calc(3, 0, "/")
return a / b
raise ValueError("Invalid operator")
Exercise: raises
• In basic/test_raises.py, write another test with [Link], to ensure
that ValueError is raised when calling calc with an invalid operator.
• Rerun test, but edited so that no exception or a different exception is raised,
e.g. calc(1, 2, "+") and calc(1, 0, "/").
• Pass a regex pattern to the match argument to check the exception message:
with [Link](ValueError, match=r"..."): (optional)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 30
Marks
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 31
[Link]/mark
[Link]: Custom marking
Mark functions or classes:
marking/test_marking.py
@[Link] On a basic level, marks are
@[Link] slow webtest tags / labels for tests.
def test_slow_api(): test_slow_api
[Link](1)
As we’ll see later, marks are
also used to attach
@[Link] webtest
meta-information to a test,
def test_api():
test_api used by pytest itself
pass
(parametrize, skip, xfail, . . . ),
by fixtures, or by plugins.
def test_fast():
test_fast
pass
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 32
[Link]/mark
[Link]: Custom marking
Mark functions or classes: Register the custom markers:
marking/test_marking.py
@[Link] [Link]
@[Link]
def test_slow_api(): [pytest]
[Link](1) markers =
slow: Tests which take some time to run
@[Link] webtest: Tests making web requests
def test_api():
pass
Then pass e.g. -m "slow" to pytest to filter by marker.
def test_fast():
Use --markers to show a list of all known markers.
pass
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 32
[Link]/parametrize
Parametrizing tests
Tests can be parametrized to run them with various values:
marking/test_parametrization.py
@[Link]("a, b, expected", [
(1, 2, 3), # 1 + 2 = 3
(2, 3, 6), # 2 + 3 = 6 (?) test_add
(3, 4, 7), # 3 + 4 = 7
(4, 5, 9), # 4 + 5 = 9
]) test_add[1-2-3]
def test_add( a: int, b: int, expected: int ): test_add[2-3-6]
assert calc( a, b , "+") == expected
test_add[3-4-7]
@[Link](
"op", ["+", "-", "*", "/", "@"]) test_add[4-5-9]
def test_smoke(op: str):
calc(1, 2, op)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 33
[Link]/parametrize
Parametrizing tests
Tests can be parametrized to run them with various values:
marking/test_parametrization.py
@[Link]("a, b, expected", [
(1, 2, 3), # 1 + 2 = 3 test_smoke
(2, 3, 6), # 2 + 3 = 6 (?)
(3, 4, 7), # 3 + 4 = 7
(4, 5, 9), # 4 + 5 = 9 test_smoke[+]
])
test_smoke[-]
def test_add( a: int, b: int, expected: int ):
assert calc( a, b , "+") == expected test_smoke[*]
@[Link]( test_smoke[/]
"op", ["+", "-", "*", "/", "@"])
test_smoke[@]
def test_smoke(op: str):
calc(1, 2, op)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 33
[Link]/parametrize
Parametrizing tests
Tests can be parametrized to run them with various values:
marking/test_parametrization.py
@[Link]("a, b, expected", [
(1, 2, 3), # 1 + 2 = 3
Exercise: parametrize
(2, 3, 6), # 2 + 3 = 6 (?)
(3, 4, 7), # 3 + 4 = 7 • Write a test_multiply
(4, 5, 9), # 4 + 5 = 9 with a single, hardcoded
]) value
def test_add( a: int, b: int, expected: int ):
• Parametrize the test to
assert calc( a, b , "+") == expected
test multiple inputs and
@[Link]( expected outputs
"op", ["+", "-", "*", "/", "@"]) • Run pytest with -v
def test_smoke(op: str):
calc(1, 2, op)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 33
[Link]/skipping
Skipping or “xfailing” tests
Skip a test if:
@[Link](
• It cannot run at all on a certain platform # condition
[Link] == "win32",
• It cannot run because a dependency is missing
# text shown with -v
⇒ Test function is not run, result is “skipped” (s) reason="Linux only",
)
Use @[Link] (instead of skipif) def test_linux():
for unconditional skipping. pass
“xfail” (“expected to fail”) a test if:
@[Link](
• The implementation is currently lacking # condition optional
• It fails on a certain platform but should work reason="see #1234",
)
⇒ Test function is run, but result is “xfailed” (x), def test_new_api():
instead of failed (F). Unexpected pass: XPASS (X). pass
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 34
xfail vs. raises
[Link] [Link]
Testing a “bad case”, but intended Marking a test where the implementation
behavior: behaves in unintended ways:
⇒ When we call calc(1, 2, "@"), This test should work, but currently does
we expect a ValueError. not (e.g. upstream bug).
⇒ e.g. if the user asks for the result of
1 / 0 and gets an unhandled exception.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 35
Marks summary
• At a basic level, marks let us categorize tests by using them as “tags” / “labels”.
• However, marks also lets us attach “meta-information” to a test.
This information is used by pytest in various ways:
@[Link] Run the same test against different data
@[Link] Skip a test (not applicable)
@[Link] Expect a test to fail (broken, out of our control)
• (In fixtures or plugin hooks, we can also access a marker’s arguments to
customize behavior.)
• Test IDs are auto-generated but can be overridden
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 36
Expanding the calculator example
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 37
Reverse Polish Notation (RPN)
History
• Using a calculator without needing a = key,
and without parentheses
• Makes it much easier to implement,
using a stack data structure
• Used by all HP calculators in the 1970s–80s,
still used by some today
• Displayed here: HP 12C financial calculator,
introduced in 1981, still in production today
(HPs longest and best-selling product)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 38
Reverse Polish Notation (RPN)
Explanation
5 · (1 + 2)
1 2 5
+ *
2 2 5 5
1 1 1 3 3 3 15
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 39
Reverse Polish Notation (RPN)
In Python
1 1
In code/, using python -m rpncalc.rpn_v1
2 1 2
> 1
> 2
+ 1 2
> p
[1.0, 2.0] 3
> +
3
> 5 5 3 5
> *
15 * 3 5
> q
15
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 40
Reverse Polish Notation (RPN) def evaluate(self, inp: str):
rpncalc/rpn_v1.py
if [Link]():
from [Link] import calc n = float(inp)
[Link](n)
class RPNCalculator: elif inp in "+-*/":
def __init__(self) -> None: b = [Link]()
[Link] = [] a = [Link]()
res = calc(a, b, inp)
def run(self) -> None: [Link](res)
while True: print(res)
inp = input("> ")
if inp == "q":
return if __name__ == "__main__":
elif inp == "p": rpn = RPNCalculator()
print([Link]) [Link]()
else:
[Link](inp)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 41
Reverse Polish Notation (RPN)
Tests: Recycling the example 5 · (1 + 2)
rpncalc/test_rpn_v1.py
1 1
def test_complex_example():
rpn = RPNCalculator() 2 1 2
[Link]("1")
+ 1 2
assert [Link] == [1]
[Link]("2")
3
assert [Link] == [1, 2]
[Link]("+")
assert [Link] == [3] 5 3 5
[Link]("5")
assert [Link] == [3, 5] * 3 5
[Link]("*")
assert [Link] == [15] 15
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 42
Reverse Polish Notation (RPN)
Tests: Getting a bit smaller
def test_stack_push():
rpn = RPNCalculator() 1 1
[Link]("1")
[Link]("2") 2 1 2
assert [Link] == [1, 2]
@[Link]("op, expected", [
("+", 3), ("-", -1),
("*", 2), ("/", 0.5), + 1 2 - 1 2
])
def test_operations(op, expected): 3 -1
rpn = RPNCalculator()
[Link] = [1, 2] * 1 2 / 1 2
[Link](op)
assert [Link] == [expected] 2 0.5
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 43
Reverse Polish Notation (RPN)
Towards an improved version
• Fix bugs and add additional error handling:
• Allow negative numbers and floating-point inputs, not just .isdigit()
• Fix +- being treated as valid input due to elif inp in "+-*/":
• Print error when using an invalid operator
• Handle ZeroDivisionError when dividing by zero,
and IndexError with < 2 elements on stack
• Allow passing a Config object to the calculator, with a custom prompt,
instead of "> "
• Support multiple inputs on one line, e.g. 2 ␣ 1 ␣ + (not 2 1 + )
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 44
Reverse Polish Notation (RPN)
Fixing bugs, improving error handling
rpncalc/rpn _v1 .py rpncalc/ test_ rpn_v2.py
... @[Link]("n", [1.5, -1])
def evaluate(self, inp: str): def test_number_input(n: float):
if [Link](): rpn = RPNCalculator(Config())
n = float(inp) [Link](str(n))
[Link](n) assert [Link] == [n]
elif inp in "+-*/":
With rpn_v1.py:
...
E assert [] == [1.5]
E assert [] == [-1]
With rpn_v2.py:
. . . ::test_number_input[1.5] PASSED
. . . ::test_number_input[-1] PASSED
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 45
Reverse Polish Notation (RPN)
Fixing bugs, improving error handling
rpncalc/rpn _v1 .py rpncalc/rpn _v2 .py
... ...
def evaluate(self, inp: str): def evaluate(self, inp: str) -> None:
if [Link](): try:
n = float(inp) [Link](float(inp))
[Link](n) return
elif inp in "+-*/": except ValueError:
... pass
if inp not in ["+", "-", "*", "/"]:
[Link]/mathspp-error
...
LBYL: Look before you leap EAFP: It’s easier to ask for forgivenness,
than for permission
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 45
Reverse Polish Notation (RPN)
Fixing bugs, improving error handling
rpncalc/rpn _v1 .py rpncalc/ test_ rpn_v2.py
... @[Link](
def evaluate(self, inp: str): "op", ["@", "+-"])
if [Link](): def test_unknown_operator(op: str):
n = float(inp) rpn = RPNCalculator(Config())
[Link](n) [Link] = [1, 2]
elif inp in "+-*/": [Link](op)
...
With rpn_v1.py:
. . . ::test_unknown_operator[@] PASSED
. . . ::test_unknown_operator[+-] FAILED
E ValueError: Invalid operator
With rpn_v2.py: rpncalc/test_rpn_v2.py ..
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 45
Reverse Polish Notation (RPN)
Fixing bugs, improving error handling
rpncalc/rpn _v1 .py rpncalc/rpn _v2 .py
... ...
def evaluate(self, inp: str): def evaluate(self, inp: str) -> None:
if [Link](): try:
n = float(inp) [Link](float(inp))
[Link](n) return
elif inp in "+-*/": except ValueError:
... pass
if inp not in ["+", "-", "*", "/"]:
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 45
Fixtures
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 46
Reverse Polish Notation (RPN)
Adding a Config object
rpncalc/rpn_v2.py rpncalc/[Link]
from [Link] import calc, Config
class RPNCalculator: class Config:
def __init__(self, config): def __init__(self, prompt=">"):
[Link] = config [Link] = prompt
[Link] = []
...
...
if __name__ == "__main__":
config = Config() RPNCalculator Config
config.load_cwd()
rpn = RPNCalculator( config )
[Link]()
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 47
Beyond simple testing: fixtures!
A test fixture:
def test_example(): • Sets up objects or apps for testing
config = Config() • Provides test code with “base” app objects
rpn = RPNCalculator(config)
... • Is very important to avoid
repetitive test code
def test_stack_push():
config = Config() In pytest realized via dependency injection:
rpn = RPNCalculator(config) • Fixture functions create and return
...
fixture values
def test_operations( . . . ): • They are registered with pytest by using a
config = Config() @[Link] decorator
rpn = RPNCalculator(config)
• Test functions and classes can
...
request and use them
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 48
[Link]/fixtures
Example of pytest fixture injection
pytest calls the fixture function to inject a
dependency into the test function: rpn
fixtures/test_fixture.py
@[Link]
def rpn (): test_empty_stack
return RPNCalculator(Config())
def test_empty_stack( rpn ): Exercise: fixtures
assert [Link] == [] • Add an rpn fixture to
rpncalc/test_rpn_v2.py
Imagine pytest running your test like: • Rewrite test_operations
test_empty_stack( rpn = rpn() ) to use it
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 49
Good practices for fixtures
Consider adding type annotations:
@[Link]
def rpn() -> RPNCalculator :
"""A RPN calculator with a default config."""
...
def test_rpn( rpn: RPNCalculator ):
...
Your IDE will typically show an error if the type annotations aren’t correct,
but you should probably use a tool like mypy to validate them in a CI job.
[Link]
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 50
Good practices for fixtures
Consider adding type annotations, write a docstring for your fixtures:
@[Link]
def rpn() -> RPNCalculator :
"""A RPN calculator with a default config."""
...
--fixtures Show all defined fixtures with their docstrings.
--fixtures-per-test Show the fixtures used, grouped by test.
Output:
----------------- fixtures defined from test_fixture ----------------
rpn -- fixtures/test_fixture.py:7
A RPN calculator with a default config.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 50
Modularity: Using fixtures from fixtures
fixtures/test_fixtures_using_fixtures.py
@[Link]
def config() -> Config: config
return Config()
@[Link] rpn
def rpn( config: Config ) -> RPNCalculator:
return RPNCalculator(config)
def test_config(config: Config): test_rpn test_config
assert [Link] == ">"
def test_rpn(rpn: RPNCalculator):
assert [Link] == []
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 51
[Link]/conftest
[Link] fixtures
You can move fixture functions into [Link]: myproject/tests/
export/
• Visible to modules in same or sub directory. [Link]
• Put hooks and cross-module used fixtures into test_jsonexport.py
conftest file(s) but don’t import from them. test_yamlexport.py
[Link]
test_utils.py
tests/export/ ...
test_jsonexport.py tests/test_utils.py
def test_simple(exporter): def test_format(exporter):
[Link]
exporter
exporter
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 52
[Link]/fixt-override
Fixture visibility
Fixtures defined. . . class TestConfig:
• . . . as methods of a test class are available @[Link]
only to test methods on that class. def config(self):
return Config()
• . . . in a test module are available only to
tests in that module. def test_config(self, config):
• . . . in a [Link] file are available to ...
tests in that directory and subdirectories.
• . . . in a plugin (or built into pytest) are
available everywhere.
Use pytest --fixtures to see which fixtures
have been picked up from where.
Fixtures in a more specific location shadow
those in a more general location.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 53
[Link]/fixt-override
Fixture visibility
Fixtures defined. . . test_config.py
• . . . as methods of a test class are available @[Link]
only to test methods on that class. def config():
return Config()
• . . . in a test module are available only to
tests in that module.
• . . . in a [Link] file are available to def test_config(config):
tests in that directory and subdirectories. ...
• . . . in a plugin (or built into pytest) are
available everywhere.
Use pytest --fixtures to see which fixtures
have been picked up from where.
Fixtures in a more specific location shadow
those in a more general location.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 53
[Link]/fixt-override
Fixture visibility
Fixtures defined. . . [Link]
• . . . as methods of a test class are available @[Link]
only to test methods on that class. def rpn_config():
return Config()
• . . . in a test module are available only to
tests in that module. This makes rpn_config available for
other files, without any importing:
• . . . in a [Link] file are available to
tests in that directory and subdirectories. test_config.py
• . . . in a plugin (or built into pytest) are def test_config(rpn_config):
available everywhere. ...
Use pytest --fixtures to see which fixtures test_rpn.py
have been picked up from where.
def test_rpn( . . . , rpn_config):
Fixtures in a more specific location shadow ...
those in a more general location.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 53
[Link]/fixt-override
Fixture visibility
Fixtures defined. . . $ pip install pytest-someplugin
• . . . as methods of a test class are available ...
only to test methods on that class. Successfully installed
pytest-someplugin-1.2.3
• . . . in a test module are available only to
tests in that module.
• . . . in a [Link] file are available to $ pytest
tests in that directory and subdirectories. ====== test session starts ======
...
• . . . in a plugin (or built into pytest) are plugins: someplugin-1.2.3
available everywhere.
Use pytest --fixtures to see which fixtures
have been picked up from where.
Fixtures in a more specific location shadow
those in a more general location.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 53
[Link]/fixt-builtin
Builtin fixtures
pytest provides builtin fixtures:
capsys / capfd Capturing stdout/stderr in a test
caplog Capturing logging output from a test
monkeypatch Temporarily modify state for test duration
tmp_path / tmpdir A fresh empty directory for each test invocation
request Get information about the current test
...
More fixtures are provided by plugins.
Use pytest --fixtures to see available fixtures with docs
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 54
[Link]/capturing
Builtin fixtures
Capturing
def test_ok(): $ pytest -v basic/test_capturing.py
print("This isn't printed") ...
def test_bad(): . . . ::test_ok PASSED
print("This is printed") . . . ::test_bad FAILED
assert False
============== FAILURES ==============
______________ test_bad ______________
...
-------- Captured stdout call --------
This is printed
====== short test summary info =======
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 55
[Link]/capturing
Builtin fixtures
Capturing
def test_ok(): $ pytest -s -v basic/test_capturing.py
print("This isn't printed") ...
def test_bad(): . . . ::test_ok This isn't printed
print("This is printed") PASSED
assert False . . . ::test_bad This is printed
FAILED
============== FAILURES ==============
______________ test_bad ______________
...
====== short test summary info =======
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 55
[Link]/capturing
Builtin fixtures
Capturing
capsys Capturing stdout/stderr in a test by overriding [Link]
e.g. for print( . . . ).
capfd Capturing stdout/stderr in a test at file descriptor level,
e.g. for subprocesses or libraries printing from C code.
Type for both: [Link][str]
capsysbinary/ Like capsys/capfd, but for binary data.
capfdbinary Type for both: [Link][bytes]
caplog Capturing logging output from a test.
Type: [Link]
capsys vs. capfd: If in doubt, use capfd to ensure everything is captured
(built-in pytest capturing also uses fd by default).
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 56
[Link]/monkeypatch
Builtin fixtures
monkeypatch
The monkeypatch fixture allows to temporarily change state for
the duration of a test function execution:
• Modify attributes on objects, classes or modules
(setattr, delattr).
Also works with functions/methods, as those are attributes
of the respective module/class/instance too.
• Modify environment variables (setenv, delenv)
• Change current directory (chdir)
• Modify dictionaries (setitem, delitem)
• Prepend to [Link] for importing (syspath_prepend)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 57
[Link]/monkeypatch
Builtin fixtures
monkeypatch: How to not do things
def print_info():
path = [Link]("PATH", "")
print(f"platform: {[Link]}")
print(f"PATH: {path}")
def test_a():
[Link] = "MonkeyOS" # don't do this!
[Link]["PATH"] = "/zoo" # don't do this!
print_info()
assert False test_a test_b
test test
def test_b():
[Link] == "MonkeyOS"
print_info()
[Link]["PATH"] == "/zoo"
assert False
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 58
[Link]/monkeypatch
Builtin fixtures
monkeypatch
fixtures/test_builtin_monkeypatch.py
def print_info():
path = [Link]("PATH", "")
print(f"platform: {[Link]}")
print(f"PATH: {path}")
def test_a( monkeypatch: [Link] ):
[Link](sys, "platform", "MonkeyOS")
[Link]("PATH", "/zoo")
print_info()
assert False test_a monkeypatch test_b
test teardown test
def test_b():
[Link] == "MonkeyOS"
print_info()
[Link]["PATH"] == "/zoo"
assert False
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 59
[Link]/monkeypatch
Builtin fixtures
monkeypatch: Patching functions
fixtures/test_builtin_monkeypatch.py
def get_folder_name() -> str:
user = [Link]()
return f"pytest-of-{user}"
def fake_getuser() -> str:
return "fakeuser"
def test_get_folder_name(monkeypatch: [Link]):
[Link](
getpass, "getuser", # target, "name"
fake_getuser # value
)
assert get_folder_name() == "pytest-of-fakeuser"
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 60
[Link]/monkeypatch
Builtin fixtures
monkeypatch: Patching functions
fixtures/test_builtin_monkeypatch.py
def get_folder_name() -> str:
user = [Link]()
return f"pytest-of-{user}"
def test_get_folder_name_lambda(monkeypatch: [Link]):
[Link](getpass, "getuser", lambda: "fakeuser")
assert get_folder_name() == "pytest-of-fakeuser"
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 60
Reverse Polish Notation (RPN)
Supporting multiple inputs
rpnalc/rpn _v1 .py rpncalc/rpn _v2 .py
class RPNCalculator: class RPNCalculator:
... ...
def get_inputs(self) -> list[str]:
inp = input([Link] + " ")
return [Link]()
def run(self) -> None: def run(self) -> None:
while True: while True:
inp = input("> ") for inp in self.get_inputs():
... ...
[Link](inp) [Link](inp)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 61
[Link]/monkeypatch
Builtin fixtures
monkeypatch
rpncalc/rpn_v2.py test_run()
class RPNCalculator:
... class RPNCalculator:
def get_inputs(self) -> list[str]:
inp = input( . . . ) run()
return [Link]()
def run(self) -> None: get_inputs()
while True:
for inp in self.get_inputs() :
input()
...
[Link](
target , " name ", value ) fake_get_inputs()
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 62
[Link]/monkeypatch
Builtin fixtures
monkeypatch
rpncalc/rpn_v2.py Exercise: monkeypatch
class RPNCalculator: • Add a new test to test_rpn_v2.py,
... which will test run.
def get_inputs(self) -> list[str]: • Leave rpn_v2.py as-is!
inp = input( . . . )
return [Link]() • Use the monkeypatch fixture as arg,
to patch the get_inputs method
def run(self) -> None: on rpn_calc (instance, not class).
while True:
• Replace it by a function that returns
for inp in self.get_inputs() :
a fixed list of strings, ending in "q".
... E.g. def fake_get_inputs():
or use lambda: . . . .
[Link](
target , " name ", value ) • Remember: Arrange, Act, Assert.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 62
[Link]/tmp-path
Builtin fixtures
tmp_path / tmpdir
A fresh empty directory for every from pathlib import Path
pytest invocation and every test.
• Store generated input files for def test_temp( tmp_path: Path ):
tested code ...
• Store output files, e.g. /tmp/ (system-wide temp directory)
measurement data, pytest-of-florian/
screenshots, etc. pytest-1/
• Access data even after pytest test_temp0/
run is done, but no need for ...
manual cleanup pytest-2/
test_temp0/
...
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 63
[Link]/tmp-path
Builtin fixtures
tmp_path / tmpdir
A fresh empty directory for every tmp_path:
pytest invocation and every test.
Based on Python’s [Link], a more
• Store generated input files for object-oriented and convenient [Link] alternative:
tested code
dir_path = [Link]("output")
• Store output files, e.g. dir_path.mkdir(exist_ok=True)
measurement data, file_path = dir_path / "[Link]"
screenshots, etc. file_path.write_text("Hello world!")
• Access data even after pytest [ [Link]/3/library/[Link]
run is done, but no need for
manual cleanup tmpdir:
Based on [Link] from the py library
(pylib) instead, since pathlib only exists since
Python 3.4. Should not be used in new code.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 63
Reverse Polish Notation (RPN)
The Config object
rpncalc/rpn_v2.py rpncalc/[Link]
from [Link] import calc, Config
class RPNCalculator: class Config:
def __init__(self, config): def __init__(self, prompt=">"):
[Link] = config [Link] = prompt
[Link] = []
...
...
if __name__ == "__main__": Config
RPNCalculator
config = Config() prompt: str
config.load_cwd()
rpn = RPNCalculator( config )
[Link]() ConfigParser
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 64
Reverse Polish Notation (RPN)
Changing the prompt
class RPNCalculator
Imagine you could change the prompt inside rpncalc:
config = . . .
$ python -m rpncalc.rpn_v2
> set prompt=calc>
calc> q class Config
The change would automatically be persisted in a config: prompt = ">"
$ cat [Link]
[rpncalc] [Link](...)
prompt = calc>
[rpncalc]
And loaded again when running rpncalc a second time: prompt = calc>
$ python -m rpncalc.rpn_v2 [Link]
calc>
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 65
Reverse Polish Notation (RPN)
Changing the prompt
[Link]
Imagine you could change the prompt inside rpncalc:
[rpncalc]
$ python -m rpncalc.rpn_v2 prompt = calc>
> set prompt=calc>
calc> q
The change would automatically be persisted in a config:
[Link](...)
$ cat [Link]
[rpncalc] prompt = ">"
prompt = calc>
class Config
And loaded again when running rpncalc a second time:
$ python -m rpncalc.rpn_v2
calc>
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 65
Reverse Polish Notation (RPN)
Changing the prompt
There could be more
Imagine you could change the prompt inside rpncalc: settings in the future as
$ python -m rpncalc.rpn_v2 well (e.g. precision or
> set prompt=calc> rounding), all stored by
calc> q the Config class.
For simplicity, the set
The change would automatically be persisted in a config: command is not actually
$ cat [Link] implemented in rpncalc/
[rpncalc] rpncalc_v2.py.
prompt = calc> However, the necessary
[Link]() and
And loaded again when running rpncalc a second time: [Link]() methods
$ python -m rpncalc.rpn_v2 in [Link] are, and we
calc> want to test them now.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 65
Reverse Polish Notation (RPN)
Saving and loading config
rpncalc/[Link]
[Link]
class Config:
... [rpncalc]
prompt = rpn>
def load(self, path: [Link]) -> None:
parser = [Link]()
[Link](path)
[Link] = parser["rpncalc"]["prompt"]
[Link](...)
Loading prompt = "rpn>"
ini_path = Path("[Link]") class Config
c = Config()
[Link](ini_path)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 66
Reverse Polish Notation (RPN)
Saving and loading config
rpncalc/[Link]
class Config
class Config:
... prompt = "calc>"
def save(self, path: [Link]) -> None:
parser = [Link]() [Link](...)
parser["rpncalc"] = {"prompt": [Link]}
with [Link]("w") as f:
[Link](f)
[rpncalc]
Saving prompt = calc>
ini_path = Path("[Link]") [Link]
c = Config(prompt="calc>")
[Link](ini_path)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 66
[Link]/tmp-path
Builtin fixtures
tmp_path
rpncalc/test_utils.py /tmp/ (system-wide temp directory)
pytest-of-florian/
@[Link] pytest-0/
def ini_path( tmp_path: Path ) -> Path: test_config_save0/
return tmp_path / "[Link]" [Link]
pytest-1/
test_config_save0/
[Link]
pytest-2/
def test_config_save(
...
ini_path: Path, config: Config
):
# call [Link](...), ensure that
# the ini file is written correctly
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 67
[Link]/tmp-path
Builtin fixtures
tmp_path
rpncalc/test_utils.py /tmp/ (system-wide temp directory)
pytest-of-florian/
@[Link] pytest-0/
def example_ini( ini_path: Path ) -> Path: test_config_load0/
# creates [Link] with pathlib [Link]
ini_path.write_text( pytest-1/
"[rpncalc]\n"
test_config_load0/
"prompt = rpn>\n")
[Link]
return ini_path
pytest-2/
def test_config_load( ...
example_ini: Path, config: Config
):
# call [Link](...), ensure that the prompt is set to "rpn>"
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 67
[Link]/tmp-path
Builtin fixtures
tmp_path
rpncalc/test_utils.py temp directory
tmp_path
@[Link] (built-in fixture)
def ini_path(tmp_path: Path) -> Path:
return tmp_path / "[Link]" [Link]
ini_path
doesn’t exist
[Link]
@[Link] example_ini
exists
def example_ini(ini_path: Path) -> Path:
# creates [Link] with pathlib
ini_path.write_text( config
"[rpncalc]\n"
"prompt = rpn>\n")
test_config_load
return ini_path
test_config_save
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 67
[Link]/tmp-path
Builtin fixtures
tmp_path
temp dir Exercise: rpncalc/test_utils.py load-save
tmp_path
(built-in) • Complete test_config_load:
.ini • Call the load method with the prepared config file
ini_path
doesn’t exist • Ensure that [Link] has changed from the
.ini default value (> → rpn>)
example_ini
exists • Complete test_config_save:
• Set [Link] to a value
config
• Call the save method with the non-existing ini path
test_config_load • Ensure the written file looks correct
(.exists(), .read_text(), or configparser)
test_config_save • (optional) Test calling [Link] with an ini
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025
containing just rpn> (exc.: [Link]) 68
pytest fixtures: Where we are, what’s next
We’ve seen how: Next:
• Fixture values are returned from a • Fixture values can be cached on a
fixture function (often just “fixture”) per scope basis
• Each fixture has a name • [Link]/.xfail in fixtures
(the function name)
• Doing cleanup / teardown
• Test functions get its value injected as
• Using fixtures implicitly
an argument by name
(autouse)
• Fixtures can use other fixtures
• Fixture functions can introspect
• Fixtures can be defined in a class, file, calling side (request)
[Link], plugin, or built into
• Configuring fixtures via markers/CLI
pytest
• Fixture functions can be
• pytest provides various built-in fixtures
parametrized
(monkeypatch, tmp_path)
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 69
[Link]/fixt-scope
Caching fixture results
Fixture functions can declare a caching scope: Function scope: Module scope:
fixtures/test_fixture_scope.py rpn rpn
@[Link](scope="module")
(2s) (2s) (2s)
def rpn() -> RPNCalculator:
[Link](2) rpn() rpn() rpn()
return RPNCalculator(Config())
def test_a(rpn: RPNCalculator): test_a test_b test_a test_b
[Link](42)
assert [Link] == [42]
Available scopes:
def test_b(rpn: RPNCalculator): "function" (default), "class",
assert not [Link]
"module", "package", "session"
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 70
[Link]/fixt-scope
Caching fixture results
Fixture functions can declare a caching scope: Beware:
fixtures/test_fixture_scope.py + Faster tests (4s → 2s)
@[Link](scope="module") – Less isolation between tests:
def rpn() -> RPNCalculator:
. . . ::test_a PASSED
[Link](2)
. . . ::test_b FAILED
return RPNCalculator(Config())
def test_a(rpn: RPNCalculator):
def test_b(rpn: RPNCalculator):
[Link](42)
> assert not [Link]
assert [Link] == [42]
E assert not [42]
def test_b(rpn: RPNCalculator):
assert not [Link]
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 70
[Link]/fixt-scope
Caching fixture results
Fixture functions can declare a caching scope: How to avoid this pitfall?
fixtures/test_fixture_scope.py • Can you make the return value
immutable somehow?
@[Link](scope="module")
def rpn() -> RPNCalculator: • Can you copy it (e.g. deepcopy)?
[Link](2)
• Can you reset the state between
return RPNCalculator(Config())
tests with a second fixture?
def test_a(rpn: RPNCalculator): • ...or maybe even take a snapshot
[Link](42) before the test and restore it
assert [Link] == [42] after?
def test_b(rpn: RPNCalculator):
assert not [Link]
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 70
[Link]/fixt-yield
Doing cleanup with yield
A fixture can use yield instead of return
to run cleanup steps after the test: Demo: yield
• Observe the teardown
fixtures/test_yield_fixture.py
behaviour using -s
@[Link](scope="function") and/or --setup-show
def connected_client() -> Iterator[Client]:
• Modify caching scope,
client = Client()
setup check how the behavior
[Link]()
yield client call changes
[Link]() teardown • Remove caching again,
call [Link](...)
def test_client_1(connected_client: Client): before [Link](),
print("in the test 1") see what happens
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 71
Fixture summary
pytest fixtures are a modular, extensible mechanism to:
• Inject configurable resources into test functions
• Manage life-time / caching scope of resources
• Setup resources implicitly (autouse)
• Interact with tests requiring the resource
• Re-run tests with differently configured resources
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 72
Debugging failing tests
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 73
[Link]/output
Arguments to control output
--tb Control traceback generation
--tb=auto / long / short / line / native / no
-l --showlocals Show locals in tracebacks
-s --capture=no Disable output capturing
-q --quiet Decrease verbosity
-v --verbose Increase verbosity (can be given multiple times)
$ pytest --tb=short basic/test_traceback.py
...
______________ test_divide ______________
basic/test_traceback.py:5: in test_divide
assert calc(2, 0, "/") == 0
rpncalc/[Link]: in calc
return a / b
E ZeroDivisionError: division by zero
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 74
[Link]/mathspp-select
Arguments to select tests
after 1 2 3 4 5 pass: fail: new:
--lf --last-failed Run last-failed only
2 4
--ff --failed-first Run last-failed first
2 4 1 3 5
--nf --new-first Run new tests first
6 1 2 3 4 5
-x --maxfail=n Exit on first / n-th failure
1 2
--sw --stepwise Look at failures step by step
1 2 , 2 , 2 , 2 3 4 , 4 , 4 5
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 75
Tracing fixture setup/teardown
--setup-show Show fixtures as they are set up, used and torn down.
--setup-only Only setup fixtures, do not execute tests.
--setup-plan Show what fixtures/tests would be executed, but don’t run.
Reminder: --fixtures and --fixtures-per-test can be useful as well.
Output:
fixtures/test_fixture.py
SETUP F rpn
fixtures/test_fixture.py::test_empty_stack (fixtures used: rpn) .
TEARDOWN F rpn
F: function scope, there is also Class, Module, Package, and Session
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 76
Adding information to an assert
Using a comma after assert . . . , additional information can be printed:
def test_add(rpn: RPNCalculator):
[Link]("2")
[Link]("3")
[Link]("1")
[Link]("+")
assert [Link][-1] == 6 , [Link]
Output:
E AssertionError: [2.0, 4.0]
E assert 4.0 == 6
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 77
Some plugins
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 78
[Link]/cov
Using the coverage plugin
Install plugin: pip install pytest-cov
Options:
--cov=path filesystem path to generate coverage for
rpncalc/ as our code under test is there
--cov-report=type type of report (“term”, “html”, . . . )
term-missing to show lines not covered
testpath path to tests
rpncalc/ as our tests are there too
Exercise: coverage
• Run pytest --cov=rpncalc/ --cov-report=term-missing rpncalc/
in the code/ folder, take a look at the terminal output.
• Rerun with --cov-report=html, then open htmlcov/[Link],
look at the report
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 79
Reporting plugins / features
• pytest-instafail
code/plugins/reporting$ pytest --instafail
reports failure
...
details while tests
test_instafail.py ..............................F
are running.
• pytest-html ____________________ test_bad ____________________
• –junitxml
def test_bad():
• pytest-reportlog > assert False
E assert False
• record_property
• custom plugin test_instafail.py:8: AssertionError
test_instafail.py .........................
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 80
Reporting plugins / features
• pytest-instafail
• pytest-html
generates
(customizable)
HTML reports.
• –junitxml
• pytest-reportlog
• record_property
• custom plugin
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 80
Reporting plugins / features
• pytest-instafail
<?xml version="1.0" encoding="utf-8"?>
• pytest-html <testsuites>
<testsuite name="pytest" errors="0" failures="1"
• –junitxml skipped="0" tests="2" time="0.029" . . . >
generates JUnit <testcase name="test_ok" . . . />
XML reports. <testcase name="test_bad" . . . >
• pytest-reportlog <failure message="ZeroDivisionError: ...">
def test_bad():
• record_property > 1/0
• custom plugin E ZeroDivisionError: division by zero
test_reporting.py:5: ZeroDivisionError
</failure>
</testcase>
</testsuite>
</testsuites>
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 80
Reporting plugins / features
• pytest-instafail
{. . . , "$report_type": "SessionStart"}
• pytest-html {. . . , "$report_type": "CollectReport"}
{
• –junitxml "nodeid": "test_rep.py::test_ok",
• pytest-reportlog "location": ["test_rep.py", 0, "test_ok"],
JSON reports in a "keywords": { . . . },
pytest-specific "outcome": "passed", "longrepr": null,
format. "when": "setup", # setup / call / teardown
"user_properties": [], "sections": [],
• record_property "duration": . . . , "start": . . . , "stop": . . . ,
• custom plugin "$report_type": "TestReport"
}
...
{"exitstatus": 1, "$report_type": "SessionFinish"}
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 80
Reporting plugins / features
• pytest-instafail
def test_function(record_property):
• pytest-html record_property("example_key", 1)
• –junitxml
• pytest-reportlog <testcase . . . >
• record_property <properties>
fixture to add <property name="example_key" value="1" />
</properties>
custom data to
</testcase>
XML / JSON
reports. {
• custom plugin ...,
"user_properties": [["example_key", 1]],
"$report_type": "TestReport"
}
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 80
Reporting plugins / features
• pytest-instafail
def pytest_sessionstart(session: Session ):
• pytest-html ...
• –junitxml
def pytest_collectreport(report: CollectReport ):
• pytest-reportlog ...
• record_property
def pytest_runtest_logreport(report: TestReport ):
• custom plugin ...
Hooks to get data
as Python objects. def pytest_warning_recorded( . . . ):
...
def pytest_sessionfinish(session, exitstatus):
...
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 80
[Link]/plugins
Plugins, plugins . . .
• Property-based testing: hypothesis
• Customized reporting: pytest-html, pytest-rich,
pytest-instafail, pytest-emoji
• Repeating tests: pytest-repeat,
pytest-rerunfailures, pytest-benchmark
• Framework/Language integration: pytest-twisted,
pytest-django, pytest-qt, pytest-asyncio, pytest-cpp
• Coverage and mock integration:
pytest-cov, pytest-mock
• Other: pytest-bdd (behaviour-driven testing),
pytest-xdist (distributed testing)
• . . . > 1600 more: [Link]
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 81
Writing your own plugins
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 82
[Link]/writing-plugins
Writing pytest plugins
Plugins come in two flavours:
• Local plugins: [Link] files
• Installable plugins via packaging entry points
They can contain fixtures, plus hook implementations for:
• configuration
• collection
• test running
• reporting
A hook is auto-discovered by its pytest_· · · name.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 83
[Link]/report-header
Adding to header and summary
pytest_report_header
hooks/reporting/[Link]
def pytest_report_header() -> list[str]:
return ["extrainfo: line 1"]
$ pytest
======================== test session starts ========================
platform linux -- Python . . . , pytest- . . . , pluggy- . . .
extrainfo: line 1
...
======================= no tests ran in 0.00s =======================
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 84
Book recommendation
Brian Okken: Python Testing with pytest, Second Edition (The Pragmatic Bookshelf)
• ISBN 978–1680508604
• [Link]
• Discount code: EuropythonJuly2025
35% off until end of July
(DRM-free PDF/epub/mobi ebook)
• Full disclosure: I’m technical reviewer
(but don’t earn any money from it)
Also by Brian Okken:
“Test & Code” podcast, [Link]
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 85
Where to go next
aka “shameless self promotion”
pytest tips and tricks Description
for a better testsuite
[Link]/program/DSFWRC/
Florian Bruhin
Repository
§ The-Compiler/pytest-tips-and-tricks
PyConDE & PyData Berlin Recording (Youtube)
April 22nd, 2024 [Link]/talk-tips
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 86
Where to go next
aka “shameless self promotion”
At Swiss Python Summit 2024:
Property based testing with Hypothesis
Repository
§ The-Compiler/hypothesis-talk
§ The-Compiler/hypothesis-talk
Florian Bruhin
October 17th, 2024 Recording ([Link])
[Link]/talk-hypothesis
Recording (Youtube)
[Link]/6a1RvMKj0ws
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 86
Upcoming events
• Custom training / coaching:
• Python
• March 3rd – 5th 2026 • pytest
Python Academy ([Link])
• GUI programming with Qt
Professional Testing with Python (3 days)
Leipzig, Germany & Remote • Best Practices
(packaging, linting, etc.)
• Git
• ...
Remote or on-site
florian@[Link]
[Link]
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 87
Feedback and questions
Florian Bruhin ï [Link]/in/florian-bruhin
# florian@[Link] X @the_compiler
[Link] ø @the_compiler@[Link]
§ @The-Compiler @[Link]
§ The-Compiler/pytest-basics
Copyright 2015 – 2025 Florian Bruhin
LM CC BY 4.0
Originally based on materials copyright 2013 – 2015 by Holger Krekel, used with permission.
© Florian Bruhin / Bruhin Software, CC BY 4.0 EuroPython 2025 88