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…

Link to Example Code

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
5
def 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
18
import 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
16
$ pytest -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 or pipx, 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
2
3
4
5
6
7
8
9
10
11
12
[tox]  
requires =
tox>=4
env_list =
py{310,311,312}

[testenv]
description = run the tests with pytest
deps =
-r requirements-dev.txt
commands =
pytest {posargs:tests}

You can learn about how to define tox config in the official docs.

[tox] section: (called Core Settings)

  • requires: This specifies that tox 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 with pytest.
  • deps: Specifies dependencies for this test environment. It uses -r requirements-dev.txt, which means it will install all the dependencies listed in the requirements-dev.txt file.
  • commands: Defines the commands to run in this environment. pytest tests {posargs} will run pytest 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 command

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
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
55
$ tox -- -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 Test

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
$  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 get

1
2
3
4
5
6
7
8
9
10
11
12
13
$ tox -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
2
cd 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
14
$ poetry 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 using

1
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
32
$ poetry 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
5
$ poetry 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
11
$ poetry 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 this

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ poetry 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 command poetry within the test environments.
  • Use of commands_pre = poetry install and commands = 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 using

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
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
$ poetry 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
6
$ poetry 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