# 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.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. 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. ```python # 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). ```python 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. ```python 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. ```python 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. ```python 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.