All posts

April 23, 2025

Pytest: The Python Testing Framework

PythonPytestTesting

Pytest logo

Introduction

Untested code is a liability. Every untested function is a potential regression, a silent bug waiting to surface in production, and a barrier to confident refactoring. Pytest is the industry-standard testing framework that transforms Python testing from a chore into a competitive advantage — eliminating boilerplate, unlocking powerful fixture composition, and powering a plugin ecosystem that covers everything from Django integration to property-based testing.

Teams that adopt pytest ship faster with fewer regressions. Plain assert statements replace verbose assertEqual chains. Fixtures replace fragile setUp/tearDown methods. Parametrize eliminates copy-paste test variants. The result is a test suite that developers actually maintain.

Key Benefits

Feature Pytest unittest nose
Assertion style Plain assert assertEqual, assertTrue Plain assert
Fixtures Composable, scoped ✅ setUp/tearDown only ❌ Limited ⚠️
Parametrize Built-in @pytest.mark.parametrize Manual loops ❌ Plugin required ⚠️
Plugin ecosystem 1000+ plugins ✅ None ❌ Limited ⚠️
Django integration pytest-django Built-in but verbose ⚠️ Plugin required ⚠️
Async support pytest-asyncio Manual ❌ None ❌
Parallel execution pytest-xdist None ❌ None ❌
Coverage pytest-cov Manual setup ❌ Plugin required ⚠️
Maintenance status Actively maintained ✅ Standard library ⚠️ Unmaintained ❌
pip install pytest

Basic Tests

# test_math.py
def add(a, b):
    return a + b

def test_add():
    assert add(2, 3) == 5
    assert add(-1, 1) == 0

def test_add_floats():
    assert add(1.5, 2.5) == 4.0
pytest test_math.py
pytest test_math.py -v          # verbose
pytest -k "test_add"            # filter by name
pytest --tb=short               # shorter tracebacks

Fixtures

import pytest

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

def test_user_name(sample_user):
    assert sample_user["name"] == "John"

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

Fixture Scopes

@pytest.fixture(scope="session")   # once per test session
def db_connection():
    conn = create_connection()
    yield conn
    conn.close()

@pytest.fixture(scope="module")    # once per module
def api_client():
    return TestClient(app)

@pytest.fixture(scope="function")  # default — once per test
def fresh_data():
    return {"count": 0}

Fixtures with Teardown

@pytest.fixture
def temp_file(tmp_path):
    file = tmp_path / "test.txt"
    file.write_text("hello")
    yield file
    # teardown runs after test
    file.unlink(missing_ok=True)

Parametrize

import pytest

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

# Multiple parametrize decorators (cartesian product)
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
    assert x * y > 0

Markers

import pytest

@pytest.mark.slow
def test_heavy_computation():
    ...

@pytest.mark.skip(reason="not implemented yet")
def test_future_feature():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_feature():
    ...

@pytest.mark.xfail(reason="known bug")
def test_known_bug():
    assert False
pytest -m slow          # run only slow tests
pytest -m "not slow"    # skip slow tests

Exception Testing

import pytest

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

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

def test_divide_normal():
    assert divide(10, 2) == 5.0

Mocking

from unittest.mock import Mock, patch

def test_with_mock():
    mock_service = Mock()
    mock_service.get_user.return_value = {"id": 1, "name": "John"}

    result = mock_service.get_user(1)
    assert result["name"] == "John"
    mock_service.get_user.assert_called_once_with(1)

def test_with_patch():
    with patch("mymodule.requests.get") as mock_get:
        mock_get.return_value.json.return_value = {"status": "ok"}
        result = fetch_status()
        assert result == "ok"

Django Integration

# conftest.py
import pytest
from django.test import Client

@pytest.fixture
def client():
    return Client()

@pytest.fixture
def authenticated_client(client, django_user_model):
    user = django_user_model.objects.create_user(
        username="testuser", password="password"
    )
    client.login(username="testuser", password="password")
    return client

# test_views.py
@pytest.mark.django_db
def test_homepage(client):
    response = client.get("/")
    assert response.status_code == 200

@pytest.mark.django_db
def test_protected_view(authenticated_client):
    response = authenticated_client.get("/dashboard/")
    assert response.status_code == 200

conftest.py

# conftest.py — shared fixtures across all tests
import pytest
from myapp.models import User

@pytest.fixture(scope="session")
def django_db_setup():
    pass  # custom DB setup

@pytest.fixture
def user(db):
    return User.objects.create_user(
        username="testuser",
        email="test@example.com",
        password="password"
    )

Use Cases

  • Unit testing pure functions and classes
  • Integration testing with databases
  • API endpoint testing
  • Django view and model testing
  • Property-based testing with Hypothesis

Best Practices

  • Keep tests small and focused — one assertion per test when possible
  • Use fixtures for shared setup, not setUp methods
  • Name tests descriptively: test_user_cannot_login_with_wrong_password
  • Use parametrize to avoid copy-paste test variants
  • Run tests in CI on every push

Conclusion

The difference between teams that ship confidently and teams that fear deployments is a reliable test suite. Pytest eliminates every friction point that makes testing feel like overhead — verbose assertions, rigid setup methods, and slow feedback loops. Teams that adopt pytest ship faster with fewer regressions, refactor without fear, and onboard new developers into a codebase that documents its own behavior through tests.

The investment is minimal: pip install pytest, rename your test files, and start writing plain assert statements. The return — caught bugs before production, confident refactoring, and a codebase that scales — is immediate and compounding.

👉 Read more on Medium

References