python-testing-patterns

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Python Testing Patterns

Python 测试模式

Comprehensive guide to implementing robust testing strategies in Python using pytest, fixtures, mocking, parameterization, and test-driven development practices.
本文是一份使用pytest、fixtures、Mocking、参数化和测试驱动开发(TDD)实践在Python中实现稳健测试策略的综合指南。

When to Use This Skill

何时使用该技能

  • Writing unit tests for Python code
  • Setting up test suites and test infrastructure
  • Implementing test-driven development (TDD)
  • Creating integration tests for APIs and services
  • Mocking external dependencies and services
  • Testing async code and concurrent operations
  • Setting up continuous testing in CI/CD
  • Implementing property-based testing
  • Testing database operations
  • Debugging failing tests
  • 为Python代码编写单元测试
  • 搭建测试套件和测试基础设施
  • 实施测试驱动开发(TDD)
  • 为API和服务创建集成测试
  • Mock外部依赖和服务
  • 测试异步代码和并发操作
  • 在CI/CD中设置持续测试
  • 实施基于属性的测试
  • 测试数据库操作
  • 调试失败的测试

Core Concepts

核心概念

1. Test Types

1. 测试类型

  • Unit Tests: Test individual functions/classes in isolation
  • Integration Tests: Test interaction between components
  • Functional Tests: Test complete features end-to-end
  • Performance Tests: Measure speed and resource usage
  • 单元测试:独立测试单个函数/类
  • 集成测试:测试组件之间的交互
  • 功能测试:端到端测试完整功能
  • 性能测试:测量速度和资源使用情况

2. Test Structure (AAA Pattern)

2. 测试结构(AAA模式)

  • Arrange: Set up test data and preconditions
  • Act: Execute the code under test
  • Assert: Verify the results
  • Arrange(准备):设置测试数据和前置条件
  • Act(执行):运行待测试的代码
  • Assert(断言):验证结果

3. Test Coverage

3. 测试覆盖率

  • Measure what code is exercised by tests
  • Identify untested code paths
  • Aim for meaningful coverage, not just high percentages
  • 测量测试覆盖的代码范围
  • 识别未测试的代码路径
  • 注重有意义的覆盖率,而非单纯追求高百分比

4. Test Isolation

4. 测试隔离

  • Tests should be independent
  • No shared state between tests
  • Each test should clean up after itself
  • 测试之间应相互独立
  • 测试之间无共享状态
  • 每个测试完成后应自行清理

Quick Start

快速开始

python
undefined
python
undefined

test_example.py

test_example.py

def add(a, b): return a + b
def test_add(): """Basic test example.""" result = add(2, 3) assert result == 5
def test_add_negative(): """Test with negative numbers.""" assert add(-1, 1) == 0
def add(a, b): return a + b
def test_add(): """Basic test example.""" result = add(2, 3) assert result == 5
def test_add_negative(): """Test with negative numbers.""" assert add(-1, 1) == 0

Run with: pytest test_example.py

Run with: pytest test_example.py

undefined
undefined

Fundamental Patterns

基础模式

Pattern 1: Basic pytest Tests

模式1:基础pytest测试

python
undefined
python
undefined

test_calculator.py

test_calculator.py

import pytest
class Calculator: """Simple calculator for testing."""
def add(self, a: float, b: float) -> float:
    return a + b

def subtract(self, a: float, b: float) -> float:
    return a - b

def multiply(self, a: float, b: float) -> float:
    return a * b

def divide(self, a: float, b: float) -> float:
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
def test_addition(): """Test addition.""" calc = Calculator() assert calc.add(2, 3) == 5 assert calc.add(-1, 1) == 0 assert calc.add(0, 0) == 0
def test_subtraction(): """Test subtraction.""" calc = Calculator() assert calc.subtract(5, 3) == 2 assert calc.subtract(0, 5) == -5
def test_multiplication(): """Test multiplication.""" calc = Calculator() assert calc.multiply(3, 4) == 12 assert calc.multiply(0, 5) == 0
def test_division(): """Test division.""" calc = Calculator() assert calc.divide(6, 3) == 2 assert calc.divide(5, 2) == 2.5
def test_division_by_zero(): """Test division by zero raises error.""" calc = Calculator() with pytest.raises(ValueError, match="Cannot divide by zero"): calc.divide(5, 0)
undefined
import pytest
class Calculator: """Simple calculator for testing."""
def add(self, a: float, b: float) -> float:
    return a + b

def subtract(self, a: float, b: float) -> float:
    return a - b

def multiply(self, a: float, b: float) -> float:
    return a * b

def divide(self, a: float, b: float) -> float:
    if b == 0:
        raise ValueError("Cannot divide by zero")
    return a / b
def test_addition(): """Test addition.""" calc = Calculator() assert calc.add(2, 3) == 5 assert calc.add(-1, 1) == 0 assert calc.add(0, 0) == 0
def test_subtraction(): """Test subtraction.""" calc = Calculator() assert calc.subtract(5, 3) == 2 assert calc.subtract(0, 5) == -5
def test_multiplication(): """Test multiplication.""" calc = Calculator() assert calc.multiply(3, 4) == 12 assert calc.multiply(0, 5) == 0
def test_division(): """Test division.""" calc = Calculator() assert calc.divide(6, 3) == 2 assert calc.divide(5, 2) == 2.5
def test_division_by_zero(): """Test division by zero raises error.""" calc = Calculator() with pytest.raises(ValueError, match="Cannot divide by zero"): calc.divide(5, 0)
undefined

Pattern 2: Fixtures for Setup and Teardown

模式2:用于初始化和清理的Fixtures

python
undefined
python
undefined

test_database.py

test_database.py

import pytest from typing import Generator
class Database: """Simple database class."""
def __init__(self, connection_string: str):
    self.connection_string = connection_string
    self.connected = False

def connect(self):
    """Connect to database."""
    self.connected = True

def disconnect(self):
    """Disconnect from database."""
    self.connected = False

def query(self, sql: str) -> list:
    """Execute query."""
    if not self.connected:
        raise RuntimeError("Not connected")
    return [{"id": 1, "name": "Test"}]
@pytest.fixture def db() -> Generator[Database, None, None]: """Fixture that provides connected database.""" # Setup database = Database("sqlite:///:memory:") database.connect()
# Provide to test
yield database

# Teardown
database.disconnect()
def test_database_query(db): """Test database query with fixture.""" results = db.query("SELECT * FROM users") assert len(results) == 1 assert results[0]["name"] == "Test"
@pytest.fixture(scope="session") def app_config(): """Session-scoped fixture - created once per test session.""" return { "database_url": "postgresql://localhost/test", "api_key": "test-key", "debug": True }
@pytest.fixture(scope="module") def api_client(app_config): """Module-scoped fixture - created once per test module.""" # Setup expensive resource client = {"config": app_config, "session": "active"} yield client # Cleanup client["session"] = "closed"
def test_api_client(api_client): """Test using api client fixture.""" assert api_client["session"] == "active" assert api_client["config"]["debug"] is True
undefined
import pytest from typing import Generator
class Database: """Simple database class."""
def __init__(self, connection_string: str):
    self.connection_string = connection_string
    self.connected = False

def connect(self):
    """Connect to database."""
    self.connected = True

def disconnect(self):
    """Disconnect from database."""
    self.connected = False

def query(self, sql: str) -> list:
    """Execute query."""
    if not self.connected:
        raise RuntimeError("Not connected")
    return [{"id": 1, "name": "Test"}]
@pytest.fixture def db() -> Generator[Database, None, None]: """Fixture that provides connected database.""" # Setup database = Database("sqlite:///:memory:") database.connect()
# Provide to test
yield database

# Teardown
database.disconnect()
def test_database_query(db): """Test database query with fixture.""" results = db.query("SELECT * FROM users") assert len(results) == 1 assert results[0]["name"] == "Test"
@pytest.fixture(scope="session") def app_config(): """Session-scoped fixture - created once per test session.""" return { "database_url": "postgresql://localhost/test", "api_key": "test-key", "debug": True }
@pytest.fixture(scope="module") def api_client(app_config): """Module-scoped fixture - created once per test module.""" # Setup expensive resource client = {"config": app_config, "session": "active"} yield client # Cleanup client["session"] = "closed"
def test_api_client(api_client): """Test using api client fixture.""" assert api_client["session"] == "active" assert api_client["config"]["debug"] is True
undefined

Pattern 3: Parameterized Tests

模式3:参数化测试

python
undefined
python
undefined

test_validation.py

test_validation.py

import pytest
def is_valid_email(email: str) -> bool: """Check if email is valid.""" return "@" in email and "." in email.split("@")[1]
@pytest.mark.parametrize("email,expected", [ ("user@example.com", True), ("test.user@domain.co.uk", True), ("invalid.email", False), ("@example.com", False), ("user@domain", False), ("", False), ]) def test_email_validation(email, expected): """Test email validation with various inputs.""" assert is_valid_email(email) == expected
@pytest.mark.parametrize("a,b,expected", [ (2, 3, 5), (0, 0, 0), (-1, 1, 0), (100, 200, 300), (-5, -5, -10), ]) def test_addition_parameterized(a, b, expected): """Test addition with multiple parameter sets.""" from test_calculator import Calculator calc = Calculator() assert calc.add(a, b) == expected
import pytest
def is_valid_email(email: str) -> bool: """Check if email is valid.""" return "@" in email and "." in email.split("@")[1]
@pytest.mark.parametrize("email,expected", [ ("user@example.com", True), ("test.user@domain.co.uk", True), ("invalid.email", False), ("@example.com", False), ("user@domain", False), ("", False), ]) def test_email_validation(email, expected): """Test email validation with various inputs.""" assert is_valid_email(email) == expected
@pytest.mark.parametrize("a,b,expected", [ (2, 3, 5), (0, 0, 0), (-1, 1, 0), (100, 200, 300), (-5, -5, -10), ]) def test_addition_parameterized(a, b, expected): """Test addition with multiple parameter sets.""" from test_calculator import Calculator calc = Calculator() assert calc.add(a, b) == expected

Using pytest.param for special cases

Using pytest.param for special cases

@pytest.mark.parametrize("value,expected", [ pytest.param(1, True, id="positive"), pytest.param(0, False, id="zero"), pytest.param(-1, False, id="negative"), ]) def test_is_positive(value, expected): """Test with custom test IDs.""" assert (value > 0) == expected
undefined
@pytest.mark.parametrize("value,expected", [ pytest.param(1, True, id="positive"), pytest.param(0, False, id="zero"), pytest.param(-1, False, id="negative"), ]) def test_is_positive(value, expected): """Test with custom test IDs.""" assert (value > 0) == expected
undefined

Pattern 4: Mocking with unittest.mock

模式4:使用unittest.mock进行Mocking

python
undefined
python
undefined

test_api_client.py

test_api_client.py

import pytest from unittest.mock import Mock, patch, MagicMock import requests
class APIClient: """Simple API client."""
def __init__(self, base_url: str):
    self.base_url = base_url

def get_user(self, user_id: int) -> dict:
    """Fetch user from API."""
    response = requests.get(f"{self.base_url}/users/{user_id}")
    response.raise_for_status()
    return response.json()

def create_user(self, data: dict) -> dict:
    """Create new user."""
    response = requests.post(f"{self.base_url}/users", json=data)
    response.raise_for_status()
    return response.json()
def test_get_user_success(): """Test successful API call with mock.""" client = APIClient("https://api.example.com")
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_response.raise_for_status.return_value = None

with patch("requests.get", return_value=mock_response) as mock_get:
    user = client.get_user(1)

    assert user["id"] == 1
    assert user["name"] == "John Doe"
    mock_get.assert_called_once_with("https://api.example.com/users/1")
def test_get_user_not_found(): """Test API call with 404 error.""" client = APIClient("https://api.example.com")
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")

with patch("requests.get", return_value=mock_response):
    with pytest.raises(requests.HTTPError):
        client.get_user(999)
@patch("requests.post") def test_create_user(mock_post): """Test user creation with decorator syntax.""" client = APIClient("https://api.example.com")
mock_post.return_value.json.return_value = {"id": 2, "name": "Jane Doe"}
mock_post.return_value.raise_for_status.return_value = None

user_data = {"name": "Jane Doe", "email": "jane@example.com"}
result = client.create_user(user_data)

assert result["id"] == 2
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["json"] == user_data
undefined
import pytest from unittest.mock import Mock, patch, MagicMock import requests
class APIClient: """Simple API client."""
def __init__(self, base_url: str):
    self.base_url = base_url

def get_user(self, user_id: int) -> dict:
    """Fetch user from API."""
    response = requests.get(f"{self.base_url}/users/{user_id}")
    response.raise_for_status()
    return response.json()

def create_user(self, data: dict) -> dict:
    """Create new user."""
    response = requests.post(f"{self.base_url}/users", json=data)
    response.raise_for_status()
    return response.json()
def test_get_user_success(): """Test successful API call with mock.""" client = APIClient("https://api.example.com")
mock_response = Mock()
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
mock_response.raise_for_status.return_value = None

with patch("requests.get", return_value=mock_response) as mock_get:
    user = client.get_user(1)

    assert user["id"] == 1
    assert user["name"] == "John Doe"
    mock_get.assert_called_once_with("https://api.example.com/users/1")
def test_get_user_not_found(): """Test API call with 404 error.""" client = APIClient("https://api.example.com")
mock_response = Mock()
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")

with patch("requests.get", return_value=mock_response):
    with pytest.raises(requests.HTTPError):
        client.get_user(999)
@patch("requests.post") def test_create_user(mock_post): """Test user creation with decorator syntax.""" client = APIClient("https://api.example.com")
mock_post.return_value.json.return_value = {"id": 2, "name": "Jane Doe"}
mock_post.return_value.raise_for_status.return_value = None

user_data = {"name": "Jane Doe", "email": "jane@example.com"}
result = client.create_user(user_data)

assert result["id"] == 2
mock_post.assert_called_once()
call_args = mock_post.call_args
assert call_args.kwargs["json"] == user_data
undefined

Pattern 5: Testing Exceptions

模式5:测试异常

python
undefined
python
undefined

test_exceptions.py

test_exceptions.py

import pytest
def divide(a: float, b: float) -> float: """Divide a by b.""" if b == 0: raise ZeroDivisionError("Division by zero") if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): raise TypeError("Arguments must be numbers") return a / b
def test_zero_division(): """Test exception is raised for division by zero.""" with pytest.raises(ZeroDivisionError): divide(10, 0)
def test_zero_division_with_message(): """Test exception message.""" with pytest.raises(ZeroDivisionError, match="Division by zero"): divide(5, 0)
def test_type_error(): """Test type error exception.""" with pytest.raises(TypeError, match="must be numbers"): divide("10", 5)
def test_exception_info(): """Test accessing exception info.""" with pytest.raises(ValueError) as exc_info: int("not a number")
assert "invalid literal" in str(exc_info.value)
undefined
import pytest
def divide(a: float, b: float) -> float: """Divide a by b.""" if b == 0: raise ZeroDivisionError("Division by zero") if not isinstance(a, (int, float)) or not isinstance(b, (int, float)): raise TypeError("Arguments must be numbers") return a / b
def test_zero_division(): """Test exception is raised for division by zero.""" with pytest.raises(ZeroDivisionError): divide(10, 0)
def test_zero_division_with_message(): """Test exception message.""" with pytest.raises(ZeroDivisionError, match="Division by zero"): divide(5, 0)
def test_type_error(): """Test type error exception.""" with pytest.raises(TypeError, match="must be numbers"): divide("10", 5)
def test_exception_info(): """Test accessing exception info.""" with pytest.raises(ValueError) as exc_info: int("not a number")
assert "invalid literal" in str(exc_info.value)
undefined

Advanced Patterns

高级模式

Pattern 6: Testing Async Code

模式6:测试异步代码

python
undefined
python
undefined

test_async.py

test_async.py

import pytest import asyncio
async def fetch_data(url: str) -> dict: """Fetch data asynchronously.""" await asyncio.sleep(0.1) return {"url": url, "data": "result"}
@pytest.mark.asyncio async def test_fetch_data(): """Test async function.""" result = await fetch_data("https://api.example.com") assert result["url"] == "https://api.example.com" assert "data" in result
@pytest.mark.asyncio async def test_concurrent_fetches(): """Test concurrent async operations.""" urls = ["url1", "url2", "url3"] tasks = [fetch_data(url) for url in urls] results = await asyncio.gather(*tasks)
assert len(results) == 3
assert all("data" in r for r in results)
@pytest.fixture async def async_client(): """Async fixture.""" client = {"connected": True} yield client client["connected"] = False
@pytest.mark.asyncio async def test_with_async_fixture(async_client): """Test using async fixture.""" assert async_client["connected"] is True
undefined
import pytest import asyncio
async def fetch_data(url: str) -> dict: """Fetch data asynchronously.""" await asyncio.sleep(0.1) return {"url": url, "data": "result"}
@pytest.mark.asyncio async def test_fetch_data(): """Test async function.""" result = await fetch_data("https://api.example.com") assert result["url"] == "https://api.example.com" assert "data" in result
@pytest.mark.asyncio async def test_concurrent_fetches(): """Test concurrent async operations.""" urls = ["url1", "url2", "url3"] tasks = [fetch_data(url) for url in urls] results = await asyncio.gather(*tasks)
assert len(results) == 3
assert all("data" in r for r in results)
@pytest.fixture async def async_client(): """Async fixture.""" client = {"connected": True} yield client client["connected"] = False
@pytest.mark.asyncio async def test_with_async_fixture(async_client): """Test using async fixture.""" assert async_client["connected"] is True
undefined

Pattern 7: Monkeypatch for Testing

模式7:使用Monkeypatch进行测试

python
undefined
python
undefined

test_environment.py

test_environment.py

import os import pytest
def get_database_url() -> str: """Get database URL from environment.""" return os.environ.get("DATABASE_URL", "sqlite:///:memory:")
def test_database_url_default(): """Test default database URL.""" # Will use actual environment variable if set url = get_database_url() assert url
def test_database_url_custom(monkeypatch): """Test custom database URL with monkeypatch.""" monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test") assert get_database_url() == "postgresql://localhost/test"
def test_database_url_not_set(monkeypatch): """Test when env var is not set.""" monkeypatch.delenv("DATABASE_URL", raising=False) assert get_database_url() == "sqlite:///:memory:"
class Config: """Configuration class."""
def __init__(self):
    self.api_key = "production-key"

def get_api_key(self):
    return self.api_key
def test_monkeypatch_attribute(monkeypatch): """Test monkeypatching object attributes.""" config = Config() monkeypatch.setattr(config, "api_key", "test-key") assert config.get_api_key() == "test-key"
undefined
import os import pytest
def get_database_url() -> str: """Get database URL from environment.""" return os.environ.get("DATABASE_URL", "sqlite:///:memory:")
def test_database_url_default(): """Test default database URL.""" # Will use actual environment variable if set url = get_database_url() assert url
def test_database_url_custom(monkeypatch): """Test custom database URL with monkeypatch.""" monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test") assert get_database_url() == "postgresql://localhost/test"
def test_database_url_not_set(monkeypatch): """Test when env var is not set.""" monkeypatch.delenv("DATABASE_URL", raising=False) assert get_database_url() == "sqlite:///:memory:"
class Config: """Configuration class."""
def __init__(self):
    self.api_key = "production-key"

def get_api_key(self):
    return self.api_key
def test_monkeypatch_attribute(monkeypatch): """Test monkeypatching object attributes.""" config = Config() monkeypatch.setattr(config, "api_key", "test-key") assert config.get_api_key() == "test-key"
undefined

Pattern 8: Temporary Files and Directories

模式8:临时文件和目录

python
undefined
python
undefined

test_file_operations.py

test_file_operations.py

import pytest from pathlib import Path
def save_data(filepath: Path, data: str): """Save data to file.""" filepath.write_text(data)
def load_data(filepath: Path) -> str: """Load data from file.""" return filepath.read_text()
def test_file_operations(tmp_path): """Test file operations with temporary directory.""" # tmp_path is a pathlib.Path object test_file = tmp_path / "test_data.txt"
# Save data
save_data(test_file, "Hello, World!")

# Verify file exists
assert test_file.exists()

# Load and verify data
data = load_data(test_file)
assert data == "Hello, World!"
def test_multiple_files(tmp_path): """Test with multiple temporary files.""" files = { "file1.txt": "Content 1", "file2.txt": "Content 2", "file3.txt": "Content 3" }
for filename, content in files.items():
    filepath = tmp_path / filename
    save_data(filepath, content)

# Verify all files created
assert len(list(tmp_path.iterdir())) == 3

# Verify contents
for filename, expected_content in files.items():
    filepath = tmp_path / filename
    assert load_data(filepath) == expected_content
undefined
import pytest from pathlib import Path
def save_data(filepath: Path, data: str): """Save data to file.""" filepath.write_text(data)
def load_data(filepath: Path) -> str: """Load data from file.""" return filepath.read_text()
def test_file_operations(tmp_path): """Test file operations with temporary directory.""" # tmp_path is a pathlib.Path object test_file = tmp_path / "test_data.txt"
# Save data
save_data(test_file, "Hello, World!")

# Verify file exists
assert test_file.exists()

# Load and verify data
data = load_data(test_file)
assert data == "Hello, World!"
def test_multiple_files(tmp_path): """Test with multiple temporary files.""" files = { "file1.txt": "Content 1", "file2.txt": "Content 2", "file3.txt": "Content 3" }
for filename, content in files.items():
    filepath = tmp_path / filename
    save_data(filepath, content)

# Verify all files created
assert len(list(tmp_path.iterdir())) == 3

# Verify contents
for filename, expected_content in files.items():
    filepath = tmp_path / filename
    assert load_data(filepath) == expected_content
undefined

Pattern 9: Custom Fixtures and Conftest

模式9:自定义Fixtures和Conftest

python
undefined
python
undefined

conftest.py

conftest.py

"""Shared fixtures for all tests.""" import pytest
@pytest.fixture(scope="session") def database_url(): """Provide database URL for all tests.""" return "postgresql://localhost/test_db"
@pytest.fixture(autouse=True) def reset_database(database_url): """Auto-use fixture that runs before each test.""" # Setup: Clear database print(f"Clearing database: {database_url}") yield # Teardown: Clean up print("Test completed")
@pytest.fixture def sample_user(): """Provide sample user data.""" return { "id": 1, "name": "Test User", "email": "test@example.com" }
@pytest.fixture def sample_users(): """Provide list of sample users.""" return [ {"id": 1, "name": "User 1"}, {"id": 2, "name": "User 2"}, {"id": 3, "name": "User 3"}, ]
"""Shared fixtures for all tests.""" import pytest
@pytest.fixture(scope="session") def database_url(): """Provide database URL for all tests.""" return "postgresql://localhost/test_db"
@pytest.fixture(autouse=True) def reset_database(database_url): """Auto-use fixture that runs before each test.""" # Setup: Clear database print(f"Clearing database: {database_url}") yield # Teardown: Clean up print("Test completed")
@pytest.fixture def sample_user(): """Provide sample user data.""" return { "id": 1, "name": "Test User", "email": "test@example.com" }
@pytest.fixture def sample_users(): """Provide list of sample users.""" return [ {"id": 1, "name": "User 1"}, {"id": 2, "name": "User 2"}, {"id": 3, "name": "User 3"}, ]

Parametrized fixture

Parametrized fixture

@pytest.fixture(params=["sqlite", "postgresql", "mysql"]) def db_backend(request): """Fixture that runs tests with different database backends.""" return request.param
def test_with_db_backend(db_backend): """This test will run 3 times with different backends.""" print(f"Testing with {db_backend}") assert db_backend in ["sqlite", "postgresql", "mysql"]
undefined
@pytest.fixture(params=["sqlite", "postgresql", "mysql"]) def db_backend(request): """Fixture that runs tests with different database backends.""" return request.param
def test_with_db_backend(db_backend): """This test will run 3 times with different backends.""" print(f"Testing with {db_backend}") assert db_backend in ["sqlite", "postgresql", "mysql"]
undefined

Pattern 10: Property-Based Testing

模式10:基于属性的测试

python
undefined
python
undefined

test_properties.py

test_properties.py

from hypothesis import given, strategies as st import pytest
def reverse_string(s: str) -> str: """Reverse a string.""" return s[::-1]
@given(st.text()) def test_reverse_twice_is_original(s): """Property: reversing twice returns original.""" assert reverse_string(reverse_string(s)) == s
@given(st.text()) def test_reverse_length(s): """Property: reversed string has same length.""" assert len(reverse_string(s)) == len(s)
@given(st.integers(), st.integers()) def test_addition_commutative(a, b): """Property: addition is commutative.""" assert a + b == b + a
@given(st.lists(st.integers())) def test_sorted_list_properties(lst): """Property: sorted list is ordered.""" sorted_lst = sorted(lst)
# Same length
assert len(sorted_lst) == len(lst)

# All elements present
assert set(sorted_lst) == set(lst)

# Is ordered
for i in range(len(sorted_lst) - 1):
    assert sorted_lst[i] <= sorted_lst[i + 1]
undefined
from hypothesis import given, strategies as st import pytest
def reverse_string(s: str) -> str: """Reverse a string.""" return s[::-1]
@given(st.text()) def test_reverse_twice_is_original(s): """Property: reversing twice returns original.""" assert reverse_string(reverse_string(s)) == s
@given(st.text()) def test_reverse_length(s): """Property: reversed string has same length.""" assert len(reverse_string(s)) == len(s)
@given(st.integers(), st.integers()) def test_addition_commutative(a, b): """Property: addition is commutative.""" assert a + b == b + a
@given(st.lists(st.integers())) def test_sorted_list_properties(lst): """Property: sorted list is ordered.""" sorted_lst = sorted(lst)
# Same length
assert len(sorted_lst) == len(lst)

# All elements present
assert set(sorted_lst) == set(lst)

# Is ordered
for i in range(len(sorted_lst) - 1):
    assert sorted_lst[i] <= sorted_lst[i + 1]
undefined

Test Design Principles

测试设计原则

One Behavior Per Test

每个测试验证一种行为

Each test should verify exactly one behavior. This makes failures easy to diagnose and tests easy to maintain.
python
undefined
每个测试应仅验证一种行为。这能让故障诊断更简单,测试更易维护。
python
undefined

BAD - testing multiple behaviors

BAD - testing multiple behaviors

def test_user_service(): user = service.create_user(data) assert user.id is not None assert user.email == data["email"] updated = service.update_user(user.id, {"name": "New"}) assert updated.name == "New"
def test_user_service(): user = service.create_user(data) assert user.id is not None assert user.email == data["email"] updated = service.update_user(user.id, {"name": "New"}) assert updated.name == "New"

GOOD - focused tests

GOOD - focused tests

def test_create_user_assigns_id(): user = service.create_user(data) assert user.id is not None
def test_create_user_stores_email(): user = service.create_user(data) assert user.email == data["email"]
def test_update_user_changes_name(): user = service.create_user(data) updated = service.update_user(user.id, {"name": "New"}) assert updated.name == "New"
undefined
def test_create_user_assigns_id(): user = service.create_user(data) assert user.id is not None
def test_create_user_stores_email(): user = service.create_user(data) assert user.email == data["email"]
def test_update_user_changes_name(): user = service.create_user(data) updated = service.update_user(user.id, {"name": "New"}) assert updated.name == "New"
undefined

Test Error Paths

测试错误路径

Always test failure cases, not just happy paths.
python
def test_get_user_raises_not_found():
    with pytest.raises(UserNotFoundError) as exc_info:
        service.get_user("nonexistent-id")

    assert "nonexistent-id" in str(exc_info.value)

def test_create_user_rejects_invalid_email():
    with pytest.raises(ValueError, match="Invalid email format"):
        service.create_user({"email": "not-an-email"})
务必测试失败场景,而不仅仅是正常流程。
python
def test_get_user_raises_not_found():
    with pytest.raises(UserNotFoundError) as exc_info:
        service.get_user("nonexistent-id")

    assert "nonexistent-id" in str(exc_info.value)

def test_create_user_rejects_invalid_email():
    with pytest.raises(ValueError, match="Invalid email format"):
        service.create_user({"email": "not-an-email"})

Testing Best Practices

测试最佳实践

Test Organization

测试组织

python
undefined
python
undefined

tests/

tests/

init.py

init.py

conftest.py # Shared fixtures

conftest.py # Shared fixtures

test_unit/ # Unit tests

test_unit/ # Unit tests

test_models.py

test_models.py

test_utils.py

test_utils.py

test_integration/ # Integration tests

test_integration/ # Integration tests

test_api.py

test_api.py

test_database.py

test_database.py

test_e2e/ # End-to-end tests

test_e2e/ # End-to-end tests

test_workflows.py

test_workflows.py

undefined
undefined

Test Naming Convention

测试命名规范

A common pattern:
test_<unit>_<scenario>_<expected_outcome>
. Adapt to your team's preferences.
python
undefined
常见模式:
test_<单元>_<场景>_<预期结果>
。可根据团队偏好调整。
python
undefined

Pattern: test_<unit><scenario><expected>

Pattern: test_<unit><scenario><expected>

def test_create_user_with_valid_data_returns_user(): ...
def test_create_user_with_duplicate_email_raises_conflict(): ...
def test_get_user_with_unknown_id_returns_none(): ...
def test_create_user_with_valid_data_returns_user(): ...
def test_create_user_with_duplicate_email_raises_conflict(): ...
def test_get_user_with_unknown_id_returns_none(): ...

Good test names - clear and descriptive

Good test names - clear and descriptive

def test_user_creation_with_valid_data(): """Clear name describes what is being tested.""" pass
def test_login_fails_with_invalid_password(): """Name describes expected behavior.""" pass
def test_api_returns_404_for_missing_resource(): """Specific about inputs and expected outcomes.""" pass
def test_user_creation_with_valid_data(): """Clear name describes what is being tested.""" pass
def test_login_fails_with_invalid_password(): """Name describes expected behavior.""" pass
def test_api_returns_404_for_missing_resource(): """Specific about inputs and expected outcomes.""" pass

Bad test names - avoid these

Bad test names - avoid these

def test_1(): # Not descriptive pass
def test_user(): # Too vague pass
def test_function(): # Doesn't explain what's tested pass
undefined
def test_1(): # Not descriptive pass
def test_user(): # Too vague pass
def test_function(): # Doesn't explain what's tested pass
undefined

Testing Retry Behavior

测试重试行为

Verify that retry logic works correctly using mock side effects.
python
from unittest.mock import Mock

def test_retries_on_transient_error():
    """Test that service retries on transient failures."""
    client = Mock()
    # Fail twice, then succeed
    client.request.side_effect = [
        ConnectionError("Failed"),
        ConnectionError("Failed"),
        {"status": "ok"},
    ]

    service = ServiceWithRetry(client, max_retries=3)
    result = service.fetch()

    assert result == {"status": "ok"}
    assert client.request.call_count == 3

def test_gives_up_after_max_retries():
    """Test that service stops retrying after max attempts."""
    client = Mock()
    client.request.side_effect = ConnectionError("Failed")

    service = ServiceWithRetry(client, max_retries=3)

    with pytest.raises(ConnectionError):
        service.fetch()

    assert client.request.call_count == 3

def test_does_not_retry_on_permanent_error():
    """Test that permanent errors are not retried."""
    client = Mock()
    client.request.side_effect = ValueError("Invalid input")

    service = ServiceWithRetry(client, max_retries=3)

    with pytest.raises(ValueError):
        service.fetch()

    # Only called once - no retry for ValueError
    assert client.request.call_count == 1
使用Mock副作用验证重试逻辑是否正常工作。
python
from unittest.mock import Mock

def test_retries_on_transient_error():
    """Test that service retries on transient failures."""
    client = Mock()
    # Fail twice, then succeed
    client.request.side_effect = [
        ConnectionError("Failed"),
        ConnectionError("Failed"),
        {"status": "ok"},
    ]

    service = ServiceWithRetry(client, max_retries=3)
    result = service.fetch()

    assert result == {"status": "ok"}
    assert client.request.call_count == 3

def test_gives_up_after_max_retries():
    """Test that service stops retrying after max attempts."""
    client = Mock()
    client.request.side_effect = ConnectionError("Failed")

    service = ServiceWithRetry(client, max_retries=3)

    with pytest.raises(ConnectionError):
        service.fetch()

    assert client.request.call_count == 3

def test_does_not_retry_on_permanent_error():
    """Test that permanent errors are not retried."""
    client = Mock()
    client.request.side_effect = ValueError("Invalid input")

    service = ServiceWithRetry(client, max_retries=3)

    with pytest.raises(ValueError):
        service.fetch()

    # Only called once - no retry for ValueError
    assert client.request.call_count == 1

Mocking Time with Freezegun

使用Freezegun Mock时间

Use freezegun to control time in tests for predictable time-dependent behavior.
python
from freezegun import freeze_time
from datetime import datetime, timedelta

@freeze_time("2026-01-15 10:00:00")
def test_token_expiry():
    """Test token expires at correct time."""
    token = create_token(expires_in_seconds=3600)
    assert token.expires_at == datetime(2026, 1, 15, 11, 0, 0)

@freeze_time("2026-01-15 10:00:00")
def test_is_expired_returns_false_before_expiry():
    """Test token is not expired when within validity period."""
    token = create_token(expires_in_seconds=3600)
    assert not token.is_expired()

@freeze_time("2026-01-15 12:00:00")
def test_is_expired_returns_true_after_expiry():
    """Test token is expired after validity period."""
    token = Token(expires_at=datetime(2026, 1, 15, 11, 30, 0))
    assert token.is_expired()

def test_with_time_travel():
    """Test behavior across time using freeze_time context."""
    with freeze_time("2026-01-01") as frozen_time:
        item = create_item()
        assert item.created_at == datetime(2026, 1, 1)

        # Move forward in time
        frozen_time.move_to("2026-01-15")
        assert item.age_days == 14
使用Freezegun控制测试中的时间,确保时间相关行为可预测。
python
from freezegun import freeze_time
from datetime import datetime, timedelta

@freeze_time("2026-01-15 10:00:00")
def test_token_expiry():
    """Test token expires at correct time."""
    token = create_token(expires_in_seconds=3600)
    assert token.expires_at == datetime(2026, 1, 15, 11, 0, 0)

@freeze_time("2026-01-15 10:00:00")
def test_is_expired_returns_false_before_expiry():
    """Test token is not expired when within validity period."""
    token = create_token(expires_in_seconds=3600)
    assert not token.is_expired()

@freeze_time("2026-01-15 12:00:00")
def test_is_expired_returns_true_after_expiry():
    """Test token is expired after validity period."""
    token = Token(expires_at=datetime(2026, 1, 15, 11, 30, 0))
    assert token.is_expired()

def test_with_time_travel():
    """Test behavior across time using freeze_time context."""
    with freeze_time("2026-01-01") as frozen_time:
        item = create_item()
        assert item.created_at == datetime(2026, 1, 1)

        # Move forward in time
        frozen_time.move_to("2026-01-15")
        assert item.age_days == 14

Test Markers

测试标记

python
undefined
python
undefined

test_markers.py

test_markers.py

import pytest
@pytest.mark.slow def test_slow_operation(): """Mark slow tests.""" import time time.sleep(2)
@pytest.mark.integration def test_database_integration(): """Mark integration tests.""" pass
@pytest.mark.skip(reason="Feature not implemented yet") def test_future_feature(): """Skip tests temporarily.""" pass
@pytest.mark.skipif(os.name == "nt", reason="Unix only test") def test_unix_specific(): """Conditional skip.""" pass
@pytest.mark.xfail(reason="Known bug #123") def test_known_bug(): """Mark expected failures.""" assert False
import pytest
@pytest.mark.slow def test_slow_operation(): """Mark slow tests.""" import time time.sleep(2)
@pytest.mark.integration def test_database_integration(): """Mark integration tests.""" pass
@pytest.mark.skip(reason="Feature not implemented yet") def test_future_feature(): """Skip tests temporarily.""" pass
@pytest.mark.skipif(os.name == "nt", reason="Unix only test") def test_unix_specific(): """Conditional skip.""" pass
@pytest.mark.xfail(reason="Known bug #123") def test_known_bug(): """Mark expected failures.""" assert False

Run with:

Run with:

pytest -m slow # Run only slow tests

pytest -m slow # Run only slow tests

pytest -m "not slow" # Skip slow tests

pytest -m "not slow" # Skip slow tests

pytest -m integration # Run integration tests

pytest -m integration # Run integration tests

undefined
undefined

Coverage Reporting

覆盖率报告

bash
undefined
bash
undefined

Install coverage

Install coverage

pip install pytest-cov
pip install pytest-cov

Run tests with coverage

Run tests with coverage

pytest --cov=myapp tests/
pytest --cov=myapp tests/

Generate HTML report

Generate HTML report

pytest --cov=myapp --cov-report=html tests/
pytest --cov=myapp --cov-report=html tests/

Fail if coverage below threshold

Fail if coverage below threshold

pytest --cov=myapp --cov-fail-under=80 tests/
pytest --cov=myapp --cov-fail-under=80 tests/

Show missing lines

Show missing lines

pytest --cov=myapp --cov-report=term-missing tests/
undefined
pytest --cov=myapp --cov-report=term-missing tests/
undefined

Testing Database Code

测试数据库代码

python
undefined
python
undefined

test_database_models.py

test_database_models.py

import pytest from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, Session
Base = declarative_base()
class User(Base): """User model.""" tablename = "users"
id = Column(Integer, primary_key=True)
name = Column(String(50))
email = Column(String(100), unique=True)
@pytest.fixture(scope="function") def db_session() -> Session: """Create in-memory database for testing.""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()

yield session

session.close()
def test_create_user(db_session): """Test creating a user.""" user = User(name="Test User", email="test@example.com") db_session.add(user) db_session.commit()
assert user.id is not None
assert user.name == "Test User"
def test_query_user(db_session): """Test querying users.""" user1 = User(name="User 1", email="user1@example.com") user2 = User(name="User 2", email="user2@example.com")
db_session.add_all([user1, user2])
db_session.commit()

users = db_session.query(User).all()
assert len(users) == 2
def test_unique_email_constraint(db_session): """Test unique email constraint.""" from sqlalchemy.exc import IntegrityError
user1 = User(name="User 1", email="same@example.com")
user2 = User(name="User 2", email="same@example.com")

db_session.add(user1)
db_session.commit()

db_session.add(user2)

with pytest.raises(IntegrityError):
    db_session.commit()
undefined
import pytest from sqlalchemy import create_engine, Column, Integer, String from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, Session
Base = declarative_base()
class User(Base): """User model.""" tablename = "users"
id = Column(Integer, primary_key=True)
name = Column(String(50))
email = Column(String(100), unique=True)
@pytest.fixture(scope="function") def db_session() -> Session: """Create in-memory database for testing.""" engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine)
SessionLocal = sessionmaker(bind=engine)
session = SessionLocal()

yield session

session.close()
def test_create_user(db_session): """Test creating a user.""" user = User(name="Test User", email="test@example.com") db_session.add(user) db_session.commit()
assert user.id is not None
assert user.name == "Test User"
def test_query_user(db_session): """Test querying users.""" user1 = User(name="User 1", email="user1@example.com") user2 = User(name="User 2", email="user2@example.com")
db_session.add_all([user1, user2])
db_session.commit()

users = db_session.query(User).all()
assert len(users) == 2
def test_unique_email_constraint(db_session): """Test unique email constraint.""" from sqlalchemy.exc import IntegrityError
user1 = User(name="User 1", email="same@example.com")
user2 = User(name="User 2", email="same@example.com")

db_session.add(user1)
db_session.commit()

db_session.add(user2)

with pytest.raises(IntegrityError):
    db_session.commit()
undefined

CI/CD Integration

CI/CD集成

yaml
undefined
yaml
undefined

.github/workflows/test.yml

.github/workflows/test.yml

name: Tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest
strategy:
  matrix:
    python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
  - uses: actions/checkout@v3

  - name: Set up Python
    uses: actions/setup-python@v4
    with:
      python-version: ${{ matrix.python-version }}

  - name: Install dependencies
    run: |
      pip install -e ".[dev]"
      pip install pytest pytest-cov

  - name: Run tests
    run: |
      pytest --cov=myapp --cov-report=xml

  - name: Upload coverage
    uses: codecov/codecov-action@v3
    with:
      file: ./coverage.xml
undefined
name: Tests
on: [push, pull_request]
jobs: test: runs-on: ubuntu-latest
strategy:
  matrix:
    python-version: ["3.9", "3.10", "3.11", "3.12"]

steps:
  - uses: actions/checkout@v3

  - name: Set up Python
    uses: actions/setup-python@v4
    with:
      python-version: ${{ matrix.python-version }}

  - name: Install dependencies
    run: |
      pip install -e ".[dev]"
      pip install pytest pytest-cov

  - name: Run tests
    run: |
      pytest --cov=myapp --cov-report=xml

  - name: Upload coverage
    uses: codecov/codecov-action@v3
    with:
      file: ./coverage.xml
undefined

Configuration Files

配置文件

ini
undefined
ini
undefined

pytest.ini

pytest.ini

[pytest] testpaths = tests python_files = test_.py python_classes = Test python_functions = test_* addopts = -v --strict-markers --tb=short --cov=myapp --cov-report=term-missing markers = slow: marks tests as slow integration: marks integration tests unit: marks unit tests e2e: marks end-to-end tests

```toml
[pytest] testpaths = tests python_files = test_.py python_classes = Test python_functions = test_* addopts = -v --strict-markers --tb=short --cov=myapp --cov-report=term-missing markers = slow: marks tests as slow integration: marks integration tests unit: marks unit tests e2e: marks end-to-end tests

```toml

pyproject.toml

pyproject.toml

[tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] addopts = [ "-v", "--cov=myapp", "--cov-report=term-missing", ]
[tool.coverage.run] source = ["myapp"] omit = ["/tests/", "/migrations/"]
[tool.coverage.report] exclude_lines = [ "pragma: no cover", "def repr", "raise AssertionError", "raise NotImplementedError", ]
undefined
[tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] addopts = [ "-v", "--cov=myapp", "--cov-report=term-missing", ]
[tool.coverage.run] source = ["myapp"] omit = ["/tests/", "/migrations/"]
[tool.coverage.report] exclude_lines = [ "pragma: no cover", "def repr", "raise AssertionError", "raise NotImplementedError", ]
undefined

Resources

资源

Best Practices Summary

最佳实践总结

  1. Write tests first (TDD) or alongside code
  2. One assertion per test when possible
  3. Use descriptive test names that explain behavior
  4. Keep tests independent and isolated
  5. Use fixtures for setup and teardown
  6. Mock external dependencies appropriately
  7. Parametrize tests to reduce duplication
  8. Test edge cases and error conditions
  9. Measure coverage but focus on quality
  10. Run tests in CI/CD on every commit
  1. 优先编写测试(TDD)或与代码同步编写
  2. 尽可能每个测试一个断言
  3. 使用描述性的测试名称,清晰说明验证的行为
  4. 保持测试独立且相互隔离
  5. 使用Fixtures进行初始化和清理
  6. 合理Mock外部依赖
  7. 参数化测试以减少代码重复
  8. 测试边界情况和错误条件
  9. 测量覆盖率但更注重测试质量
  10. 在CI/CD中运行测试,每次提交都执行