How To Test And Build Python Packages With Pytest, Tox And Poetry
You often face daunting challenges in Python - writing robust tests, managing multiple testing environments, and handling complex dependencies.
These hurdles can turn even the most straightforward project into a labyrinth of complexities.
How do you ensure your code stands the test of time and technology?
How do you maintain consistency across various environments?
And how do you streamline your project’s dependencies without getting lost in a web of version conflicts?
This article offers a comprehensive guide on an exquisite solution — integrating Pytest, Tox, and Poetry to transform your development, build and release workflow.
Pytest is a versatile tool to write simple yet powerful Python tests.
Tox, is your gateway to consistent testing environments, ensuring your tests run seamlessly across different Python versions and configurations.
While Poetry sweetly handles dependency management, package build and publishing and packaging.
This guide will walk you through building your Python packages and testing, illustrating how Pytest’s flexibility pairs perfectly with Tox’s consistency, all while Poetry handles your project dependencies with ease.
Excited? Let’s get started…
Basic Concepts of Pytest
Pytest is a powerful testing framework for Python, renowned for its simplicity and scalability.
Unlike traditional frameworks, Pytest allows you to write tests with minimal boilerplate, using plain assert statements.
Its rich plugin architecture, ease of parametrization and fixtures for setup/teardown operations make it highly adaptable.
For instance:1
2
3
4
5def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
Here, test_add
function tests the add
function, ensuring it correctly adds 2 and 3.
Simple yet effective, Pytest makes testing intuitive and accessible. No classes, no boilerplate.
What is Tox and Why is it Useful?
According to the official Tox documentation
tox is a generic virtual environment management and test command line tool you can use for:
- checking your package builds and installs correctly under different environments (such as different Python implementations, versions or installation dependencies),
- running your tests in each of the environments with the test tool of choice,
- acting as a frontend to continuous integration servers, greatly reducing boilerplate and merging CI and shell-based testing.
Tox is an essential tool in Python development, primarily used for testing your code in multiple environments.
It shines in scenarios where your application needs to run across different Python versions or configurations.
Tox automates the process of setting up and managing these environments, ensuring that your tests run consistently everywhere.
Configuration in tox is specified using the tox.ini
file.
For example, a basic tox.ini
file may look like this:1
2
3
4
5
6[tox]
envlist = py37, py38, py39
[testenv]
deps = pytest
commands = pytest
This configuration tells Tox to test your application in Python 3.7, 3.8, and 3.9 environments, using Pytest for running the tests.
We’ll learn more about how to configure tox.ini
in further sections.
Introduction to Poetry
Poetry is a modern tool in Python for dependency management and packaging, designed to address the complexities and shortcomings of traditional tools like pip, pipenv, venv, and Conda.
It streamlines project setup, dependency resolution, and package publishing with its intuitive CLI and pyproject.toml
configuration.
Setting up Poetry in your project involves a simple initialization command, poetry init
, which guides you through creating the pyproject.toml
file.
This file becomes the heart of your project’s dependency management.
Poetry also easily integrates with Pytest and Tox and manages dependencies reliably and reproducibly (via a lock file) much like pipenv
.
Together, they tools form the foundation of a robust Python development framework.
Enough theory, now let’s experience these tools in action.
Step-by-step guide on setting up a project with these tools.
Getting Started
Our repo has the following structure:1
2
3
4
5
6
7
8
9
10
11
12
13.
├── .gitignore
├── poetry.lock
├── pyproject.toml
├── pytest.ini
├── requirements-dev.txt
├── requirements.txt
├── src
│ └── calculator.py
├── tests
│ ├── __init__.py
│ └── test_calculator.py
└── tox.ini
We’ll explain all of the files and what they mean shortly below.
Prerequisites
To achieve the above objectives, the following is recommended:
- Basic knowledge of the Python
- Basics of Pytest or Unittest
Source Code
To keep things simple so we can focus more on the Tox and Poetry side, our source code is a simple basic calculator module.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15"""Simple Calculator module."""
def addition(a: int | float, b: int | float) -> int | float:
return a + b
def subtraction(a: int | float, b: int | float) -> int | float:
return a - b
def multiplication(a: int | float, b: int | float) -> int | float:
return a * b
def division(a: int | float, b: int | float) -> int | float:
if b == 0:
raise ZeroDivisionError("Cannot divide by zero!")
return a / b
Our module contains 4 basic functions — addition, subtraction, multiplication and division.
For ease, we haven’t included any external dependencies.
Before we move on to Poetry, let’s look at the old way of managing packages, using a requirements.txt
or requirements-dev.txt
file.
The drawback of this approach is there is no dependency management between the packages.
For now, let’s install our requirements using pip install -r requirements-dev.txt
.
Unit Tests
Our Unit Test module contains the following tests.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import pytest
from calculator import addition, subtraction, multiplication, division
def test_addition():
assert addition(2, 3) == 5
def test_subtraction():
assert subtraction(2, 3) == -1
def test_multiplication():
assert multiplication(2, 3) == 6
def test_division():
assert division(2, 3) == 2 / 3
def test_division_by_zero():
with pytest.raises(ZeroDivisionError):
division(2, 0)
These are simple assert statements including the use of pytest.raises
to test the ZeroDivisionError
.
If you’re not familiar with how to test exceptions in Pytest we’ve got you covered here.
Note that we’ve specified the PYTHONPATH
in the pytest.ini
file which allows us to import the calculator
module into the unit tests without additional __init__.py
files in the tests
folder.1
2[pytest]
pythonpath = ./src
Let’s run our tests.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16pytest -v -s
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py::test_addition ✓ 20% ██
tests/test_calculator.py::test_subtraction ✓ 40% ████
tests/test_calculator.py::test_multiplication ✓ 60% ██████
tests/test_calculator.py::test_division ✓ 80% ████████
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.03s):
5 passed
Note that we’re using Pytest Sugar to format the output.
You can use some of the popular Pytest Plugins to make your outputs prettier.
Now convinced that our tests are working and feeling confident about our source code, let’s run those tests against other Python Versions.
We can do it manually and create a venv for each Python version but it’s too much of a hassle.
So let’s use Tox instead.
Using Tox
To use tox, all you need are a couple of things.
- Install tox using
pip
orpipx
, read more here. - Define tox configuration in a
tox.ini
file.
Note — You can also generate a tox.ini file automatically by running tox quickstart
and then answering a few interactive questions.
The tox.ini
File
1 | [tox] |
You can learn about how to define tox config in the official docs.
[tox] section: (called Core Settings)
requires
: This specifies thattox
itself needs to be at least version 4 to run.env_list
: Defines the Python environments to test against. In this case, it’s set to test against Python 3.10, 3.11, and 3.12.
[testenv] section: (called Test Environments) — Default Environment
description
: Provides a brief description of what this test environment does. Here, it’s set up to run tests withpytest
.deps
: Specifies dependencies for this test environment. It uses-r requirements-dev.txt
, which means it will install all the dependencies listed in therequirements-dev.txt
file.commands
: Defines the commands to run in this environment.pytest tests {posargs}
will runpytest
on the tests directory, and{posargs}
allows passing additional arguments from the command line.
The above testenv
section is the default one and each environment can be specified using testenv:ENV
where ENV
is the environment name.
Running Tox
Now let’s run our tox environment using the tox
command1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149 tox
py310: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.10.13, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py310/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.04s):
5 passed
py310: OK ✔ in 1.78 seconds
py311: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.11.6, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py311: OK ✔ in 1.42 seconds
py312: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py312/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py310: OK (1.78=setup[0.22]+cmd[1.56] seconds)
py311: OK (1.42=setup[0.03]+cmd[1.39] seconds)
py312: OK (0.79=setup[0.01]+cmd[0.78] seconds)
congratulations :) (4.20 seconds)
We can see that tox created a virtual environment and ran Pytest for each of our specified Python environments — 3.10, 3.11 and 3.12.
Now let’s try something. Let’s specify Python 3.9 in our `tox.ini` file.
Modify the line `env_list` to
env_list =
py{39,310,311,312}
Let’s run `tox` again.
tox
py39: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.9.17, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py39/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collecting ...
―――――――――――――――――――――――――――――――――――――――――――――――――――――――――― ERROR collecting tests/test_calculator.py ―――――――――――――――――――――――――――――――――――――――――――――――――――――――――――
.tox/py39/lib/python3.9/site-packages/_pytest/runner.py:341: in from_call
result: Optional[TResult] = func()
.tox/py39/lib/python3.9/site-packages/_pytest/runner.py:372: in <lambda>
call = CallInfo.from_call(lambda: list(collector.collect()), "collect")
.tox/py39/lib/python3.9/site-packages/_pytest/python.py:531: in collect
self._inject_setup_module_fixture()
.tox/py39/lib/python3.9/site-packages/_pytest/python.py:545: in _inject_setup_module_fixture
self.obj, ("setUpModule", "setup_module")
.tox/py39/lib/python3.9/site-packages/_pytest/python.py:310: in obj
self._obj = obj = self._getobj()
.tox/py39/lib/python3.9/site-packages/_pytest/python.py:528: in _getobj
return self._importtestmodule()
.tox/py39/lib/python3.9/site-packages/_pytest/python.py:617: in _importtestmodule
mod = import_path(self.path, mode=importmode, root=self.config.rootpath)
.tox/py39/lib/python3.9/site-packages/_pytest/pathlib.py:567: in import_path
importlib.import_module(module_name)
/usr/local/Cellar/python@3.9/3.9.17_1/Frameworks/Python.framework/Versions/3.9/lib/python3.9/importlib/__init__.py:127: in import_module
return _bootstrap._gcd_import(name[level:], package, level)
<frozen importlib._bootstrap>:1030: in _gcd_import
???
<frozen importlib._bootstrap>:1007: in _find_and_load
???
<frozen importlib._bootstrap>:986: in _find_and_load_unlocked
???
<frozen importlib._bootstrap>:680: in _load_unlocked
???
.tox/py39/lib/python3.9/site-packages/_pytest/assertion/rewrite.py:186: in exec_module
exec(co, module.__dict__)
tests/test_calculator.py:2: in <module>
from calculator import addition, subtraction, multiplication, division
src/calculator.py:4: in <module>
def addition(a: int | float, b: int | float) -> int | float:
E TypeError: unsupported operand type(s) for |: 'type' and 'type'
collected 0 items / 1 error
=================================================================== short test summary info ====================================================================
FAILED tests/test_calculator.py - TypeError: unsupported operand type(s) for |: 'type' and 'type'
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
Results (0.40s):
py39: exit 2 (1.71 seconds) /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example> pytest tests pid=74348
py39: FAIL ✖ in 1.82 seconds
py310: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.10.13, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py310/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py310: OK ✔ in 0.63 seconds
py311: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.11.6, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py311: OK ✔ in 0.59 seconds
py312: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py312/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py39: FAIL code 2 (1.82=setup[0.11]+cmd[1.71] seconds)
py310: OK (0.63=setup[0.02]+cmd[0.61] seconds)
py311: OK (0.59=setup[0.02]+cmd[0.58] seconds)
py312: OK (0.54=setup[0.01]+cmd[0.52] seconds)
evaluation failed :( (3.71 seconds)
Interesting observation, we can see that our tests failed for Python 3.9.
This is because the use of type hinting in the form (int | float
) etc. was introduced in Python 3.10+.
A cool way of validating your code against different Python versions. We can say with confidence that our code works with Python 3.10+.
Pass Pytest CLI Arguments to Tox
You may be wondering, wow Tox is great. But what if I want to run specific Pytest commands?
For example, only run a specific test, increase verbosity or use a custom logging?
Easily done.
By passing the value commands = pytest {posargs:tests}
in our tox.ini
file, we tell tox to allow us to pass custom arguments to this Pytest command.
We do that in the following way.
Show More Verbosity (-v option)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55tox -- -v -s
py310: commands[0]> pytest -v -s
Test session starts (platform: darwin, Python 3.10.13, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py310/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py::test_addition ✓ 20% ██
tests/test_calculator.py::test_subtraction ✓ 40% ████
tests/test_calculator.py::test_multiplication ✓ 60% ██████
tests/test_calculator.py::test_division ✓ 80% ████████
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.04s):
5 passed
py310: OK ✔ in 0.74 seconds
py311: commands[0]> pytest -v -s
Test session starts (platform: darwin, Python 3.11.6, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py::test_addition ✓ 20% ██
tests/test_calculator.py::test_subtraction ✓ 40% ████
tests/test_calculator.py::test_multiplication ✓ 60% ██████
tests/test_calculator.py::test_division ✓ 80% ████████
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.03s):
5 passed
py311: OK ✔ in 0.64 seconds
py312: commands[0]> pytest -v -s
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py312/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py::test_addition ✓ 20% ██
tests/test_calculator.py::test_subtraction ✓ 40% ████
tests/test_calculator.py::test_multiplication ✓ 60% ██████
tests/test_calculator.py::test_division ✓ 80% ████████
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.03s):
5 passed
py310: OK (0.74=setup[0.09]+cmd[0.65] seconds)
py311: OK (0.64=setup[0.02]+cmd[0.63] seconds)
py312: OK (0.36=setup[0.01]+cmd[0.35] seconds)
congratulations :) (1.90 seconds)
We can see the verbosity has increased and it now shows the % completion on the right side.
Running a Single Test1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43 tox -- tests/test_calculator.py::test_division_by_zero -v
py310: commands[0]> pytest tests/test_calculator.py::test_division_by_zero -v
Test session starts (platform: darwin, Python 3.10.13, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py310/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 1 item
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.03s):
1 passed
py310: OK ✔ in 0.69 seconds
py311: commands[0]> pytest tests/test_calculator.py::test_division_by_zero -v
Test session starts (platform: darwin, Python 3.11.6, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 1 item
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.02s):
1 passed
py311: OK ✔ in 0.38 seconds
py312: commands[0]> pytest tests/test_calculator.py::test_division_by_zero -v
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py312/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 1 item
tests/test_calculator.py::test_division_by_zero ✓ 100% ██████████
Results (0.02s):
1 passed
py310: OK (0.69=setup[0.10]+cmd[0.59] seconds)
py311: OK (0.38=setup[0.02]+cmd[0.36] seconds)
py312: OK (0.37=setup[0.01]+cmd[0.35] seconds)
congratulations :) (1.59 seconds)
The --
delimits flags for the tox tool and arguments passed after this are forwarded to the tool within.
Formatting and Linting with Tox
Now what if we wanna do more with tox?
Perhaps check if our code is statically type-checked (e.g. using Mypy)?
Or check PEP8 compliance and formatting? (using Black or Ruff).
It’s easy to add these steps in tox.
We can add 2 additional Environments to our tox.ini
file.1
2
3
4
5
6
7
8
9
10
11
12
13[testenv:type]
description = run type checks
deps =
-r requirements-dev.txt
commands =
mypy {posargs:src tests}
[testenv:lint]
description = run linter
skip_install = true
deps =
-r requirements-dev.txt
commands = ruff {posargs:src tests}
The first one runs the command mypy
on our src
and tests
folder.
The second runs the ruff
formatter on the same folders.
We also need to update our env_list
. Our tox.ini
file now looks like this.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28[tox]
requires =
tox>=4
env_list =
py{310,311,312}
lint
type
[testenv]
description = run the tests with pytest
deps =
-r requirements-dev.txt
commands =
pytest {posargs:tests}
[testenv:type]
description = run type checks
deps =
-r requirements-dev.txt
commands =
mypy {posargs:src tests}
[testenv:lint]
description = run linter
skip_install = true
deps =
-r requirements-dev.txt
commands = ruff {posargs:src tests}
Running the tox
command produces….1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52 tox
py310: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.10.13, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py310/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.04s):
5 passed
py310: OK ✔ in 0.8 seconds
py311: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.11.6, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py311: OK ✔ in 0.7 seconds
py312: commands[0]> pytest tests
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py312/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py312: OK ✔ in 0.58 seconds
lint: install_deps> python -I -m pip install -r requirements-dev.txt
lint: commands[0]> ruff src tests
lint: OK ✔ in 15.85 seconds
type: install_deps> python -I -m pip install -r requirements-dev.txt
type: commands[0]> mypy src tests
Success: no issues found in 3 source files
py310: OK (0.80=setup[0.11]+cmd[0.69] seconds)
py311: OK (0.70=setup[0.02]+cmd[0.68] seconds)
py312: OK (0.58=setup[0.01]+cmd[0.56] seconds)
lint: OK (15.85=setup[15.28]+cmd[0.57] seconds)
type: OK (28.56=setup[17.18]+cmd[11.39] seconds)
congratulations :) (46.66 seconds)
We can see that the ruff
and mypy
commands were run on the src
and tests
folders.
This is amazing!
But 46.66 seconds to run this?
That’s exactly where the parallel flag comes into use.
- Note — Tox caches packages and environments in a
.tox
folder so you may experience different runtimes based on that and your connection speed.
Run Tox in Parallel
Tox parallel mode, activated with the -p
flag, significantly speeds up your test runs by executing environments concurrently.
It’s a game-changer for efficiency, particularly in complex projects with multiple test environments.
By simply adding a -p
flag to your Tox command, Tox intelligently allocates resources to run tests in parallel, reducing overall execution time.
Running this we get1
2
3
4
5
6
7
8
9
10
11
12
13tox -p
lint: OK ✔ in 0.32 seconds
py312: OK ✔ in 0.85 seconds
tests: OK ✔ in 0.85 seconds
type: OK ✔ in 0.91 seconds
py311: OK ✔ in 0.98 seconds
py310: OK (0.99=setup[0.24]+cmd[0.75] seconds)
py311: OK (0.98=setup[0.24]+cmd[0.74] seconds)
py312: OK (0.85=setup[0.23]+cmd[0.62] seconds)
lint: OK (0.32=setup[0.24]+cmd[0.08] seconds)
type: OK (0.91=setup[0.24]+cmd[0.68] seconds)
tests: OK (0.85=setup[0.22]+cmd[0.63] seconds)
congratulations :) (1.13 seconds)
These packages may have been cached contributing to the speed but when you compare like for like you’ll see the difference.
You can read more about the parallel mode in the official docs.
How To Use Poetry
We briefly introduced Poetry above but now let’s see how to use it in our project to manage package dependencies.
We’ll drop the use of our requirements file.
Installing Poetry
You can follow the instructions here on how to install Poetry.
As advised, make sure to install Poetry in a separate virtual environment.
Once you’ve installed Poetry the next step is to create your pyproject.toml
file.
The pyproject.toml
file
This file can be created interactively from the CLI or manually copied over and updated.
Navigate to the repo and run the following commands to generate your pyproject.toml
file.1
2cd pre-existing-project
poetry init
Our pyproject.toml
file looks like this.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23[tool.poetry]
name = "my-calculator"
version = "0.1.0"
description = "Simple Calculator Package"
authors = ["<AUTHOR>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.10"
requests = "^2.31.0"
[tool.poetry.group.dev.dependencies]
pytest = "^7.4"
pytest-sugar = "*"
pytest-cov = "*"
black = "*"
mypy = "*"
ruff = "*"
isort = "*"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
You can see we can group our dependencies into dev
dependencies (what you need to test and run the app locally) and application-specific dependencies (what you need to run the app).
In our config file, we specified that this project needs Python 3.10+. But our system default (in my case managed by Anaconda) uses 3.8.
So Poetry will throw a warning and try to find a higher version of Python when you run it.
You can read more about how Poetry expects Python versions here.
You can check this using.1
2
3
4
5
6
7
8
9
10
11
12
13
14poetry env info
Virtualenv
Python: 3.8.16
Implementation: CPython
Path: NA
Executable: NA
System
Platform: darwin
OS: posix
Python: 3.8.16
Path: /usr/local/anaconda3
Executable: /usr/local/anaconda3/bin/python3.8
To avoid receiving this warning, let’s quickly make a venv with a higher Python version (as I don’t want to mess with the default one).
I’ll create it with conda using1
conda create -n "py312_venv" python=3.12
Once this is complete, activate it.
Install Dependencies
Next, we install our dependencies using poetry install
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32poetry install
Updating dependencies
Resolving dependencies... (3.5s)
Package operations: 24 installs, 0 updates, 0 removals
• Installing cachetools (5.3.2)
• Installing certifi (2023.11.17)
• Installing chardet (5.2.0)
• Installing charset-normalizer (3.3.2)
• Installing click (8.1.7)
• Installing colorama (0.4.6)
• Installing coverage (7.3.3)
• Installing idna (3.6)
• Installing mypy-extensions (1.0.0)
• Installing pathspec (0.12.1)
• Installing pyproject-api (1.6.1)
• Installing pytest (7.4.3)
• Installing termcolor (2.4.0)
• Installing typing-extensions (4.9.0)
• Installing urllib3 (2.1.0)
• Installing virtualenv (20.25.0)
• Installing black (23.12.0)
• Installing isort (5.13.2)
• Installing mypy (1.7.1)
• Installing pytest-cov (4.1.0)
• Installing pytest-sugar (0.9.7)
• Installing requests (2.31.0)
• Installing ruff (0.1.8)
• Installing tox (4.11.4)
Writing lock file
We can add additional dependencies to the config and lock file using poetry add <PACKAGE>
for example poetry add pendulum
.
You can re-generate the lock file from the pyproject.toml
file using poetry lock
or use poetry lock --no-update
to update the lock file without upgrading dependencies.
Run Python Commands
Now that we have a working venv and shell let’s run our Python script using Poetry.1
2
3
4
5poetry run python src/calculator.py
5
-1
6
0.6666666666666666
Run Pytest Commands
Similarly you can run Pytest using Poetry.1
2
3
4
5
6
7
8
9
10
11poetry run pytest
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.04s):
5 passed
We can also generate our coverage report like this1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19poetry run pytest --cov=src tests/
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
---------- coverage: platform darwin, python 3.12.0-final-0 ----------
Name Stmts Miss Cover
---------------------------------------
src/calculator.py 10 0 100%
---------------------------------------
TOTAL 10 0 100%
Results (0.07s):
5 passed
Using Tox with Poetry
So far it’s been amazing.
You may ask, well now that we’re using Poetry to manage our dependencies, how can I use tox to make sure my code still runs on multiple Python versions?
Well, that’s easy and nicely documented.
Let’s update our tox.ini
file and add a coverage
section.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42[tox]
requires =
tox>=4
env_list =
py{310,311,312}
lint
type
coverage
[testenv]
description = run the tests with pytest
skip_install = true
allowlist_externals = poetry
commands_pre =
poetry install
commands =
poetry run pytest {posargs:tests}
[testenv:type]
description = run type checks
skip_install = true
allowlist_externals = poetry
commands_pre =
poetry install
commands =
poetry run mypy {posargs:src tests}
[testenv:lint]
description = run linter
skip_install = true
allowlist_externals = poetry
commands_pre =
poetry install
commands = poetry run ruff {posargs:src tests}
[testenv:coverage]
description = run coverage report
skip_install = true
allowlist_externals = poetry
commands_pre =
poetry install
commands = poetry run pytest --cov=src tests/
Observations
- We dropped the
requirements.txt
files. - We set
skip_install = true
to tell Tox not to install packages into the test environment. This is particularly useful if you have a different way of setting up the environment (like using Poetry). - We also set
allowlist_externals = poetry
This tells Tox that it’s okay to use the external commandpoetry
within the test environments. - Use of
commands_pre = poetry install
andcommands = poetry run pytest {posargs:tests}
to run our commands with Poetry. - We added a
coverage
environment at the end.
Let’s go ahead and run tox using1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128poetry run tox
py310: commands_pre[0]> poetry install
Installing dependencies from lock file
No dependencies to install or update
py310: commands[0]> poetry run pytest tests
Test session starts (platform: darwin, Python 3.10.13, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py310/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py310: OK ✔ in 1.79 seconds
py311: commands_pre[0]> poetry install
Installing dependencies from lock file
No dependencies to install or update
py311: commands[0]> poetry run pytest tests
Test session starts (platform: darwin, Python 3.11.6, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py311/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.03s):
5 passed
py311: OK ✔ in 1.72 seconds
py312: commands_pre[0]> poetry install
Installing dependencies from lock file
No dependencies to install or update
py312: commands[0]> poetry run pytest tests
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/py312/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
Results (0.02s):
5 passed
py312: OK ✔ in 1.7 seconds
lint: commands_pre[0]> poetry install
Installing dependencies from lock file
No dependencies to install or update
lint: commands[0]> poetry run ruff src tests
lint: OK ✔ in 1.4 seconds
type: commands_pre[0]> poetry install
Installing dependencies from lock file
No dependencies to install or update
type: commands[0]> poetry run mypy src tests
Success: no issues found in 3 source files
type: OK ✔ in 1.67 seconds
coverage: commands_pre[0]> poetry install
Installing dependencies from lock file
Package operations: 30 installs, 0 updates, 0 removals
• Installing distlib (0.3.8)
• Installing filelock (3.13.1)
• Installing iniconfig (2.0.0)
• Installing packaging (23.2)
• Installing platformdirs (4.1.0)
• Installing pluggy (1.3.0)
• Installing cachetools (5.3.2)
• Installing certifi (2023.11.17)
• Installing chardet (5.2.0): Installing...
• Installing chardet (5.2.0)
• Installing charset-normalizer (3.3.2)
• Installing click (8.1.7)
• Installing colorama (0.4.6)
• Installing coverage (7.3.3)
• Installing idna (3.6)
• Installing mypy-extensions (1.0.0)
• Installing pathspec (0.12.1)
• Installing pyproject-api (1.6.1)
• Installing pytest (7.4.3)
• Installing termcolor (2.4.0)
• Installing typing-extensions (4.9.0)
• Installing urllib3 (2.1.0)
• Installing virtualenv (20.25.0)
• Installing black (23.12.0)
• Installing isort (5.13.2)
• Installing mypy (1.7.1)
• Installing pytest-cov (4.1.0)
• Installing pytest-sugar (0.9.7)
• Installing requests (2.31.0)
• Installing ruff (0.1.8)
• Installing tox (4.11.4)
coverage: commands[0]> poetry run pytest --cov=src tests/
Test session starts (platform: darwin, Python 3.12.0, pytest 7.4.3, pytest-sugar 0.9.7)
cachedir: .tox/coverage/.pytest_cache
rootdir: /Users/ericsda/PycharmProjects/Pytest-with-Eric/pytest-tox-poetry-example
configfile: pytest.ini
plugins: cov-4.1.0, sugar-0.9.7
collected 5 items
tests/test_calculator.py ✓✓✓✓✓ 100% ██████████
---------- coverage: platform darwin, python 3.12.0-final-0 ----------
Name Stmts Miss Cover
---------------------------------------
src/calculator.py 10 0 100%
---------------------------------------
TOTAL 10 0 100%
Results (0.08s):
5 passed
py310: OK (1.79=setup[0.05]+cmd[0.93,0.81] seconds)
py311: OK (1.72=setup[0.01]+cmd[0.91,0.79] seconds)
py312: OK (1.70=setup[0.01]+cmd[0.89,0.80] seconds)
lint: OK (1.40=setup[0.01]+cmd[0.89,0.50] seconds)
type: OK (1.67=setup[0.01]+cmd[0.91,0.74] seconds)
coverage: OK (20.76=setup[0.41]+cmd[18.61,1.74] seconds)
congratulations :) (29.08 seconds)
Poetry nicely manages the packages for us.
The build and run time may vary depending on if you’re running this for the first time and your internet speed.
Build and Publish Package
Among the last steps, you can build your Python package using the poetry build
command.1
2
3
4
5
6poetry build
Building calculator (0.1.0)
- Building sdist
- Built calculator-0.1.0.tar.gz
- Building wheel
- Built calculator-0.1.0-py3-none-any.whl
Poetry generates 2 files for our package — a .gz
and a .whl
.
Lastly, if you want to publish it to a repo like Pypi, you’ll need to configure your credentials and run the poetry publish
command.
Note that your library name must be unique in the Pypi repo.
Conclusion
In this journey, you’ve navigated through a typical Python package development and release cycle.
We explored the robust capabilities of Pytest, Tox, and Poetry.
Pytest makes testing intuitive, Tox guarantees consistency across environments, and Poetry streamlines dependency management.
This powerful combination ensures robust, maintainable code across diverse projects.
Now, armed with this knowledge, I encourage you to experiment with these tools in different scenarios.
You can even take this to the next level and run it via GitHub actions with a “passing” test badge. But that’s one for another article.
Dive in and let these tools empower your coding journey! 🚀🐍💻
If you have ideas for improvement or would like me to cover anything specific, please send me a message via Twitter, GitHub or Email.
Additional Learning
Link to Example Code
How to Effortlessly Generate Unit Test Cases with Pytest Parameterized Tests
8 Useful Pytest Plugins To Make Your Python Unit Tests Easier, Faster and Prettier
What is Setup and Teardown in Pytest? (Importance of a Clean Test Environment)
How To Test Python Exception Handling Using Pytest Assert (A Simple Guide)
How To Run A Single Test In Pytest (Using CLI And Markers)
How To Use Pytest Logging And Print To Console And File (A Comprehensive Guide)
How To Debug Failing Tests Like A Pro (Use Pytest Verbosity Options)
How to Build and Publish Python Packages With Poetry
Automated Testing in Python with pytest, tox, and GitHub Actions