📘 Technical Design 025 — Unit Test Quality Standards¶
| Field | Value |
|---|---|
| ID | TD-025 |
| Title | Unit Test Quality Standards |
| Status | Active |
| Owner | Contributors |
| Audience | Contributors, test developers |
| Created | 2025-10-18 |
| Last Updated | 2025-10-18 |
| Reviewers | TBD |
| Approvers | TBD |
| Tracking | Phase of DI Roadmap |
| Related | TD-024 (Meta), TD-026 (Integration) |
🧭 1. Summary¶
This technical design establishes prescriptive standards for unit tests in tests/unit/. It implements universal principles from TD-024 with concrete rules, patterns, and examples specific to unit testing. The design includes a module-by-module migration strategy prioritized by issue severity.
Unit tests validate individual components in isolation (<100ms execution, focused scope, prefer real implementations).
📚 2. Context¶
See TD-024 (Meta) for full context. This TD focuses on:
- Current Pain Points:
- ~33% of tests call private methods
- 22 inline fixtures scattered across suite
-
Tests brittle to non-behavioral changes
-
Scope:
tests/unit/(580 tests across reflection, DI, markers, annotations) -
Approach: Module-by-module migration with high-priority targets
🎯 3. Unit Test Standards¶
3.1 Rule: Test Public APIs Only¶
Statement: NEVER call _private() or _protected() methods.
Bad Example - tests/unit/reflection/test_high_fidelity_components.py:268:
def test_attempt_direct_resolution_success(self) -> None:
"""Test _attempt_direct_resolution with successful resolution."""
# ❌ FORBIDDEN: Testing private method directly
hints, priority = self.provider._attempt_direct_resolution(
test_func, {}, f"{test_func.__module__}.{test_func.__qualname__}"
)
assert hints is not None
Problem: - Changes to _attempt_direct_resolution signature break test - Implementation details leak into test - No guarantee behavior is actually tested
Good Example - tests/unit/di/application/test_application_decorator.py:11:
def test_decorator_attaches_metadata_correctly() -> None:
"""Test that @application decorator attaches configuration metadata."""
@application(scan_packages=["myapp.services"])
class TestApp(ApplicationBase):
def run(self) -> None:
pass
# ✅ GOOD: Test through public API (decorator)
annotation = find_marker(TestApp, ApplicationMarker)
assert annotation is not None
assert annotation.config.scan_packages == ["myapp.services"]
Benefit: - Tests public API contract - Implementation changes don't break tests - Clear what's being validated
3.2 Rule: Behavior-Based Assertions¶
Statement: Assert observable outcomes, never internal state.
Bad Pattern:
# ❌ FORBIDDEN: Checking internal state
def test_caching():
result1 = provider.for_callable(func)
result2 = provider.for_callable(func)
assert result1 is result2
assert len(provider._cache) == 1 # ❌ Testing _cache internals
assert isinstance(provider._validator, RegistrationValidator) # ❌ Type check on private
def test_dependency_injection():
container.register(Service, Service)
assert "Service" in container._registrations # ❌ Checking internal dict
assert len(container._instances) == 0 # ❌ Checking internal state
Good Pattern:
# ✅ GOOD: Observable behavior
def test_caching_returns_same_instance():
"""Test that cached results are reused."""
result1 = provider.for_callable(func)
result2 = provider.for_callable(func)
assert result1 is result2 # Observable: same instance returned
def test_dependency_injection():
"""Test that registered services can be resolved."""
container.register(Service, Service)
# Observable: service resolves correctly
resolved = container.resolve(Service)
assert isinstance(resolved, Service)
# Observable: error when dependency missing
with pytest.raises(DiResolutionError):
container.resolve(MissingService)
3.3 Rule: Fixture Organization¶
Statement: - Centralize shared fixtures in tests/fixtures/ - Localize test-specific fixtures in test file (when needed) - Never duplicate fixtures across multiple files
Bad Pattern:
# ❌ test_file_1.py
@pytest.fixture
def simple_container():
return Container()
# ❌ test_file_2.py
@pytest.fixture
def simple_container(): # Same fixture duplicated!
return Container()
Good Pattern:
# ✅ tests/fixtures/di/container.py
@pytest.fixture
def simple_container() -> Container:
"""Provide a simple DI container for testing."""
return Container()
# ✅ tests/unit/di/test_container.py
from tests.fixtures.di.container import simple_container
def test_container_registration(simple_container):
"""Test container registration."""
simple_container.register(Service, Service)
Fixture Organization:
| Fixture Type | Location | Example |
|---|---|---|
| Container with defaults | tests/fixtures/di/container.py | simple_container() |
| Mock objects | tests/fixtures/di/mocks.py | mock_service() |
| Reflection context | tests/fixtures/reflection/context.py | reflection_context() |
| Module factory | tests/fixtures/common/module_factory.py | make_module() |
| Test-specific | Test file inline | Application-specific setup |
3.4 Rule: Clear, Descriptive Naming¶
Statement: Test names should answer "what is being tested?" and "when?"
Pattern: test_component_behavior_when_condition()
Good Examples:
test_container_resolves_registered_service()
test_container_raises_error_when_dependency_missing()
test_decorator_preserves_class_identity_when_applied()
test_provider_attempts_fallback_when_direct_resolution_fails()
test_cache_returns_same_instance_when_called_twice()
Bad Examples:
test_container() # What about container?
test_error() # What error?
test_success() # Success of what?
test_case_1() # No information about scenario
test_failure_handling() # Too vague
3.5 Rule: Every Test Has Explanatory Docstring¶
Statement: Every test must have a docstring explaining "why this test exists."
def test_container_validates_circular_dependencies() -> None:
"""Test that container detects circular dependency chains.
This test ensures the container's dependency validator catches cycles
like A → B → C → A and raises BootstrapError before registration completes.
Critical for preventing infinite recursion at runtime.
"""
# Test implementation
Why: - Documents expected behavior - Explains edge cases or non-obvious scenarios - Helps future maintainers understand requirements
3.6 Rule: Use AAA Pattern¶
Statement: Structure all tests as Arrange-Act-Assert.
def test_example() -> None:
"""Test example behavior."""
# ARRANGE: Set up test data and fixtures
container = make_container()
service = MockService()
# ACT: Perform the action being tested
container.register(MockService, service)
resolved = container.resolve(MockService)
# ASSERT: Verify outcomes
assert resolved is service
📐 4. Unit Test Patterns¶
4.1 Testing Public APIs¶
Pattern: Test through defined public interface.
def test_reflection_context_inspects_callable() -> None:
"""Test that context inspects callable types."""
def sample_func(x: int) -> str:
return str(x)
# Public API: inspect_callable
context = ReflectionContext()
info = context.inspect_callable(sample_func)
# Observable behavior: returns CallableInfo with correct hints
assert info.hints["x"] is int
assert info.hints["return"] is str
4.2 Testing Error Conditions¶
Pattern: Use pytest.raises() for expected exceptions.
def test_container_raises_error_when_dependency_missing() -> None:
"""Test container raises DiResolutionError for missing dependencies."""
container = Container()
# Act & Assert: verify exception is raised
with pytest.raises(DiResolutionError, match="Cannot resolve MissingService"):
container.resolve(MissingService)
4.3 Testing Observable State Changes¶
Pattern: Assert results, not internal state.
def test_registration_makes_service_resolvable() -> None:
"""Test that registered services become resolvable."""
container = Container()
service_instance = Service()
# Register service
container.register(Service, service_instance, Lifecycle.SINGLETON)
# Observable: service can be resolved
resolved = container.resolve(Service)
assert resolved is service_instance
# Observable: resolved again gets same instance (singleton)
resolved2 = container.resolve(Service)
assert resolved is resolved2
4.4 Testing with Fixtures¶
Pattern: Use fixtures for common setup.
@pytest.fixture
def provider_with_context() -> tuple[TypeHintProvider, ReflectionContext]:
"""Provide a type hint provider with reflection context."""
context = ReflectionContext()
provider = SimpleTypeHintProvider(context)
return provider, context
def test_provider_resolves_builtin_types(
provider_with_context: tuple[TypeHintProvider, ReflectionContext]
) -> None:
"""Test provider correctly handles builtin types."""
provider, context = provider_with_context
hints = provider.for_callable(builtin_func)
assert hints["param"] is int
🔄 5. Migration Strategy¶
5.1 Priority Modules¶
Audit Results:
| Module | Private API Calls | Status | Priority |
|---|---|---|---|
| test_high_fidelity_components.py | 10+ | 🔴 Critical | 🔥 High |
| test_reflection_provider.py | TBD | Audit needed | 🔥 High |
| test_composite_providers.py | 0 | ✅ Good | Skip |
| test_di/application/ | 0 | ✅ Good | Skip |
| test_markers/ | TBD | Audit needed | ⚡ Medium |
| test_annotations/ | TBD | Audit needed | ⚡ Medium |
5.2 Migration Process per Module¶
For each target module:
-
Audit: Count private API calls
grep "provider\._\|context\._\|obj\._" tests/unit/reflection/test_*.py -
Identify Patterns: What private methods are being tested?
- Internal resolution steps?
- Cache checking?
-
Type checking on private fields?
-
Refactor: Convert to test public APIs
- Test the public method that uses private logic
- Assert observable outcome
-
Remove direct private calls
-
Fill Gaps: Add tests for scenarios only testable via private API
- If scenario can't be tested publicly: likely code smell
-
Consider exposing behavior through public API
-
Verify: Ensure all tests pass
make testfor full suite- No new violations introduced
5.3 Example Refactoring¶
Before (testing private method):
def test_attempt_direct_resolution_success(self) -> None:
"""Test _attempt_direct_resolution with successful resolution."""
def test_func(x: int) -> str:
return str(x)
# ❌ Testing private method
hints, priority = self.provider._attempt_direct_resolution(
test_func, {}, f"{test_func.__module__}.{test_func.__qualname__}"
)
assert hints is not None
assert "x" in hints
assert hints["x"] is int
After (testing public API):
def test_provider_resolves_simple_function_types() -> None:
"""Test provider correctly resolves type hints for simple functions."""
def sample_func(x: int) -> str:
return str(x)
# ✅ Testing public API (for_callable)
hints = self.provider.for_callable(sample_func)
# Observable: correct hints returned
assert hints is not None
assert "x" in hints
assert hints["x"] is int
assert hints["return"] is str
📊 6. Success Criteria¶
- Zero private API calls in unit tests (grep -r "._" returns 0)
- All high-priority modules migrated
- All tests have explanatory docstrings
- All fixtures centralized or documented as test-specific
- Tests follow AAA pattern consistently
- No test breaks due to non-behavioral code changes
📈 7. Progress Tracking¶
Legend: [x] = complete, [~] = in progress, [ ] = pending
Phase 1: High Fidelity Type Hints ✅ Completed¶
Module: tests/unit/reflection/high_fidelity_type_hints/
Test files (21 function-based tests): - test_type_hint_cache_manager.py: 4 tests, TypeHintCacheManager public API - test_type_hint_metrics_emitter.py: 7 tests with mock-based metric verification - test_high_fidelity_provider.py: 10 tests covering type resolution workflows
Completion status: - [x] Audit test_high_fidelity_components.py (identified 10+ private API calls, 4 test classes) - [x] Refactor into 3 focused test files with 21 function-based tests - [x] Remove all private API calls - [x] Localize test-specific fixtures - [x] Implement mock-based metrics verification - [x] Add descriptive docstrings to all tests - [x] Organize with subdirectory structure - [x] All tests passing (705/705)
Phase 2: Type Resolution Module ✅ Completed¶
Module: tests/unit/reflection/type_resolution/ - Component Tests Only
Unit test files (8 function-based tests): - test_import_resolver.py: 2 tests, component API only - test_import_resolution.py: 5 tests, component API only - test_runtime_type_resolver.py: 1 test, RuntimeTypeResolver introspection and object mapping
Integration test files (3 tests moved): - Moved to tests/integration/reflection/test_dotted_path_resolution.py: 3 tests - Tests ReflectionContext.resolve.dotted() facade method - Reason: Tests high-level context API, not component in isolation
Completion status: - [x] Audit for private API calls - 0 violations found - [x] All unit tests function-based (no test classes) - [x] All tests have descriptive docstrings - [x] Unit tests passing (8/8) ✅ - [x] Unit/integration boundary correctly identified
Phase 3: Type Hints Components ✅ Completed¶
Module: tests/unit/reflection/type_hints/ - Component Tests Only
Unit test files (3 function-based tests): - test_type_hints_annotations.py: 1 test, component API only - test_composite_providers.py: 2 tests, component behavior only
Integration test files (2 tests moved): - Moved to tests/integration/reflection/test_field_type_resolution.py: 1 test - Tests ReflectionContext.hints.for_field() facade method - Reason: Tests high-level context API - Moved to tests/integration/reflection/test_context_builder.py: 1 test - Tests ReflectionContext constructor/factory helpers used to customize metrics/type-hint behaviour - Reason: Tests builder integration, not component in isolation
Completion status: - [x] Audit for private API calls - 0 violations found - [x] All unit tests function-based (no test classes) - [x] All tests have descriptive docstrings - [x] Fixture updates applied (member_builder, null_metrics) - [x] Unit tests passing (3/3) ✅ - [x] Unit/integration boundary correctly identified
Phase 4: Scanning Module ✅ Completed¶
Module: tests/unit/reflection/scanning/ - Component Tests Only
Unit test files (3 function-based tests): - test_scanner.py: 1 test, PackageScanner direct behavior - test_scanner_component.py: 2 tests, PackageScanner component functionality
Integration test files (5 tests moved): - Moved to tests/integration/reflection/test_context_scanning_policies.py: 3 tests - Tests ReflectionContext.scan.* facade methods - Tests: error policies, public-only filtering, member-kind filtering - Reason: Tests high-level context scanning API - Moved to tests/integration/reflection/test_context_error_policies.py: 2 tests - Tests ScanError exception structure and behavior - Reason: Tests error propagation through context
Completion status: - [x] Audit for private API calls - 0 violations found - [x] All unit tests function-based (no test classes) - [x] All tests have descriptive docstrings - [x] Fixture updates applied (scanner fixture for component tests) - [x] Unit tests passing (3/3) ✅ - [x] CRITICAL: Unit/integration boundary discovery - Revealed that ReflectionContext tests should NOT be unit tests - Facade pattern recognized: context delegates to components - All context tests moved to integration - Applies to Phases 2, 3, and future phases
Phase 5: Member Information Builder ✅ Completed¶
Module: tests/unit/reflection/ - Split by Component
Unit test files (5 function-based tests):
Component: MemberBuilder (tests/unit/reflection/member_builder/): - test_member_builder.py: 4 tests, module building, type structure, function params, caching
Component: SafeCallableInvoker & TypeInstanceFactory (tests/unit/reflection/invocation/): - test_invocation.py: 1 test, constructor invocation and instance creation
Completion status: - [x] Audit for private API calls - 0 violations found - [x] All tests already function-based (no test classes) - [x] All tests have descriptive docstrings with Sphinx format - [x] Fixture consolidation applied: - Phase-specific member_builder fixture in member_builder/conftest.py - Reused shared null_metrics fixture - RuntimeTypeResolver, SafeCallableInvoker inline (test-specific) - [x] All tests passing (5/5) ✅ - [x] Tests organized by component (MemberBuilder vs Invocation)
Phase 6: Metrics & Error Handling [ ]¶
Test files (7 existing tests with test classes): - test_metrics_components.py - Metrics component functionality - test_metrics_hook.py - Metrics hook integration - test_error_payloads_schema.py - Error payload schemas
Audit checklist: - [ ] Audit for private API calls - [ ] Convert test classes to function-based tests - [ ] Add docstrings to all tests - [ ] Verify all tests pass
Phase 7: Fixture Organization & Consolidation [ ]¶
Cross-module fixture work: - [ ] Review and localize test-specific fixtures - [ ] Consolidate shared fixtures in tests/fixtures/reflection/ - [ ] Update centralized fixture imports - [ ] Document fixture usage patterns
Phase 8: Full Verification & Closure [ ]¶
- Run full test suite with all modules migrated
- Code review of all migrated modules
- Update documentation with completion status
- Archive completed TD-025
📝 8. Changelog¶
- 2025-10-19: Phase 5 (Member Information Builder) - Component Organization
- Unit tests: 5 (MemberBuilder: 4 tests, Invocation: 1 test)
- Tests organized by component into separate directories:
- tests/unit/reflection/member_builder/: 4 tests, phase-specific fixture
- tests/unit/reflection/invocation/: 1 test, shared fixtures
- Applied fixture consolidation:
- Created member_builder/conftest.py with local fixture
- Reused shared null_metrics fixture
- Test-specific components inline (RuntimeTypeResolver, SafeCallableInvoker)
- All tests compliant from start: function-based, documented, 0 private API calls
-
All 5 tests passing ✅
-
2025-10-19: RuntimeTypeResolver test consolidation
- Renamed: test_type_resolver.py → test_runtime_type_resolver.py
- Clarified test function name for better intent
- Confirmed test_invocation.py uses RuntimeTypeResolver as helper (not subject of test)
-
Confirmed test_type_hints_annotations.py uses RuntimeTypeResolver as helper (not subject of test)
-
2025-10-19: CRITICAL: Unit/Integration boundary reclassification
- Discovery: ReflectionContext tests incorrectly classified as unit tests
- Issue: Testing facades (coordinators/delegates) requires mocking internals = implementation testing
- Solution: Moved all ReflectionContext tests to integration layer
- Affected phases: 2, 3, 4 - total 10 tests reclassified
- Details:
- Phase 2: 3 tests moved (test_resolve_dotted_success.py → integration)
- Phase 3: 2 tests moved (test_type_hints_fields.py + builder test → integration)
- Phase 4: 5 tests moved (context/*.py → integration)
- Lesson: Updated TD-024 with facade pattern recognition and unit/integration classification criteria
-
Going Forward: Future phases must apply this boundary correctly from start
-
2025-10-19: Phase 4 (Scanning Module) - Reclassified
- Unit tests: 3 (PackageScanner component)
- Reclassified to integration: 5 (ReflectionContext tests)
- Created scanner fixture for component tests
-
Updated documentation with unit/integration classification guidance
-
2025-10-19: Phase 3 (Type Hints Components) - Reclassified
- Unit tests: 3 (component behavior only)
-
Reclassified to integration: 2 (ReflectionContext tests)
-
2025-10-19: Phase 2 (Type Resolution Module) - Reclassified
- Unit tests: 8 (component behavior only)
-
Reclassified to integration: 3 (ReflectionContext tests)
-
2025-10-19: Phases 1-4 Summary (After Reclassification)
- Total unit tests: 35 (Phase 1: 21, Phase 2: 8, Phase 3: 3, Phase 4: 3)
- Total reclassified to integration: 10 (Phases 2, 3, 4)
- All comply with public API only rule
- All function-based, no test classes
-
All have descriptive Sphinx docstrings
-
2025-10-19: TD-024 Updated with Patterns Documentation
- Added Section 5.6: Unit vs Integration classification with facade pattern recognition
-
Added Section 5.7: Successful patterns from Phases 1-4
- Real implementations over mocks
- Fixture factory pattern
- Progressive fixture scoping
- Sphinx docstring requirement
-
2025-10-18: Initial unit test quality standards TD created
📖 9. Glossary¶
- Unit Test: Fast (<100ms) test of single component with own logic/behavior
- Integration Test: Cross-component workflow test (<500ms) validating how components work together
- Facade: Component whose primary responsibility is coordinating/delegating to other components
- Component: Class/module with distinct responsibility and observable behavior (not just delegation)
- Private API: Methods/properties starting with underscore (
_internal) - Public API: Methods/properties without underscore
- Observable Behavior: Outcomes visible through public interface
- Implementation Testing: Verifying how something works (internal details), not what it does (behavior)
- Fixture: Test helper (factory, mock, builder) that sets up test data
- AAA Pattern: Arrange-Act-Assert structure for tests