# Pytest, From Zero

> Python testing that gets out of your way: plain assert, fixtures for setup and teardown, parametrize for table-driven tests, and a rich plugin ecosystem.


---

# Pytest, From Zero

You wrote some Python that works, and now you want a safety net so the next change doesn't quietly break it. You looked at the standard library's `unittest`, saw `self.assertEqual` and class boilerplate everywhere, and quietly closed the tab. Pytest is the tool most Python people actually reach for: you write `assert x == y`, run one command, and get a failure message that tells you exactly what went wrong. This guide takes you from zero to writing tests you'll keep.

## How to read this

Read the phases in order the first time. Phase 1 builds the mental model: what pytest is, how it finds your tests with zero config, and why plain `assert` is the whole pitch. Phase 2 is the day-to-day: fixtures for setup and teardown, `parametrize` for running one test against many inputs, and marks for selecting what runs. Phase 3 is the harder edges: `conftest.py`, faking the outside world with `monkeypatch`, and the traps that waste an afternoon. If you already write pytest tests and want the gotchas, skim 1 and 2 and slow down on 3.

## The phases

1. [The mental model: plain assert and zero-config discovery](01-the-mental-model.md) - what pytest is, how it finds tests, and why `assert` beats `self.assertEqual`.
2. [The everyday core: fixtures, parametrize, and marks](02-fixtures-parametrize-marks.md) - dependency-injected setup, table-driven tests, and selecting what runs.
3. [Production reality: conftest, monkeypatch, and the gotchas](03-conftest-monkeypatch-gotchas.md) - sharing fixtures, faking the outside world, and the traps that bite.


---

# The mental model: plain assert and zero-config discovery

You have a function. You believe it works. The trouble is that "I believe it works" is not something you can run, and six months from now when you change one line, your belief won't tell you what you broke. A test is the part where you write down what "works" means in code, so the machine can check it for you forever.

Pytest exists to make that step cheap enough that you actually do it. The whole pitch fits in one idea: **a test is a function whose name starts with `test_`, and inside it you write a plain `assert`.** If the assert holds, the test passes. If it fails, pytest tells you why in detail. That's the model. Everything else in this guide is convenience layered on top.

## What a test looks like

Say you have a tiny module. Here it is, and the test next to it.

```python
# calc.py
def add(a, b):
    return a + b
```

```python
# test_calc.py
from calc import add

def test_add_two_positives():
    assert add(2, 3) == 5

def test_add_with_zero():
    assert add(7, 0) == 7
```

You run the test runner from the project folder:

```console
$ pytest
========================= test session starts =========================
collected 2 items

test_calc.py ..                                                  [100%]

========================== 2 passed in 0.01s ==========================
```

*What just happened:* pytest found `test_calc.py` on its own, ran both `test_` functions, and each dot is one passing test. You never registered the file, named a test suite, or wrote a `main`. You wrote two functions and ran one command.

## Why plain `assert` is the whole point

The standard library ships `unittest`, and it works, but it makes you say things like `self.assertEqual(add(2, 3), 5)` inside a class that inherits from `TestCase`. There's a method for every comparison: `assertEqual`, `assertTrue`, `assertIn`, `assertGreater`, on and on. You have to remember which one, and you have to wrap every test in a class.

Pytest throws all of that out. You use Python's own `assert`. The reason this works so well is **assertion introspection**: when an `assert` fails, pytest rewrites it behind the scenes to capture both sides of the comparison and print them. Watch what a failure looks like.

```python
# test_calc.py
from calc import add

def test_add_is_wrong_on_purpose():
    assert add(2, 3) == 6
```

```console
$ pytest
========================= test session starts =========================
collected 1 item

test_calc.py F                                                   [100%]

============================== FAILURES ===============================
____________________ test_add_is_wrong_on_purpose _____________________

    def test_add_is_wrong_on_purpose():
>       assert add(2, 3) == 6
E       assert 5 == 6
E        +  where 5 = add(2, 3)

test_calc.py:4: AssertionError
======================== 1 short test in 0.01s ========================
```

*What just happened:* the failure didn't say "AssertionError" and stop. It showed `assert 5 == 6` and told you that the `5` came from `add(2, 3)`. You can see the actual value, the expected value, and where the actual one came from, all without writing a single extra word. That introspection is what makes plain `assert` better than a dozen named assertion methods.

## How pytest finds your tests

Pytest discovers tests by convention, so you spend zero effort wiring them up. The default rules are worth memorizing because they explain "why isn't my test running":

- Files named `test_*.py` or `*_test.py`.
- Functions named `test_*` inside those files.
- Methods named `test_*` inside classes named `Test*` (and the class must have no `__init__`).

```text
myproject/
├── calc.py
├── shop/
│   └── cart.py
└── tests/
    ├── test_calc.py        ← discovered
    ├── test_cart.py        ← discovered
    └── helpers.py          ← NOT discovered (no test_ prefix)
```

*What just happened:* pytest walks the directory tree from where you ran it, collects every file matching the naming pattern, and runs the matching functions. The `helpers.py` file is ignored as a test file, which is exactly what you want for shared helper code. A common first confusion is naming a file `calc_test.py` but the function `check_add` instead of `test_add` - the file is found, but the function silently never runs because it lacks the `test_` prefix.

> **The one early gotcha:** if you have two test files with the same name in different folders and no `__init__.py` or package layout, pytest can collide on the module name and error out. The simplest fix early on is to keep test filenames unique, or add a `conftest.py` at the project root (an empty file works) so pytest treats the root as the base for imports. More on `conftest.py` in phase 3.

## Installing and running it

Pytest is not in the standard library; you install it. Inside a virtual environment:

```bash
pip install pytest
pytest                      # run everything it can discover
pytest test_calc.py        # run one file
pytest test_calc.py::test_add_two_positives   # run one test
pytest -v                  # verbose: one line per test, with names
pytest -k "add and not zero"   # run tests whose name matches the expression
```

*What just happened:* `pytest` with no arguments is the everyday command. The rest narrow the run: a file, a single test with the `::` separator, verbose output for readable names, and `-k` to filter by a substring expression on the test name. When you're chasing one failing test, `pytest path::test_name -v` is the loop you'll live in.

If you've never written a unit test before, the companion guide [/guides/your-first-unit-test](/guides/your-first-unit-test) walks through the mindset of what to test and why before you worry about the tool.

## For builders

Reach for pytest on any new Python project unless you have a specific reason not to. It runs `unittest`-style `TestCase` classes too, so adopting it on a legacy codebase doesn't mean rewriting old tests - pytest discovers and runs them as-is, and you write new tests the pytest way alongside them. There's almost no migration cost, which is a big part of why it became the default.

```quiz
[
  {
    "q": "What two things does pytest need to recognize and run a test by default?",
    "choices": ["A class inheriting from TestCase and a setUp method", "A file matching test_*.py (or *_test.py) and a function named test_*", "A pytest.ini file and an explicit test registry", "A @test decorator on every function"],
    "answer": 1,
    "explain": "Discovery is by naming convention: a test_*.py / *_test.py file and a test_* function inside it. No registration or base class required."
  },
  {
    "q": "Why does pytest let you use plain `assert` instead of methods like assertEqual?",
    "choices": ["It silently skips failed asserts", "Assertion introspection rewrites the assert to show both sides of the comparison and where each value came from", "Python's assert is faster than method calls", "It disables Python's -O optimization flag globally"],
    "answer": 1,
    "explain": "Pytest rewrites assert statements so a failure prints the actual and expected values and their source, giving rich output from a plain assert."
  },
  {
    "q": "You named a file test_cart.py but the function check_total. It never runs. Why?",
    "choices": ["The file name is wrong", "Functions must be inside a Test class", "The function lacks the test_ prefix, so pytest doesn't collect it", "You must register it in conftest.py"],
    "answer": 2,
    "explain": "Pytest only collects functions whose names start with test_. The file is discovered, but check_total is ignored."
  }
]
```


---

# The everyday core: fixtures, parametrize, and marks

The basics get you writing tests. The day-to-day gets you writing them without repeating yourself. Two patterns show up in almost every test file: "I need the same setup in many tests" and "I need to run the same test against many inputs." Pytest has a clean answer for each - fixtures and `parametrize` - and a third tool, marks, for choosing which tests to run. Learn these three and you have the working vocabulary for real test suites.

## Fixtures: setup and teardown as dependency injection

A fixture is a function that builds something a test needs - a temp file, a database connection, a sample object - and hands it over. You declare it with `@pytest.fixture`. A test asks for it by naming it as a parameter. Pytest sees the parameter name, runs the matching fixture, and passes in whatever the fixture returns.

```python
# test_user.py
import pytest

class User:
    def __init__(self, name):
        self.name = name
        self.active = True

@pytest.fixture
def sample_user():
    return User("Ada")

def test_user_starts_active(sample_user):
    assert sample_user.active is True

def test_user_has_name(sample_user):
    assert sample_user.name == "Ada"
```

*What just happened:* both tests asked for `sample_user` by putting it in their parameter list. Pytest ran the `sample_user` fixture fresh for each test and injected the result. You wrote the setup once and used it twice, and each test got its own clean `User` rather than sharing one. This is dependency injection: the test declares what it needs, and pytest supplies it.

### Teardown with `yield`

Setup is half the job. When a fixture opens something - a file, a connection, a temp directory - you need to close it after the test, pass or fail. Use `yield` instead of `return`: everything before `yield` is setup, everything after is teardown, and pytest runs the teardown even if the test fails.

```python
import pytest

@pytest.fixture
def temp_file(tmp_path):
    path = tmp_path / "data.txt"
    f = open(path, "w")
    yield f                 # hand the open file to the test
    f.close()               # teardown: always runs after the test

def test_write(temp_file):
    temp_file.write("hello")
    assert not temp_file.closed
```

*What just happened:* the fixture opened a file, yielded it to the test, and closed it afterward. The code after `yield` is the cleanup, and pytest guarantees it runs even if the test raises. The `tmp_path` here is a fixture pytest gives you for free - a unique temporary directory per test, cleaned up automatically - so you never write to a real path or collide between tests.

### Scope: how often a fixture runs

By default a fixture runs once per test that uses it (`scope="function"`). For expensive setup you don't want to repeat - a database connection, a large loaded file - you widen the scope so the fixture runs once and is shared.

```python
@pytest.fixture(scope="module")
def db_connection():
    conn = connect_to_test_db()     # expensive: do it once per file
    yield conn
    conn.close()
```

*What just happened:* `scope="module"` means the fixture runs once for the whole file and every test in that file shares the same connection, instead of reconnecting per test. The scopes, from narrowest to widest, are `function` (default), `class`, `module`, `package`, and `session`. Wider scope is faster but riskier: shared state between tests can let one test's leftovers leak into the next, so reserve it for things that are genuinely expensive and safe to share.

> **Mental model for scope:** narrow scope is safe and slow, wide scope is fast and shared. Start at `function`. Widen only when a profiler or the wall clock tells you the setup is the bottleneck - not before.

## Parametrize: one test, many cases

You wrote `test_add_two_positives` and `test_add_with_zero`. They're the same test with different numbers. Copy-pasting a test per case is how test files rot. `@pytest.mark.parametrize` runs one test body against a table of inputs.

```python
import pytest
from calc import add

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

```console
$ pytest -v
test_calc.py::test_add[2-3-5] PASSED
test_calc.py::test_add[7-0-7] PASSED
test_calc.py::test_add[-1-1-0] PASSED
test_calc.py::test_add[-5--5--10] PASSED
```

*What just happened:* one test function became four independent tests, one per row in the table. The first argument is a string naming the parameters; the second is a list of tuples, one per case. Each case shows up as its own line with the values in brackets, so when row three fails you see exactly which inputs broke it - and the other rows still run. This is table-driven testing, and it's the cleanest way to cover edge cases like zero, negatives, and empty inputs.

## Marks: tag and select tests

A mark is a label you attach to a test. Some marks change behavior, some are for selecting what runs. The two you'll use constantly:

```python
import pytest

@pytest.mark.skip(reason="endpoint not built yet")
def test_future_feature():
    assert build_the_future() == 42

@pytest.mark.skipif(sys.version_info < (3, 11), reason="needs 3.11+ syntax")
def test_new_syntax():
    ...

@pytest.mark.slow            # a custom mark you define
def test_full_pipeline():
    ...
```

```bash
pytest -m slow               # run ONLY tests marked slow
pytest -m "not slow"         # run everything EXCEPT slow tests
```

*What just happened:* `skip` always skips with a reason in the report; `skipif` skips only when a condition holds, which is how you handle version- or platform-specific tests. The custom `slow` mark does nothing on its own - it's a tag - but `-m slow` and `-m "not slow"` let you split a fast inner-loop run from the slow full suite. To avoid a warning on custom marks, register them in your config:

```text
# pytest.ini
[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
```

*What just happened:* registering the mark in `pytest.ini` tells pytest the `slow` mark is intentional, so it stops warning about an unknown mark. This is the standard place for project-wide pytest settings; `pyproject.toml` under a `[tool.pytest.ini_options]` table works too.

## In the wild

A mature test suite leans on all three together: fixtures build the world (a logged-in client, a seeded database), `parametrize` hammers each function with its edge cases, and marks split the fast unit tests from the slow integration ones so CI can run `pytest -m "not slow"` on every push and the full suite nightly. For where that unit/integration line actually falls, see [/guides/unit-integration-e2e](/guides/unit-integration-e2e).

```quiz
[
  {
    "q": "In a fixture, what does the code after `yield` do?",
    "choices": ["It runs only if the test passes", "It is the teardown - it runs after the test, pass or fail", "It is dead code; yield ends the fixture", "It runs before the test as extra setup"],
    "answer": 1,
    "explain": "Everything before yield is setup, everything after is teardown, and pytest runs the teardown even when the test fails."
  },
  {
    "q": "What does @pytest.mark.parametrize give you?",
    "choices": ["It runs one test body once against many input rows, each as a separate test", "It marks a test to be skipped", "It runs the test in parallel across CPU cores", "It sets the fixture scope to session"],
    "answer": 0,
    "explain": "Parametrize turns one function into many tests, one per row in the table, each reported and run independently."
  },
  {
    "q": "Why default to scope=\"function\" for fixtures instead of \"session\"?",
    "choices": ["Function scope is the only scope that supports yield teardown", "Function scope gives each test fresh state, avoiding leaks between tests; widen only when setup is genuinely expensive", "Session scope is deprecated", "Function scope runs tests in parallel automatically"],
    "answer": 1,
    "explain": "Narrow scope is safe and isolated but slower; wide scope shares state and risks leaks. Start narrow, widen only for expensive, safe-to-share setup."
  }
]
```


---

# Production reality: conftest, monkeypatch, and the gotchas

Your test suite grows. The same fixture gets copy-pasted into five files. A test needs to call a real API, or read the system clock, or hit a paid service - none of which you want happening during a test run. And every so often a test passes when the code is broken, or fails for a reason that has nothing to do with your code. This phase covers the three things that turn a toy test suite into one you trust: sharing fixtures with `conftest.py`, faking the outside world with `monkeypatch`, and the gotchas that waste an afternoon.

## conftest.py: fixtures every test can see

When a fixture is useful in more than one file, you don't import it - you move it to a file named `conftest.py`, and pytest makes it available to every test in that directory and below, automatically, with no import.

```text
tests/
├── conftest.py          ← fixtures here are visible to everything below
├── test_orders.py       ← can use fixtures from conftest.py
└── api/
    ├── conftest.py       ← extra fixtures just for api/ tests
    └── test_endpoints.py ← sees BOTH conftest.py files
```

```python
# tests/conftest.py
import pytest

@pytest.fixture
def db():
    conn = connect_to_test_db()
    yield conn
    conn.rollback()       # undo any writes so tests don't pollute each other
    conn.close()
```

```python
# tests/test_orders.py  - no import needed
def test_order_saves(db):
    save_order(db, item="book")
    assert count_orders(db) == 1
```

*What just happened:* `test_orders.py` used the `db` fixture without importing it, because pytest auto-discovers fixtures from any `conftest.py` up the directory tree. The nearer `conftest.py` wins on name conflicts, so you can override a shared fixture for one subfolder. This is also the file where project-wide hooks and plugin config live. A `conftest.py` is magic in the literal sense - pytest loads it implicitly - which is convenient and occasionally confusing, so keep it for genuinely shared things.

## monkeypatch: faking the outside world

A good unit test doesn't call the real payment API, doesn't depend on today's date, and doesn't read your actual environment variables. The built-in `monkeypatch` fixture lets you replace an attribute, a function, an environment variable, or a dict entry for the duration of one test - and pytest puts the original back automatically when the test ends.

```python
# weather.py
import requests

def temperature(city):
    resp = requests.get(f"https://api.example.com/temp/{city}")
    return resp.json()["celsius"]
```

```python
# test_weather.py
import weather

class FakeResponse:
    def json(self):
        return {"celsius": 21}

def test_temperature(monkeypatch):
    def fake_get(url):
        return FakeResponse()
    monkeypatch.setattr(weather.requests, "get", fake_get)

    assert weather.temperature("oslo") == 21
```

*What just happened:* the test replaced `requests.get` inside the `weather` module with a fake that returns canned data, so no real network call happened. The test is fast, deterministic, and runs offline. When the test finishes, pytest restores the real `requests.get` - you never have to undo the patch yourself, which is the whole reason to use `monkeypatch` over manually swapping attributes.

The same fixture handles environment and dicts:

```python
def test_uses_api_key(monkeypatch):
    monkeypatch.setenv("API_KEY", "test-key-123")
    monkeypatch.delenv("PROXY", raising=False)
    assert load_config()["api_key"] == "test-key-123"
```

*What just happened:* `setenv` set an environment variable for this test only, `delenv` removed one (`raising=False` means "don't error if it's already absent"), and both are reverted after the test. This keeps your real shell environment out of the test and the test's fake values out of the next test.

> **Patch where it's looked up, not where it's defined.** The single most common monkeypatch mistake: if `weather.py` does `from requests import get` and then calls `get(...)`, patching `requests.get` does nothing - `weather` already holds its own reference named `get`. You must patch `weather.get`. The rule: replace the name in the module that *uses* it, not the module that *defines* it.

## The gotchas that waste an afternoon

**Tests share state through wide-scope fixtures.** A `scope="session"` fixture that returns a mutable object - a list, a dict, a connection with uncommitted writes - leaks between tests. Test A appends to it, test B sees A's leftovers, and now your tests pass or fail depending on order. If you see a test that passes alone but fails in the suite (or vice versa), suspect shared mutable state first. Narrow the scope or reset the object in teardown.

**The import that isn't your code.** If your test file imports a module that doesn't exist or has a syntax error, pytest reports a *collection error*, not a test failure - the test never ran. Read the top of the output, not the bottom: collection errors appear before the test results.

**Assertions that always pass.** `assert (x == y)` is fine, but `assert(x == y, "message")` is a trap - that's asserting a two-element tuple, which is always truthy, so the test can never fail. If you want a message, use `assert x == y, "message"` with a comma, no parentheses around the pair.

```console
$ pytest
=========================== warnings summary ===========================
test_calc.py:4: PytestAssertRewriteWarning: assertion is always true,
  perhaps remove parentheses?
```

*What just happened:* pytest noticed the parenthesized assert-with-tuple and warned you, because that pattern silently disables the test. Treat this warning as an error - it means a test you thought was guarding something is guarding nothing.

**Disappearing output.** `print()` inside a passing test shows nothing by default - pytest captures stdout and only shows it for failing tests. Pass `-s` to see prints live, or `--capture=no`, when you're debugging. And when a test fails and you want to drop into a debugger at the failure, `pytest --pdb` opens `pdb` right at the assertion that blew up.

## For builders

The plugin ecosystem is the other half of pytest's pull. A few you'll meet on real projects: `pytest-cov` for coverage reports (`pytest --cov=myapp`), `pytest-xdist` to run tests across multiple cores (`pytest -n auto`), and `pytest-mock` which wraps the standard library's `unittest.mock` in a fixture if you outgrow `monkeypatch`. You don't need any of them to start - plain pytest plus `monkeypatch` covers most of what you'll write - but it's good to know the escape hatches exist before you need them.

```quiz
[
  {
    "q": "What makes conftest.py special?",
    "choices": ["It must be imported at the top of every test file", "Fixtures defined in it are auto-available to all tests in its directory and below, with no import", "It is the only place you can write assertions", "It runs your tests in a separate process"],
    "answer": 1,
    "explain": "pytest auto-discovers fixtures from conftest.py files up the directory tree, so tests use them without importing anything."
  },
  {
    "q": "Your module does `from requests import get` then calls `get(...)`. To fake it in a test, what do you patch?",
    "choices": ["requests.get, where it is defined", "The name in the module that uses it, e.g. mymodule.get", "Nothing - monkeypatch can't reach imported names", "Python's builtins.get"],
    "answer": 1,
    "explain": "The using module already bound its own reference named get. Patch where it is looked up (mymodule.get), not where it is defined."
  },
  {
    "q": "Why is `assert(x == y, \"oops\")` a dangerous test bug?",
    "choices": ["It raises a SyntaxError", "It asserts a non-empty tuple, which is always truthy, so the test can never fail", "It only works in Python 2", "It silently skips the test"],
    "answer": 1,
    "explain": "The parentheses make it a 2-tuple, which is always truthy. The assert always passes and guards nothing. Use a comma with no parens: assert x == y, \"oops\"."
  }
]
```
