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, use AskQuestion:
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 CorniceService()or@resourcedefinitionspermission=parameter on view configuration- ACL
__acl__lists in context factory classes effective_principalsor 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.pyortests/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:
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:
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:
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
- Split by concern:
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>ortest_<what>_<expected_outcome> - Brief docstring on each test function stating what is being tested
Testing priority (strictly enforced)
- Real integrations and real code paths — always prefer exercising actual code
responseslibrary for external HTTP calls (@responses.activate)factory-boyfor test data generation (SQLAlchemyModelFactory)- Pytest fixtures for reusable setup
unittest.mock.patchonly 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:
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:
@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:
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.fixtureinconftest.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
@pytest.fixture
def verified_person(dbsession):
"""A person that has passed KYC verification."""
person = PersonFactory(status="verified")
dbsession.flush()
return person
Factory-boy patterns
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.Fakerfor realistic data - Use
factory.SubFactoryfor relationships - Use
factory.LazyFunctionfor computed fields
Integration test patterns
For external HTTP services — use responses:
@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:
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
- Run
pytest <test_file> -vto confirm all tests pass - Report results to the user: which tests passed, which failed, any issues
- 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
responsesfor 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
Source: tomascorrea/money-warp — distributed by TomeVault.