# Tests (Python)
This guide provides Python-specific testing patterns, commands, and
examples. For language-neutral testing principles, see the
[testing guide](tests.md).
## Core Testing Principles
- **Dependency Injection Over Monkey-Patching**: This codebase uses immutable objects that prevent monkey-patching by design. Use dependency injection patterns instead.
- **Performance-Conscious Resource Use**: I/O performance is important. Prefer in-memory filesystems (e.g., `pyfakefs`) over ones backed by higher-latency storage (temporary directories).
- **Avoid Monkey-Patching**: Try to avoid monkey-patching entirely. Any exceptions to this rule should be discussed and documented on a case-by-case basis.
- **100% Coverage Goal**: Always aim for 100% line and branch coverage. Use `# pragma: no cover` only as a last resort after exhausting dependency injection and architectural alternatives.
## Anti-Patterns to Avoid
- **Monkey-Patching Internal Code**: Will fail with immutable objects:
# WRONG - will fail with AttributeImmutability
with patch.object( MyClass, '_some_method' ): pass
with patch( 'mypackage.module._some_function' ): pass
- **Excessive Mocking**: Over-mocking leads to tests that pass but don't catch real bugs.
- **Testing Implementation Details**: Tests should verify behavior, not internal implementation specifics.
- **External Network Testing**: NEVER test against real external sites. Use mocks or test doubles instead:
# WRONG - tests against real external services
response = httpx.get( 'https://example.com' )
response = httpx.get( 'https://httpbin.org/post' )
# CORRECT - use httpx MockTransport
def handler( request ):
return httpx.Response( 200, json = { 'result': 'success' } )
transport = httpx.MockTransport( handler )
async with httpx.AsyncClient( transport = transport ) as client:
response = await client.get( 'https://api.example.com/data' )
assert response.json( ) == { 'result': 'success' }
## Test Organization
Test files should use a systematic numbering system to enforce execution order and logical grouping.
### Test Directory Structure
tests/
├── README.md # Test organization documentation
├── data/ # Test data and fixtures
│ ├── packages/ # Fake packages for extension testing
│ ├── artifacts/ # Captured artifacts for regression testing
│ ├── snapshots/ # Snapshots for output comparison testing
│ └── mocks/ # Mock data files for structured test input
└── test_000_/ # Package-specific test namespace
├── __init__.py # Test package initialization
├── fixtures.py # Common test fixtures and utilities
├── test_000_package.py # Package-level tests
├── test_010_base.py # Internal utilities
├── test_100_.py # Lowest levels of public API
├── test_200_.py # Lower levels of public API
├── ... # Higher levels of API
└── test_N00_.py # Top levels of API; Integration tests
### Numbering System
- **000-099**: Package internals (private utilities, base classes, etc.)
- **100-999**: Public API aspects, allocated by layer in blocks of 100
- Lower-level functionality → lower-numbered blocks
- Higher-level functionality and integration → higher-numbered blocks
- Test modules should generally correspond to source modules
- Siblings can be separated by increments of 10
- **Subpackage modules**: Use format `test_0__.py`
- `M` = major component number (e.g., 1, 2, 3)
- `N` = advances by 1 for each module within subpackage (0, 1, 2, ...)
- Examples: `test_110_auth_tokens.py`, `test_120_auth_sessions.py`
> **Warning: Do Not Confuse Module and Function Numbering**
>
> - **Test modules**: `test_100_exceptions.py` (hundreds place)
> - **Test functions**: `def test_100_basic_validation():` (within modules)
>
> These are completely separate numbering schemes. Module numbers indicate
> architectural hierarchy; function numbers indicate test organization within
> that module.
### Test Function Numbering
Within test modules, number test functions similarly:
def test_000_basic_functionality():
''' Basic feature works as expected. '''
def test_100_error_handling():
''' Error conditions are handled gracefully. '''
def test_200_advanced_scenarios():
''' Advanced usage patterns work correctly. '''
- **000-099**: Foundational tests for the module
- **100-199, 200-299, etc.**: Each function/class gets its own 100 block
- Closely related siblings can share a block (separated by 10 or 20)
- Different aspects of the same function/class separated by 5 or 10
### Test README Documentation
Maintain a `tests/README.md` file documenting:
- Test module numbering scheme specific to your package
- Rationale for any use of `patch` or other exceptions to standard patterns
- Project-specific testing conventions and fixtures
**Maintenance Requirements**:
- **Update when adding test modules**: Add entries for new test files with their numbering rationale
- **Keep current**: Remove references to deprecated test modules
- **Document changes**: Note any modifications to testing conventions or fixture usage
- **Responsibility**: Update during test planning phase, not during implementation
## Preferred Testing Patterns
### Import Patterns
**Direct imports (preferred for most cases)**:
from mypackage import mymodule
def test_100_basic_functionality():
''' Module function works correctly with valid input. '''
result = mymodule.process_data( 'test' )
assert result == 'processed: test'
**Dynamic imports for subpackage patterns**:
Use `cache_import_module` when working with similar modules across multiple subpackages or when module names are determined at runtime:
from . import __
@pytest.mark.parametrize( 'package_name', __.PACKAGES_NAMES )
def test_000_sanity( package_name ):
''' Package is sane. '''
package = __.cache_import_module( package_name )
assert package.__package__ == package_name
### Dependency Injection
The most important testing pattern. Inject dependencies via parameters:
# Function with injectable dependency
async def process_data( data: str, processor: Callable = default_processor ):
return await processor( data )
# Test with custom processor
async def test_process_data():
def mock_processor( data ):
return f"processed: {data}"
result = await process_data( "test", processor = mock_processor )
assert result == "processed: test"
Constructor injection for objects:
@dataclass( frozen = True )
class DataProcessor:
validator: Callable[ [ str ], bool ] = default_validator
def process( self, data: str ) -> str:
if not self.validator( data ):
raise ValueError( "Invalid data" )
return data.upper()
# Test with custom validator
def test_data_processor():
def always_valid( data ):
return True
processor = DataProcessor( validator = always_valid )
result = processor.process( "test" )
assert result == "TEST"
### Filesystem Operations
Prefer in-memory filesystems for performance. Use real temporary directories only when necessary:
from pyfakefs.fake_filesystem_unittest import Patcher
from pathlib import Path
# Preferred - use pyfakefs for most filesystem operations
def test_sync_file_operations():
with Patcher() as patcher:
fs = patcher.fs
fs.create_file( '/fake/config.toml', contents = '[section]\nkey = "value"' )
result = process_config_file( Path( '/fake/config.toml' ) )
assert result.key == 'value'
# Efficient - lazy-load existing test data at module level for performance
@pytest.fixture( scope = 'module' )
def test_data_fs( ):
''' Provides fake filesystem with tests/data loaded once per module. '''
with Patcher( ) as patcher:
# Lazy-load tests/data into fake filesystem for efficiency
patcher.fs.add_real_directory( 'tests/data', lazy_read = True )
yield patcher.fs
def test_100_config_processing( test_data_fs ):
''' Configuration files are processed correctly. '''
# All files in tests/data are now available in-memory
result = process_config_file( Path( 'tests/data/config.toml' ) )
assert result.setting == 'expected_value'
# When necessary - use real temp directories for async operations
@pytest.mark.asyncio
async def test_async_file_operations():
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path( temp_dir )
config_file = temp_path / 'config.toml'
config_file.write_text( '[section]\nkey = "value"' )
result = await async_process_config_file( config_file )
assert result.key == 'value'
### Test Data and Fixtures
Store test data files under `tests/data/` in organized subdirectories:
- **Fake packages**: `tests/data/packages/` for extension mechanism testing
- **Captured artifacts**: `tests/data/artifacts/` for regression testing
- **Snapshots**: `tests/data/snapshots/` for output comparison testing
- **Mock data files**: `tests/data/mocks/` for structured test input
Use fixture files when data is complex or reused across multiple tests. Prefer in-memory test data for simple, single-use scenarios.
### When to Mock
- **Third-party libraries**: Some provide their own mocks (e.g., `httpx` mock transport). Prefer these over writing custom mocks.
- **External services**: Mock network calls, database connections, etc.
- **Complex object creation**: When real objects are expensive to create.
Example with third-party mock:
import httpx
def test_http_client():
def handler( request ):
return httpx.Response( 200, json = { "result": "success" } )
transport = httpx.MockTransport( handler )
client = httpx.Client( transport = transport )
response = client.get( "https://example.com/api" )
assert response.json() == { "result": "success" }
### When to Patch
Avoid patching when possible. When necessary, only patch standard library and external packages, and document the justification.
Note: `importlib_metadata` is a third-party package that maintains forward compatibility with the latest `importlib.metadata` in the stdlib.
## Testing Strategy by Code Type
| Code Type | Strategy | Key Points |
|----------------------------|----------------------------------------------------|-----------------------------------------------------------------------------------|
| **Sync Filesystem** | `pyfakefs` with `Patcher()` | Fast, preferred for most file operations |
| **Async Operations** | Real temp directories | `aiofiles` bypasses `pyfakefs` thread pool |
| **Business Logic** | Dependency injection | Inject dependencies via constructor or method parameters |
| **Third-Party Boundaries** | Mocking or case-by-case patching | Use library-provided mocks when available |
| **Abstract Methods** | `# pragma: no cover` | Apply to `NotImplementedError` lines only |
| **Cross-Platform** | `pathlib.Path` with `.resolve()` and `.samefile()` | Use `Path.resolve()` to unfurl symlinks; `.samefile()` for comparisons on Windows |
## Development Environment
- **Always use hatch environment** for all testing commands:
hatch --env develop run pytest # run tests
hatch --env develop run linters # run linters
hatch --env develop run testers # run full test suite with coverage
- **Test performance**: The elapsed time reported by `pytest` should be under two seconds for the full test suite.
## Test Code Standards
### Docstring Guidelines
- **Describe behavior**, not function names
- **Keep headlines single-line** (don't spill across lines)
- **Good**: `''' Error interceptor returns Value for successful awaitable. '''`
- **Bad**: `''' intercept_error_async returns Value for successful awaitable. '''`
### Code Style
- Follow the project [code style guide](style.md) for all test code
- **Mark slow tests** with `@pytest.mark.slow`
- **Narrow try blocks** around exception-raising statements only
## Advanced Testing Patterns
### Frame Inspection Testing
Mock frame chains for call stack simulation (document justification):
def test_caller_discovery():
# Mock frame chain simulating call stack
external_frame = MagicMock()
external_frame.f_code.co_filename = '/external/caller.py'
external_frame.f_back = None
internal_frame = MagicMock()
internal_frame.f_code.co_filename = '/internal/module.py'
internal_frame.f_back = external_frame
with patch( 'inspect.currentframe', return_value = internal_frame ):
result = module._discover_invoker_location()
assert result == Path( '/external' )
### Resource Management
Use `ExitStack` for multiple temporary resources:
from contextlib import ExitStack
def test_multiple_temp_files():
with ExitStack() as stack:
temp1 = stack.enter_context(
tempfile.NamedTemporaryFile( mode = 'w', delete = False ) )
temp2 = stack.enter_context(
tempfile.NamedTemporaryFile( mode = 'w', delete = False ) )
# Both files cleaned up automatically
### Error Simulation and Recovery
Test error conditions and recovery paths:
def safe_config_edit( config ):
try:
config[ 'application' ][ 'safe_mode' ] = True
except Exception:
config[ 'fallback' ] = True # Apply fallback
@pytest.mark.asyncio
async def test_error_recovery():
async with contextlib.AsyncExitStack() as exits:
result = await prepare_with_config(
exits, configedits = ( safe_config_edit, ) )
assert result.configuration.get( 'fallback' )
## Performance Optimization
### Strategies
- **Avoid subprocess calls** when possible
- **Use pyfakefs for most filesystem tests** → Significant performance improvement
- **Minimize patching** → Maintain architecture integrity
- **Accept some real I/O for complex async operations** → Hybrid approach
## Coverage Guidelines
### When to Use `# pragma: no cover`
- **Abstract methods** with `NotImplementedError`
- **Defensive code** that's impossible to trigger
- **Platform-specific branches** that can't be tested in current environment
- **Last resort only** - prefer dependency injection
### 100% Coverage Standards
Target 100% line and branch coverage systematically:
@pytest.mark.asyncio
async def test_development_mode_missing_package():
''' Prepare triggers development mode for missing package. '''
with patch( 'importlib_metadata.packages_distributions', return_value = {} ):
info = await module.prepare( 'nonexistent-package' )
assert info.editable is True # Development mode verified
Every line and branch should be covered by tests. Use `# pragma: no cover` only as a last resort.
## Pre-Commit Validation
**Always run validation before committing** to avoid Git hook failures:
hatch --env develop run linters # Check code style and quality
hatch --env develop run testers # Run full test suite with coverage
Git hooks will run these validations automatically, but running them manually first saves turnaround time from CI failures.
## Troubleshooting Common Issues
1. **AttributeImmutability errors** → Use dependency injection instead of patching
2. **aiofiles not working with pyfakefs** → Fall back to real temp directories
3. **Test parameter conflicts** → Use `Patcher()` context manager, not `@patchfs`
4. **Line number shifts in bulk editing** → Work from back to front
## Decision Framework
If you can't test something without monkey-patching:
1. **Try dependency injection** patterns above
2. **Check if interface supports injection** extension
3. **Consider available mocks** from third-party libraries
4. **Discuss design and justification** with team
5. **Last resort** - apply `# pragma: no cover` with justification
The goal is testable code through good design, not circumventing the architecture.
## Benefits of This Approach
1. **Realistic testing** - appropriate resource use catches more bugs
2. **Flexible code** - dependency injection improves design
3. **Maintainable tests** - less fragile than monkey-patching
4. **Preserved architecture** - immutability provides thread safety
5. **Optimized performance** - strategic use of in-memory filesystems
6. **Comprehensive coverage** - systematic targeting of uncovered branches