Skip to content

Commit 591434e

Browse files
committed
Detect async tests automatically and document when to use each kind
pytest-asyncio now runs in auto mode: any async def test runs on an event loop, plain def tests run normally. Removes 14 identical @pytest.mark.asyncio decorators. The test file docstring explains the rule for contributors and AI agents: use async def only for tests that await ASGI callables, plain def for sync APIs like StaticFiles() and url(). An async def test with no await in its body will pass but misleads readers.
1 parent ca658f0 commit 591434e

2 files changed

Lines changed: 20 additions & 19 deletions

File tree

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ Documentation = "https://feldroy.github.io/staticware/"
5656
Homepage = "https://github.com/feldroy/staticware"
5757
Source = "https://github.com/feldroy/staticware"
5858

59+
[tool.pytest.ini_options]
60+
# "auto" means any `async def test_*` runs on an event loop automatically.
61+
# No @pytest.mark.asyncio decorator needed. Plain `def test_*` runs normally.
62+
asyncio_mode = "auto"
63+
5964
[tool.ty]
6065
# All rules are enabled as "error" by default; no need to specify unless overriding.
6166
# Example override: relax a rule for the entire project (uncomment if needed).

tests/test_staticware.py

Lines changed: 15 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,18 @@
1-
"""Tests for staticware."""
1+
"""Tests for staticware.
2+
3+
Async test detection:
4+
pytest-asyncio is configured with asyncio_mode = "auto" in pyproject.toml.
5+
This means any test written as ``async def`` automatically runs on an event
6+
loop. Regular ``def`` tests run normally without one.
7+
8+
Use ``async def`` for tests that call ASGI apps (they are async callables).
9+
Use plain ``def`` for tests that only exercise sync APIs like StaticFiles()
10+
construction, url(), and file_map lookups.
11+
12+
Do NOT write ``async def`` for a test that has no await in its body. It will
13+
still pass, but it runs on an event loop for no reason and misleads readers
14+
into thinking the test exercises async behavior.
15+
"""
216

317
import hashlib
418
from pathlib import Path
@@ -154,7 +168,6 @@ def test_multi_dot_filename(tmp_path: Path) -> None:
154168
# ── StaticFiles: ASGI serving ───────────────────────────────────────────
155169

156170

157-
@pytest.mark.asyncio
158171
async def test_serve_original_filename(static: StaticFiles, static_dir: Path) -> None:
159172
resp = ResponseCollector()
160173
await static(make_scope("/static/styles.css"), receive, resp)
@@ -163,7 +176,6 @@ async def test_serve_original_filename(static: StaticFiles, static_dir: Path) ->
163176
assert b"cache-control" not in resp.headers
164177

165178

166-
@pytest.mark.asyncio
167179
async def test_serve_hashed_filename_with_immutable_cache(static: StaticFiles) -> None:
168180
hashed_name = static.file_map["styles.css"]
169181
resp = ResponseCollector()
@@ -173,44 +185,38 @@ async def test_serve_hashed_filename_with_immutable_cache(static: StaticFiles) -
173185
assert resp.headers[b"cache-control"] == b"public, max-age=31536000, immutable"
174186

175187

176-
@pytest.mark.asyncio
177188
async def test_serve_404_for_missing_file(static: StaticFiles) -> None:
178189
resp = ResponseCollector()
179190
await static(make_scope("/static/nope.css"), receive, resp)
180191
assert resp.status == 404
181192

182193

183-
@pytest.mark.asyncio
184194
async def test_serve_404_outside_prefix(static: StaticFiles) -> None:
185195
resp = ResponseCollector()
186196
await static(make_scope("/other/styles.css"), receive, resp)
187197
assert resp.status == 404
188198

189199

190-
@pytest.mark.asyncio
191200
async def test_path_traversal_rejected(static: StaticFiles) -> None:
192201
resp = ResponseCollector()
193202
await static(make_scope("/static/../../etc/passwd"), receive, resp)
194203
assert resp.status == 404
195204

196205

197-
@pytest.mark.asyncio
198206
async def test_non_http_scope_ignored(static: StaticFiles) -> None:
199207
"""WebSocket and lifespan scopes should be silently ignored."""
200208
resp = ResponseCollector()
201209
await static({"type": "websocket", "path": "/static/styles.css"}, receive, resp)
202210
assert resp.status == 0 # send was never called
203211

204212

205-
@pytest.mark.asyncio
206213
async def test_serve_subdirectory_file(static: StaticFiles) -> None:
207214
resp = ResponseCollector()
208215
await static(make_scope("/static/images/logo.png"), receive, resp)
209216
assert resp.status == 200
210217
assert resp.body == b"\x89PNG fake image data"
211218

212219

213-
@pytest.mark.asyncio
214220
async def test_content_type_header(static: StaticFiles) -> None:
215221
resp = ResponseCollector()
216222
await static(make_scope("/static/styles.css"), receive, resp)
@@ -255,7 +261,6 @@ async def app(scope: dict, receive: Any, send: Any) -> None:
255261
return app
256262

257263

258-
@pytest.mark.asyncio
259264
async def test_rewrite_html_response(static: StaticFiles) -> None:
260265
html = '<link href="/static/styles.css">'
261266
app = StaticRewriteMiddleware(make_html_app(html), static=static)
@@ -267,7 +272,6 @@ async def test_rewrite_html_response(static: StaticFiles) -> None:
267272
assert "/static/styles.css" not in resp.text
268273

269274

270-
@pytest.mark.asyncio
271275
async def test_rewrite_updates_content_length(static: StaticFiles) -> None:
272276
html = '<link href="/static/styles.css">'
273277
app = StaticRewriteMiddleware(make_html_app(html), static=static)
@@ -278,7 +282,6 @@ async def test_rewrite_updates_content_length(static: StaticFiles) -> None:
278282
assert declared_length == len(resp.body)
279283

280284

281-
@pytest.mark.asyncio
282285
async def test_rewrite_leaves_unknown_paths_alone(static: StaticFiles) -> None:
283286
html = '<script src="/static/app.js"></script>'
284287
app = StaticRewriteMiddleware(make_html_app(html), static=static)
@@ -287,7 +290,6 @@ async def test_rewrite_leaves_unknown_paths_alone(static: StaticFiles) -> None:
287290
assert "/static/app.js" in resp.text
288291

289292

290-
@pytest.mark.asyncio
291293
async def test_non_html_passes_through(static: StaticFiles) -> None:
292294
data = b'{"path": "/static/styles.css"}'
293295
app = StaticRewriteMiddleware(make_json_app(data), static=static)
@@ -296,7 +298,6 @@ async def test_non_html_passes_through(static: StaticFiles) -> None:
296298
assert resp.body == data
297299

298300

299-
@pytest.mark.asyncio
300301
async def test_rewrite_multiple_paths(static: StaticFiles) -> None:
301302
html = '<link href="/static/styles.css"><img src="/static/images/logo.png">'
302303
app = StaticRewriteMiddleware(make_html_app(html), static=static)
@@ -307,7 +308,6 @@ async def test_rewrite_multiple_paths(static: StaticFiles) -> None:
307308
assert f"/static/{static.file_map['images/logo.png']}" in resp.text
308309

309310

310-
@pytest.mark.asyncio
311311
async def test_rewrite_non_http_passes_through(static: StaticFiles) -> None:
312312
"""Non-HTTP scopes are forwarded to the wrapped app without rewriting."""
313313
calls: list[str] = []
@@ -320,7 +320,6 @@ async def ws_app(scope: dict, receive: Any, send: Any) -> None:
320320
assert calls == ["websocket"]
321321

322322

323-
@pytest.mark.asyncio
324323
async def test_rewrite_raises_runtime_error_on_body_before_start(
325324
static: StaticFiles,
326325
) -> None:
@@ -344,7 +343,6 @@ async def broken_app(scope: dict, receive: Any, send: Any) -> None:
344343
await app(make_scope("/"), receive, ResponseCollector())
345344

346345

347-
@pytest.mark.asyncio
348346
async def test_rewrite_streaming_html_response(static: StaticFiles) -> None:
349347
"""Middleware rewrites static paths even when the body arrives in multiple chunks."""
350348
chunk1 = b'<link href="/static/'
@@ -372,7 +370,6 @@ async def streaming_app(scope: dict, receive: Any, send: Any) -> None:
372370
assert "/static/styles.css" not in resp.text
373371

374372

375-
@pytest.mark.asyncio
376373
async def test_serve_prefix_only_returns_404(static: StaticFiles) -> None:
377374
"""Requesting /static or /static/ with no filename returns 404."""
378375
# /static with no trailing slash
@@ -386,7 +383,6 @@ async def test_serve_prefix_only_returns_404(static: StaticFiles) -> None:
386383
assert resp_slash.status == 404
387384

388385

389-
@pytest.mark.asyncio
390386
async def test_rewrite_non_utf8_html_passes_through(static: StaticFiles) -> None:
391387
"""HTML response with non-UTF-8 bytes passes through unchanged."""
392388
raw_body = b"<html>\x80\x81\x82 not valid utf-8</html>"

0 commit comments

Comments
 (0)