I've been writing Python tests for years and I still see the same mistakes everywhere. Including in my own code, which is the annoying part.

pytest is not hard. But it has a few patterns that, once you internalize them, make everything cleaner. Here are the three things I got wrong for way too long.

Code editor with Python tests
My test file before I learned fixtures. Messy.

1. Stop Duplicating Setup Code

This is the most common one. You have five tests that all need a temporary database, or a mock API client, or a sample config file. So you copy-paste the setup into each test function.

( yes, I did this for months )

Then something changes and you update five places. Then you miss one and a test fails for the wrong reason. Use fixtures instead.

Here is a basic fixture:

# conftest.py
import pytest
import tempfile
import os
from myapp.db import Database

@pytest.fixture
def db():
    """Temporary database for each test."""
    fd, path = tempfile.mkstemp(suffix=".db")
    database = Database(path)
    database.connect()
    yield database
    database.close()
    os.close(fd)
    os.unlink(path)

Now every test that needs a database just takes `db` as an argument. pytest handles the rest.

The `yield` is where the test runs. Everything before it is setup, everything after is teardown. Clean.

# test_users.py
def test_create_user(db):
    user = db.create_user("davide", "dav@example.com")
    assert user.id is not None
    assert user.username == "davide"

def test_duplicate_user_fails(db):
    db.create_user("davide", "dav@example.com")
    with pytest.raises(ValueError):
        db.create_user("davide", "other@example.com")

Each test gets its own fresh database. No cross-contamination. No cleanup code in the test itself.

2. Use Scopes Properly

Terminal running pytest
pytest running clean. The only green I want to see.

By default, fixtures run once per test. That's fine for most things. But some setups are expensive, creating a database, starting a mock server, loading a big dataset. You don't want that overhead for every single test.

Use `scope` to control it.

@pytest.fixture(scope="session")
def api_client():
    """Start a mock API once for the entire test session."""
    client = MockAPIClient(port=9999)
    client.start()
    yield client
    client.stop()

@pytest.fixture(scope="module") 
def sample_data():
    """Load test data once per module."""
    return load_test_data("tests/fixtures/data.json")

Scopes from widest to narrowest: `session` > `package` > `module` > `class` > `function`.

The default is `function`, which means every test gets a fresh instance. I use `session` for things that are read-only or expensive to create. I keep `function` for anything stateful that tests might mutate.

Getting this wrong means either slow tests (scope too narrow) or flaky tests (scope too wide). I've done both.

3. Parametrize Instead of Copy-Pasting

If you have a function and you want to test it with five different inputs, don't write five test functions. Don't write a loop inside one test either, because when it fails you don't know which case broke.

Code on screen
Parametrize is not optional. It's just the right way.

Use `@pytest.mark.parametrize`:

@pytest.mark.parametrize("input,expected", [
    ("hello", "HELLO"),
    ("world", "WORLD"),
    ("", ""),
    ("123", "123"),
    ("Café", "CAFÉ"),
])
def test_uppercase(input, expected):
    assert input.upper() == expected

Each case runs as a separate test. If "Café" breaks, pytest tells you exactly which one failed. You get the case ID in the output.

You can also parametrize fixtures, which is powerful but I won't pretend I use it often. Most of the time parametrizing the test function is enough.

Bonus: Run Only What Changed

Install `pytest-testmon` and run `pytest --testmon`. It figures out which tests are affected by your latest code changes and runs only those.

On a large codebase this saves minutes per run. On a small one it saves seconds, which still adds up when you run tests 50 times a day.

# Install
pip install pytest-testmon

# Run only affected tests
pytest --testmon

# Or use with coverage for better accuracy
pytest --testmon --cov=myapp

I also use `pytest-xdist` for parallel execution on bigger projects:

# Install
pip install pytest-xdist

# Run tests across 4 CPU cores
pytest -n 4

# Auto-detect number of cores
pytest -n auto

Combined with fixtures and proper scoping, this is all you need for most Python projects. I wasted a lot of time not knowing this stuff.

Fixtures, scopes, and parametrize. That's 90% of pytest. The rest is edge cases and plugins you'll pick up as you go.