---
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.

```python
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.

```python
# Simple stub
provider.get_secret.return_value = "secret-value"

# Sequential returns
provider.get_secret.side_effect = ["first", "second", None]

# Conditional stub
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.

```python
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.).

```python
from unittest.mock import patch


@patch("boto3.client")
def Should_CallSSM_When_AwsProviderUsed(mock_boto: Mock) -> None:
    # Arrange
    mock_ssm = Mock()
    mock_boto.return_value = mock_ssm
    mock_ssm.get_parameter.return_value = {"Parameter": {"Value": "secret"}}

    # Act
    sut = AwsSsmSecretProvider()
    actual = sut.get_secret("/app/key")

    # Assert
    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.

```python
provider.get_secret.side_effect = ClientError(
    {"Error": {"Code": "ParameterNotFound"}}, "GetParameter"
)
```

---

## Verification Patterns

### Basic Verification

```python
provider.get_secret.assert_called_once_with("/ssm/path")
provider.get_secret.assert_called_with("/ssm/path")
logger.info.assert_called_once()
```

### Not Called

```python
provider.get_secret.assert_not_called()
logger.error.assert_not_called()
```

### Call Count

```python
assert provider.get_secret.call_count == 3
```

### Argument Inspection

```python
from unittest.mock import call

provider.get_secret.assert_has_calls([
    call("/app/db"),
    call("/app/key"),
], any_order=True)
```

### Async Verification

```python
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:

```python
@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:

```python
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:

```python
# Arrange
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:

1. Create port mocks with `Mock(spec=Interface)` in fixtures
2. Configure stubs with `.return_value` in Arrange
3. **Always verify** mock interactions in Assert
4. Use Mother pattern for complex test data
5. **Always use `spec=`** to catch typo bugs
6. Prefer fixture injection over inline `Mock()` creation
