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---
111 lines
5.9 KiB
Markdown
111 lines
5.9 KiB
Markdown
# 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. |