Writing Effective Python Tests
Core Principles
Every test should be atomic, self-contained, and test single functionality. A test that tests multiple things is harder to debug and maintain.
Test Structure
Atomic unit tests
Each test should verify a single behavior. The test name should tell you what's broken when it fails. Multiple assertions are fine when they all verify the same behavior.
# Good: Name tells you what's broken def test_user_creation_sets_defaults(): user = User(name="Alice") assert user.role == "member" assert user.id is not None assert user.created_at is not None # Bad: If this fails, what behavior is broken? def test_user(): user = User(name="Alice") assert user.role == "member" user.promote() assert user.role == "admin" assert user.can_delete_others()
Use parameterization for variations of the same concept
import pytest @pytest.mark.parametrize("input,expected", [ ("hello", "HELLO"), ("World", "WORLD"), ("", ""), ("123", "123"), ]) def test_uppercase_conversion(input, expected): assert input.upper() == expected
Use separate tests for different functionality
Don't parameterize unrelated behaviors. If the test logic differs, write separate tests.
Project-Specific Rules
No async markers needed
This project uses asyncio_mode = "auto" globally. Write async tests without decorators:
# Correct async def test_async_operation(): result = await some_async_function() assert result == expected # Wrong - don't add this @pytest.mark.asyncio async def test_async_operation(): ...
Imports at module level
Put ALL imports at the top of the file:
# Correct import pytest from fastmcp import FastMCP from fastmcp.client import Client async def test_something(): mcp = FastMCP("test") ... # Wrong - no local imports async def test_something(): from fastmcp import FastMCP # Don't do this ...
Use in-memory transport for testing
Pass FastMCP servers directly to clients:
from fastmcp import FastMCP from fastmcp.client import Client mcp = FastMCP("TestServer") @mcp.tool def greet(name: str) -> str: return f"Hello, {name}!" async def test_greet_tool(): async with Client(mcp) as client: result = await client.call_tool("greet", {"name": "World"}) assert result[0].text == "Hello, World!"
Only use HTTP transport when explicitly testing network features.
Inline snapshots for complex data
Use inline-snapshot for testing JSON schemas and complex structures:
from inline_snapshot import snapshot def test_schema_generation(): schema = generate_schema(MyModel) assert schema == snapshot() # Will auto-populate on first run
Commands:
pytest --inline-snapshot=create- populate empty snapshotspytest --inline-snapshot=fix- update after intentional changes
Fixtures
Prefer function-scoped fixtures
@pytest.fixture def client(): return Client() async def test_with_client(client): result = await client.ping() assert result is not None
Use tmp_path for file operations
def test_file_writing(tmp_path): file = tmp_path / "test.txt" file.write_text("content") assert file.read_text() == "content"
Mocking
Mock at the boundary
from unittest.mock import patch, AsyncMock async def test_external_api_call(): with patch("mymodule.external_client.fetch", new_callable=AsyncMock) as mock: mock.return_value = {"data": "test"} result = await my_function() assert result == {"data": "test"}
Don't mock what you own
Test your code with real implementations when possible. Mock external services, not internal classes.
Test Naming
Use descriptive names that explain the scenario:
# Good def test_login_fails_with_invalid_password(): def test_user_can_update_own_profile(): def test_admin_can_delete_any_user(): # Bad def test_login(): def test_update(): def test_delete():
Error Testing
import pytest def test_raises_on_invalid_input(): with pytest.raises(ValueError, match="must be positive"): calculate(-1) async def test_async_raises(): with pytest.raises(ConnectionError): await connect_to_invalid_host()
Running Tests
uv run pytest -n auto # Run all tests in parallel uv run pytest -n auto -x # Stop on first failure uv run pytest path/to/test.py # Run specific file uv run pytest -k "test_name" # Run tests matching pattern uv run pytest -m "not integration" # Exclude integration tests
Checklist
Before submitting tests:
- Each test tests one thing
- No
@pytest.mark.asynciodecorators - Imports at module level
- Descriptive test names
- Using in-memory transport (not HTTP) unless testing networking
- Parameterization for variations of same behavior
- Separate tests for different behaviors