Overview

pytest is the most widely used testing framework in Python. It runs plain functions, offers powerful fixtures, and produces readable failure output. This tutorial covers installation, writing tests, fixtures, parametrization, and mocking.

Why pytest over unittest

Aspectunittestpytest
Test styleClasses inheriting TestCasePlain functions
Assertionsself.assertEqual(a, b)assert a == b
FixturessetUp / tearDownComposable fixtures by name
ParametrizationManual loops@pytest.mark.parametrize
Failure outputBasicDetailed diff and local variables

Installation

pip install pytest pytest-cov

Verify:

pytest --version

Your First Test

Create test_math.py:

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

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

def test_add_negative_numbers():
    assert add(-1, -1) == -2

def test_add_zero():
    assert add(0, 5) == 5

Run it:

pytest

pytest discovers files matching test_*.py or *_test.py and functions starting with test_.

Useful Command-Line Flags

FlagEffect
-vVerbose output with each test name
-qQuiet output
-k "add and not zero"Run tests matching an expression
-xStop after the first failure
--lfRe-run only the last failed tests
--cov=myappMeasure code coverage
-sShow print output

Testing Exceptions

import pytest

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

def test_divide_by_zero():
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        divide(1, 0)

Fixtures

Fixtures provide reusable setup and teardown. A test requests a fixture by naming it as a parameter.

import pytest

@pytest.fixture
def sample_user():
    return {"id": 1, "name": "Alice", "email": "alice@example.com"}

def test_user_has_name(sample_user):
    assert sample_user["name"] == "Alice"

def test_user_email(sample_user):
    assert "@" in sample_user["email"]

Fixture Scopes

ScopeFixture created
function (default)Once per test function
classOnce per test class
moduleOnce per module
sessionOnce per test session
@pytest.fixture(scope="session")
def db_connection():
    conn = connect_to_db()
    yield conn
    conn.close()

Code before yield runs as setup; code after runs as teardown.

Parametrize: Multiple Inputs in One Test

import pytest

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

Each tuple produces a separate test case, and a failure identifies exactly which input failed.

Parametrize with IDs

@pytest.mark.parametrize("value, expected", [
    pytest.param("", False, id="empty-string"),
    pytest.param("abc", True, id="simple-string"),
    pytest.param("   ", False, id="whitespace-only"),
])
def test_is_valid(value, expected):
    assert is_valid(value) == expected

Mocking with unittest.mock

from unittest.mock import patch, MagicMock

def fetch_weather(city):
    response = requests.get(f"https://api.weather.com/{city}")
    return response.json()["temp"]

@patch("myapp.requests.get")
def test_fetch_weather(mock_get):
    mock_get.return_value.json.return_value = {"temp": 22}
    assert fetch_weather("London") == 22
    mock_get.assert_called_once_with("https://api.weather.com/London")

Patch where the name is looked up, not where it is defined. If myapp.py does import requests, patch myapp.requests.get.

Testing Flask Endpoints

# app.py
from flask import Flask, jsonify
app = Flask(__name__)

@app.route("/health")
def health():
    return jsonify({"status": "ok"})

# test_app.py
import pytest
from app import app

@pytest.fixture
def client():
    app.config["TESTING"] = True
    with app.test_client() as client:
        yield client

def test_health(client):
    response = client.get("/health")
    assert response.status_code == 200
    assert response.get_json() == {"status": "ok"}

Configuration with pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
addopts = -v --cov=myapp --cov-report=term-missing

Best Practices

  • One behavior per test. If the name contains "and", split it.
  • Use descriptive test names: test_login_fails_with_wrong_password.
  • Keep fixtures small and composable rather than one giant setup fixture.
  • Use pytest.mark.skipif and xfail for known-unsupported cases.
  • Aim for coverage of critical paths, not 100% for its own sake.
  • Run tests in parallel with pytest-xdist once the suite grows.