5.0 KiB
Test Writing Rules
Mandatory rules when generating test code. Never violate without explicit user approval.
File Structure
# 1. stdlib imports
# 2. third-party imports
# 3. local imports (blank line between groups — match project convention)
# 4. Module-level constants or type aliases (if needed)
# 5. Fixtures local to this file (if not in conftest)
# 6. Test functions / class
Always match the import grouping style found in existing tests.
Naming
| Target | Convention | Example |
|---|---|---|
| Test file | test_{module}.py or {module}_test.py (match project) |
test_auth.py |
| Test function | test_{what}_{condition} |
test_login_wrong_password |
| Test class | Test{Subject} |
TestLoginService |
| Fixture | {noun} or {noun}_{qualifier} |
db_session, admin_user |
| Parametrize ID | descriptive string | "valid", "empty_email" |
Type Annotations — Python 3.13+
All test functions must have return type -> None.
Fixture functions must have explicit return type annotation.
# Correct
def test_login_valid(db_session: AsyncSession, user: User) -> None:
...
@pytest.fixture
def admin_user(db_session: AsyncSession) -> User:
...
Never use Optional[X] — use X | None.
Never use List[X], Dict[K, V] — use list[X], dict[K, V].
Assertions
Use the same assertion style as the project (discovered in Step 2).
For plain assert style:
- Always compare with
==, notis, for value equality - Use
assert x is None/assert x is not Nonefor None checks - Use
assert isinstance(x, SomeClass)for type checks - Include a failure message for non-obvious assertions:
assert result.status == 200, f"Expected 200, got {result.status}"
For exception assertions:
with pytest.raises(ValueError, match="invalid email"):
service.create_user(email="bad")
Never assert True or False directly from a function that returns bool:
# Bad
assert service.is_valid(x) == True
# Good
assert service.is_valid(x)
Fixtures
- Prefer existing fixtures from conftest.py over creating new ones
- Create a new fixture only if it will be reused in ≥ 2 tests in this file
- One-off setup → inline in the test body
- Scope: use
functionby default;module/sessiononly for read-only shared resources - Never use
autouse=Truefor fixtures defined inside a test file
Parametrize
Use @pytest.mark.parametrize when the same logic is tested with ≥ 3 data variants:
@pytest.mark.parametrize(
("email", "expected_error"),
[
("", "Email cannot be empty"),
("not-an-email", "Invalid email format"),
("a" * 256 + "@x.com", "Email too long"),
],
)
def test_create_user_invalid_email(
email: str,
expected_error: str,
db_session: AsyncSession,
) -> None:
with pytest.raises(ValueError, match=expected_error):
UserService(db_session).create_user(email=email)
Mocking
Match the project's mock style (discovered in Step 2).
With pytest-mock:
def test_sends_email(mocker: MockerFixture) -> None:
send = mocker.patch("myapp.notifications.send_email")
service.register(email="u@example.com")
send.assert_called_once_with("u@example.com", subject=mocker.ANY)
With unittest.mock:
from unittest.mock import AsyncMock, patch
@patch("myapp.notifications.send_email", new_callable=AsyncMock)
async def test_sends_email(mock_send: AsyncMock) -> None:
await service.register(email="u@example.com")
mock_send.assert_awaited_once()
Use create_autospec when the interface must be enforced:
mock_repo = create_autospec(UserRepository)
Never use MagicMock() without speccing — it silently accepts any attribute.
Async Tests
Match asyncio_mode from pyproject.toml:
asyncio_mode = "auto"→ no decorator needed, justasync def test_asyncio_mode = "strict"→ must add@pytest.mark.asyncio
Never mix sync and async in the same test — use AsyncMock for async dependencies.
Test Isolation Rules
- Each test must be independent — no shared mutable state between tests
- Never rely on test execution order
- Clean up side effects: files, DB rows, environment variables
- Use
monkeypatchfor environment variable overrides (notos.environdirect) - Use
tmp_pathfixture for temporary file I/O
Scenario Coverage per Function
At minimum, cover:
| Scenario | Must have |
|---|---|
| Happy path | ✅ Always |
| Empty / zero input | ✅ If applicable |
| Boundary values | ✅ If numeric limits exist |
| Invalid type or format | ✅ If input is validated |
| Not found / missing resource | ✅ For DB/file lookups |
| Permission denied | ✅ If auth is involved |
| External service failure | ✅ If network/DB call exists |
What NOT to Test
- Internal private methods (
_method) directly — test via public API - Framework internals (Django ORM, SQLAlchemy internals)
- Third-party library behaviour (mock at the boundary, don't re-test the lib)
- Trivial getters/setters with zero logic