cityhive / rules
sergeyklay/cityhive/.cursor/rules/testing-guidelines.mdc
Testing Guidelines
Cursor rule2 starsChanged 16 months agoArchived repository
---
description: Testing Guidelines
globs: **/tests/**/*.py
alwaysApply: false
---
# Testing Guidelines
**For test files:** Comprehensive testing practices for reliable code.
## Test Structure
- **Pure functional style** - always write tests using plain functions. Avoid class-based or OOP-style tests entirely unless a concrete technical requirement makes them unavoidable
- **One test file per implementation file** - a 1:1 ratio between test files and implementation files
- **Clear test names** - describe what is being tested and expected outcome
```python
# ✅ DO: Descriptive test name
def test_user_registration_with_valid_email_creates_user():
# Test implementation
# ❌ DON'T: Vague test name
def test_user():
# Test implementation
```
## Fixture Organization and conftest.py
- **Follow "Local when possible, shared when necessary" principle**
- **Move fixtures to conftest.py** when they are used by 2+ test modules
- **Keep fixtures in test files** when they are domain-specific or likely to change
### When to Move Fixtures to conftest.py
**✅ Move to conftest.py:**
- **Cross-module reuse**: Used by multiple test files across different domains
- **Fundamental patterns**: Core infrastructure like database session mocks
- **Stable APIs**: Utilities that won't change often
- **Generic helpers**: AsyncMock context managers, common mocks
```python
# ✅ DO: Shared fixtures in tests/conftest.py
class MockAsyncContextManager:
"""Reusable async context manager for mocking database sessions."""
def __init__(self, session):
self.session = session
async def __aenter__(self):
return self.session
async def __aexit__(self, exc_type, exc_val, exc_tb):
pass
@pytest.fixture
def mock_session():
"""Create a mock async database session."""
return AsyncMock()
@pytest.fixture
def session_maker(mock_session):
"""Create a session maker function that returns a context manager."""
def _session_maker():
return MockAsyncContextManager(mock_session)
return _session_maker
```
**❌ Keep in test files:**
- **Domain-specific**: Test data tied to specific APIs or business logic
- **Frequent changes**: Fixtures that evolve with feature development
- **Complex setup**: Multi-step scenarios specific to test cases
- **Single-use fixtures**: Only needed by one test module
```python
# ✅ DO: Domain-specific fixtures in test files
@pytest.fixture
def user_data():
return {"name": "John Beekeeper", "email": "john@example.com"}
@pytest.fixture
def mock_user(mocker):
user = User(id=1, name="John", email="john@example.com")
user.registered_at = mocker.MagicMock()
return user
@pytest.fixture
def base_app():
"""Create a basic aiohttp application without database setup."""
app = web.Application()
app.router.add_post("/api/users", create_user)
return app
```
### Fixture Composition Patterns
- **Build hierarchical fixtures**: Create composable fixtures that depend on each other
- **Separate concerns**: Split database setup from application setup from client creation
- **Use descriptive naming**: Make fixture purpose clear from the name
```python
# ✅ DO: Composable fixture hierarchy
@pytest.fixture
def base_app():
"""Basic aiohttp application with routes."""
app = web.Application()
app.router.add_post("/api/users", create_user)
return app
@pytest.fixture
def app_with_db(base_app, session_maker):
"""Application with database configured."""
base_app[db_key] = session_maker
return base_app
@pytest.fixture
async def client(aiohttp_client, base_app):
"""Test client for validation-only tests."""
return await aiohttp_client(base_app)
@pytest.fixture
async def client_with_db(aiohttp_client, app_with_db):
"""Test client with database configured."""
return await aiohttp_client(app_with_db)
```
### Fixture Scope Strategy
- **Function scope** (default): For test data and mocks that should be fresh per test
- **Module scope**: For expensive setup that can be shared within a module
- **Session scope**: For very expensive resources shared across entire test suite
```python
# ✅ DO: Appropriate fixture scoping
@pytest.fixture(scope="session")
def expensive_resource():
"""Expensive setup shared across all tests."""
return create_expensive_resource()
@pytest.fixture(scope="module")
def module_database():
"""Database setup shared within module."""
return setup_test_database()
@pytest.fixture # Default: scope="function"
def fresh_data():
"""Fresh data for each test."""
return {"count": 0}
```
### DRY Principle in Test Fixtures
- **Extract common setup patterns** into reusable fixtures
- **Eliminate duplication** across test files without over-centralizing
- **Refactor repetitive test setup** into fixture composition
```python
# ❌ DON'T: Repetitive setup in every test
async def test_endpoint_success(aiohttp_client):
app = web.Application()
app[db_key] = session_maker
app.router.add_post("/api/users", create_user)
client = await aiohttp_client(app)
# Test logic...
async def test_endpoint_failure(aiohttp_client):
app = web.Application()
app[db_key] = session_maker # Duplicated!
app.router.add_post("/api/users", create_user) # Duplicated!
client = await aiohttp_client(app)
# Test logic...
# ✅ DO: Use composable fixtures
async def test_endpoint_success(client_with_db):
# Test logic only...
async def test_endpoint_failure(client_with_db):
# Test logic only...
```
## aiohttp Testing Patterns
### Unit Tests vs Integration Tests
**Choose the right testing approach:**
- **Unit Tests**: Test view functions directly without HTTP infrastructure
- Use `make_mocked_request` from `aiohttp.test_utils`
- Call view functions directly: `await create_user(request)`
- No networking, no DNS resolution, no resource leaks
- Always prefer strategies with faster execution
- **Integration Tests**: Test full HTTP request/response cycle
- Use `aiohttp_client` for real HTTP testing
- Tests middleware, routing, and full request pipeline
- Use sparingly for critical user journeys
```python
# ✅ DO: Unit testing with direct function calls
from aiohttp.test_utils import make_mocked_request
async def test_create_user_with_valid_data_returns_success(app_with_db):
data = {"name": "John Doe", "email": "john@example.com"}
request = make_mocked_request("POST", "/api/users", app=app_with_db)
request.json = AsyncMock(return_value=data)
response = await create_user(request)
assert response.status == 201
# ✅ DO: Integration testing for critical paths
async def test_full_user_registration_flow(aiohttp_client, app_with_db):
client = await aiohttp_client(app_with_db)
async with client.post("/api/users", json=user_data) as response:
assert response.status == 201
data = await response.json()
assert data["user"]["email"] == user_data["email"]
# ❌ DON'T: Use aiohttp_client for simple validation tests
async def test_user_validation(aiohttp_client): # Creates unnecessary HTTP server
app = web.Application()
client = await aiohttp_client(app)
# This is unit testing disguised as integration testing
```
### Resource Management in Tests
**Prevent resource leaks and DNS warnings:**
- **Avoid networking in unit tests** - eliminates resource leaks and DNS resolution
- **Use proper fixture cleanup** - ensure all resources are properly closed
- **Mock external dependencies** - prevent real network calls
```python
# ✅ DO: Proper resource management with fixtures
@pytest.fixture
async def mock_session():
"""Mock database session with proper cleanup."""
session = AsyncMock()
yield session
# Cleanup handled by AsyncMock
@pytest.fixture
def session_maker(mock_session):
"""Session maker that returns a mock context manager."""
def _session_maker():
return MockAsyncContextManager(mock_session)
return _session_maker
# ✅ DO: Unit test without HTTP infrastructure
async def test_user_creation_validation_only():
# No aiohttp_client needed - tests validation logic directly
data = {"name": "", "email": "invalid"}
request = make_mocked_request("POST", "/api/users")
request.json = AsyncMock(return_value=data)
response = await create_user(request)
assert response.status == 400
# ❌ DON'T: Create unnecessary HTTP infrastructure for unit tests
async def test_validation(aiohttp_client):
app = web.Application() # Unnecessary overhead
app.router.add_post("/api/users", create_user) # Routing not needed
client = await aiohttp_client(app) # Creates test server
# This can cause resource leaks and DNS warnings
```
### Logging in Tests: Simple and Minimal
**Default Approach: Global Suppression**
All unit tests automatically suppress INFO-level logs via `tests/unit/conftest.py`:
```python
# ✅ DO: Most tests don't need to worry about logging
def test_business_logic():
# Logging automatically suppressed - focus on behavior
result = function_under_test()
assert result == expected_value
```
**When Testing Logging Behavior Specifically:**
```python
# ✅ DO: Mock logger only when testing logging calls
def test_function_logs_correctly(mocker):
mock_logger = mocker.patch("module.logger")
function_under_test()
# Verify specific logging behavior
mock_logger.info.assert_called_once_with("Expected message", key="value")
mock_logger.warning.assert_not_called()
```
**Integration Tests:**
```python
# ✅ DO: Integration tests use real logging with auto-suppression
@pytest.mark.integration
async def test_full_workflow(full_app_client):
# Real logging infrastructure, INFO logs suppressed
# WARNING/ERROR logs still visible for debugging
async with full_app_client.get("/api/health") as response:
assert response.status == 200
```
**CI/CD Test Strategy:**
- **Unit Tests**: Run first, fast feedback, no external dependencies
- **Integration Tests**: Run after database setup, test real connections
- **Separation**: `make test-unit` vs `make test-integration` for CI efficiency
**Logging Strategy Summary:**
- **Unit Tests**: Global suppression + selective mocking when testing logging
- **Integration Tests**: Real logging with INFO suppression for clean output
### aiohttp Testing Anti-Patterns
**Common mistakes that cause resource leaks:**
```python
# ❌ DON'T: Repetitive app creation in every test
async def test_endpoint_a(aiohttp_client):
app = web.Application()
app[db_key] = session_maker
client = await aiohttp_client(app)
async def test_endpoint_b(aiohttp_client):
app = web.Application() # Duplicated setup
app[db_key] = session_maker # Duplicated setup
client = await aiohttp_client(app)
# ❌ DON'T: Using integration tests for validation logic
async def test_email_validation(aiohttp_client):
# This creates unnecessary HTTP server for simple validation
app = web.Application()
client = await aiohttp_client(app)
# ❌ DON'T: Mixing test types
async def test_user_creation(aiohttp_client):
# Tests both HTTP handling AND business logic
# Should be split into unit and integration tests
# ❌ DON'T: Complex logging fixtures for every test
def test_function(mock_logger, suppress_all_logging):
# Over-engineered approach - unnecessary complexity
# ✅ DO: Simple selective mocking when testing logging
def test_function_logs_correctly(mocker):
mock_logger = mocker.patch("module.logger")
function_under_test()
mock_logger.info.assert_called_with("message", data="value")
# ✅ DO: Most tests just focus on behavior (logging auto-suppressed)
def test_function():
result = function_under_test() # Clean, no logging noise
assert result == expected_value
```
### Test Performance Optimization
**Prefer faster unit tests over slower integration tests:**
- **Unit tests**: ~0.25 seconds for 42 tests
- **Integration tests**: Several seconds due to HTTP overhead
- **Use integration tests sparingly** for end-to-end validation
- **Bulk of testing** should be fast unit tests
```python
# ✅ DO: Fast unit test suite
async def test_validation_errors_return_400():
# Direct function call - no HTTP overhead
response = await create_user(invalid_request)
assert response.status == 400
async def test_database_error_returns_500():
# Mock database failure - no real database
with patch("service.create_user") as mock:
mock.side_effect = DatabaseError()
response = await create_user(request)
assert response.status == 500
# ✅ DO: Targeted integration test
async def test_complete_user_registration_flow(aiohttp_client):
# Only test critical user journey end-to-end
client = await aiohttp_client(app)
response = await client.post("/api/users", json=valid_data)
assert response.status == 201
```
### Mock Request Creation
**Use aiohttp's test utilities for unit tests:**
```python
from aiohttp.test_utils import make_mocked_request
from unittest.mock import AsyncMock
# ✅ DO: Proper mock request setup
async def test_json_parsing_error():
request = make_mocked_request("POST", "/api/users")
request.json = AsyncMock(side_effect=json.JSONDecodeError("Invalid", "", 0))
response = await create_user(request)
assert response.status == 400
# ✅ DO: Mock successful JSON parsing
async def test_successful_user_creation():
data = {"name": "John", "email": "john@example.com"}
request = make_mocked_request("POST", "/api/users", app=app_with_db)
request.json = AsyncMock(return_value=data)
response = await create_user(request)
assert response.status == 201
```
### Test Architecture Patterns
**Follow clean separation between test types:**
```python
# ✅ DO: Separate validation from database tests
async def test_user_validation_rejects_invalid_email():
"""Test validation logic without database."""
request = make_mocked_request("POST", "/api/users")
request.json = AsyncMock(return_value={"email": "invalid"})
response = await create_user(request)
assert response.status == 400
async def test_user_creation_stores_in_database(app_with_db):
"""Test database integration with mocked session."""
request = make_mocked_request("POST", "/api/users", app=app_with_db)
request.json = AsyncMock(return_value={"name": "John", "email": "john@example.com"})
response = await create_user(request)
assert response.status == 201
```
### aiohttp Integration Testing Best Practices
**Use async context managers for proper resource cleanup:**
```python
# ✅ DO: Use async context manager for HTTP requests
async def test_api_endpoint(aiohttp_client):
client = await aiohttp_client(app)
async with client.get("/api/users") as response:
assert response.status == 200
data = await response.json()
assert "users" in data
# ❌ DON'T: Direct response handling (can cause resource leaks)
async def test_api_endpoint_bad(aiohttp_client):
client = await aiohttp_client(app)
response = await client.get("/api/users") # No context manager
assert response.status == 200
```
**Use hierarchical fixtures for different test scenarios:**
```python
# ✅ DO: Use appropriate fixture for test type
async def test_validation_only(client):
"""Use basic client for validation tests."""
async with client.post("/api/users", json=invalid_data) as response:
assert response.status == 400
async def test_database_integration(client_with_db):
"""Use client_with_db for database tests."""
async with client_with_db.post("/api/users", json=valid_data) as response:
assert response.status == 201
async def test_full_application(full_app_client):
"""Use full_app_client for end-to-end tests."""
async with full_app_client.get("/health") as response:
assert response.status == 200
```
**Test HTTP-specific concerns in integration tests:**
```python
# ✅ DO: Test HTTP headers, content types, and status codes
async def test_json_content_type(aiohttp_client):
client = await aiohttp_client(app)
async with client.get("/api/users") as response:
assert response.content_type == "application/json"
assert "application/json" in response.headers["Content-Type"]
# ✅ DO: Test custom headers
async def test_custom_headers(aiohttp_client):
client = await aiohttp_client(app)
headers = {"Authorization": "Bearer token"}
async with client.get("/api/protected", headers=headers) as response:
assert response.status == 200
```
## Test Organization
- **Arrange-Act-Assert pattern** clearly separated
- **Use fixtures** for setup/teardown instead of setUp/tearDown methods
- **Parametrize tests** to reduce duplication
```python
# ✅ DO: Use parametrize for multiple test cases
@pytest.mark.parametrize(
"email,expected_valid",
[
("user@example.com", True),
("invalid-email", False),
("", False),
],
)
def test_email_validation(email: str, expected_valid: bool):
result = validate_email(email)
assert result.is_valid is expected_valid
# ❌ DON'T:
def test_validation_function():
assert validate_input("valid_input") is True
assert validate_input("invalid_input") is False
assert validate_input("another_case") is False
```
## Async Testing
- **Use async test functions** for testing async code
- **Proper await patterns** - await all async operations
- **Mock async functions** correctly with AsyncMock
```python
# ✅ DO: Async test function
async def test_async_database_operation():
async with async_session() as session:
result = await session.execute(select(User))
users = result.scalars().all()
assert len(users) >= 0
# ✅ DO: Mock async operations
async def test_user_creation_with_async_mock(mocker):
mock_execute = mocker.patch("sqlalchemy.ext.asyncio.AsyncSession.execute")
mock_execute.return_value = AsyncMock()
result = await create_user(user_data)
assert result.success
```
## Mock and Patch Strategy
- **Flatten nested context managers** using comma syntax
```python
# ✅ DO:
with patch("module.first_dependency", mock_value1), \
patch("module.second_dependency", mock_value2), \
patch("module.third_dependency", mock_value3):
# Test code
# ❌ DON'T:
with patch("module.first_dependency", mock_value1):
with patch("module.second_dependency", mock_value2):
with patch("module.third_dependency", mock_value3):
# Test code
```
- **Mock external dependencies** but not internal business logic
- **Use pytest-mock** for consistent mocking patterns
```python
# ✅ DO: Flat context managers
def test_user_service_with_dependencies(mocker):
mock_db = mocker.patch("cityhive.infrastructure.database.get_session")
mock_email = mocker.patch("cityhive.infrastructure.email.send_email")
result = user_service.create_user(user_data)
assert result.success
mock_db.assert_called_once()
mock_email.assert_called_once()
```
## Test Commands
- **Run all tests**: `uv run --frozen pytest ./tests`
- **Run unit tests only**: `uv run --frozen pytest -v ./tests/unit` (fast, no database required)
- **Run integration tests only**: `uv run --frozen pytest -v ./tests/integration -m integration` (requires database)
- **Run specific test**: `uv run --frozen pytest ./tests/unit/infrastructure/test_config.py`
- **Run only fast tests**: `uv run --frozen pytest -m "not slow"`
- **Test aiohttp app**: `uv run --frozen pytest ./tests/unit/app/`
### Logging Control During Tests
**Global Configuration (pyproject.toml):**
- **Silent tests** (default): INFO logs suppressed, WARNING/ERROR visible
- **Integration tests**: Auto-suppress via fixtures with level control
**Command-Line Overrides:**
- **Verbose logging**: `uv run --frozen pytest --log-cli-level=INFO -s`
- **Debug logging**: `uv run --frozen pytest --log-cli-level=DEBUG -s`
- **No logging**: `uv run --frozen pytest --log-disable=cityhive`
- **Enable specific logger**: `uv run --frozen pytest --log-cli-level=INFO --capture=no`
**Unit Test Approach:**
- **Global suppression**: INFO logs automatically suppressed in `tests/unit/conftest.py`
- **Selective mocking**: Use `mocker.patch("module.logger")` only when testing logging behavior
- **Clean by default**: Most tests focus on business logic without logging noise
## Test Markers
- **Mark slow tests**: `@pytest.mark.slow` for integration tests
- **Mark integration tests**: `@pytest.mark.integration`
- **Mark web tests**: `@pytest.mark.web` for aiohttp endpoint tests
- **Skip tests conditionally**: Use `@pytest.mark.skipif` with clear reason
## Test Quality
- **No comments in tests** - DO NOT add comments to tests. Tests should be understandable without comments. Refactor complex tests into smaller ones.
- **Break complex tests** into smaller, focused tests
- **All new features need tests** - no exceptions
- **Bug fixes need regression tests** - prevent reoccurrence
## Fixtures
- **Scope appropriately**: session, module, function based on needs
- **Use dependency injection**: pass fixtures as parameters
- **Clean setup/teardown**: ensure tests don't affect each other
```python
# ✅ DO: Well-scoped fixture
@pytest.fixture(scope="function")
def user_data():
return {
"email": "test@example.com",
"name": "Test User",
"age": 25
}
# ✅ DO: aiohttp app fixture
@pytest.fixture
async def app():
return await create_app()
```
---
**See also:**
- [python-standards.mdc](mdc:.cursor/rules/python-standards.mdc) for general Python conventions
- [Development Guide](mdc:docs/development.md) for test commands and setup
- [Architecture Guide](mdc:docs/architecture.md) for testing architecture patterns
- [CONTRIBUTING.md](mdc:CONTRIBUTING.md) for contribution and review process
Discussion
Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.
Posts are public.Sign in to post
No one has posted yet. Be the first.

