| 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
class TestUserService:
def Should_CreateUser_When_Valid(self) -> None:
user = UserFactory.build()
✅ CORRECT
class TestUserService:
def Should_CreateUser_When_Valid(self) -> None:
user = UserFactory.build()
actual = self._sut.create(user)
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
def Should_CreateGroup_When_RequestIsValid(
group_repository: Mock,
sut: GroupService
) -> None:
request = CreateGroupRequest(
name="Test Group",
type=GroupType.RECURRING
)
group_repository.get_group_by_id.return_value = None
actual = sut.create_group(request)
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:
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)
[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)
@pytest.mark.asyncio
async def Should_ReturnUser_When_UserExists(
user_repository: AsyncMock,
sut: GetUserHandler
) -> None:
expected = UserMother.create()
user_repository.get_by_id.return_value = expected
actual = await sut.handle(expected.id)
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
def Should_RaiseValueError_When_NameIsEmpty(sut: GroupService) -> None:
request = CreateGroupRequest(name="", type=GroupType.RECURRING)
action = lambda: sut.create_group(request)
with pytest.raises(ValueError, match="Group name is required"):
action()
❌ FORBIDDEN: combined Act & Assert
def Should_RaiseValueError_When_NameIsEmpty(sut: GroupService) -> None:
request = CreateGroupRequest(name="", type=GroupType.RECURRING)
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
@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:
secrets = {"MY_TOKEN": "token-123"}
env_cleanup.extend(secrets.keys())
EnvilderClient.inject_into_environment(secrets)
assert os.environ["MY_TOKEN"] == "token-123"
❌ FORBIDDEN: try/finally in test
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.
group_repository.add_group.assert_called_once()
group_repository.save.assert_called_once()
group_repository.delete_group.assert_not_called()
Async:
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
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:
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
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
expected = GroupBuilder().build()
expected = GroupBuilder().with_name("Custom Name").with_type(GroupType.RECURRING).build()
groups = GroupBuilder().with_type(GroupType.RECURRING).build_batch(5)
Anti-Patterns (PROHIBITED)
❌ Missing AAA
def Should_CreateGroup():
sut = GroupService(Mock())
sut.create_group(CreateGroupRequest(name="Test"))
❌ No mock verification
def Should_SaveGroup_When_Valid(sut: GroupService):
sut.create_group(CreateGroupRequest(name="Test"))
assert True
❌ Natural language / snake_case
def should_create_group_successfully():
...
❌ Combined Act & Assert
with pytest.raises(ValueError):
sut.do_something()
❌ try/catch/finally in tests
try:
sut.inject(secrets)
assert os.environ["KEY"] == "value"
finally:
os.environ.pop("KEY", None)
❌ Conditional logic (if/switch) in Arrange, Act, or Assert
if result is not None:
assert result.name == "Test"
Test Organization
Mirror Structure (MANDATORY)
Tests MUST mirror the production code structure using descriptive file naming.
Production code structure:
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):
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:
[tool.pytest.ini_options]
pythonpath = ["../../../src/apps/myapp"]
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*:
class TestProcessInvoiceHandler:
async def Should_SaveRequest_When_ValidInput(self) -> None:
...
Test Markers
Use markers to categorize tests and run them selectively:
@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.