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
| Aspect | unittest | pytest |
|---|---|---|
| Test style | Classes inheriting TestCase | Plain functions |
| Assertions | self.assertEqual(a, b) | assert a == b |
| Fixtures | setUp / tearDown | Composable fixtures by name |
| Parametrization | Manual loops | @pytest.mark.parametrize |
| Failure output | Basic | Detailed 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
| Flag | Effect |
|---|---|
-v | Verbose output with each test name |
-q | Quiet output |
-k "add and not zero" | Run tests matching an expression |
-x | Stop after the first failure |
--lf | Re-run only the last failed tests |
--cov=myapp | Measure code coverage |
-s | Show 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
| Scope | Fixture created |
|---|---|
function (default) | Once per test function |
class | Once per test class |
module | Once per module |
session | Once 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.skipifandxfailfor known-unsupported cases. - Aim for coverage of critical paths, not 100% for its own sake.
- Run tests in parallel with
pytest-xdistonce the suite grows.
