Files
atelier/languages/py-testing.md
T
Jon Chery 4e433158cd docs(P03): complete language-derived extension — v0.4
---ci---
project: atelier
phase: 3
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.3
requirements:
  covered: [ATELIER-102, ATELIER-103, ATELIER-104, ATELIER-105]
  partial: []
---/ci---
2026-08-05 16:07:21 +00:00

5.9 KiB

Python Testing — Derived Application

Applies Atelier's domain principles to Python testing specifically. Derives from domains/ docs; introduces no new P-rules (D-063). See languages/python.md for the language first-principles stub.

pytest and Spec-Driven Tests (Testing P1 Tests as Specification, C2 Clarity)

  • pytest is the default; unittest only for stdlib-only libraries: pytest fixtures, parametrize, and assertion rewriting beat unittest's boilerplate (Clarity C2).
  • Tests co-located with source: user.pytest_user.py. A test far from its subject rots (Documentation P5 Discoverability).
  • Test names read as a spec: def test_create_user_rejects_invalid_email(): — a reader understands the unit from the name. Avoid def test_user1():.
  • assert over self.assertEqual: pytest rewrites assert to show the failing values; assertEqual is unittest's escape hatch and loses readability.
  • Applies Testing P1: the test is a specification; the failure message is the spec violation.
# test_user.py
import pytest
from user import create_user, ValidationError

def test_create_user_rejects_invalid_email():
    with pytest.raises(ValidationError):
        create_user(email='not-an-email')

def test_create_user_returns_persisted_id():
    u = create_user(email='a@b.co')
    assert u.id  # truthy persisted id

Factories and Fixture Discipline (Testing P2 Independence, Testing P7 Realism)

  • factory_boy or pytest-factoryboy over shared fixtures for mutable state: UserFactory.build() returns a fresh object per call; a session-scoped fixture mutated across tests couples them (Testing P2 Independence).
  • Fixtures for setup/teardown, factories for data: a db fixture sets up the DB once per test; a make_user factory produces fresh data per assertion. Conflating them produces order-dependent tests.
  • scope='function' is the default and the safe default: scope='session' for read-only resources (a schema migration), never for mutable state.
  • Mock at the boundary, not the unit: mocker.patch('requests.get') for HTTP; do not patch user.User.save (that mocks the unit under test — Testing P7 realism).
import factory
from user import User

class UserFactory(factory.Factory):
    class Meta:
        model = User
    email = factory.Sequence(lambda n: f'u{n}@b.co')
    name = 'Test User'

def test_user_factory_is_fresh():
    u1 = UserFactory.build()
    u2 = UserFactory.build()
    assert u1.email != u2.email   # independent

Parametrize and Edge Cases (Testing P9 Edge Case Coverage, Testing P3 Determinism)

  • @pytest.mark.parametrize for input tables: one parametrized test runs N cases; each is an independent test with its own name and failure output (Testing P9).
  • Edge cases as rows, not special tests: empty list, None, max int, unicode — each a row. An ad-hoc test_handles_edge with multiple asserts hides which case failed (Testing P6 Failure Specificity).
  • pytest --randomly catches order coupling: a test passing alone but failing in a suite has hidden shared state. The random plugin makes it visible (Testing P2 Independence).
  • Property tests via hypothesis: for invariants (e.g., "parse(serialize(x)) == x"), hypothesis generates hundreds of inputs and shrinks failures to a minimal counterexample.
import pytest

@pytest.mark.parametrize('email, reason', [
    ('', 'empty'),
    ('a' * 1000 + '@b.co', 'too long'),
    ('no-at-sign', 'missing @'),
    ('a@b', 'missing TLD'),
])
def test_create_user_rejects(email, reason):
    with pytest.raises(ValidationError):
        create_user(email=email)

Determinism and Time (Testing P3 Determinism, Testing P9 Edge Case Coverage)

  • No datetime.now(), time.time(), uuid.uuid4(), random.random() in code under test: inject a Clock, UUIDGen, Random port. In tests, provide deterministic fakes.
  • freezegun for time: @freeze_time('2024-01-01') makes datetime.now() deterministic. Do not call datetime.now() directly in code — wrap it in a Clock port so production and tests both inject.
  • pytest --randomly-seed=last to reproduce a failing order: when --randomly finds an order bug, the seed is logged; re-run with it to debug deterministically.
from freezegun import freeze_time

@freeze_time('2024-01-01')
def test_user_has_created_at():
    u = create_user(email='a@b.co')
    assert u.created_at.year == 2024

Async Tests (Concurrency P10 Test for Race Conditions, Testing P1 Tests as Specification)

  • pytest-asyncio (or anyio's pytest plugin) for async def tests: @pytest.mark.asyncio runs the coroutine on a loop. Without it, an async def test is silently skipped (returns a coroutine, never awaited).
  • anyio's plugin runs the same test on asyncio and trio: one parametrized run across both runtimes catches runtime-specific bugs.
  • Race-sensitive tests use --randomly and bounded concurrency: a Semaphore(1) test under random order surfaces hidden state.
  • Applies concurrency/P10: async tests are the race detector's first line — if a test passes alone but fails under gather of N, there's a race.
import pytest

@pytest.mark.asyncio
async def test_async_fetch_returns_user():
    u = await fetch_user('abc')
    assert u.email

Cross-References

  • domains/testing/pyramid.md — where unit/integration/property tests sit; hypothesis is the property layer.
  • domains/testing/fixtures.md — factory-vs-fixture discipline applied via factory_boy.
  • domains/testing/first-principles.md — Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage.
  • languages/py-types.md — the Result and Pydantic models that tests assert.
  • languages/py-async.md — async tests use the cancellation/timeout patterns from that doc.
  • languages/py-tooling.md — the pyproject.toml [tool.pytest] config that runs these tests.