448 lines
8.7 KiB
Markdown
448 lines
8.7 KiB
Markdown
# Pytest Configuration Guide
|
|
|
|
Pytest is a powerful testing framework for Python that makes it easy to write simple and scalable tests.
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
uv add --dev pytest
|
|
uv add --dev pytest-cov # For coverage
|
|
uv add --dev pytest-mock # For mocking
|
|
uv add --dev pytest-asyncio # For async tests
|
|
```
|
|
|
|
## Basic Configuration
|
|
|
|
Add to `pyproject.toml`:
|
|
|
|
```toml
|
|
[tool.pytest.ini_options]
|
|
testpaths = ["tests"]
|
|
python_files = ["test_*.py", "*_test.py"]
|
|
python_classes = ["Test*"]
|
|
python_functions = ["test_*"]
|
|
addopts = [
|
|
"--strict-markers",
|
|
"--strict-config",
|
|
"--showlocals",
|
|
"-ra",
|
|
]
|
|
markers = [
|
|
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
|
|
"integration: marks tests as integration tests",
|
|
"unit: marks tests as unit tests",
|
|
]
|
|
```
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
project/
|
|
├── src/
|
|
│ └── project_name/
|
|
│ └── module.py
|
|
├── tests/
|
|
│ ├── __init__.py
|
|
│ ├── conftest.py # Shared fixtures
|
|
│ ├── test_module.py # Unit tests
|
|
│ ├── integration/
|
|
│ │ ├── __init__.py
|
|
│ │ └── test_api.py # Integration tests
|
|
│ └── fixtures/
|
|
│ └── sample_data.json
|
|
```
|
|
|
|
## Writing Tests
|
|
|
|
### Basic Test Function
|
|
|
|
```python
|
|
def test_simple_addition():
|
|
"""Test that addition works correctly."""
|
|
result = add(2, 3)
|
|
assert result == 5
|
|
```
|
|
|
|
### Test Class
|
|
|
|
```python
|
|
class TestCalculator:
|
|
"""Tests for Calculator class."""
|
|
|
|
def test_addition(self):
|
|
calc = Calculator()
|
|
assert calc.add(2, 3) == 5
|
|
|
|
def test_subtraction(self):
|
|
calc = Calculator()
|
|
assert calc.subtract(5, 3) == 2
|
|
```
|
|
|
|
### Using Assertions
|
|
|
|
```python
|
|
def test_assertions():
|
|
# Equality
|
|
assert result == expected
|
|
|
|
# Boolean
|
|
assert is_valid()
|
|
assert not is_invalid()
|
|
|
|
# Membership
|
|
assert item in collection
|
|
assert key in dictionary
|
|
|
|
# Type checking
|
|
assert isinstance(obj, MyClass)
|
|
|
|
# Exceptions
|
|
with pytest.raises(ValueError):
|
|
raise_error()
|
|
|
|
# Approximate equality (floats)
|
|
assert result == pytest.approx(expected, rel=1e-5)
|
|
```
|
|
|
|
## Fixtures
|
|
|
|
### Basic Fixture
|
|
|
|
```python
|
|
import pytest
|
|
|
|
@pytest.fixture
|
|
def sample_user():
|
|
"""Create a sample user for testing."""
|
|
return User(name="Alice", age=30)
|
|
|
|
def test_user_greeting(sample_user):
|
|
assert sample_user.greet() == "Hello, I'm Alice"
|
|
```
|
|
|
|
### Fixture Scopes
|
|
|
|
```python
|
|
@pytest.fixture(scope="function") # Default, new instance per test
|
|
def function_scope():
|
|
return setup()
|
|
|
|
@pytest.fixture(scope="class") # One instance per test class
|
|
def class_scope():
|
|
return setup()
|
|
|
|
@pytest.fixture(scope="module") # One instance per module
|
|
def module_scope():
|
|
return setup()
|
|
|
|
@pytest.fixture(scope="session") # One instance per test session
|
|
def session_scope():
|
|
return setup()
|
|
```
|
|
|
|
### Fixture Cleanup
|
|
|
|
```python
|
|
@pytest.fixture
|
|
def resource():
|
|
# Setup
|
|
res = acquire_resource()
|
|
yield res
|
|
# Teardown
|
|
res.cleanup()
|
|
|
|
# Or using context manager
|
|
@pytest.fixture
|
|
def database():
|
|
with create_database() as db:
|
|
yield db
|
|
# Automatic cleanup
|
|
```
|
|
|
|
### Fixture Dependencies
|
|
|
|
```python
|
|
@pytest.fixture
|
|
def database():
|
|
return Database()
|
|
|
|
@pytest.fixture
|
|
def user_repository(database):
|
|
return UserRepository(database)
|
|
|
|
def test_find_user(user_repository):
|
|
user = user_repository.find(1)
|
|
assert user is not None
|
|
```
|
|
|
|
## Parametrized Tests
|
|
|
|
### Basic Parametrization
|
|
|
|
```python
|
|
@pytest.mark.parametrize("input,expected", [
|
|
(2, 4),
|
|
(3, 9),
|
|
(4, 16),
|
|
(5, 25),
|
|
])
|
|
def test_square(input, expected):
|
|
assert square(input) == expected
|
|
```
|
|
|
|
### Multiple Parameters
|
|
|
|
```python
|
|
@pytest.mark.parametrize("a,b,expected", [
|
|
(1, 1, 2),
|
|
(2, 3, 5),
|
|
(10, -5, 5),
|
|
])
|
|
def test_addition(a, b, expected):
|
|
assert add(a, b) == expected
|
|
```
|
|
|
|
### Parametrizing Fixtures
|
|
|
|
```python
|
|
@pytest.fixture(params=[1, 2, 3])
|
|
def number(request):
|
|
return request.param
|
|
|
|
def test_with_different_numbers(number):
|
|
assert number > 0
|
|
```
|
|
|
|
## Test Markers
|
|
|
|
### Built-in Markers
|
|
|
|
```python
|
|
@pytest.mark.skip(reason="Not implemented yet")
|
|
def test_future_feature():
|
|
pass
|
|
|
|
@pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+")
|
|
def test_new_feature():
|
|
pass
|
|
|
|
@pytest.mark.xfail(reason="Known bug")
|
|
def test_buggy_feature():
|
|
pass
|
|
```
|
|
|
|
### Custom Markers
|
|
|
|
```python
|
|
# Define in pyproject.toml
|
|
# markers = ["slow: marks tests as slow"]
|
|
|
|
@pytest.mark.slow
|
|
def test_expensive_operation():
|
|
# Long-running test
|
|
pass
|
|
|
|
# Run with: pytest -m "not slow"
|
|
```
|
|
|
|
## Exception Testing
|
|
|
|
```python
|
|
def test_raises_value_error():
|
|
with pytest.raises(ValueError):
|
|
raise ValueError("Invalid value")
|
|
|
|
def test_raises_with_message():
|
|
with pytest.raises(ValueError, match="Invalid"):
|
|
raise ValueError("Invalid value")
|
|
|
|
def test_exception_info():
|
|
with pytest.raises(ValueError) as exc_info:
|
|
raise ValueError("Invalid value")
|
|
assert "Invalid" in str(exc_info.value)
|
|
```
|
|
|
|
## Mocking
|
|
|
|
### Using pytest-mock
|
|
|
|
```python
|
|
def test_with_mock(mocker):
|
|
# Mock a function
|
|
mock = mocker.patch('module.function')
|
|
mock.return_value = 42
|
|
|
|
result = call_function()
|
|
assert result == 42
|
|
mock.assert_called_once()
|
|
|
|
def test_mock_method(mocker):
|
|
# Mock a method
|
|
mock = mocker.patch.object(MyClass, 'method')
|
|
mock.return_value = "mocked"
|
|
|
|
obj = MyClass()
|
|
assert obj.method() == "mocked"
|
|
```
|
|
|
|
### Using unittest.mock
|
|
|
|
```python
|
|
from unittest.mock import Mock, patch
|
|
|
|
def test_with_mock():
|
|
with patch('module.function') as mock:
|
|
mock.return_value = 42
|
|
result = call_function()
|
|
assert result == 42
|
|
```
|
|
|
|
## Async Tests
|
|
|
|
```python
|
|
import pytest
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_function():
|
|
result = await async_operation()
|
|
assert result == expected
|
|
|
|
@pytest.fixture
|
|
async def async_client():
|
|
client = AsyncClient()
|
|
yield client
|
|
await client.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_with_async_fixture(async_client):
|
|
result = await async_client.get("/endpoint")
|
|
assert result.status_code == 200
|
|
```
|
|
|
|
## Running Tests
|
|
|
|
```bash
|
|
# All tests
|
|
pytest
|
|
|
|
# Specific file
|
|
pytest tests/test_module.py
|
|
|
|
# Specific test
|
|
pytest tests/test_module.py::test_function
|
|
|
|
# Specific class
|
|
pytest tests/test_module.py::TestClass
|
|
|
|
# Pattern matching
|
|
pytest -k "test_user"
|
|
|
|
# Markers
|
|
pytest -m slow # Only slow tests
|
|
pytest -m "not slow" # Exclude slow tests
|
|
|
|
# Last failed
|
|
pytest --lf
|
|
|
|
# Failed first
|
|
pytest --ff
|
|
|
|
# Stop on first failure
|
|
pytest -x
|
|
|
|
# Stop after N failures
|
|
pytest --maxfail=3
|
|
|
|
# Verbose output
|
|
pytest -v
|
|
|
|
# Show print statements
|
|
pytest -s
|
|
|
|
# Show locals in tracebacks
|
|
pytest --showlocals
|
|
|
|
# Parallel execution (requires pytest-xdist)
|
|
pytest -n auto
|
|
```
|
|
|
|
## conftest.py
|
|
|
|
Shared configuration and fixtures:
|
|
|
|
```python
|
|
# tests/conftest.py
|
|
import pytest
|
|
|
|
@pytest.fixture(scope="session")
|
|
def database():
|
|
"""Create database for entire test session."""
|
|
db = create_test_database()
|
|
yield db
|
|
db.drop()
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_database(database):
|
|
"""Reset database before each test."""
|
|
database.clear()
|
|
|
|
def pytest_configure(config):
|
|
"""Pytest configuration hook."""
|
|
config.addinivalue_line(
|
|
"markers", "integration: mark test as integration test"
|
|
)
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
1. **One assertion per test**: Keep tests focused
|
|
2. **Descriptive names**: Use `test_user_creation_with_valid_data`
|
|
3. **AAA pattern**: Arrange, Act, Assert
|
|
4. **Use fixtures**: Avoid duplicating setup code
|
|
5. **Test edge cases**: Not just happy paths
|
|
6. **Fast tests**: Keep unit tests under 100ms
|
|
7. **Independent tests**: Tests should not depend on each other
|
|
8. **Clear assertions**: Make failures obvious
|
|
|
|
## Example Test File
|
|
|
|
```python
|
|
"""Tests for user module."""
|
|
import pytest
|
|
from project_name.models import User
|
|
|
|
@pytest.fixture
|
|
def valid_user_data():
|
|
"""Valid user data for testing."""
|
|
return {
|
|
"name": "Alice",
|
|
"email": "alice@example.com",
|
|
"age": 30
|
|
}
|
|
|
|
class TestUser:
|
|
"""Tests for User model."""
|
|
|
|
def test_create_user(self, valid_user_data):
|
|
"""Test creating a user with valid data."""
|
|
# Arrange & Act
|
|
user = User(**valid_user_data)
|
|
|
|
# Assert
|
|
assert user.name == "Alice"
|
|
assert user.email == "alice@example.com"
|
|
assert user.age == 30
|
|
|
|
def test_user_greeting(self, valid_user_data):
|
|
"""Test user greeting message."""
|
|
user = User(**valid_user_data)
|
|
assert user.greet() == "Hello, I'm Alice"
|
|
|
|
@pytest.mark.parametrize("age", [-1, 0, 151])
|
|
def test_invalid_age(self, valid_user_data, age):
|
|
"""Test that invalid ages raise ValueError."""
|
|
valid_user_data["age"] = age
|
|
with pytest.raises(ValueError, match="Invalid age"):
|
|
User(**valid_user_data)
|
|
```
|