Test Workflow
Run Python tests with pytest, coverage, and various testing patterns.
When to Use
- “run python tests”
- “pytest”
- “test coverage”
- “run specific test”
- “test with fixtures”
Quick Commands
Basic Testing
# Run all tests
uv run pytest
# Verbose output
uv run pytest -v
# Very verbose (show test names as they run)
uv run pytest -vv
# Run specific test file
uv run pytest tests/test_auth.py
# Run specific test function
uv run pytest tests/test_auth.py::test_login
# Run specific test class
uv run pytest tests/test_auth.py::TestAuth
# Run specific test method
uv run pytest tests/test_auth.py::TestAuth::test_login
# Run tests matching pattern
uv run pytest -k "auth"
uv run pytest -k "test_login or test_logout"
Test Coverage
# Run tests with coverage
uv run pytest --cov
# Coverage for specific module
uv run pytest --cov=src/myproject
# Show missing lines
uv run pytest --cov --cov-report=term-missing
# Generate HTML coverage report
uv run pytest --cov --cov-report=html
# Open htmlcov/index.html in browser
# Generate XML coverage (for CI)
uv run pytest --cov --cov-report=xml
# Fail if coverage below threshold
uv run pytest --cov --cov-fail-under=80
Test Output Control
# Show print statements
uv run pytest -s
# Stop at first failure
uv run pytest -x
# Stop after N failures
uv run pytest --maxfail=3
# Show local variables in traceback
uv run pytest -l
# Show summary of all test outcomes
uv run pytest -ra
Test Selection
# Run only failed tests from last run
uv run pytest --lf
# Run failed tests first, then others
uv run pytest --ff
# Run tests in random order (requires pytest-random-order)
uv run pytest --random-order
Test Structure
Basic Test File
# tests/test_calculator.py
"""Tests for calculator module."""
import pytest
from myproject.calculator import Calculator
class TestCalculator:
"""Test suite for Calculator class."""
def test_add(self) -> None:
"""Test addition operation."""
calc = Calculator()
result = calc.add(2, 3)
assert result == 5
def test_subtract(self) -> None:
"""Test subtraction operation."""
calc = Calculator()
result = calc.subtract(5, 3)
assert result == 2
def test_multiply(self) -> None:
"""Test multiplication operation."""
calc = Calculator()
result = calc.multiply(2, 3)
assert result == 6
def test_divide(self) -> None:
"""Test division operation."""
calc = Calculator()
result = calc.divide(6, 3)
assert result == 2.0
def test_divide_by_zero(self) -> None:
"""Test division by zero raises error."""
calc = Calculator()
with pytest.raises(ValueError, match="Cannot divide by zero"):
calc.divide(10, 0)
Using Fixtures
# tests/test_database.py
"""Tests for database operations."""
import pytest
from myproject.database import Database
@pytest.fixture
def db() -> Database:
"""Provide a test database instance."""
database = Database(":memory:") # SQLite in-memory
database.create_tables()
yield database
database.close()
@pytest.fixture
def sample_data() -> dict[str, str]:
"""Provide sample test data."""
return {
"name": "John Doe",
"email": "john@example.com",
}
def test_insert_user(db: Database, sample_data: dict[str, str]) -> None:
"""Test inserting a user into database."""
user_id = db.insert_user(sample_data)
assert user_id > 0
def test_get_user(db: Database, sample_data: dict[str, str]) -> None:
"""Test retrieving a user from database."""
user_id = db.insert_user(sample_data)
user = db.get_user(user_id)
assert user["name"] == sample_data["name"]
assert user["email"] == sample_data["email"]
Parametrized Tests
# tests/test_validation.py
"""Tests for validation functions."""
import pytest
from myproject.validation import validate_email, validate_phone
@pytest.mark.parametrize("email,expected", [
("user@example.com", True),
("user.name@example.co.uk", True),
("user+tag@example.com", True),
("invalid.email", False),
("@example.com", False),
("user@", False),
("", False),
])
def test_validate_email(email: str, expected: bool) -> None:
"""Test email validation with various inputs."""
assert validate_email(email) == expected
@pytest.mark.parametrize("phone,expected", [
("+1-555-123-4567", True),
("555-123-4567", True),
("5551234567", True),
("invalid", False),
("", False),
])
def test_validate_phone(phone: str, expected: bool) -> None:
"""Test phone validation with various inputs."""
assert validate_phone(phone) == expected
# Test with multiple parameters
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(10, -5, 5),
(100, 200, 300),
])
def test_addition(a: int, b: int, expected: int) -> None:
"""Test addition with multiple input combinations."""
assert a + b == expected
Fixture Scopes
# tests/conftest.py
"""Shared fixtures for all tests."""
import pytest
from myproject.app import create_app
from myproject.database import Database
@pytest.fixture(scope="session")
def app():
"""Provide application instance for entire test session."""
app = create_app({"TESTING": True})
yield app
@pytest.fixture(scope="module")
def db():
"""Provide database for all tests in a module."""
database = Database(":memory:")
database.create_tables()
yield database
database.close()
@pytest.fixture(scope="function")
def clean_db(db: Database):
"""Provide clean database for each test function."""
yield db
db.clear_all_tables() # Clean up after each test
@pytest.fixture
def client(app):
"""Provide test client for making requests."""
return app.test_client()
Advanced Testing Patterns
Testing Exceptions
def test_exception_raised() -> None:
"""Test that exception is raised."""
with pytest.raises(ValueError):
my_function(-1)
def test_exception_with_message() -> None:
"""Test exception message matches pattern."""
with pytest.raises(ValueError, match="must be positive"):
my_function(-1)
def test_exception_attributes() -> None:
"""Test exception has expected attributes."""
with pytest.raises(ValueError) as exc_info:
my_function(-1)
assert exc_info.value.args[0] == "Value must be positive"
assert exc_info.type is ValueError
Testing Async Code
import pytest
@pytest.mark.asyncio
async def test_async_function() -> None:
"""Test async function."""
result = await fetch_data()
assert result is not None
@pytest.mark.asyncio
async def test_async_with_fixture(async_client) -> None:
"""Test async code with fixture."""
response = await async_client.get("/api/data")
assert response.status_code == 200
Mocking and Patching
from unittest.mock import Mock, patch, MagicMock
import pytest
def test_with_mock() -> None:
"""Test using mock objects."""
# Create a mock
mock_db = Mock()
mock_db.get_user.return_value = {"id": 1, "name": "John"}
# Use the mock
user = mock_db.get_user(1)
assert user["name"] == "John"
# Verify mock was called
mock_db.get_user.assert_called_once_with(1)
@patch("myproject.api.requests.get")
def test_with_patch(mock_get) -> None:
"""Test using patch decorator."""
# Configure mock return value
mock_response = Mock()
mock_response.json.return_value = {"status": "ok"}
mock_response.status_code = 200
mock_get.return_value = mock_response
# Call function that uses requests.get
result = fetch_api_data()
# Verify
assert result["status"] == "ok"
mock_get.assert_called_once()
def test_with_pytest_mock(mocker) -> None:
"""Test using pytest-mock plugin."""
# Mock a method
mock = mocker.patch("myproject.api.requests.get")
mock.return_value.json.return_value = {"data": "test"}
result = fetch_api_data()
assert result["data"] == "test"
Testing with Temporary Files
import pytest
from pathlib import Path
def test_with_tmp_path(tmp_path: Path) -> None:
"""Test using temporary directory.
tmp_path is a pytest fixture that provides a temporary directory
unique to the test invocation.
"""
# Create a file in temp directory
test_file = tmp_path / "test.txt"
test_file.write_text("test content")
# Use the file
result = process_file(test_file)
assert result is not None
assert test_file.exists()
def test_with_tmp_path_factory(tmp_path_factory) -> None:
"""Test using temporary directory factory.
Useful for session-scoped fixtures.
"""
temp_dir = tmp_path_factory.mktemp("data")
test_file = temp_dir / "test.txt"
test_file.write_text("content")
assert test_file.read_text() == "content"
Test Configuration
pyproject.toml
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
# Add options
addopts = [
"--strict-markers", # Fail on unknown markers
"--strict-config", # Fail on config errors
"--verbose", # Verbose output
"--cov=src", # Coverage for src directory
"--cov-report=term-missing", # Show missing lines
"--cov-report=html", # Generate HTML report
"--cov-fail-under=80", # Fail if coverage < 80%
]
# Markers for categorizing tests
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
"unit: marks tests as unit tests",
]
# Filter warnings
filterwarnings = [
"error", # Treat warnings as errors
"ignore::DeprecationWarning", # Ignore deprecation warnings
]
Coverage Configuration
[tool.coverage.run]
source = ["src"]
omit = [
"tests/*",
"**/__pycache__/*",
"**/site-packages/*",
]
branch = true # Measure branch coverage
[tool.coverage.report]
precision = 2
show_missing = true
skip_covered = false
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"@abstractmethod",
]
[tool.coverage.html]
directory = "htmlcov"
Test Markers
Using Markers
import pytest
@pytest.mark.slow
def test_slow_operation() -> None:
"""Test that takes a long time to run."""
# ... slow test ...
@pytest.mark.integration
def test_api_integration() -> None:
"""Integration test with external API."""
# ... integration test ...
@pytest.mark.unit
def test_unit_calculation() -> None:
"""Fast unit test."""
# ... unit test ...
@pytest.mark.skip(reason="Not implemented yet")
def test_future_feature() -> None:
"""Test for future feature."""
@pytest.mark.skipif(sys.platform == "win32", reason="Unix only")
def test_unix_specific() -> None:
"""Test that only runs on Unix."""
@pytest.mark.xfail(reason="Known bug #123")
def test_known_failure() -> None:
"""Test expected to fail due to known bug."""
Running Tests by Marker
# Run only unit tests
uv run pytest -m unit
# Run everything except slow tests
uv run pytest -m "not slow"
# Run integration tests only
uv run pytest -m integration
# Combine markers
uv run pytest -m "unit and not slow"
Continuous Testing
Watch Mode (with pytest-watch)
# Install pytest-watch
uv add --dev pytest-watch
# Watch for changes and re-run tests
uv run ptw
# Watch specific directory
uv run ptw tests/
# Watch with coverage
uv run ptw -- --cov
Pre-commit Hook
# .pre-commit-config.yaml
repos:
- repo: local
hooks:
- id: pytest
name: pytest
entry: uv run pytest
language: system
pass_filenames: false
always_run: true
Common Test Commands
# Run tests with coverage and generate HTML report
uv run pytest --cov --cov-report=html
# Run only tests that failed last time
uv run pytest --lf
# Run tests in parallel (requires pytest-xdist)
uv add --dev pytest-xdist
uv run pytest -n auto
# Generate JUnit XML report (for CI)
uv run pytest --junit-xml=report.xml
# Profile slow tests (requires pytest-profiling)
uv add --dev pytest-profiling
uv run pytest --profile
# Debug tests with pdb
uv run pytest --pdb # Drop into debugger on failure
uv run pytest -x --pdb # Stop at first failure and debug
Best Practices
- Organize tests by module - Mirror your src structure in tests
- Use descriptive names - Test names should describe what they test
- One assertion per test - Keep tests focused (when practical)
- Use fixtures - Share setup code across tests
- Parametrize - Test multiple inputs with one test function
- Test edge cases - Empty inputs, None, negative numbers, etc.
- Mock external dependencies - Don’t hit real APIs or databases
- Aim for >80% coverage - But 100% coverage doesn’t mean bug-free
- Keep tests fast - Mark slow tests and run them separately
- Use markers - Categorize tests for selective running