← Back to DevBytes

Testing Pytest Applications: Unit Tests to Integration

Introduction to Pytest

Pytest is one of the most popular testing frameworks in the Python ecosystem. It makes writing small, readable tests easy while scaling to support complex functional testing for applications and libraries. Unlike Python's built-in unittest module, pytest requires no boilerplate, supports plain assert statements, and offers powerful fixtures for managing test state.

In this tutorial, you'll learn how to build a testing strategy that progresses from simple unit tests to more complex integration tests. We'll cover the essentials of pytest, fixtures, parametrization, mocking, and best practices that will help you write maintainable test suites for real-world applications.

Why Testing Matters

Testing is not optional in professional software development. It provides a safety net that allows you to refactor code confidently, catch bugs early, and document expected behavior. Without tests, every change becomes a gamble. With tests, you have measurable confidence that your application works as intended.

Unit Tests vs Integration Tests

A healthy test suite follows the testing pyramid: many unit tests at the base, fewer integration tests in the middle, and a small number of end-to-end tests at the top.

Setting Up Pytest

Start by installing pytest in your project. It's recommended to use a virtual environment to keep dependencies isolated.

# Create and activate a virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install pytest
pip install pytest

# Install pytest with coverage support
pip install pytest pytest-cov

Pytest discovers tests automatically by following simple conventions:

Here's a typical project structure:

my_project/
β”œβ”€β”€ src/
β”‚   └── myapp/
β”‚       β”œβ”€β”€ __init__.py
β”‚       β”œβ”€β”€ calculator.py
β”‚       β”œβ”€β”€ user_service.py
β”‚       └── database.py
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ __init__.py
β”‚   β”œβ”€β”€ conftest.py
β”‚   β”œβ”€β”€ unit/
β”‚   β”‚   β”œβ”€β”€ test_calculator.py
β”‚   β”‚   └── test_user_service.py
β”‚   └── integration/
β”‚       └── test_user_workflow.py
β”œβ”€β”€ pytest.ini
└── requirements.txt

Create a pytest.ini file to configure pytest behavior:

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --strict-markers
markers =
    slow: marks tests as slow
    integration: marks tests as integration tests

Writing Your First Unit Test

Let's start with a simple example. Create a calculator module that we'll test:

# src/myapp/calculator.py

class Calculator:
    def add(self, a, b):
        return a + b

    def subtract(self, a, b):
        return a - b

    def multiply(self, a, b):
        return a * b

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

Now write the corresponding unit tests:

# tests/unit/test_calculator.py

import pytest
from myapp.calculator import Calculator


@pytest.fixture
def calculator():
    return Calculator()


def test_add(calculator):
    assert calculator.add(2, 3) == 5


def test_subtract(calculator):
    assert calculator.subtract(5, 3) == 2


def test_multiply(calculator):
    assert calculator.multiply(3, 4) == 12


def test_divide(calculator):
    assert calculator.divide(10, 2) == 5


def test_divide_by_zero_raises_error(calculator):
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        calculator.divide(10, 0)

Run the tests with the following command:

pytest tests/unit/test_calculator.py -v

Notice how pytest uses plain assert statements. There's no need for self.assertEqual() or similar methods. Pytest introspects the assert expression and provides detailed failure messages automatically.

Understanding Fixtures

Fixtures are pytest's mechanism for setting up and tearing down test state. They replace the setUp and tearDown methods found in unittest with a more flexible, composable approach.

Basic Fixtures

A fixture is a function decorated with @pytest.fixture. When a test function references a fixture by name as a parameter, pytest automatically calls the fixture and injects the result.

# tests/conftest.py

import pytest
from myapp.database import Database


@pytest.fixture
def db_connection():
    """Create a fresh database connection for each test."""
    db = Database("sqlite:///:memory:")
    db.connect()
    yield db
    db.disconnect()


@pytest.fixture(scope="session")
def app_config():
    """Load configuration once for the entire test session."""
    return {
        "debug": True,
        "database_url": "sqlite:///:memory:",
        "secret_key": "test-secret"
    }

Fixture Scopes

Fixtures have scopes that control how often they are created:

@pytest.fixture(scope="module")
def expensive_resource():
    """This resource is shared across all tests in the module."""
    resource = create_expensive_resource()
    yield resource
    resource.cleanup()

The yield Statement

Using yield instead of return allows you to include teardown code after the yield. This is the recommended pattern for fixtures that need cleanup.

@pytest.fixture
def temp_file():
    import tempfile
    import os

    fd, path = tempfile.mkstemp()
    os.close(fd)
    yield path
    os.unlink(path)

Fixture Parametrization

Fixtures can be parametrized to run tests multiple times with different data:

@pytest.fixture(params=[
    {"username": "alice", "email": "alice@example.com"},
    {"username": "bob", "email": "bob@example.com"},
    {"username": "charlie", "email": "charlie@example.com"},
])
def user_data(request):
    return request.param


def test_user_creation(user_data):
    user = create_user(user_data)
    assert user.username == user_data["username"]
    assert user.email == user_data["email"]

Parametrized Tests

Parametrized tests let you run the same test logic with multiple inputs and expected outputs. This is one of pytest's most powerful features for reducing code duplication.

# tests/unit/test_calculator.py

import pytest
from myapp.calculator import Calculator


@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (-1, 1, 0),
    (0, 0, 0),
    (100, 200, 300),
    (-5, -10, -15),
    (0.1, 0.2, 0.3),
])
def test_add_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.add(a, b) == expected


@pytest.mark.parametrize("a, b, expected", [
    (10, 2, 5),
    (9, 3, 3),
    (7, 1, 7),
    (-10, 2, -5),
    (0, 5, 0),
])
def test_divide_parametrized(a, b, expected):
    calc = Calculator()
    assert calc.divide(a, b) == expected

You can also parametrize with IDs for better test output readability:

@pytest.mark.parametrize(
    "input_str, expected",
    [
        ("hello", "HELLO"),
        ("World", "WORLD"),
        ("Python 3", "PYTHON 3"),
    ],
    ids=["lowercase", "mixed_case", "with_number"]
)
def test_to_upper(input_str, expected):
    assert input_str.upper() == expected

Mocking and Patching

Unit tests should run in isolation without depending on external systems like databases, APIs, or file systems. Mocking allows you to replace these dependencies with controlled substitutes.

Python's unittest.mock module provides the Mock and patch utilities, which integrate seamlessly with pytest.

Mocking with patch

Let's say we have a user service that depends on an external API:

# src/myapp/user_service.py

import requests


class UserService:
    def __init__(self, api_url):
        self.api_url = api_url

    def get_user(self, user_id):
        response = requests.get(f"{self.api_url}/users/{user_id}")
        response.raise_for_status()
        return response.json()

    def create_user(self, username, email):
        response = requests.post(
            f"{self.api_url}/users",
            json={"username": username, "email": email}
        )
        response.raise_for_status()
        return response.json()

Now write unit tests that mock the HTTP calls:

# tests/unit/test_user_service.py

import pytest
from unittest.mock import patch, Mock
from myapp.user_service import UserService


@pytest.fixture
def user_service():
    return UserService("https://api.example.com")


@patch("myapp.user_service.requests.get")
def test_get_user_success(mock_get, user_service):
    # Arrange
    mock_response = Mock()
    mock_response.status_code = 200
    mock_response.json.return_value = {
        "id": 1,
        "username": "alice",
        "email": "alice@example.com"
    }
    mock_response.raise_for_status.return_value = None
    mock_get.return_value = mock_response

    # Act
    user = user_service.get_user(1)

    # Assert
    assert user["username"] == "alice"
    assert user["email"] == "alice@example.com"
    mock_get.assert_called_once_with("https://api.example.com/users/1")


@patch("myapp.user_service.requests.post")
def test_create_user_success(mock_post, user_service):
    # Arrange
    mock_response = Mock()
    mock_response.status_code = 201
    mock_response.json.return_value = {
        "id": 10,
        "username": "newuser",
        "email": "newuser@example.com"
    }
    mock_response.raise_for_status.return_value = None
    mock_post.return_value = mock_response

    # Act
    user = user_service.create_user("newuser", "newuser@example.com")

    # Assert
    assert user["id"] == 10
    mock_post.assert_called_once_with(
        "https://api.example.com/users",
        json={"username": "newuser", "email": "newuser@example.com"}
    )

Using pytest-mock for Cleaner Syntax

The pytest-mock plugin provides a mocker fixture that simplifies mocking:

pip install pytest-mock
# tests/unit/test_user_service_with_mocker.py

import pytest
from myapp.user_service import UserService


@pytest.fixture
def user_service():
    return UserService("https://api.example.com")


def test_get_user_with_mocker(mocker, user_service):
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"id": 1, "username": "alice"}
    mock_response.raise_for_status.return_value = None
    mocker.patch("myapp.user_service.requests.get", return_value=mock_response)

    user = user_service.get_user(1)

    assert user["username"] == "alice"

Mocking Database Calls

# src/myapp/user_repository.py

class UserRepository:
    def __init__(self, db):
        self.db = db

    def find_by_id(self, user_id):
        return self.db.query("SELECT * FROM users WHERE id = ?", user_id)

    def save(self, user):
        return self.db.execute(
            "INSERT INTO users (username, email) VALUES (?, ?)",
            user["username"], user["email"]
        )
# tests/unit/test_user_repository.py

import pytest
from unittest.mock import Mock
from myapp.user_repository import UserRepository


def test_find_by_id_returns_user():
    mock_db = Mock()
    mock_db.query.return_value = {"id": 1, "username": "alice"}
    repo = UserRepository(mock_db)

    user = repo.find_by_id(1)

    assert user["username"] == "alice"
    mock_db.query.assert_called_once_with(
        "SELECT * FROM users WHERE id = ?", 1
    )


def test_save_calls_execute_with_correct_params():
    mock_db = Mock()
    mock_db.execute.return_value = 1
    repo = UserRepository(mock_db)

    result = repo.save({"username": "bob", "email": "bob@example.com"})

    assert result == 1
    mock_db.execute.assert_called_once_with(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "bob", "bob@example.com"
    )

Testing Exceptions

Pytest makes it straightforward to test that your code raises the expected exceptions:

import pytest
from myapp.calculator import Calculator


def test_divide_by_zero():
    calc = Calculator()
    with pytest.raises(ValueError) as exc_info:
        calc.divide(10, 0)
    assert str(exc_info.value) == "Cannot divide by zero"


def test_divide_by_zero_with_match():
    calc = Calculator()
    with pytest.raises(ValueError, match="Cannot divide by zero"):
        calc.divide(10, 0)


@pytest.mark.parametrize("a, b", [
    (10, 0),
    (0, 0),
    (-5, 0),
])
def test_divide_by_zero_parametrized(a, b):
    calc = Calculator()
    with pytest.raises(ValueError):
        calc.divide(a, b)

Writing Integration Tests

Integration tests verify that multiple components work together. These tests may interact with real databases, file systems, or external services. The key is to use realistic but isolated environments.

Testing with a Real Database

Let's create a database module and write integration tests for it:

# src/myapp/database.py

import sqlite3


class Database:
    def __init__(self, connection_string):
        self.connection_string = connection_string
        self.conn = None

    def connect(self):
        self.conn = sqlite3.connect(self.connection_string)
        self.conn.row_factory = sqlite3.Row
        return self.conn

    def disconnect(self):
        if self.conn:
            self.conn.close()
            self.conn = None

    def execute(self, query, *params):
        cursor = self.conn.cursor()
        cursor.execute(query, params)
        self.conn.commit()
        return cursor.lastrowid

    def query(self, query, *params):
        cursor = self.conn.cursor()
        cursor.execute(query, params)
        return cursor.fetchone()

    def query_all(self, query, *params):
        cursor = self.conn.cursor()
        cursor.execute(query, params)
        return cursor.fetchall()

    def create_tables(self):
        self.execute("""
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                username TEXT UNIQUE NOT NULL,
                email TEXT UNIQUE NOT NULL,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)

Now write integration tests that use a real in-memory SQLite database:

# tests/integration/test_database.py

import pytest
from myapp.database import Database


@pytest.fixture
def db():
    """Create a fresh in-memory database for each test."""
    database = Database(":memory:")
    database.connect()
    database.create_tables()
    yield database
    database.disconnect()


def test_create_and_retrieve_user(db):
    user_id = db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "alice", "alice@example.com"
    )

    user = db.query("SELECT * FROM users WHERE id = ?", user_id)

    assert user is not None
    assert user["username"] == "alice"
    assert user["email"] == "alice@example.com"


def test_unique_username_constraint(db):
    db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "alice", "alice@example.com"
    )

    with pytest.raises(Exception):
        db.execute(
            "INSERT INTO users (username, email) VALUES (?, ?)",
            "alice", "alice2@example.com"
        )


def test_retrieve_all_users(db):
    db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "alice", "alice@example.com"
    )
    db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "bob", "bob@example.com"
    )

    users = db.query_all("SELECT * FROM users ORDER BY username")

    assert len(users) == 2
    assert users[0]["username"] == "alice"
    assert users[1]["username"] == "bob"


def test_update_user(db):
    user_id = db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "alice", "alice@example.com"
    )

    db.execute(
        "UPDATE users SET email = ? WHERE id = ?",
        "newalice@example.com", user_id
    )

    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    assert user["email"] == "newalice@example.com"


def test_delete_user(db):
    user_id = db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "alice", "alice@example.com"
    )

    db.execute("DELETE FROM users WHERE id = ?", user_id)

    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    assert user is None

Integration Testing a Full Workflow

Now let's test a complete workflow that combines the user service with the database:

# src/myapp/user_workflow.py

from myapp.database import Database


class UserWorkflow:
    def __init__(self, db):
        self.db = db

    def register_user(self, username, email):
        existing = self.db.query(
            "SELECT * FROM users WHERE username = ? OR email = ?",
            username, email
        )
        if existing:
            raise ValueError("User with this username or email already exists")

        user_id = self.db.execute(
            "INSERT INTO users (username, email) VALUES (?, ?)",
            username, email
        )
        return self.db.query("SELECT * FROM users WHERE id = ?", user_id)

    def get_user_profile(self, user_id):
        user = self.db.query("SELECT * FROM users WHERE id = ?", user_id)
        if not user:
            raise ValueError(f"User with id {user_id} not found")
        return user

    def update_email(self, user_id, new_email):
        user = self.db.query("SELECT * FROM users WHERE id = ?", user_id)
        if not user:
            raise ValueError(f"User with id {user_id} not found")

        self.db.execute(
            "UPDATE users SET email = ? WHERE id = ?",
            new_email, user_id
        )
        return self.db.query("SELECT * FROM users WHERE id = ?", user_id)
# tests/integration/test_user_workflow.py

import pytest
from myapp.database import Database
from myapp.user_workflow import UserWorkflow


@pytest.fixture
def workflow():
    db = Database(":memory:")
    db.connect()
    db.create_tables()
    wf = UserWorkflow(db)
    yield wf
    db.disconnect()


def test_register_new_user(workflow):
    user = workflow.register_user("alice", "alice@example.com")

    assert user["username"] == "alice"
    assert user["email"] == "alice@example.com"
    assert user["id"] is not None


def test_register_duplicate_username_raises_error(workflow):
    workflow.register_user("alice", "alice@example.com")

    with pytest.raises(ValueError, match="already exists"):
        workflow.register_user("alice", "different@example.com")


def test_register_duplicate_email_raises_error(workflow):
    workflow.register_user("alice", "alice@example.com")

    with pytest.raises(ValueError, match="already exists"):
        workflow.register_user("bob", "alice@example.com")


def test_get_user_profile(workflow):
    registered = workflow.register_user("alice", "alice@example.com")
    profile = workflow.get_user_profile(registered["id"])

    assert profile["username"] == "alice"
    assert profile["email"] == "alice@example.com"


def test_get_nonexistent_user_raises_error(workflow):
    with pytest.raises(ValueError, match="not found"):
        workflow.get_user_profile(999)


def test_update_email(workflow):
    registered = workflow.register_user("alice", "alice@example.com")
    updated = workflow.update_email(registered["id"], "newalice@example.com")

    assert updated["email"] == "newalice@example.com"
    assert updated["username"] == "alice"


def test_full_user_lifecycle(workflow):
    # Register
    user = workflow.register_user("alice", "alice@example.com")
    assert user["username"] == "alice"

    # Retrieve
    profile = workflow.get_user_profile(user["id"])
    assert profile["email"] == "alice@example.com"

    # Update
    updated = workflow.update_email(user["id"], "newalice@example.com")
    assert updated["email"] == "newalice@example.com"

    # Verify update persisted
    final_profile = workflow.get_user_profile(user["id"])
    assert final_profile["email"] == "newalice@example.com"

Testing Flask Applications

If you're building web applications, you'll want to test your HTTP endpoints. Here's how to integration test a Flask app with pytest:

# src/myapp/app.py

from flask import Flask, request, jsonify
from myapp.database import Database

app = Flask(__name__)
db = Database(":memory:")
db.connect()
db.create_tables()


@app.route("/users", methods=["POST"])
def create_user():
    data = request.get_json()
    if not data or "username" not in data or "email" not in data:
        return jsonify({"error": "username and email are required"}), 400

    try:
        user_id = db.execute(
            "INSERT INTO users (username, email) VALUES (?, ?)",
            data["username"], data["email"]
        )
        user = db.query("SELECT * FROM users WHERE id = ?", user_id)
        return jsonify(dict(user)), 201
    except Exception:
        return jsonify({"error": "User already exists"}), 409


@app.route("/users/", methods=["GET"])
def get_user(user_id):
    user = db.query("SELECT * FROM users WHERE id = ?", user_id)
    if not user:
        return jsonify({"error": "User not found"}), 404
    return jsonify(dict(user)), 200


@app.route("/users", methods=["GET"])
def list_users():
    users = db.query_all("SELECT * FROM users ORDER BY id")
    return jsonify([dict(u) for u in users]), 200


@app.route("/health", methods=["GET"])
def health_check():
    return jsonify({"status": "healthy"}), 200
# tests/integration/test_flask_app.py

import pytest
from myapp.app import app


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


def test_health_check(client):
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json == {"status": "healthy"}


def test_create_user_success(client):
    response = client.post("/users", json={
        "username": "alice",
        "email": "alice@example.com"
    })
    assert response.status_code == 201
    assert response.json["username"] == "alice"
    assert response.json["email"] == "alice@example.com"
    assert "id" in response.json


def test_create_user_missing_fields(client):
    response = client.post("/users", json={"username": "alice"})
    assert response.status_code == 400
    assert "error" in response.json


def test_create_user_no_body(client):
    response = client.post("/users")
    assert response.status_code == 400


def test_get_user_success(client):
    # First create a user
    create_response = client.post("/users", json={
        "username": "bob",
        "email": "bob@example.com"
    })
    user_id = create_response.json["id"]

    # Then retrieve it
    response = client.get(f"/users/{user_id}")
    assert response.status_code == 200
    assert response.json["username"] == "bob"


def test_get_nonexistent_user(client):
    response = client.get("/users/99999")
    assert response.status_code == 404
    assert response.json["error"] == "User not found"


def test_list_users(client):
    client.post("/users", json={
        "username": "alice",
        "email": "alice@example.com"
    })
    client.post("/users", json={
        "username": "bob",
        "email": "bob@example.com"
    })

    response = client.get("/users")
    assert response.status_code == 200
    assert len(response.json) >= 2

Using Markers to Organize Tests

Markers let you categorize tests and run specific subsets. This is especially useful for separating slow integration tests from fast unit tests.

# tests/unit/test_calculator.py

import pytest
from myapp.calculator import Calculator


@pytest.mark.slow
def test_large_calculation():
    calc = Calculator()
    result = calc.multiply(1000000, 1000000)
    assert result == 1000000000000


@pytest.mark.integration
def test_database_calculation(db_connection):
    # This test uses a database fixture
    pass


# Run only fast tests
# pytest -m "not slow"

# Run only integration tests
# pytest -m integration

# Run everything except integration tests
# pytest -m "not integration"

Register custom markers in your pytest.ini to avoid warnings:

[pytest]
markers =
    slow: marks tests as slow (deselect with '-m "not slow"')
    integration: marks tests as integration tests
    unit: marks tests as unit tests
    smoke: marks critical smoke tests

Measuring Test Coverage

Coverage tells you which lines of code are executed during your tests. While 100% coverage doesn't guarantee bug-free code, it helps identify untested paths.

# Install pytest-cov
pip install pytest-cov

# Run tests with coverage
pytest --cov=myapp --cov-report=term-missing tests/

# Generate HTML coverage report
pytest --cov=myapp --cov-report=html tests/

# Fail if coverage drops below a threshold
pytest --cov=myapp --cov-fail-under=80 tests/

Add coverage configuration to your pytest.ini or .coveragerc:

# .coveragerc
[run]
source = myapp
omit =
    */tests/*
    */__init__.py
    */migrations/*

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise NotImplementedError
    if __name__ == .__main__.:
    if TYPE_CHECKING:

Best Practices

Follow the AAA Pattern

Structure your tests using Arrange, Act, Assert. This makes tests readable and consistent:

def test_user_registration():
    # Arrange
    service = UserService("https://api.example.com")
    username = "newuser"
    email = "newuser@example.com"

    # Act
    result = service.register(username, email)

    # Assert
    assert result.username == username
    assert result.email == email

One Concept Per Test

Each test should verify one specific behavior. Avoid testing multiple unrelated things in a single test function. This makes failures easier to diagnose.

Use Descriptive Test Names

Test names should describe what is being tested and the expected outcome:

# Bad
def test_user():
    ...

# Good
def test_register_user_with_valid_data_returns_user_object():
    ...

def test_register_user_with_duplicate_email_raises_value_error():
    ...

def test_get_user_when_user_does_not_exist_returns_none():
    ...

Keep Tests Independent

Tests should not depend on each other or on execution order. Each test should set up its own state and clean up after itself. Use fixtures with proper teardown to achieve this.

Test Behavior, Not Implementation

Focus on what the code does, not how it does it. Tests that are tightly coupled to implementation details break easily when you refactor, even if the behavior hasn't changed.

Use conftest.py for Shared Fixtures

Place fixtures that are shared across multiple test files in conftest.py. Pytest automatically discovers fixtures in this file without needing explicit imports.

# tests/conftest.py

import pytest
from myapp.database import Database


@pytest.fixture
def db():
    database = Database(":memory:")
    database.connect()
    database.create_tables()
    yield database
    database.disconnect()


@pytest.fixture
def sample_user(db):
    user_id = db.execute(
        "INSERT INTO users (username, email) VALUES (?, ?)",
        "testuser", "testuser@example.com"
    )
    return db.query("SELECT * FROM users WHERE id = ?", user_id)

Avoid Sleep in Tests

Using time.sleep() makes tests slow and flaky. Instead, use events, callbacks, or polling with timeouts:

# Bad
def test_async_operation():
    start_async_operation()
    time.sleep(5)
    assert operation_completed()

# Good
import pytest

@pytest.mark.timeout(10)
def test_async_operation():
    start_async_operation()
    wait_for_completion()
    assert operation_completed()

Use Factory Fixtures for Complex Objects

When tests need to create multiple objects with variations, use factory fixtures:

@pytest.fixture
def make_user(db):
    """Factory fixture for creating users with custom attributes."""
    created_ids = []

    def _make_user(username="testuser", email="test@example.com"):
        user_id = db.execute(
            "INSERT INTO users (username, email) VALUES (?, ?)",
            username, email
        )
        created_ids.append(user_id)
        return db.query("SELECT * FROM users WHERE id = ?", user_id)

    yield _make_user

    # Cleanup
    for uid in created_ids:
        db.execute("DELETE FROM users WHERE id = ?", uid)


def test_multiple_users(make_user):
    alice = make_user(username="alice", email="alice@example.com")
    bob = make_user(username="bob", email="bob@example.com")

    assert alice["username"] == "alice"
    assert bob["username"] == "bob"

Running Tests Efficiently

As your test suite grows, execution time becomes important. Here are strategies to keep tests fast:

# Run tests in parallel with pytest-xdist
pip install pytest-xdist
pytest -n auto  # Uses all CPU cores

# Run only tests that failed last time
pytest --lf

# Run only tests that changed since last commit
pytest --sw

# Stop at first failure
pytest -x

# Drop into debugger on failure
pytest --pdb

# Show local variables in tracebacks
pytest -l

# Run tests matching a keyword expression
pytest -k "test_user and not integration"

# Show the slowest tests
pytest --durations=10

Continuous Integration Example

Here's a GitHub Actions workflow that runs your pytest suite on every push:

# .github/workflows/tests.yml

name: Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

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

    steps:
    - uses: actions/checkout@v4

    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
        pip install pytest pytest-cov pytest-xdist

    - name: Run unit tests
      run: |
        pytest tests/unit/ -v --cov=myapp --cov-report=xml

    - name: Run integration tests
      run: |
        pytest tests/integration/ -v -m integration

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      if: matrix.python-version == '3.11'

Conclusion

Testing is a fundamental skill for any serious Python developer, and pytest provides an excellent framework for building comprehensive test suites. By starting with simple unit tests that verify individual functions in isolation, you establish a fast feedback loop that catches bugs early. As your application grows, integration tests ensure that components work together correctly, catching issues like database schema mismatches and API contract violations that unit tests alone would miss. The key to a maintainable test suite is following consistent patterns: use the AAA structure for readability, leverage fixtures for setup and teardown, parametrize tests to reduce duplication, and mock external dependencies to keep unit tests fast and deterministic. Remember that test coverage is a metric, not a goalβ€”what matters is that your tests give you confidence to refactor and ship code without fear. By investing in a well-organized test suite with clear separation between unit and integration tests, you build a safety net that pays dividends throughout the lifetime of your project.

β€” Ad β€”

Google AdSense will appear here after approval

← Back to all articles