4e433158cd
---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---
5.9 KiB
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). Seelanguages/python.mdfor the language first-principles stub.
pytest and Spec-Driven Tests (Testing P1 Tests as Specification, C2 Clarity)
pytestis the default;unittestonly for stdlib-only libraries:pytestfixtures, parametrize, and assertion rewriting beatunittest's boilerplate (Clarity C2).- Tests co-located with source:
user.py→test_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. Avoiddef test_user1():. assertoverself.assertEqual: pytest rewritesassertto show the failing values;assertEqualis 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_boyorpytest-factoryboyover 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
dbfixture sets up the DB once per test; amake_userfactory 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 patchuser.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.parametrizefor 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-hoctest_handles_edgewith multiple asserts hides which case failed (Testing P6 Failure Specificity). pytest --randomlycatches 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"),hypothesisgenerates 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 aClock,UUIDGen,Randomport. In tests, provide deterministic fakes. freezegunfor time:@freeze_time('2024-01-01')makesdatetime.now()deterministic. Do not calldatetime.now()directly in code — wrap it in aClockport so production and tests both inject.pytest --randomly-seed=lastto reproduce a failing order: when--randomlyfinds 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(oranyio's pytest plugin) forasync deftests:@pytest.mark.asyncioruns the coroutine on a loop. Without it, anasync deftest 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
--randomlyand bounded concurrency: aSemaphore(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 undergatherof 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 viafactory_boy.domains/testing/first-principles.md— Testing P1 Specification, P2 Independence, P3 Determinism, P9 Edge Coverage.languages/py-types.md— theResultand Pydantic models that tests assert.languages/py-async.md— async tests use the cancellation/timeout patterns from that doc.languages/py-tooling.md— thepyproject.toml [tool.pytest]config that runs these tests.