0% found this document useful (0 votes)
25 views120 pages

Pytest Basics

The document provides an overview of pytest, a popular testing framework for Python, including its features, setup, and basic usage. It covers topics such as automated testing, test discovery, fixtures, and debugging, along with practical examples and a suggested directory structure for organizing tests. The content is aimed at both beginners and those familiar with other testing frameworks, emphasizing the simplicity and effectiveness of pytest.

Uploaded by

frankvu.uk
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
25 views120 pages

Pytest Basics

The document provides an overview of pytest, a popular testing framework for Python, including its features, setup, and basic usage. It covers topics such as automated testing, test discovery, fixtures, and debugging, along with practical examples and a suggested directory structure for organizing tests. The content is aimed at both beginners and those familiar with other testing frameworks, emphasizing the simplicity and effectiveness of pytest.

Uploaded by

frankvu.uk
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd

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 &gt; 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

You might also like