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
- Unit tests verify individual components in isolation. They are fast, focused, and should make up the majority of your test suite.
- Integration tests verify that multiple components work together correctly. They are slower but catch issues that unit tests miss, such as database schema mismatches or API contract violations.
- End-to-end tests verify entire user flows through the application. They are the slowest and most brittle, so they should be used sparingly.
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:
- Test files must be named
test_*.pyor*_test.py - Test functions must be prefixed with
test_ - Test classes must be prefixed with
Testand should not have an__init__method
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:
function(default): The fixture is created once per test function.class: The fixture is created once per test class.module: The fixture is created once per test module.session: The fixture is created once for the entire test session.
@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.