Skip to content

Setting up testing with pytest and uv

Every Python project needs tests, but setting up a test suite from scratch involves decisions about project layout, dependency managementInstalling, updating, and tracking the external packages your project needs. Includes resolving compatible versions, separating dev from production deps, and keeping installs reproducible. , and configuration. This tutorial walks through the full setup using pytest and uv: creating a project, writing tests, running one test over many inputs, measuring coverage, and configuring defaults.

Prerequisites

Install uv on your system.

Creating a Project with Tests

Start by creating a sample project with a test directory structure:

$ uv init testing-demo
Initialized project `testing-demo` at `/path/to/testing-demo`
$ cd testing-demo

This creates a Python package project with the following structure:

testing-demo/
├── .gitignore
├── .python-version
├── pyproject.toml
├── README.md
└── src
    └── testing_demo
        └── __init__.py

uv scaffoldsGenerate the initial file and folder structure for a new project. the src/testing_demo/ layout by default. That layout is what makes from testing_demo.calculator import add work in the tests that follow, because uv installs the package into the project environment. If uv init gave you a main.py at the project root and no src/, re-run it with --package to get the layout shown here; a flat layout makes pytest report ModuleNotFoundError: No module named 'testing_demo' once the tests start importing.

Adding pytest as a Development Dependency

Add pytest to your project. The --dev flag marks it as a development dependencyA package needed during development (testing, linting, formatting) that is not shipped to users of your project. Installed with the --dev flag. , keeping it out of production installs:

$ uv add --dev pytest
Using CPython 3.14.6
Creating virtual environment at: .venv
Resolved 7 packages in 106ms
   Building testing-demo @ file:///path/to/testing-demo
      Built testing-demo @ file:///path/to/testing-demo
Prepared 1 package in 6ms
Installed 6 packages in 10ms
 + iniconfig==2.3.0
 + packaging==26.2
 + pluggy==1.6.0
 + pygments==2.20.0
 + pytest==9.1.1
 + testing-demo==0.1.0 (from file:///path/to/testing-demo)

The Building and Built lines are uv building testing-demo itself. Your own project is one of the six installed packages, which is what makes import testing_demo work from the tests.

If you see error: No `pyproject.toml` found in current directory or any parent directory, you ran the command outside the testing-demo directory.

This command:

  • Updates your pyproject.toml with pytest as a development dependency
  • Creates the project’s lockfileA file that records the exact version of every installed package, so everyone working on the project gets identical installs.
  • Installs pytest in your project’s virtual environmentAn isolated folder where Python installs packages for one project, keeping them separate from other projects and your system Python. Learn more →

Open pyproject.toml and notice the new [dependency-groups] table. pytest is registered there, not under [project] dependencies, so it ships with the project source but not with built wheelsA prebuilt Python package file (.whl) that installs without compiling anything. The standard distribution format for Python packages. Learn more → .

Creating a Simple Module to Test

Create a calculator module at src/testing_demo/calculator.py:

def add(a, b):
    return a + b


def subtract(a, b):
    return a - b


def multiply(a, b):
    return a * b


def divide(a, b):
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b

Creating Test Files

Create a tests directory at the root of your project:

$ mkdir tests

Now, create a test file for the calculator module in tests/test_calculator.py:

import pytest
from testing_demo.calculator import add, subtract, multiply, divide


def test_add():
    assert add(1, 2) == 3
    assert add(-1, 1) == 0
    assert add(-1, -1) == -2


def test_subtract():
    assert subtract(3, 2) == 1
    assert subtract(2, 3) == -1
    assert subtract(0, 0) == 0


def test_multiply():
    assert multiply(2, 3) == 6
    assert multiply(-2, 3) == -6
    assert multiply(-2, -3) == 6


def test_divide():
    assert divide(6, 3) == 2
    assert divide(6, -3) == -2
    assert divide(-6, -3) == 2


def test_divide_by_zero():
    with pytest.raises(ValueError):
        divide(5, 0)

Running Tests

uv run executes the command inside the project’s virtual environment, ensuring all dependenciesAn external package your project needs, listed in pyproject.toml so tools can install it automatically. are installed first. Run the test suite:

$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 5 items

tests/test_calculator.py .....                                           [100%]

============================== 5 passed in 0.01s ===============================

Each dot represents a passing test. The platform line will show linux or win32 instead of darwin on those systems. pytest treats pyproject.toml as a config file even with no [tool.pytest.ini_options] section, which is why configfile: appears in the header already.

To see more detailed output, use the verbose flag:

$ uv run pytest -v
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 -- /path/to/testing-demo/.venv/bin/python
cachedir: .pytest_cache
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collecting ... collected 5 items

tests/test_calculator.py::test_add PASSED                                [ 20%]
tests/test_calculator.py::test_subtract PASSED                           [ 40%]
tests/test_calculator.py::test_multiply PASSED                           [ 60%]
tests/test_calculator.py::test_divide PASSED                             [ 80%]
tests/test_calculator.py::test_divide_by_zero PASSED                     [100%]

============================== 5 passed in 0.00s ===============================

The interpreter path on the platform line reflects your operating system. macOS and Linux show .venv/bin/python; Windows shows .venv\Scripts\python.exe. Both point at the same project interpreterThe program that reads and executes Python code. When you run "python3 hello.py", python3 is the interpreter. uv created.

Notice the new .pytest_cache/ directory pytest created in your project root. It stores test outcomes between runs to support features like --last-failed. pytest writes a .gitignore inside that directory that excludes it from git, so you don’t have to list it yourself.

Adding Test Coverage

coverage.py measures which lines of code your tests execute. Add it as a development dependency:

$ uv add --dev coverage
Resolved 8 packages in 93ms
   Building testing-demo @ file:///path/to/testing-demo
      Built testing-demo @ file:///path/to/testing-demo
Prepared 1 package in 3ms
Uninstalled 1 package in 0.69ms
Installed 2 packages in 4ms
 + coverage==7.15.2
 ~ testing-demo==0.1.0 (from file:///path/to/testing-demo)

Two packages are installed because a dependency change reinstalls your project alongside the new one. The ~ marks that reinstall; the + marks coverage arriving for the first time.

Run your tests through coverage:

$ uv run coverage run -m pytest
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 5 items

tests/test_calculator.py .....                                           [100%]

============================== 5 passed in 0.00s ===============================

Notice the new .coverage file in your project root. That binary file holds the line-by-line execution data the next two commands turn into reports. The .gitignore uv generated covers __pycache__/ and .venv but not this file, so append two lines to it:

.gitignore
# Coverage data
.coverage

Then view the report:

$ uv run coverage report
Name                             Stmts   Miss  Cover
----------------------------------------------------
src/testing_demo/__init__.py         2      1    50%
src/testing_demo/calculator.py      10      0   100%
tests/test_calculator.py            21      0   100%
----------------------------------------------------
TOTAL                               33      1    97%

calculator.py is fully covered. The miss in src/testing_demo/__init__.py is the starter print() statement uv generated, which the tests never exercise.

To see which specific lines were missed:

$ uv run coverage report -m
Name                             Stmts   Miss  Cover   Missing
--------------------------------------------------------------
src/testing_demo/__init__.py         2      1    50%   2
src/testing_demo/calculator.py      10      0   100%
tests/test_calculator.py            21      0   100%
--------------------------------------------------------------
TOTAL                               33      1    97%

The Missing column shows line 2 of __init__.py is the uncovered line.

Tip

pytest-cov is a pytest plugin that wraps coverage.py with a --cov flag. If you prefer integrating coverage directly into your pytest command (instead of running coverage run -m pytest separately), see How to measure code coverage with pytest-cov. This tutorial uses coverage.py directly because it’s one fewer dependency and teaches you the tool that’s doing the actual work.

Configuring pytest

Customize the default options when running pytest by adding the following to your pyproject.toml file:

[tool.pytest.ini_options]
addopts = "--maxfail=1"

Now re-run pytest on the command line. It will automatically run with this option set, stopping after the first failure.

$ uv run pytest
   Building testing-demo @ file:///path/to/testing-demo
      Built testing-demo @ file:///path/to/testing-demo
Uninstalled 1 package in 0.48ms
Installed 1 package in 1ms
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 5 items

tests/test_calculator.py .....                                           [100%]

============================== 5 passed in 0.00s ===============================

Editing pyproject.toml invalidates the installed project, so uv run rebuilds and reinstalls it before pytest starts. Those four lines appear once; the next run starts straight at test session starts.

All five tests pass, so --maxfail=1 does not change the output here. If a test fails, pytest stops and reports the failure immediately rather than continuing to run the remaining tests.

Running One Test Over Many Inputs

test_add checks three cases inside one function, so pytest counts it as a single test. Move the cases into @pytest.mark.parametrize and pytest collects one test per case instead.

Add this test file at tests/test_calculator_parametrized.py:

import pytest
from testing_demo.calculator import add


@pytest.mark.parametrize(
    "a, b, expected",
    [
        (1, 2, 3),
        (-1, 1, 0),
        (-1, -1, -2),
        (0, 0, 0),
    ],
)
def test_add_cases(a, b, expected):
    assert add(a, b) == expected

The first argument names the parameters. The second is a list of value tuples, one tuple per test. Keep the two in step: a tuple carrying a value the name string never declared stops collection with the number of names (2) must be equal to the number of values (3), and a function signature missing one of the names stops it with function uses no argument 'expected'.

Run the suite:

$ uv run pytest
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 9 items

tests/test_calculator.py .....                                           [ 55%]
tests/test_calculator_parametrized.py ....                               [100%]

============================== 9 passed in 0.01s ===============================

pytest collected 9 items: five from test_calculator.py and four from the one function you just wrote. It found the new file without any configuration because pytest searches for files matching test_*.py in the project tree.

Verbose mode shows the label pytest gives each case:

$ uv run pytest -v tests/test_calculator_parametrized.py
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0 -- /path/to/testing-demo/.venv/bin/python
cachedir: .pytest_cache
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collecting ... collected 4 items

tests/test_calculator_parametrized.py::test_add_cases[1-2-3] PASSED      [ 25%]
tests/test_calculator_parametrized.py::test_add_cases[-1-1-0] PASSED     [ 50%]
tests/test_calculator_parametrized.py::test_add_cases[-1--1--2] PASSED   [ 75%]
tests/test_calculator_parametrized.py::test_add_cases[0-0-0] PASSED      [100%]

============================== 4 passed in 0.00s ===============================

The bracketed suffix is the test ID, built from the parameter values. Pass one of those IDs to run a single case, quoting it so your shell leaves the brackets alone:

$ uv run pytest "tests/test_calculator_parametrized.py::test_add_cases[1-2-3]"
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 1 item

tests/test_calculator_parametrized.py .                                  [100%]

============================== 1 passed in 0.00s ===============================

Now break a case to see what a failure looks like. Change (0, 0, 0) to (0, 0, 1) and run the file again:

$ uv run pytest tests/test_calculator_parametrized.py
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 4 items

tests/test_calculator_parametrized.py ...F

=================================== FAILURES ===================================
____________________________ test_add_cases[0-0-1] _____________________________

a = 0, b = 0, expected = 1

    @pytest.mark.parametrize(
        "a, b, expected",
        [
            (1, 2, 3),
            (-1, 1, 0),
            (-1, -1, -2),
            (0, 0, 1),
        ],
    )
    def test_add_cases(a, b, expected):
>       assert add(a, b) == expected
E       assert 0 == 1
E        +  where 0 = add(0, 0)

tests/test_calculator_parametrized.py:15: AssertionError
=========================== short test summary info ============================
FAILED tests/test_calculator_parametrized.py::test_add_cases[0-0-1] - assert ...
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
========================= 1 failed, 3 passed in 0.01s ==========================

The report names the failing case and the values behind it, down to where 0 = add(0, 0). Inside test_add, a failing first assertion would stop the function and leave you guessing about the two after it. The stopping after 1 failures banner is the --maxfail=1 you configured in the previous section.

Change the case back to (0, 0, 0) before moving on.

Running Specific Tests

As a test suite grows, running every test on each change slows you down. pytest provides several ways to run a subset:

Run a single test file:

$ uv run pytest tests/test_calculator.py
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 5 items

tests/test_calculator.py .....                                           [100%]

============================== 5 passed in 0.00s ===============================

Run a single test function:

$ uv run pytest tests/test_calculator.py::test_add
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 1 item

tests/test_calculator.py .                                               [100%]

============================== 1 passed in 0.00s ===============================

Run tests matching a keyword expression:

$ uv run pytest -k "add"
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 9 items / 4 deselected / 5 selected

tests/test_calculator.py .                                               [ 20%]
tests/test_calculator_parametrized.py ....                               [100%]

======================= 5 passed, 4 deselected in 0.00s ========================

The 9 items / 4 deselected / 5 selected line is how pytest tells you the filter worked. Five tests match add on the substring in their function name: test_add from test_calculator.py, plus the four cases of test_add_cases.

-k reads the bracketed part of a test ID too, so a parameter value narrows the run to a single case:

$ uv run pytest -k "1-2-3"
============================= test session starts ==============================
platform darwin -- Python 3.14.6, pytest-9.1.1, pluggy-1.6.0
rootdir: /path/to/testing-demo
configfile: pyproject.toml
collected 9 items / 8 deselected / 1 selected

tests/test_calculator_parametrized.py .                                  [100%]

======================= 1 passed, 8 deselected in 0.00s ========================

Final Project Structure

After completing this tutorial, the project’s tracked files look like this:

    • .gitignore
    • .python-version
    • pyproject.toml
    • uv.lock
    • README.md
        • __init__.py
        • calculator.py
      • test_calculator.py
      • test_calculator_parametrized.py

The generated .venv/, .pytest_cache/, and .coverage sit alongside them, ignored rather than committed.

Next Steps

This handbook is free, independent, and ad-free. If it saved you time, consider sponsoring it on GitHub.

Last updated on