music on atproto
plyr.fm
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.