---
name: write-pytest
description: Write pytest tests for Pyramid applications. Use when the user asks to write tests, create tests, add tests, or test a module/service/endpoint. Follows a research-then-discuss-then-write flow.
---

# Write Pytest Tests

Write pytest tests for Pyramid applications following a conversational, phased approach. Always research first, discuss with the user, then write.

## Setup Question (Asked at Install Time)

During installation, ask the user:

```yaml
title: "Companion Rule"
questions:
  - id: install_rule
    prompt: "Do you want to install a Cursor rule that enforces pytest patterns whenever test files are created or modified?"
    options:
      - id: "yes"
        label: "Yes, install the test-patterns rule"
      - id: "no"
        label: "No, just the skill"
```

If yes, copy `templates/test-patterns-rule.mdc` to `.cursor/rules/test-patterns.mdc` in the target project.

---

## Phase 1: Research the Project (Read-Only)

Before proposing any tests, gather context. Do NOT write code yet.

### 1.1 Read the source code

Read the file(s) the user wants tested. Identify the type of code:

- **View/endpoint** — Cornice service or resource with route configuration
- **Service** — business logic class or functions
- **Model** — SQLAlchemy model definitions
- **Celery task** — async task functions
- **Utility** — pure functions, validators, helpers

### 1.2 Map dependencies

For each source file, identify:

- External HTTP services (look for `requests.get/post`, HTTP client calls)
- gRPC clients (look for client imports and stub usage)
- Database models (look for SQLAlchemy queries, session usage)
- Other internal services or modules

### 1.3 Detect security requirements

Look for these signals that indicate a protected endpoint:

- `factory=` parameter on Cornice `Service()` or `@resource` definitions
- `permission=` parameter on view configuration
- ACL `__acl__` lists in context factory classes
- `effective_principals` or JWT-related logic
- Any authentication/authorization decorators

If ANY of these are found, security tests are **mandatory** — never ask the user whether to include them.

### 1.4 Check existing test infrastructure

Read these files if they exist:

- `tests/conftest.py` — available fixtures (`app`, `testapp`, `db_session`, `dbsession`, `auth_headers`, etc.)
- `tests/factories.py` or `tests/factories/` directory — existing factory-boy factories
- Existing test files for similar modules — to match naming conventions and style
- Check the size of any existing test file you would append to (relevant for the ~200 line limit)

### 1.5 Detect reuse opportunities

- Look for duplicated setup logic across existing test functions
- Identify setup patterns that should be fixtures
- Identify validation/pure-function code that would benefit from hypothesis

### 1.6 Build draft test case list

Organize proposed test cases by category:

- **Happy path** — success scenarios
- **Validation errors** — missing/invalid fields, bad input
- **Security** (if endpoint is protected) — mandatory, see Phase 3
- **Edge cases** — boundary conditions, empty data, large payloads
- **Integration** — external service calls, response handling
- **Property-based** (if validators/numeric logic detected) — hypothesis candidates

---

## Phase 2: Discuss with the User

Use `AskQuestion` one question at a time. Never skip this phase.

### Q1 — Review proposed test cases

Present the draft test case list from Phase 1, grouped by category. Use `AskQuestion`:

```yaml
title: "Proposed Test Cases"
questions:
  - id: test_cases
    prompt: "<present the grouped list here with brief descriptions>"
    options:
      - id: accept
        label: "Looks good, proceed"
      - id: revise
        label: "I want to adjust the list"
```

If `revise`, collect the user's changes before continuing.

### Q2 — Confirm factory needs (if applicable)

If new factory-boy factories or updates to existing ones are needed:

```yaml
title: "Factory-Boy Factories"
questions:
  - id: factories
    prompt: "<list new/updated factories needed>"
    options:
      - id: accept
        label: "Create/update these factories"
      - id: revise
        label: "I want to adjust"
```

Skip this question if no new factories are needed.

### Q3 — Confirm fixture needs (if applicable)

If new `conftest.py` fixtures are needed or existing setup should be extracted into fixtures:

```yaml
title: "Pytest Fixtures"
questions:
  - id: fixtures
    prompt: "<list new fixtures or extractions proposed>"
    options:
      - id: accept
        label: "Create/update these fixtures"
      - id: revise
        label: "I want to adjust"
```

Skip this question if no new fixtures are needed.

---

## Phase 3: Write the Tests

Generate code following ALL of these rules. No exceptions.

### Structure rules

- Test file mirrors source structure in the `tests/` directory
- One test file per module: `test_<module_name>.py`
- **Pytest functions only** — never use test classes
- **Imports at the top of the file** — never inside functions
- **Keep test files under ~200 lines.** If adding tests would exceed this:
  - Split by concern: `test_<module>_api.py`, `test_<module>_service.py`, `test_<module>_integration.py`, `test_<module>_security.py`
  - Propose the split to the user before writing

### Test function rules

- **No conditional logic** — no `if/else`, `try/except`, `for`, `while`, ternary operators
- **One test, one purpose** — each function tests exactly one behavior
- **Descriptive names**: `test_<action>_<scenario>` or `test_<what>_<expected_outcome>`
- Brief docstring on each test function stating what is being tested

### Testing priority (strictly enforced)

1. **Real integrations** and real code paths — always prefer exercising actual code
2. **`responses`** library for external HTTP calls (`@responses.activate`)
3. **`factory-boy`** for test data generation (`SQLAlchemyModelFactory`)
4. **Pytest fixtures** for reusable setup
5. **`unittest.mock.patch`** only as absolute last resort (gRPC clients, etc.)

### Mandatory security tests for protected endpoints

If Phase 1 detected ANY security signal, include ALL of these tests — they are non-negotiable:

- **Wrong/invalid token** — request with malformed or expired token, expect 401 or 403
- **Wrong permission scope** — request with valid token but insufficient/wrong ACL permissions, expect 403
- **Missing authentication** — request with no auth headers at all, expect 403
- **Wrong credentials** — if the endpoint accepts credentials (e.g., login), test with wrong password/username, expect 401

Example pattern:

```python
def test_endpoint_rejects_invalid_token(testapp, dbsession):
    """Verify endpoint rejects requests with an invalid token."""
    resource = ResourceFactory()

    headers = {"Authorization": "Bearer invalid-token-here"}
    response = testapp.get(
        f"/api/v1/resource/{resource.uuid}",
        headers=headers,
        expect_errors=True,
    )

    assert response.status_code == 403


def test_endpoint_rejects_wrong_permission(testapp, dbsession, auth_headers):
    """Verify endpoint rejects requests with insufficient permissions."""
    resource = ResourceFactory()

    headers = auth_headers(["wrong::permission::scope"])
    response = testapp.get(
        f"/api/v1/resource/{resource.uuid}",
        headers=headers,
        expect_errors=True,
    )

    assert response.status_code == 403


def test_endpoint_rejects_missing_auth(testapp, dbsession):
    """Verify endpoint rejects unauthenticated requests."""
    resource = ResourceFactory()

    response = testapp.get(
        f"/api/v1/resource/{resource.uuid}",
        expect_errors=True,
    )

    assert response.status_code == 403
```

### Use `pytest.mark.parametrize` to avoid duplication

When multiple tests share the same logic but differ only in input/expected output, use `@pytest.mark.parametrize`:

```python
@pytest.mark.parametrize(
    "payload,missing_field",
    [
        ({"name": "John Doe", "email": "john@example.com"}, "tax_id"),
        ({"tax_id": "12345678901", "email": "john@example.com"}, "name"),
        ({"tax_id": "12345678901", "name": "John Doe"}, "email"),
    ],
    ids=["missing_tax_id", "missing_name", "missing_email"],
)
def test_create_person_rejects_missing_field(app, payload, missing_field):
    """Verify endpoint rejects payload missing a required field."""
    response = app.post_json("/api/v1/person", payload, expect_errors=True)

    assert response.status_code == 400
```

Do NOT use parametrize when the test logic itself differs between cases.

### Use `hypothesis` for property-based testing

When the code under test involves validators, numeric calculations, or data parsing, suggest `@given()` from `hypothesis`:

```python
from hypothesis import given, strategies as st

@given(email=st.emails())
def test_email_validator_accepts_valid_emails(email):
    """Verify email validator accepts any valid email format."""
    result = validate_email(email)

    assert result is True


@given(value=st.floats(allow_nan=False, allow_infinity=False))
def test_score_normalization_stays_in_range(value):
    """Verify score normalization always produces a value in [0, 1]."""
    result = normalize_score(value)

    assert 0.0 <= result <= 1.0
```

Good candidates: email/CPF/CNPJ validators, numeric calculations (scores, amounts, percentages), data transformations, serialization/deserialization. Not for API or integration tests.

### Reusable setup must be pytest fixtures

- Extract repeated setup into `@pytest.fixture` in `conftest.py`
- Never create standalone helper functions like `create_user()` — use fixtures
- Place fixtures in the nearest `conftest.py` (directory-level for local, root for project-wide)
- Fixtures can compose other fixtures and factory-boy factories

```python
@pytest.fixture
def verified_person(dbsession):
    """A person that has passed KYC verification."""
    person = PersonFactory(status="verified")
    dbsession.flush()
    return person
```

### Factory-boy patterns

```python
class CompanyFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = Company
        sqlalchemy_session_persistence = "flush"

    registration_number = factory.LazyFunction(
        lambda: f"{random.randint(10000000000000, 99999999999999)}"
    )
    name = factory.Faker("company")
    revenue = factory.Faker("random_int", min=100000, max=10000000)
    created_at = factory.Faker("date_time")
```

- Use `factory.Faker` for realistic data
- Use `factory.SubFactory` for relationships
- Use `factory.LazyFunction` for computed fields

### Integration test patterns

For external HTTP services — use `responses`:

```python
@responses.activate
def test_kyc_service_validates_person():
    """Verify KYC service handles person validation response."""
    responses.add(
        responses.POST,
        "https://api.kyc-provider.com/v1/validate",
        json={"valid": True, "status": "verified", "confidence": 0.95},
        status=200,
    )

    service = KYCService()
    result = service.validate_person(person_data)

    assert result["valid"] is True
    assert result["status"] == "verified"
```

For gRPC clients — use `patch` as last resort:

```python
def test_legal_entity_grpc_integration():
    """Verify legal entity gRPC client handles validation response."""
    with patch("module.clients.legal_entity.LegalEntityClient") as mock_client:
        mock_client.return_value.validate_company.return_value = {
            "valid": True,
            "company_status": "active",
        }

        service = LegalEntityService()
        result = service.validate_company("12345678000190")

        assert result["valid"] is True
```

---

## Phase 4: Verify

1. Run `pytest <test_file> -v` to confirm all tests pass
2. Report results to the user: which tests passed, which failed, any issues
3. If tests fail, fix and re-run until green

---

## Anti-Patterns — NEVER Do These

- **No conditional logic in tests** — no `if`, `else`, `try`, `except`, `for`, `while`
- **No class-based tests** — always plain functions
- **No imports inside functions** — all imports at the top
- **No standalone helper functions for setup** — use fixtures
- **No mocking unless absolutely necessary** — prefer `responses` for HTTP, real code for everything else
- **No copy-paste test duplication** — use `parametrize`
- **No skipping security tests** for protected endpoints
- **No test files over ~200 lines** — split by concern
- **Never use FastAPI or pydantic**
