---
name: python-testing
description: Mandatory testing conventions including AAA pattern, test naming, assertions, and mocks. Use for unit, integration, or E2E tests with pytest, unittest, pytest-asyncio, or Playwright.
---

# Testing Conventions (Python)

This skill defines the **MANDATORY** testing conventions for Python projects.
These are **rules**, not guidelines.

---

## Documentation Rules

### NO Docstrings, NO Comments

* **Do NOT write docstrings** - method/class names must be self-explanatory
* **Do NOT write comments** except for AAA markers (`# Arrange`, `# Act`, `# Assert`)
* The test name `Should_X_When_Y` already documents the intent

**Exception: SDK public API:** Code under `src/sdks/*/` that is consumed by
external developers (e.g. facade classes, public entry points) **SHOULD** have
docstrings with usage examples. External users rely on IDE tooltips and `help()`.
This exception does **not** apply to tests or internal helpers.

#### ❌ FORBIDDEN

```python
class TestUserService:
    
    def Should_CreateUser_When_Valid(self) -> None:
        # This creates a user  # NO explanatory comments
        user = UserFactory.build()
```

#### ✅ CORRECT

```python
class TestUserService:
    def Should_CreateUser_When_Valid(self) -> None:
        # Arrange
        user = UserFactory.build()
        
        # Act
        actual = self._sut.create(user)
        
        # Assert
        assert actual is not None
```

---

## Core Principles

### 1. AAA Pattern (Arrange – Act – Assert)

**ALL tests MUST follow the AAA pattern**, separated by inline comments.

#### Rules

* Each phase **MUST** be separated with comments
* **Never mix phases**
* **Each comment (`# Arrange`, `# Act`, `# Assert`) appears AT MOST ONCE per test**: if you need two actions
or two asserts, write two tests
* **No `if`, `switch`, or conditional logic** inside Arrange, Act, or Assert blocks
* **No `try/catch/finally`** inside tests: use pytest fixtures with `yield` for teardown/cleanup
* **No `# Act & Assert` combined blocks**: Act and Assert are ALWAYS separate
* For exception testing, extract the action into a `lambda` before asserting
* If no Arrange is needed, omit it
* If there is no Assert, the test is invalid

#### pytest Example

```python
def Should_CreateGroup_When_RequestIsValid(
    group_repository: Mock,
    sut: GroupService
) -> None:
    # Arrange
    request = CreateGroupRequest(
        name="Test Group",
        type=GroupType.RECURRING
    )
    group_repository.get_group_by_id.return_value = None

    # Act
    actual = sut.create_group(request)

    # Assert
    assert actual is not None
    assert actual.id is not None
    assert actual.name == "Test Group"
    group_repository.add_group.assert_called_once()
    group_repository.save.assert_called_once()
```

---

### 2. Test Naming Convention

Test names **MUST** follow exactly:

```text
Should_{ExpectedBehavior}_When_{Condition}
```

#### Test Rules

* **PascalCase**
* **NO** natural language
* **NO** vague names
* **NO** missing `When` clause
* `test_` prefix is **FORBIDDEN**

#### pytest Discovery Configuration (MANDATORY)

```toml
[tool.pytest.ini_options]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["Should_*"]
```

If this config is missing → **tests are wrong**.

---

## Variable Naming (MANDATORY)

| Purpose            | Name       |
| ------------------ | ---------- |
| Subject under test | `sut`      |
| Expected value     | `expected` |
| Actual result      | `actual`   |

No creativity allowed here.

---

## Async Testing (pytest-asyncio)

```python
@pytest.mark.asyncio
async def Should_ReturnUser_When_UserExists(
    user_repository: AsyncMock,
    sut: GetUserHandler
) -> None:
    # Arrange
    expected = UserMother.create()
    user_repository.get_by_id.return_value = expected

    # Act
    actual = await sut.handle(expected.id)

    # Assert
    assert actual == expected
    user_repository.get_by_id.assert_awaited_once()
```

---

## Exception Testing

**Act and Assert MUST be separate.** Extract the action into a `lambda` in the Act phase.

### ✅ CORRECT: separate Act and Assert

```python
def Should_RaiseValueError_When_NameIsEmpty(sut: GroupService) -> None:
    # Arrange
    request = CreateGroupRequest(name="", type=GroupType.RECURRING)

    # Act
    action = lambda: sut.create_group(request)

    # Assert
    with pytest.raises(ValueError, match="Group name is required"):
        action()
```

### ❌ FORBIDDEN: combined Act & Assert

```python
def Should_RaiseValueError_When_NameIsEmpty(sut: GroupService) -> None:
    # Arrange
    request = CreateGroupRequest(name="", type=GroupType.RECURRING)

    # Act & Assert   ← NEVER DO THIS
    with pytest.raises(ValueError, match="Group name is required"):
        sut.create_group(request)
```

---

## Teardown & Cleanup (MANDATORY pattern)

**Never use `try/finally` in tests.** Use pytest fixtures with `yield` for cleanup.

### ✅ CORRECT: fixture with yield

```python
@pytest.fixture()
def env_cleanup() -> Generator[list[str], None, None]:
    keys: list[str] = []
    yield keys
    for key in keys:
        os.environ.pop(key, None)


class TestEnvilderClient:
    def Should_SetEnvVars_When_InjectCalled(
        self, env_cleanup: list[str]
    ) -> None:
        # Arrange
        secrets = {"MY_TOKEN": "token-123"}
        env_cleanup.extend(secrets.keys())

        # Act
        EnvilderClient.inject_into_environment(secrets)

        # Assert
        assert os.environ["MY_TOKEN"] == "token-123"
```

### ❌ FORBIDDEN: try/finally in test

```python
def Should_SetEnvVars_When_InjectCalled(self) -> None:
    secrets = {"MY_TOKEN": "token-123"}
    try:
        EnvilderClient.inject_into_environment(secrets)
        assert os.environ["MY_TOKEN"] == "token-123"
    finally:
        os.environ.pop("MY_TOKEN", None)
```

---

## Mocking & Verification (OBLIGATORY)

If you mock something, **you MUST verify it**.

```python
group_repository.add_group.assert_called_once()
group_repository.save.assert_called_once()
group_repository.delete_group.assert_not_called()
```

Async:

```python
repository.save.assert_awaited_once()
```

No verification → **test rejected**.

---

## Test Data Creation (RECOMMENDED)

Use **Mother Pattern** or **Builder Pattern** for creating test data.
Both approaches are valid and recommended over inline object creation.

### Mother Pattern

```python
from dataclasses import dataclass
from uuid import UUID, uuid4
from typing import Optional


@dataclass
class Group:
    id: UUID
    name: str
    type: GroupType


class GroupMother:
    @staticmethod
    def create(
        id: Optional[UUID] = None,
        name: Optional[str] = None,
        type: Optional[GroupType] = None,
    ) -> Group:
        return Group(
            id=id or uuid4(),
            name=name or "Test Group",
            type=type or GroupType.RECURRING,
        )
```

Usage:

```python
# Arrange
expected = GroupMother.create(name="Custom Name")
```

### Builder Pattern (polyfactory + shared `Builder[T]`)

The shared test package provides a generic `Builder[T]` that wraps `polyfactory`
to create type-safe builders for any Pydantic model. The `with_*` methods are
generated dynamically via `__getattr__`.

#### Step 1: Define Factory + Builder

```python
from polyfactory.factories.pydantic_factory import ModelFactory
from shared.factories import Builder


class GroupFactory(ModelFactory[Group]):
    __model__ = Group


class GroupBuilder(Builder[Group]):
    _factory = GroupFactory
```

### Step 2: Use in tests

```python
# Arrange - default random data
expected = GroupBuilder().build()

# Arrange - override specific fields
expected = GroupBuilder().with_name("Custom Name").with_type(GroupType.RECURRING).build()

# Arrange - build a batch
groups = GroupBuilder().with_type(GroupType.RECURRING).build_batch(5)
```

---

## Anti-Patterns (PROHIBITED)

### ❌ Missing AAA

```python
def Should_CreateGroup():
    sut = GroupService(Mock())
    sut.create_group(CreateGroupRequest(name="Test"))
```

### ❌ No mock verification

```python
def Should_SaveGroup_When_Valid(sut: GroupService):
    sut.create_group(CreateGroupRequest(name="Test"))
    assert True
```

### ❌ Natural language / snake_case

```python
def should_create_group_successfully():
    ...
```

### ❌ Combined Act & Assert

```python
# Act & Assert   ← FORBIDDEN, always separate
with pytest.raises(ValueError):
    sut.do_something()
```

### ❌ try/catch/finally in tests

```python
try:
    sut.inject(secrets)
    assert os.environ["KEY"] == "value"
finally:
    os.environ.pop("KEY", None)
```

### ❌ Conditional logic (if/switch) in Arrange, Act, or Assert

```python
# Assert
if result is not None:   # ← FORBIDDEN, split into separate tests
    assert result.name == "Test"
```

---

## Test Organization

### Mirror Structure (MANDATORY)

Tests **MUST** mirror the production code structure using descriptive file naming.

**Production code structure:**

```txt
src/apps/myapp/
├── lambda_handler.py
├── infrastructure/
│   ├── config.py
│   ├── container.py
│   └── logging/
│       ├── json_formatter.py
│       └── logger_factory.py
├── application/
│   └── handlers/
│       └── create_user.py
└── domain/
    └── entities/
        └── user.py
```

**Test structure (hierarchical mirror):**

```txt
test/apps/myapp/
├── test_lambda_handler.py                    # mirrors lambda_handler.py
├── infrastructure/
│   ├── test_config.py                        # mirrors infrastructure/config.py
│   ├── test_container.py                     # mirrors infrastructure/container.py
│   └── logging/
│       ├── test_json_formatter.py            # mirrors infrastructure/logging/json_formatter.py
│       └── test_logger_factory.py            # mirrors infrastructure/logging/logger_factory.py
├── application/
│   └── handlers/
│       └── test_create_user.py               # mirrors application/handlers/create_user.py
└── domain/
    └── entities/
        └── test_user.py                      # mirrors domain/entities/user.py
```

**Naming convention:**

* Format: `{path}/test_{module}.py`
* Same folder structure as production code
* Test files prefixed with `test_`
* Exact mirror of production code hierarchy

#### Why hierarchical structure

* Test files live in the same logical location as production code
* Easy to find corresponding test file
* Natural organization that mirrors the codebase structure
* Clear one-to-one mapping

### pytest Configuration

**REQUIRED** configuration in `pyproject.toml`:

```toml
[tool.pytest.ini_options]
pythonpath = ["../../../src/apps/myapp"]  # Adjust path to your src directory
testpaths = ["."]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["Should_*"]
asyncio_mode = "auto"
markers = [
    "acceptance: marks tests as acceptance tests (require Docker)",
    "unit: marks tests as unit tests (fast, no dependencies)",
    "integration: marks tests as integration tests",
]
```

**⚠️ Important:** The `pythonpath` must point to your production code directory to ensure imports work correctly
from test files.

### Test Classes (Optional)

Tests can be grouped in classes prefixed with `Test*`:

```python
class TestProcessInvoiceHandler:
    async def Should_SaveRequest_When_ValidInput(self) -> None:
        # Arrange / Act / Assert
        ...
```

### Test Markers

Use markers to categorize tests and run them selectively:

```python
@pytest.mark.acceptance
class TestLambdaAcceptance:
    def Should_SaveToS3_When_LambdaInvoked(self) -> None:
        ...

@pytest.mark.unit
def Should_ValidateInput_When_EmptyName() -> None:
    ...
```

Run specific markers: `pytest -m "not acceptance"` or `pytest -m unit`

---

## Final Summary

This skill enforces:

* ✅ AAA pattern with explicit comments
* ✅ Strict naming: `Should_{ExpectedBehavior}_When_{Condition}`
* ✅ `sut / actual / expected` variables
* ✅ Mock verification for all mocks
* ✅ Mother or Builder pattern for test data (recommended)
* ✅ Async support (pytest-asyncio)
* ✅ Mirror structure with hierarchical organization
* ✅ Enforceable pytest configuration

**If a test doesn't follow this → it fails review.**
