🌐 Detecting your location…
📢 Advertisement — Configure AdSense in Appearance → Customize → AdSense Settings

Python Testing with pytest 2026: Fixtures, Mocking and CI Integration

⏱️5 min read  ·  1,009 words

pytest is the gold standard for Python testing in 2026. Used by Django, FastAPI, NumPy, and thousands of open-source projects, pytest makes writing and running tests intuitive and powerful. This complete guide takes you from your first test to advanced fixtures, parametrize, and CI integration.

Why pytest over unittest?

  • Simpler syntax — plain assert statements with helpful failure messages
  • Powerful fixtures — dependency injection with automatic teardown
  • Parametrize — run one test with many inputs
  • Rich plugin ecosystem — coverage, xdist, mock, asyncio, and more
  • Better discovery — finds tests automatically without boilerplate

Installation and First Test

# Install pytest
pip install pytest pytest-cov pytest-asyncio

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

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

# Run tests
pytest                    # run all tests
pytest test_math.py       # specific file
pytest -v                 # verbose output
pytest -k "add"           # run tests matching name
pytest --tb=short         # shorter tracebacks

Test Organization

# Recommended structure
# tests/
#   conftest.py          — shared fixtures
#   test_users.py
#   test_orders.py
#   integration/
#     test_api.py
#   unit/
#     test_services.py

# pytest.ini or pyproject.toml
# [tool.pytest.ini_options]
# testpaths = ["tests"]
# addopts = "-v --tb=short"

# Test class grouping
class TestUserService:
    def test_create_user(self):
        user = create_user("alice@example.com")
        assert user.email == "alice@example.com"

    def test_create_user_invalid_email(self):
        with pytest.raises(ValueError, match="Invalid email"):
            create_user("not-an-email")

    def test_duplicate_email_raises(self, db):
        create_user("alice@example.com")
        with pytest.raises(DuplicateError):
            create_user("alice@example.com")

Fixtures — pytest’s Superpower

import pytest
from app.database import Database
from app.models import User

# Simple fixture
@pytest.fixture
def sample_user():
    return User(id=1, name="Alice", email="alice@example.com")

# Fixture with setup and teardown
@pytest.fixture
def db():
    database = Database(url="sqlite:///:memory:")
    database.create_tables()
    yield database           # test runs here
    database.drop_tables()   # teardown

# Session-scoped fixture — runs once per test session
@pytest.fixture(scope="session")
def app_client():
    from app import create_app
    app = create_app(testing=True)
    return app.test_client()

# Fixture using other fixtures
@pytest.fixture
def admin_user(db):
    user = db.create_user(email="admin@test.com", role="admin")
    return user

# conftest.py — shared fixtures auto-discovered
# pytest finds conftest.py automatically

Parametrize — Test Multiple Cases

import pytest

def is_valid_email(email: str) -> bool:
    import re
    return bool(re.match(r"[^@]+@[^@]+\.[^@]+", email))

@pytest.mark.parametrize("email,expected", [
    ("alice@example.com", True),
    ("user+tag@domain.co.uk", True),
    ("invalid", False),
    ("no-at-sign", False),
    ("@nodomain.com", False),
    ("user@.com", False),
    ("", False),
])
def test_email_validation(email, expected):
    assert is_valid_email(email) == expected

# Multiple parametrize decorators — creates cartesian product
@pytest.mark.parametrize("base", [2, 10, 16])
@pytest.mark.parametrize("exp", [1, 2, 3])
def test_powers(base, exp):
    result = base ** exp
    assert result == pow(base, exp)

Mocking with pytest-mock

pip install pytest-mock

import pytest
from app.services import UserService
from app.email import EmailSender

def test_registration_sends_welcome_email(mocker):
    # Mock the email sender
    mock_send = mocker.patch("app.services.EmailSender.send")

    service = UserService()
    service.register("alice@example.com", "securepass")

    # Assert email was sent
    mock_send.assert_called_once_with(
        to="alice@example.com",
        subject="Welcome to TechPulse!"
    )

def test_external_api_timeout(mocker):
    # Mock requests.get to simulate timeout
    mocker.patch(
        "requests.get",
        side_effect=requests.exceptions.Timeout
    )

    with pytest.raises(ServiceUnavailableError):
        fetch_weather("London")

# Spy on real method (calls real code but tracks calls)
def test_cache_used_on_second_call(mocker):
    spy = mocker.spy(cache_service, "get")
    fetch_user(1)
    fetch_user(1)  # should hit cache
    assert spy.call_count == 2

Testing Async Code

pip install pytest-asyncio

import pytest
import pytest_asyncio

# Mark entire file as async
# pytest.ini: asyncio_mode = auto

@pytest.mark.asyncio
async def test_async_fetch():
    async with AsyncClient(app=app, base_url="http://test") as client:
        response = await client.get("/api/users")
    assert response.status_code == 200

# Async fixture
@pytest_asyncio.fixture
async def async_db():
    async with AsyncDatabase() as db:
        await db.create_tables()
        yield db
        await db.drop_tables()

Coverage Reporting

# Run with coverage
pytest --cov=app --cov-report=html --cov-report=term-missing

# Output:
# Name              Stmts   Miss  Cover
# app/models.py        45      3    93%
# app/services.py      87     12    86%
# TOTAL               132     15    89%

# Fail if coverage below threshold
pytest --cov=app --cov-fail-under=80

# .coveragerc or pyproject.toml
# [tool.coverage.run]
# omit = ["*/tests/*", "*/migrations/*"]

pytest Marks — Categorize Tests

import pytest

# Define marks in pyproject.toml
# [tool.pytest.ini_options]
# markers = [
#   "slow: marks tests as slow",
#   "integration: marks integration tests",
#   "unit: marks unit tests",
# ]

@pytest.mark.slow
@pytest.mark.integration
def test_full_user_registration_flow(client, db):
    # End-to-end registration test
    response = client.post("/register", json={
        "email": "test@example.com",
        "password": "SecurePass123!"
    })
    assert response.status_code == 201
    assert db.users.count() == 1

# Run only unit tests
# pytest -m unit

# Skip slow tests in CI
# pytest -m "not slow" 

GitHub Actions CI Integration

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12"]

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        run: |
          pip install uv
          uv pip install -e ".[dev]" --system

      - name: Run tests
        run: pytest --cov=app --cov-fail-under=80

      - name: Upload coverage
        uses: codecov/codecov-action@v4

pytest mastery takes practice but pays dividends. Write tests alongside features (TDD), use fixtures for setup/teardown, parametrize for edge cases, and mock external dependencies. Aim for 80%+ coverage on business logic and 100% on critical paths.

✍️ Leave a Comment

Your email address will not be published. Required fields are marked *

🌐 Read in:🇬🇧 English🇩🇪 Deutsch🇧🇷 Português🇸🇦 العربية🇮🇳 हिन्दी🇧🇩 বাংলা