| name | python-test-doubles |
| description | Test doubles with unittest.mock (Mock, AsyncMock, patch) and Mother pattern. Use when creating test data, mocking dependencies, or setting up fixtures in Python tests. |
Test Doubles (Python)
This skill defines how to use test doubles in Python (unittest.mock + pytest).
Test Doubles Types
1. Mock: Mock / AsyncMock
Use Mock and AsyncMock to create mock objects for protocol interfaces.
Purpose: Replace real dependencies with controllable test doubles.
from unittest.mock import Mock, AsyncMock
@pytest.fixture()
def secret_provider() -> Mock:
provider = Mock(spec=ISecretProvider)
provider.get_secret = Mock(return_value=None)
return provider
@pytest.fixture()
def async_provider() -> AsyncMock:
provider = AsyncMock(spec=ISecretProvider)
provider.get_secret = AsyncMock(return_value=None)
return provider
MANDATORY: Always use spec=InterfaceClass to catch typos at test time.
2. Stub: return_value / side_effect
Use return_value or side_effect to configure stubs.
Purpose: Predefined responses without caring about call verification.
provider.get_secret.return_value = "secret-value"
provider.get_secret.side_effect = ["first", "second", None]
def resolve_secret(name: str) -> str | None:
secrets = {"/app/db": "postgres://...", "/app/key": "abc123"}
return secrets.get(name)
provider.get_secret.side_effect = resolve_secret
3. Spy: wraps
Use wraps to observe calls on real objects without replacing behavior.
Purpose: Verify interactions while preserving real implementation.
real_parser = MapFileParser()
spy_parser = Mock(wraps=real_parser)
sut = EnvilderClient(provider, spy_parser)
sut.resolve_secrets(map_file)
spy_parser.parse.assert_called_once_with(map_file)
4. Module Patch: patch / patch.object
Use patch to replace module-level objects or class methods.
Purpose: Replace external dependencies (boto3, file I/O, etc.).
from unittest.mock import patch
@patch("boto3.client")
def Should_CallSSM_When_AwsProviderUsed(mock_boto: Mock) -> None:
mock_ssm = Mock()
mock_boto.return_value = mock_ssm
mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "secret"}}
sut = AwsSsmSecretProvider()
actual = sut.get_secret("/app/key")
assert actual == "secret"
mock_ssm.get_parameter.assert_called_once()
5. Error Simulation: side_effect with Exception
Use side_effect with an exception to simulate failures.
Purpose: Test error paths and exception handling.
provider.get_secret.side_effect = ClientError(
{"Error": {"Code": "ParameterNotFound"}}, "GetParameter"
)
Verification Patterns
Basic Verification
provider.get_secret.assert_called_once_with("/ssm/path")
provider.get_secret.assert_called_with("/ssm/path")
logger.info.assert_called_once()
Not Called
provider.get_secret.assert_not_called()
logger.error.assert_not_called()
Call Count
assert provider.get_secret.call_count == 3
Argument Inspection
from unittest.mock import call
provider.get_secret.assert_has_calls([
call("/app/db"),
call("/app/key"),
], any_order=True)
Async Verification
provider.get_secret.assert_awaited_once_with("/ssm/path")
provider.get_secret.assert_awaited()
provider.get_secret.assert_not_awaited()
Pytest Fixtures as Factories
Use fixtures to build test doubles with proper lifecycle:
@pytest.fixture()
def secret_provider() -> Mock:
provider = Mock(spec=ISecretProvider)
provider.get_secret.return_value = None
return provider
@pytest.fixture()
def logger() -> Mock:
return Mock(spec=ILogger)
@pytest.fixture()
def sut(secret_provider: Mock, logger: Mock) -> EnvilderClient:
return EnvilderClient(provider=secret_provider, logger=logger)
Mother Pattern
Use factory functions or classes for reusable test data:
from dataclasses import dataclass
from typing import Optional
from uuid import UUID, uuid4
class MapFileMother:
@staticmethod
def create(
provider: str = "aws",
mappings: Optional[dict[str, str]] = None,
) -> ParsedMapFile:
return ParsedMapFile(
config=MapFileConfig(provider=provider),
mappings=mappings or {"DB_URL": "/app/db"},
)
class EnvilderOptionsMother:
@staticmethod
def create(
provider: SecretProviderType = SecretProviderType.AWS,
profile: Optional[str] = None,
vault_url: Optional[str] = None,
) -> EnvilderOptions:
return EnvilderOptions(
provider=provider,
profile=profile,
vault_url=vault_url,
)
Usage:
map_file = MapFileMother.create(mappings={"API_KEY": "/prod/key"})
Summary
| Double Type | Python API | Purpose |
|---|
| Mock | Mock(spec=X) | Controllable replacement |
| Stub | .return_value / .side_effect | Predefined responses |
| Spy | Mock(wraps=real) | Observe real objects |
| Patch | @patch("module.obj") | Replace module-level deps |
| Error sim | .side_effect = Exception(...) | Failure paths |
When writing tests:
- Create port mocks with
Mock(spec=Interface) in fixtures
- Configure stubs with
.return_value in Arrange
- Always verify mock interactions in Assert
- Use Mother pattern for complex test data
- Always use
spec= to catch typo bugs
- Prefer fixture injection over inline
Mock() creation