Python Testing Excellence
When to Use This Skill
Read this when writing or reviewing tests (not implementation or async code). This covers:
- Unit tests, integration tests, test organization
- Validating both valid AND invalid inputs
- pytest patterns and fixtures
- Property-based testing with Hypothesis
- Avoiding false-positive tests
- Real code over mocks
Do NOT read this for:
- Implementation → See implementation
- Async code → See async
Docker/Docker-Compose for Real Infrastructure Testing 🐳
CRITICAL PRINCIPLE: Always prefer Docker/docker-compose/podman to spawn real infrastructure for tests.
The Infrastructure Testing Hierarchy
1. Docker/docker-compose (FIRST - spawn real infrastructure locally)
↓ Not possible locally?
2. Test instance credentials (SECOND - use provided test environment)
↓ No test environment available?
3. Mock (LAST RESORT - only when infrastructure cannot run locally)
When to Use Docker for Tests
✅ USE Docker/docker-compose for:
- PostgreSQL, MySQL, MongoDB, Redis (databases)
- RabbitMQ, Kafka (message queues)
- Elasticsearch, S3-compatible storage (MinIO)
- Any service with official Docker image
❌ DON'T USE Docker when:
- Service is proprietary SaaS without local version (Snowflake, Salesforce)
- Service requires special hardware/licenses
- ACTION: Ask dev team for test instance credentials first!
Docker-Compose for Test Infrastructure
Example: PostgreSQL + Redis
# docker-compose.test.yml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
mongodb:
image: mongo:7
environment:
MONGO_INITDB_ROOT_USERNAME: test
MONGO_INITDB_ROOT_PASSWORD: test
ports:
- "27017:27017"
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 5s
timeout: 5s
retries: 5
Running Tests with Docker-Compose
# Start infrastructure
docker-compose -f docker-compose.test.yml up -d
# Wait for health checks
docker-compose -f docker-compose.test.yml ps
# Run tests
pytest
# Cleanup
docker-compose -f docker-compose.test.yml down -v
Automated Test Script
#!/bin/bash
# scripts/run-tests.sh
set -e
echo "Starting test infrastructure..."
docker-compose -f docker-compose.test.yml up -d
echo "Waiting for services to be healthy..."
timeout 30 bash -c 'until docker-compose -f docker-compose.test.yml ps | grep -q "(healthy)"; do sleep 1; done'
echo "Running tests..."
pytest "$@"
echo "Cleaning up..."
docker-compose -f docker-compose.test.yml down -v
Testcontainers (Alternative to docker-compose)
testcontainers-python: Programmatic Docker container management for tests
pip install testcontainers
PostgreSQL with Testcontainers
from testcontainers.postgres import PostgresContainer
import psycopg2
import pytest
@pytest.fixture(scope="session")
def postgres():
"""Start PostgreSQL container for test session."""
with PostgresContainer("postgres:15-alpine") as postgres:
yield postgres
@pytest.fixture
def db_connection(postgres):
"""Create database connection."""
conn = psycopg2.connect(postgres.get_connection_url())
yield conn
conn.close()
def test_user_repository(db_connection):
"""Test with real PostgreSQL container."""
cursor = db_connection.cursor()
cursor.execute("CREATE TABLE users (id serial, name varchar);")
cursor.execute("INSERT INTO users (name) VALUES ('Alice');")
cursor.execute("SELECT name FROM users;")
result = cursor.fetchone()
assert result[0] == "Alice"
MongoDB with Testcontainers
from testcontainers.mongodb import MongoDbContainer
from pymongo import MongoClient
import pytest
@pytest.fixture(scope="session")
def mongodb():
"""Start MongoDB container for test session."""
with MongoDbContainer("mongo:7") as mongodb:
yield mongodb
@pytest.fixture
def mongo_client(mongodb):
"""Create MongoDB client."""
client = MongoClient(mongodb.get_connection_url())
yield client
client.close()
def test_user_collection(mongo_client):
"""Test with real MongoDB container."""
db = mongo_client.test_db
users = db.users
users.insert_one({"name": "Alice", "age": 30})
user = users.find_one({"name": "Alice"})
assert user["age"] == 30
Redis with Testcontainers
from testcontainers.redis import RedisContainer
import redis
import pytest
@pytest.fixture(scope="session")
def redis_container():
"""Start Redis container for test session."""
with RedisContainer("redis:7-alpine") as redis_container:
yield redis_container
@pytest.fixture
def redis_client(redis_container):
"""Create Redis client."""
client = redis.from_url(redis_container.get_connection_url())
yield client
client.close()
def test_cache_operations(redis_client):
"""Test with real Redis container."""
redis_client.set("key", "value")
result = redis_client.get("key")
assert result == b"value"
Conftest.py for Shared Docker Fixtures
# tests/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
import psycopg2
import redis
@pytest.fixture(scope="session")
def postgres_container():
"""Shared PostgreSQL container for all tests."""
with PostgresContainer("postgres:15-alpine") as container:
yield container
@pytest.fixture(scope="session")
def redis_container():
"""Shared Redis container for all tests."""
with RedisContainer("redis:7-alpine") as container:
yield container
@pytest.fixture
def db_connection(postgres_container):
"""Fresh database connection per test."""
conn = psycopg2.connect(postgres_container.get_connection_url())
# Run migrations
cursor = conn.cursor()
cursor.execute("CREATE TABLE IF NOT EXISTS users (id serial, name varchar);")
conn.commit()
yield conn
# Cleanup
cursor.execute("DROP TABLE IF EXISTS users;")
conn.commit()
conn.close()
@pytest.fixture
def cache_client(redis_container):
"""Fresh Redis client per test."""
client = redis.from_url(redis_container.get_connection_url())
yield client
client.flushdb() # Clear data between tests
client.close()
Decision Tree for Database Testing
Need to test database code?
├─ Can database run in Docker? (PostgreSQL, MySQL, MongoDB, etc.)
│ ├─ YES → Use docker-compose.test.yml or testcontainers ✅ BEST
│ └─ NO → Continue to next step
├─ Is there a test instance available? (Snowflake test account, etc.)
│ ├─ YES → Ask dev team for credentials, use test instance ✅ GOOD
│ └─ NO → Continue to next step
├─ Can we use SQLite as substitute? (For SQL databases only)
│ ├─ YES → Use in-memory SQLite for fast tests ✅ ACCEPTABLE
│ └─ NO → Continue to next step
└─ Must mock (proprietary SaaS, no local/test options)
└─ Use pytest-mock for external database client only ⚠️ LAST RESORT
Example: Complete Test Setup with Docker
# pyproject.toml
[tool.poetry.group.dev.dependencies]
pytest = "^7.4"
testcontainers = "^3.7"
psycopg2-binary = "^2.9"
redis = "^5.0"
# tests/conftest.py
import pytest
from testcontainers.postgres import PostgresContainer
from testcontainers.redis import RedisContainer
from myapp.database import Database
from myapp.cache import Cache
@pytest.fixture(scope="session")
def postgres():
with PostgresContainer("postgres:15-alpine") as pg:
yield pg
@pytest.fixture(scope="session")
def redis():
with RedisContainer("redis:7-alpine") as r:
yield r
@pytest.fixture
def database(postgres):
"""Database with fresh schema per test."""
db = Database(postgres.get_connection_url())
db.migrate()
yield db
db.cleanup()
@pytest.fixture
def cache(redis):
"""Cache with fresh instance per test."""
cache = Cache(redis.get_connection_url())
yield cache
cache.flush()
# tests/test_user_service.py
import pytest
def test_create_user(database, cache):
"""Test user creation with real database and cache."""
from myapp.services import UserService
service = UserService(database, cache)
user = service.create_user("Alice", "alice@example.com")
# Verify in database
assert database.get_user(user.id) is not None
# Verify in cache
cached_user = cache.get(f"user:{user.id}")
assert cached_user["name"] == "Alice"
def test_user_not_found(database):
"""Test error handling with real database."""
from myapp.services import UserService
from myapp.exceptions import UserNotFoundError
service = UserService(database, None)
with pytest.raises(UserNotFoundError):
service.get_user(99999)
GitHub Actions CI Integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install poetry
poetry install
- name: Run tests
run: poetry run pytest
env:
DATABASE_URL: postgresql://test:test@localhost:5432/testdb
REDIS_URL: redis://localhost:6379
Benefits of Docker-Based Testing
Why this matters:
- Real behavior - Tests validate actual database/service behavior
- Production parity - Same services as production
- Isolation - Each test run gets fresh infrastructure
- CI/CD friendly - Easy to replicate in GitHub Actions/GitLab CI
- No mocks - Test actual integration, not mock configuration
When Mocking is Acceptable
ONLY mock when:
- ✅ Service is proprietary SaaS without Docker image (Snowflake, Salesforce API)
- ✅ Service requires hardware/licensing unavailable in test (special GPU, enterprise license)
- ✅ Service costs money per request (payment gateways in CI - but use test mode if available)
Before mocking, ask:
- "Can I run this in Docker?"
- "Does the dev team have test instance credentials?"
- "Is there a free tier or test mode?"
- "Can I use a compatible open-source alternative?" (MinIO for S3, LocalStack for AWS)
Essential Pytest Plugins
MANDATORY: Use pytest plugins instead of manual mocking. Pytest has a rich ecosystem of plugins for common testing scenarios.
Core Pytest Plugins
pytest-mock (Wrapper for unittest.mock)
Use when: You absolutely must mock (external dependencies only)
pip install pytest-mock
def test_external_api_with_mock(mocker):
"""Use pytest-mock instead of unittest.mock directly."""
# pytest-mock provides 'mocker' fixture
mock_api = mocker.Mock()
mock_api.get_data.return_value = {"status": "ok"}
service = ExternalService(api_client=mock_api)
result = service.fetch_data()
assert result["status"] == "ok"
mock_api.get_data.assert_called_once()
pytest-asyncio (Async Testing)
Use when: Testing async code
pip install pytest-asyncio
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
# Test file
@pytest.mark.asyncio
async def test_async_function():
"""Test async code naturally."""
result = await fetch_data()
assert result is not None
pytest-cov (Coverage Reporting)
Use when: Measuring test coverage
pip install pytest-cov
# Run with coverage
pytest --cov=src --cov-report=html --cov-report=term-missing
# pyproject.toml
[tool.pytest.ini_options]
addopts = [
"--cov=src",
"--cov-report=html",
"--cov-report=term-missing",
"--cov-fail-under=80",
]
HTTP Testing Plugins
pytest-httpserver (Lightweight HTTP Server)
Use when: Testing HTTP clients with simple request/response patterns
pip install pytest-httpserver
from pytest_httpserver import HTTPServer
def test_http_client(httpserver: HTTPServer):
"""Test HTTP client with real local server."""
httpserver.expect_request("/api/users").respond_with_json(
{"users": [{"id": 1, "name": "Alice"}]}
)
client = HTTPClient()
response = client.get(httpserver.url_for("/api/users"))
assert response.json()["users"][0]["name"] == "Alice"
pytest-flask (Flask Test Client)
Use when: Testing Flask applications
pip install pytest-flask
import pytest
from myapp import create_app
@pytest.fixture
def app():
"""Create Flask app for testing."""
app = create_app({"TESTING": True})
yield app
@pytest.fixture
def client(app):
"""Create test client."""
return app.test_client()
def test_api_endpoint(client):
"""Test Flask API endpoint."""
response = client.get("/api/users")
assert response.status_code == 200
pytest-aiohttp (aiohttp Test Client)
Use when: Testing aiohttp applications
pip install pytest-aiohttp
from aiohttp import web
import pytest
async def hello(request):
return web.Response(text="Hello")
@pytest.fixture
def app():
app = web.Application()
app.router.add_get('/', hello)
return app
async def test_hello(aiohttp_client, app):
"""Test aiohttp endpoint."""
client = await aiohttp_client(app)
resp = await client.get('/')
assert resp.status == 200
text = await resp.text()
assert 'Hello' in text
Database Testing Plugins
pytest-postgresql (Real PostgreSQL)
Use when: Testing with real PostgreSQL database
pip install pytest-postgresql
from pytest_postgresql import factories
# Create database fixture
postgresql_proc = factories.postgresql_proc(port=None)
postgresql = factories.postgresql('postgresql_proc')
def test_user_repository(postgresql):
"""Test with real PostgreSQL database."""
cursor = postgresql.cursor()
cursor.execute("CREATE TABLE users (id serial PRIMARY KEY, name varchar);")
cursor.execute("INSERT INTO users (name) VALUES ('Alice');")
cursor.execute("SELECT name FROM users;")
result = cursor.fetchone()
assert result[0] == "Alice"
pytest-mysql (Real MySQL)
Use when: Testing with real MySQL database
pip install pytest-mysql
from pytest_mysql import factories
mysql_proc = factories.mysql_proc(port=None)
mysql = factories.mysql('mysql_proc')
def test_with_mysql(mysql):
"""Test with real MySQL database."""
cursor = mysql.cursor()
cursor.execute("CREATE TABLE users (id INT, name VARCHAR(50));")
cursor.execute("INSERT INTO users VALUES (1, 'Alice');")
cursor.execute("SELECT name FROM users WHERE id = 1;")
result = cursor.fetchone()
assert result[0] == "Alice"
pytest-mongodb (Real MongoDB)
Use when: Testing with real MongoDB
pip install pytest-mongodb
from pytest_mongodb import factories
mongodb_proc = factories.mongodb_proc(port=None)
mongodb = factories.mongodb('mongodb_proc')
def test_with_mongodb(mongodb):
"""Test with real MongoDB."""
db = mongodb.test_db
collection = db.users
collection.insert_one({"name": "Alice", "age": 30})
user = collection.find_one({"name": "Alice"})
assert user["age"] == 30
File and System Testing Plugins
pytest-tmpdir (Temporary Directories)
Built-in: No installation needed
def test_file_operations(tmp_path):
"""Test with real temporary directory."""
# tmp_path is a pathlib.Path object
test_file = tmp_path / "test.txt"
test_file.write_text("Hello, World!")
content = test_file.read_text()
assert content == "Hello, World!"
def test_with_tmpdir(tmpdir):
"""Alternative temporary directory fixture."""
# tmpdir is py.path.local object (legacy)
file_path = tmpdir.join("test.txt")
file_path.write("Hello, World!")
assert file_path.read() == "Hello, World!"
Parametrization and Data Plugins
pytest-parametrize-cases (Organized Test Cases)
Use when: Managing many parametrized test cases
pip install pytest-parametrize-cases
import pytest
from pytest_parametrize_cases import parametrize_cases
@parametrize_cases(
"email, expected_valid",
[
("alice@example.com", True),
("bob@test.co.uk", True),
("invalid-email", False),
("@example.com", False),
],
ids=["valid_simple", "valid_uk", "no_at_sign", "no_local_part"]
)
def test_email_validation(email, expected_valid):
"""Test email validation with clear case names."""
assert validate_email(email) == expected_valid
pytest-datadir (Test Data Files)
Use when: Tests need data files
pip install pytest-datadir
def test_load_config(datadir):
"""Test loading config from data directory.
Looks for test_module/test_load_config/ directory with test data.
"""
config_file = datadir / "config.json"
config = load_config(config_file)
assert config["setting"] == "value"
Mocking and Fixtures Plugins
pytest-freezegun (Time Mocking)
Use when: Testing time-dependent code
pip install pytest-freezegun
from freezegun import freeze_time
import datetime
@freeze_time("2024-01-01 12:00:00")
def test_time_dependent_function():
"""Test with frozen time."""
now = datetime.datetime.now()
assert now.year == 2024
assert now.month == 1
assert now.day == 1
pytest-env (Environment Variables)
Use when: Testing with environment variables
pip install pytest-env
# pyproject.toml
[tool.pytest_env]
DATABASE_URL = "postgresql://test:test@localhost/testdb"
API_KEY = "test-key"
import os
def test_with_env_vars():
"""Environment variables set automatically."""
assert os.environ["DATABASE_URL"].startswith("postgresql://")
Performance and Benchmarking Plugins
pytest-benchmark (Performance Testing)
Use when: Benchmarking code performance
pip install pytest-benchmark
def test_performance(benchmark):
"""Benchmark function performance."""
result = benchmark(expensive_function, input_data)
assert result is not None
def test_compare_implementations(benchmark):
"""Compare two implementations."""
benchmark.group = "sorting"
benchmark(quicksort, large_list)
Test Organization Plugins
pytest-xdist (Parallel Testing)
Use when: Running tests in parallel
pip install pytest-xdist
# Run tests on 4 CPUs
pytest -n 4
# Run tests with auto-detection
pytest -n auto
pytest-repeat (Repeat Tests)
Use when: Testing for flaky behavior
pip install pytest-repeat
@pytest.mark.repeat(100)
def test_potentially_flaky():
"""Run test 100 times to catch race conditions."""
result = concurrent_operation()
assert result.is_valid()
Recommended Plugin Stack
Minimal Essential Stack:
pip install pytest pytest-asyncio pytest-cov pytest-mock
Web Application Stack:
pip install pytest pytest-asyncio pytest-cov pytest-httpserver pytest-flask
Database Application Stack:
pip install pytest pytest-asyncio pytest-cov pytest-postgresql pytest-mongodb
Complete Testing Stack:
pip install \
pytest pytest-asyncio pytest-cov pytest-mock \
pytest-httpserver pytest-flask pytest-aiohttp \
pytest-postgresql pytest-mysql pytest-mongodb \
pytest-xdist pytest-benchmark pytest-freezegun \
pytest-env pytest-datadir
pyproject.toml Configuration:
[tool.poetry.group.dev.dependencies]
pytest = "^7.4"
pytest-asyncio = "^0.23"
pytest-cov = "^4.1"
pytest-mock = "^3.12"
pytest-httpserver = "^1.0"
pytest-xdist = "^3.5"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = [
"-v",
"--tb=short",
"--strict-markers",
"--cov=src",
"--cov-report=term-missing",
"--cov-report=html",
]
asyncio_mode = "auto"
Core Testing Principles
CRITICAL: Real Code Over Mocks 🚨
The Fundamental Rule: Tests must validate actual code behavior, not mock behavior.
When to Use Mocks (VERY SPARINGLY)
✅ VALID Mock Usage - External Dependencies Only:
- Third-party services - Payment gateways, external APIs, cloud services
- System resources - Hardware devices, OS calls you don't control
- Error injection - Rare failure scenarios (disk full, network partition)
❌ INVALID Mock Usage - Our Own Code:
- HTTP clients → Use pytest plugins:
pytest-httpserver,pytest-flask,pytest-aiohttp - Databases → Use pytest plugins:
pytest-postgresql,pytest-mysql,pytest-mongodb - File I/O → Use pytest fixture with
tempfilemodule - DNS → Use localhost or real DNS (with retry logic)
- Internal services → If you wrote it, test the real thing
Prefer pytest plugins over unittest.mock:
- Use
pytest-mock(pytest wrapper) instead ofunittest.mockdirectly - Use specialized pytest plugins for common scenarios
- Mocks should be last resort, not first choice
The Three Questions (Ask Before Every Mock)
# Before writing: mock = Mock()
# Ask yourself:
1. "Is this really external (third-party/OS)?"
❌ My HTTP client? → NO MOCK
✅ Stripe payment API? → Mock OK
2. "Am I testing real logic or mock setup?"
❌ Testing mock returns what I configured? → INVALID
✅ Testing my error handling of mock failure? → VALID
3. "Are integration points tested separately?"
❌ Only mock tests exist? → INVALID
✅ Have separate real integration tests? → VALID
Real Testing Tools for Python
Principle: Project Building Blocks → Stdlib → External Dependencies (in that order)
STEP 1: Check Project Building Blocks
Before adding test dependencies, search what the project already provides:
# Example: HTTP Client Testing
# Project ALREADY has:
# - http_client module with request/response handling
# - Simple HTTP parser for testing
# - Built on stdlib's http.server
# ✅ BEST - Create dedicated testing module
# File: src/myapp_testing/http_server.py
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading
from typing import Callable
class TestHTTPServer:
"""Test HTTP server built on project's HTTP types.
Uses project's existing HTTP implementation and stdlib's http.server.
No external dependencies needed.
"""
def __init__(self):
"""Initialize test server."""
self.server = None
self.thread = None
def start(self, port: int = 0) -> str:
"""Start server on random available port.
Returns:
URL of started server (e.g., "http://localhost:12345")
"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(b'OK')
self.server = HTTPServer(('127.0.0.1', port), Handler)
actual_port = self.server.server_port
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
return f"http://127.0.0.1:{actual_port}"
def stop(self):
"""Stop the server."""
if self.server:
self.server.shutdown()
# Now tests use it:
# File: tests/test_http_integration.py
from myapp_testing import TestHTTPServer
from myapp import HTTPClient
def test_http_client():
"""Test HTTP client with real server."""
server = TestHTTPServer()
url = server.start()
client = HTTPClient()
response = client.get(url)
assert response.status_code == 200
assert response.text == 'OK'
server.stop()
Why separate testing module is better:
- ✅ Clean separation: production vs test infrastructure
- ✅ Reusable: Multiple test files can import it
- ✅ No test code in production distribution
- ✅ Type-checkable test utilities
- ✅ Clear dependency:
myapp_testing→myapp
Test Organization Strategy:
# File: tests/test_http_internal.py
# Fast tests using our own TestHTTPServer
from myapp_testing import TestHTTPServer
from myapp import HTTPClient
def test_http_get():
"""Test GET request with internal test server."""
server = TestHTTPServer()
url = server.start()
client = HTTPClient()
response = client.get(url)
assert response.status_code == 200
server.stop()
def test_http_redirects():
"""Test redirect handling."""
server = TestHTTPServer()
url = server.start()
client = HTTPClient()
response = client.get(f"{url}/redirect")
assert response.status_code == 200
server.stop()
# File: tests/test_http_external.py
# Slower validation tests against real HTTP servers
import pytest
from myapp import HTTPClient
@pytest.mark.integration
@pytest.mark.slow
def test_external_httpbin_get():
"""Validate against real httpbin.org."""
client = HTTPClient()
response = client.get("http://httpbin.org/get")
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.slow
def test_external_https():
"""Validate HTTPS handling."""
client = HTTPClient()
response = client.get("https://httpbin.org/get")
assert response.status_code == 200
Test Pyramid:
- Many tests (90%): Unit tests using project's testing module - Fast, controlled
- Some tests (9%): Integration tests using project's testing module - Medium speed
- Few tests (1%): External validation with
@pytest.mark.slow- Slow, real-world
Run Strategy:
# Fast tests only (no external network calls)
pytest
# Include slow integration tests
pytest -m slow
# Run specific external test
pytest tests/test_http_external.py::test_external_httpbin_get
# Run all tests (internal + external)
pytest -m "slow or not slow"
STEP 2: Try Stdlib (if project doesn't have it)
HTTP Testing (Pure Stdlib - NO dependencies):
# ✅ BEST - Pure stdlib HTTP testing
from http.server import HTTPServer, BaseHTTPRequestHandler
import threading
import urllib.request
def test_http_request():
"""Test HTTP with stdlib only."""
# Real HTTP server (no dependencies)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'test data')
server = HTTPServer(('127.0.0.1', 0), Handler)
port = server.server_port
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
# Test actual HTTP request
with urllib.request.urlopen(f'http://127.0.0.1:{port}/') as response:
data = response.read()
assert data == b'test data'
server.shutdown()
STEP 3: External Dependencies (ONLY when necessary)
HTTP Testing (If project lacks HTTP types):
# ✅ ACCEPTABLE - Use minimal test dependency if project has NO HTTP
# requirements-dev.txt: pytest-httpserver
from pytest_httpserver import HTTPServer
def test_http_client(httpserver: HTTPServer):
"""Test HTTP client with lightweight test server."""
httpserver.expect_request("/test").respond_with_data("OK")
client = HTTPClient()
response = client.get(httpserver.url_for("/test"))
assert response.status_code == 200
assert response.text == "OK"
Decision Tree:
Need to test HTTP?
├─ Does project have HTTP types?
│ ├─ YES → Create myapp_testing module with TestHTTPServer ✅ BEST
│ └─ NO → Continue to stdlib
├─ Can stdlib do it? (http.server + urllib)
│ ├─ YES → Use stdlib HTTP server ✅ GOOD
│ └─ NO → Use minimal external dep (pytest-httpserver) ✅ ACCEPTABLE
Need to test JSON?
├─ Does project have JSON utilities?
│ ├─ YES → Create myapp_testing with helpers ✅ BEST
│ └─ NO → Use stdlib json ✅ ACCEPTABLE
Need test utilities?
├─ Multiple modules need it?
│ ├─ YES → Create dedicated testing module (myapp_testing) ✅ BEST
│ └─ NO → Small helper in test file ✅ ACCEPTABLE
When to Use Test-Only External Dependencies:
- ✅ Protocol stdlib doesn't provide AND project doesn't have
- ✅ Complex test fixtures that would be extremely verbose to write manually
- ✅ Specialized testing tools (Hypothesis, pytest plugins)
When NOT to Use External Test Dependencies:
- ❌ Project already has the building blocks (compose them instead)
- ❌ HTTP requests → Use stdlib
urlliborhttp.client - ❌ File I/O → Use stdlib
tempfile - ❌ Threads → Use stdlib
threading
Database Testing:
# ✅ GOOD - Real database testing
import pytest
from sqlalchemy import create_engine
@pytest.fixture
def db():
"""Create in-memory SQLite database for testing."""
engine = create_engine('sqlite:///:memory:')
# Run migrations
Base.metadata.create_all(engine)
yield engine
engine.dispose()
def test_user_repository(db):
"""Test with real database."""
repo = UserRepository(db)
user = repo.create("alice")
assert user.name == "alice"
File I/O Testing:
# ✅ GOOD - Real file testing
import tempfile
from pathlib import Path
def test_config_loader():
"""Test with real temporary file."""
with tempfile.TemporaryDirectory() as tmpdir:
config_path = Path(tmpdir) / "config.json"
config_path.write_text('{"key": "value"}')
config = ConfigLoader.load(config_path)
assert config["key"] == "value"
Red Flags: Integration Theater
⚠️ These are WARNING SIGNS of invalid mock usage:
# ❌ BAD - Mocking our own code
def test_http_client(mocker):
"""DON'T DO THIS - mocking our own internal components!"""
mock_dns = mocker.Mock()
mock_tcp = mocker.Mock()
client = HTTPClient(dns_resolver=mock_dns, tcp_conn=mock_tcp)
# This only tests that mocks work!
assert client.get("http://example.com") is not None
# ❌ BAD - Mock-only testing
def test_database_save(mocker):
"""DON'T DO THIS - never tests real database!"""
mock_db = mocker.Mock()
mock_db.save.return_value = True
# Never tests real database!
repo = UserRepository(mock_db)
result = repo.save(user)
assert result is True
# ✅ GOOD - Use pytest plugins for real testing
def test_http_client_real(httpserver):
"""Test with real HTTP server using pytest-httpserver."""
httpserver.expect_request("/test").respond_with_data("OK")
client = HTTPClient()
response = client.get(httpserver.url_for("/test"))
assert response.status_code == 200
assert response.text == "OK"
def test_database_save_real(postgresql):
"""Test with real PostgreSQL using pytest-postgresql."""
cursor = postgresql.cursor()
cursor.execute("CREATE TABLE users (id serial, name varchar);")
repo = UserRepository(postgresql)
user = repo.save(User(name="Alice"))
cursor.execute("SELECT name FROM users WHERE id = %s;", (user.id,))
assert cursor.fetchone()[0] == "Alice"
Required Test Coverage
MANDATORY for all features:
- Unit tests - Individual components with real dependencies
- Integration tests - Complete flows with real local services
- End-to-end tests - Full workflows (may use mocks for external services only)
Example Test Structure:
# tests/test_my_feature.py
class TestUnitLevel:
"""Unit tests with real components."""
def test_parser(self):
"""Test individual parsing logic."""
result = parse_input("test")
assert result.valid
class TestIntegration:
"""Integration tests with real services."""
def test_api_endpoint(self, test_client):
"""Test complete API flow."""
response = test_client.get("/api/users")
assert response.status_code == 200
class TestExternalMocks:
"""ONLY for external services."""
def test_payment_gateway_timeout(self, mocker):
"""Valid: External service, testing specific error scenario.
Use pytest-mock (mocker fixture) instead of unittest.mock directly.
"""
# Mock external payment gateway (not our code!)
mock_gateway = mocker.Mock(spec=PaymentGateway)
mock_gateway.charge.side_effect = TimeoutError()
processor = PaymentProcessor(gateway=mock_gateway)
result = processor.charge(100)
assert isinstance(result, PaymentError)
The Three Test Validations ✅
Every meaningful test MUST validate:
- Input Validation - Verify inputs are handled correctly
- Output Verification - Confirm result matches expectations
- Error Path Testing - Ensure error conditions produce appropriate errors
# BAD ❌ - Creates variable with no assertions
def test_process():
result = process("valid_input") # Assumes success!
# GOOD ✅ - Validates both success and error paths
def test_process_valid_input():
"""Test processing with valid input."""
result = process("valid_input")
assert result is not None
assert len(result) == 11
def test_process_invalid_input():
"""Test processing with invalid input."""
with pytest.raises(ValueError, match="Empty input"):
process("")
Anti-Pattern: Unused Variables Without Assertions
CRITICAL: Tests that create variables but never validate their content are FORBIDDEN.
# BAD ❌ - False confidence, no validation
def test_user_creation():
user = User.create("Alice", "alice@example.com")
# Variable created but NEVER checked!
# GOOD ✅ - Explicit validation
def test_user_creation():
"""Test creating a user with valid data."""
user = User.create("Alice", "alice@example.com")
assert user.name == "Alice"
assert user.email == "alice@example.com"
assert user.id > 0
Test Organization
Test Location Conventions
CRITICAL: ALL tests must be in the tests/ directory with clear separation between units and integration.
1. Test Directory Structure
Standard Project Structure:
myproject/
├── src/
│ └── myapp/
│ ├── __init__.py
│ ├── models.py
│ ├── services.py
│ └── validation.py
├── tests/
│ ├── __init__.py
│ ├── units/ # Unit tests (individual functions/classes)
│ │ ├── __init__.py
│ │ ├── test_models.py
│ │ ├── test_services.py
│ │ └── test_validation.py
│ ├── integration/ # Integration tests (workflows/APIs)
│ │ ├── __init__.py
│ │ ├── test_api_workflow.py
│ │ └── test_auth_flow.py
│ └── conftest.py # Shared fixtures
└── pyproject.toml
Monorepo/Multi-Package Structure:
workspace/
├── packages/
│ ├── package_a/
│ │ ├── src/
│ │ │ └── package_a/
│ │ │ ├── __init__.py
│ │ │ └── core.py
│ │ └── tests/
│ │ ├── units/
│ │ │ └── test_core.py
│ │ ├── integration/
│ │ │ └── test_package_a_workflow.py
│ │ └── conftest.py
│ └── package_b/
│ ├── src/
│ │ └── package_b/
│ │ ├── __init__.py
│ │ └── api.py
│ └── tests/
│ ├── units/
│ │ └── test_api.py
│ ├── integration/
│ │ └── test_package_b_workflow.py
│ └── conftest.py
└── tests/ # Cross-package integration tests
└── integration/
└── test_cross_package_workflow.py
2. Test File Naming
Unit Tests (tests/units/):
test_{module_name}.py- Tests for specific moduletest_{class_name}_logic.py- Tests for class logictest_{feature}_validation.py- Tests for validation logic
Integration Tests (tests/integration/):
test_{feature}_workflow.py- Complete feature workflowstest_{api}_endpoints.py- API endpoint integrationtest_{service}_flow.py- Service interaction flows
Examples:
# File: tests/units/test_user_service.py
"""Unit tests for UserService class.
Tests individual methods of UserService in isolation.
"""
import pytest
from myapp.services import UserService
from myapp.exceptions import ValidationError
def test_create_user__valid_data__succeeds():
"""Test creating a user succeeds with valid data."""
service = UserService()
user = service.create_user("John", "john@example.com")
assert user.name == "John"
assert user.email == "john@example.com"
def test_create_user__invalid_email__raises_error():
"""Test creating a user fails with invalid email."""
service = UserService()
with pytest.raises(ValidationError, match="Invalid email"):
service.create_user("John", "invalid-email")
# File: tests/integration/test_auth_workflow.py
"""Integration tests for authentication workflow.
Tests complete authentication flow from login to logout.
"""
import pytest
from myapp import create_app
from myapp.models import User
def test_login_logout_workflow__valid_user__succeeds(db_session):
"""Test complete login/logout workflow."""
# given: User exists in database
user = User(username="alice", email="alice@example.com")
db_session.add(user)
db_session.commit()
app = create_app()
client = app.test_client()
# when: User logs in
response = client.post("/login", json={
"username": "alice",
"password": "password123"
})
# then: Login succeeds and token is returned
assert response.status_code == 200
token = response.json["token"]
assert token is not None
# when: User logs out
response = client.post("/logout", headers={"Authorization": f"Bearer {token}"})
# then: Logout succeeds
assert response.status_code == 200
3. Test Naming Convention
Function-Based Tests (RECOMMENDED):
Use the pattern: test_{function}__{scenario}__{expected_result}
def test_validate_email__valid_format__returns_true():
"""Test email validation returns True for valid format."""
assert validate_email("test@example.com") is True
def test_validate_email__missing_at_symbol__returns_false():
"""Test email validation returns False when @ is missing."""
assert validate_email("invalid-email") is False
def test_save_user__duplicate_email__raises_integrity_error():
"""Test saving user with duplicate email raises IntegrityError."""
# ...
Class-Based Tests (OPTIONAL, for grouping related tests):
class TestUserService:
"""Tests for UserService class."""
@pytest.fixture
def service(self):
"""Create UserService instance for testing."""
return UserService()
def test_create_user__valid_data__succeeds(s
…(truncated)