# 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, {{< term "dependency-management" "dependency management" >}}, and configuration. This tutorial walks through the full setup using [pytest](https://pydevtools.com/handbook/reference/pytest.md) and [uv](https://pydevtools.com/handbook/reference/uv.md): creating a project, writing tests, running one test over many inputs, measuring coverage, and configuring defaults.

## Prerequisites

[Install uv on your system.](https://pydevtools.com/handbook/how-to/how-to-install-uv.md)

## Creating a Project with Tests

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

```console
$ 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 {{< term "scaffold" "scaffolds" >}} 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 you see a `main.py` at the project root and no `src/`, you are on an older uv version; run `uv self update` to get the current packaged-by-default behavior. Without a package layout, pytest reports `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 {{< term "dev-dependency" "development dependency" >}}, keeping it out of production installs:

```console
$ 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](https://pydevtools.com/handbook/reference/pyproject.toml.md) with pytest as a development dependency
* Creates the project's {{< term "lockfile" >}}
* Installs pytest in your project's {{< term "virtual-environment" "virtual environment" >}}

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 {{< term "wheel" "wheels" >}}.

## Creating a Simple Module to Test

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

```python
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:

```console
$ mkdir tests
```

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

```python
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 {{< term "dependency" "dependencies" >}} are installed first.
Run the test suite:

```console
$ 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:

```console
$ 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 {{< term "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](https://coverage.readthedocs.io/) measures which lines of code your tests execute. Add it as a development dependency:

```console
$ 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:

```console
$ 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:

```text {filename=".gitignore"}
# Coverage data
.coverage
```

Then view the report:

```console
$ 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:

```console
$ 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](https://pytest-cov.readthedocs.io/) 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](https://pydevtools.com/handbook/how-to/how-to-measure-code-coverage-with-pytest-cov.md). 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:

```toml
[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.

```console
$ 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`:

```python
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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

```console
$ 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:

{{< /filetree/folder >}}
    {{< /filetree/folder >}}
    {{< /filetree/folder >}}
  {{< /filetree/folder >}}
{{< /filetree/container >}}

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

### Pick your next step

- **Speeding up a large test suite?** [Run tests in parallel with pytest-xdist](https://pydevtools.com/handbook/how-to/how-to-run-tests-in-parallel-with-pytest-xdist.md) distributes tests across CPUs.
- **Tracking coverage?** [Measure code coverage with pytest-cov](https://pydevtools.com/handbook/how-to/how-to-measure-code-coverage-with-pytest-cov.md) adds coverage reporting.
- **Running tests in CI?** [Set up GitHub Actions with uv](https://pydevtools.com/handbook/tutorial/setting-up-github-actions-with-uv.md) runs tests on every push.
- **Adding linting and formatting?** [Set up Ruff](https://pydevtools.com/handbook/tutorial/set-up-ruff-for-formatting-and-checking-your-code.md) pairs a linter with your test suite.
- **Adding type checking?** [Set up type checking with ty](https://pydevtools.com/handbook/tutorial/set-up-type-checking-with-ty.md) catches type errors before tests run.
- **Want the full setup?** [Set up a complete Python project](https://pydevtools.com/handbook/tutorial/set-up-a-complete-python-project.md) adds testing, linting, type checking, and pre-commit in one tutorial.
