Python Testing Skill
Write clear, fast, trustworthy tests. Tests are code — they should be readable, maintainable, and test behaviour not implementation.
Step 1: Read the Code Under Test
Before writing any tests:
- Read the function/module to understand its contract (inputs, outputs, side effects)
- Check for existing tests — mimic the file structure and naming conventions in use
- Check
pyproject.tomlorsetup.cfgfor pytest config, markers, and plugins already installed
Step 2: Choose the Right Test Type
| Scenario | Approach |
|---|---|
| Pure functions | Unit test with assert |
| Code with I/O / external dependencies | Unit test + mock the boundary |
| FastAPI / Flask / Django endpoints | Integration test with TestClient / AsyncClient |
| Database-backed code | Integration test against real DB (use fixtures for setup/teardown) |
| Complex workflows | End-to-end test, sparingly |
Default to the smallest test type that gives you confidence. Don't mock what you can test cheaply for real.
Step 3: Test Structure
Use the Arrange / Act / Assert pattern. One logical assertion per test.
def test_discount_applied_to_eligible_order():
# Arrange
order = Order(items=[Item(price=100)], customer_tier="gold")
# Act
total = calculate_total(order)
# Assert
assert total == 90 # 10% gold discount
File and function naming
- Test files:
test_<module>.py - Test functions:
test_<what>_<when/condition>_<expected> - Keep names honest —
test_returns_noneshould return None, not raise
Core Patterns
Fixtures
import pytest
@pytest.fixture
def user():
return User(id=1, name="Ada", email="ada@example.com")
@pytest.fixture
def db(tmp_path):
"""Isolated SQLite DB per test."""
engine = create_engine(f"sqlite:///{tmp_path}/test.db")
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
# Reuse fixtures across files with conftest.py
Parametrize
@pytest.mark.parametrize("email,valid", [
("user@example.com", True),
("not-an-email", False),
("", False),
("user@", False),
])
def test_email_validation(email, valid):
assert validate_email(email) == valid
Mocking
from unittest.mock import patch, MagicMock
def test_sends_welcome_email(mock_email_client):
with patch("myapp.services.email_client") as mock_client:
register_user("ada@example.com")
mock_client.send.assert_called_once_with(
to="ada@example.com",
template="welcome",
)
# Or use pytest-mock for cleaner syntax
def test_sends_welcome_email(mocker):
mock_send = mocker.patch("myapp.services.email_client.send")
register_user("ada@example.com")
mock_send.assert_called_once()
Async tests
# Requires: pytest-asyncio
# pyproject.toml: [tool.pytest.ini_options] asyncio_mode = "auto"
import pytest
@pytest.mark.asyncio
async def test_fetch_user():
async with AsyncClient(app=app, base_url="http://test") as client:
response = await client.get("/users/1")
assert response.status_code == 200
# With pytest-asyncio asyncio_mode="auto", mark is optional
async def test_fetch_user_auto():
result = await fetch_user(user_id=1)
assert result.name == "Ada"
Testing FastAPI endpoints
from fastapi.testclient import TestClient
from httpx import AsyncClient
import pytest
client = TestClient(app) # sync — fine for most tests
def test_create_item_returns_201():
response = client.post("/items/", json={"name": "Gadget", "price": 9.99})
assert response.status_code == 201
assert response.json()["name"] == "Gadget"
def test_get_item_not_found_returns_404():
response = client.get("/items/99999")
assert response.status_code == 404
# Override dependencies for isolation
def override_get_db():
yield test_db_session
app.dependency_overrides[get_db] = override_get_db
Testing exceptions
import pytest
def test_raises_on_invalid_input():
with pytest.raises(ValueError, match="must be positive"):
compute_score(-1)
def test_raises_http_exception():
response = client.get("/users/0")
assert response.status_code == 422 # FastAPI validation error
Fixtures Best Practices
- Keep fixtures focused — one fixture does one thing
- Use
yieldfixtures for setup/teardown (replacessetup_method) - Scope appropriately:
function(default),module,sessionsession-scoped DB migrations,function-scoped transactions (rollback after each test)
- Put shared fixtures in
conftest.py— pytest auto-discovers it
# conftest.py
@pytest.fixture(scope="session")
def engine():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
yield engine
engine.dispose()
@pytest.fixture
def db(engine):
connection = engine.connect()
transaction = connection.begin()
session = Session(bind=connection)
yield session
session.close()
transaction.rollback()
connection.close()
Markers and Configuration
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
markers = [
"slow: marks tests as slow (deselect with -m 'not slow')",
"integration: marks integration tests",
]
@pytest.mark.slow
@pytest.mark.integration
def test_full_order_pipeline():
...
Run subsets: pytest -m "not slow", pytest -m integration
Coverage
pytest --cov=myapp --cov-report=term-missing
pytest --cov=myapp --cov-fail-under=80
# pyproject.toml
[tool.coverage.run]
omit = ["tests/*", "*/migrations/*"]
[tool.coverage.report]
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
Coverage is a floor, not a target. 80% with meaningful tests beats 100% with trivial ones.
What NOT to Test
- Framework internals (don't test that FastAPI parses JSON — test your logic)
- Implementation details (test behaviour, not which private method was called)
- Trivial getters/setters
- Code you don't own
Common Gotchas
- Don't patch where it's defined — patch where it's used:
patch("myapp.routes.send_email")notpatch("myapp.email.send_email") - Async fixtures need
pytest-asyncioandasync deffixtures require the right scope TestClientruns the app synchronously — useAsyncClientwhen you need true async behaviour- Avoid
time.sleepin tests — mockdatetime.now()or usefreezegun