1# testing 2 3testing philosophy and infrastructure for plyr.fm. 4 5## philosophy 6 7### test behavior, not implementation 8 9tests should verify *what* the code does, not *how* it does it. this makes tests resilient to refactoring and keeps them focused on user-facing behavior. 10 11**good**: "when a user likes a track, the like count increases" 12**bad**: "when `_increment_like_counter` is called, it executes `UPDATE tracks SET...`" 13 14signs you're testing implementation: 15- mocking internal functions that aren't boundaries 16- asserting on SQL queries or ORM calls 17- testing private methods directly 18- tests break when you refactor without changing behavior 19 20### test at the right level 21 22- **unit tests**: pure functions, utilities, data transformations 23- **integration tests**: API endpoints with real database 24- **skip mocks when possible**: prefer real dependencies (postgres, redis) over mocks 25 26### keep tests fast 27 28slow tests don't get run. we use parallel execution (xdist) and template databases to keep the full suite under 30 seconds. 29 30## parallel execution with xdist 31 32we run tests in parallel using pytest-xdist. each worker gets its own isolated database. 33 34### how it works 35 361. **template database**: first worker creates a template with all migrations applied 372. **clone per worker**: each xdist worker clones from template (`CREATE DATABASE ... WITH TEMPLATE`) 383. **instant setup**: cloning is a file copy - no migrations needed per worker 394. **advisory locks**: coordinate template creation between workers 40 41this is a common pattern for fast parallel test execution in large codebases. 42 43### the fixture chain 44 45``` 46test_database_url (session) 47 └── creates template db (once, with advisory lock) 48 └── clones worker db from template 49 └── patches settings.database.url for this worker 50 51_database_setup (session) 52 └── marker that db is ready 53 54_engine (function) 55 └── creates engine for test_database_url 56 └── clears ENGINES cache 57 58_clear_db (function) 59 └── calls clear_database() procedure after each test 60 61db_session (function) 62 └── provides AsyncSession for test 63``` 64 65### common pitfall: missing db_session dependency 66 67if a test uses the FastAPI app but doesn't depend on `db_session`, the database URL won't be patched for the worker. the test will connect to the wrong database. 68 69**wrong**: 70```python 71@pytest.fixture 72def test_app() -> FastAPI: 73 return app 74 75async def test_something(test_app: FastAPI): 76 # may connect to wrong database in xdist! 77 ... 78``` 79 80**right**: 81```python 82@pytest.fixture 83def test_app(db_session: AsyncSession) -> FastAPI: 84 _ = db_session # ensures db fixtures run first 85 return app 86 87async def test_something(test_app: FastAPI): 88 # database URL is correctly patched 89 ... 90``` 91 92## running tests 93 94```bash 95# from repo root 96just backend test 97 98# run specific test 99just backend test tests/api/test_tracks.py 100 101# run with coverage 102just backend test --cov 103 104# run single-threaded (debugging) 105just backend test -n 0 106``` 107 108## writing good tests 109 110### do 111 112- use descriptive test names that describe behavior 113- one assertion per concept (multiple asserts ok if testing one behavior) 114- use fixtures for setup, not test body 115- test edge cases and error conditions 116- add regression tests when fixing bugs 117 118### don't 119 120- use `@pytest.mark.asyncio` - we use `asyncio_mode = "auto"` 121- mock database calls - use real postgres 122- test ORM internals or SQL structure 123- leave tests that depend on execution order 124- skip tests instead of fixing them (unless truly environment-specific) 125 126## when private function tests are acceptable 127 128generally avoid testing private functions (`_foo`), but there are pragmatic exceptions: 129 130**acceptable**: 131- pure utility functions with complex logic (string parsing, data transformation) 132- functions that are difficult to exercise through public API alone 133- when the private function is a clear unit with stable interface 134 135**not acceptable**: 136- implementation details that might change (crypto internals, caching strategy) 137- internal orchestration functions 138- anything that's already exercised by integration tests 139 140the key question: "if i refactor, will this test break even though behavior didn't change?" 141 142## database fixtures 143 144### clear_database procedure 145 146instead of truncating tables between tests (slow), we use a stored procedure that deletes only rows created during the test: 147 148```sql 149CALL clear_database(:test_start_time) 150``` 151 152this deletes rows where `created_at > test_start_time`, preserving any seed data. 153 154### why not transactions? 155 156rolling back transactions is faster, but: 157- can't test commit behavior 158- can't test constraints properly 159- some ORMs behave differently in uncommitted transactions 160 161delete-by-timestamp gives us real commits while staying fast. 162 163## redis isolation for parallel tests 164 165tests that use redis (caching, background tasks) need isolation between xdist workers. without isolation, one worker's cache entries pollute another's tests. 166 167### how it works 168 169each xdist worker uses a different redis database number: 170 171| worker | redis db | 172|--------|----------| 173| master/gw0 | 1 | 174| gw1 | 2 | 175| gw2 | 3 | 176| ... | ... | 177 178db 0 is reserved for local development. 179 180### the redis_database fixture 181 182```python 183@pytest.fixture(scope="session", autouse=True) 184def redis_database(worker_id: str) -> Generator[None, None, None]: 185 """use isolated redis databases for parallel test execution.""" 186 db = _redis_db_for_worker(worker_id) 187 new_url = _redis_url_with_db(settings.docket.url, db) 188 189 # patch settings for this worker process 190 settings.docket.url = new_url 191 os.environ["DOCKET_URL"] = new_url 192 clear_client_cache() 193 194 # flush db before tests 195 sync_redis = redis.Redis.from_url(new_url) 196 sync_redis.flushdb() 197 sync_redis.close() 198 199 yield 200 201 # flush after tests 202 ... 203``` 204 205this fixture is `autouse=True` so it applies to all tests automatically. 206 207### common pitfall: unique URIs in cache tests 208 209even with per-worker database isolation, tests within the same worker share redis state. if multiple tests use the same cache keys, they can interfere with each other. 210 211**wrong**: 212```python 213async def test_caching_first(): 214 uris = ["at://did:plc:test/fm.plyr.track/1"] # generic URI 215 result = await get_active_copyright_labels(uris) 216 # caches the result 217 218async def test_caching_second(): 219 uris = ["at://did:plc:test/fm.plyr.track/1"] # same URI! 220 result = await get_active_copyright_labels(uris) 221 # gets cached value from first test - may fail unexpectedly 222``` 223 224**right**: 225```python 226async def test_caching_first(): 227 uris = ["at://did:plc:first/fm.plyr.track/1"] # unique to this test 228 ... 229 230async def test_caching_second(): 231 uris = ["at://did:plc:second/fm.plyr.track/1"] # different URI 232 ... 233``` 234 235use unique identifiers (test name, uuid, etc.) in cache keys to avoid cross-test pollution.