Spaces:
Configuration error
Configuration error
Commit ·
785dbd5
1
Parent(s): 8e0a706
test(backend): add FastAPI route tests with fake predictor service
Browse filesAdds backend/app/tests/ covering the GET /healthz and POST /v1/captions contracts end-to-end without loading TensorFlow or any real model. A duck-typed FakePredictorService stands in on app.state, bypassing the lifespan. Covers 200, 400, 413, 415, 422, 503 plus request-id propagation through x-request-id.
pyproject.toml: pythonpath = ["backend"] under [tool.pytest.ini_options] so 'from app.* import ...' resolves under pytest from the repo root.
12 new tests, full suite 90 passing in ~30s.
- CLAUDE.md +73 -0
- backend/app/tests/__init__.py +0 -0
- backend/app/tests/conftest.py +92 -0
- backend/app/tests/test_captions.py +87 -0
- backend/app/tests/test_health.py +56 -0
- pyproject.toml +1 -0
CLAUDE.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Project Conventions for Claude Code
|
| 2 |
+
|
| 3 |
+
## CRITICAL: Commit & Attribution Rules
|
| 4 |
+
|
| 5 |
+
**Claude Code MUST follow these rules without exception:**
|
| 6 |
+
|
| 7 |
+
1. **NEVER add `Co-Authored-By: Claude` or any AI co-author trailer to commit messages.**
|
| 8 |
+
2. **NEVER add `🤖 Generated with Claude Code` footers or any AI attribution.**
|
| 9 |
+
3. **NEVER mention Claude, Anthropic, OpenAI, Copilot, AI, LLMs, or any model/assistant name in commit messages, code comments, file headers, documentation, PR descriptions, or changelogs.**
|
| 10 |
+
4. **All commits must be authored solely by:**
|
| 11 |
+
- Name: `apoorvrajdev`
|
| 12 |
+
- Email: `apoorvrajmgr@gmail.com`
|
| 13 |
+
5. **NEVER stage or commit changes on your own.** Only suggest commit messages — the user runs `git commit` themselves.
|
| 14 |
+
6. **NEVER push to remote.** Only the user pushes.
|
| 15 |
+
7. **NEVER create branches, tags, or releases on your own.**
|
| 16 |
+
|
| 17 |
+
## Commit Message Format
|
| 18 |
+
|
| 19 |
+
Use Conventional Commits. Examples:
|
| 20 |
+
- `chore: initial repo scaffolding`
|
| 21 |
+
- `feat(backend): add /caption endpoint for image upload`
|
| 22 |
+
- `feat(inference): add beam search decoder`
|
| 23 |
+
- `fix(data): correct COCO split deduplication`
|
| 24 |
+
- `fix(training): stabilize loss scaling for mixed precision`
|
| 25 |
+
- `docs: update stabilized training runbook`
|
| 26 |
+
- `test(evaluation): add BLEU/CIDEr metric tests`
|
| 27 |
+
- `refactor(models): extract encoder CNN factory`
|
| 28 |
+
- `perf(inference): cache image features for batched predict`
|
| 29 |
+
|
| 30 |
+
Keep subject under 72 characters. Body optional but explains *why*, not *what*.
|
| 31 |
+
|
| 32 |
+
## Project Stack
|
| 33 |
+
|
| 34 |
+
- **Core ML:** Python 3.10+, TensorFlow / Keras, NumPy, Pillow
|
| 35 |
+
- **Model:** InceptionV3 encoder + Transformer decoder for image captioning
|
| 36 |
+
- **Backend:** FastAPI app under `backend/app/` (routes, services, schemas, utils)
|
| 37 |
+
- **Frontend:** React 18 + Vite (JSX) under `frontend/`, ESLint configured
|
| 38 |
+
- **Config:** YAML configs under `configs/` loaded via `src/captioning/config/`
|
| 39 |
+
- **Data:** MS COCO pipeline under `src/captioning/data/`
|
| 40 |
+
- **Evaluation:** BLEU, CIDEr, METEOR, ROUGE under `src/captioning/evaluation/`
|
| 41 |
+
- **Tooling:** `pyproject.toml`, `Makefile`, `pytest`, packaging as `captioning`
|
| 42 |
+
|
| 43 |
+
## Repository Layout (authoritative)
|
| 44 |
+
|
| 45 |
+
- `src/captioning/` — installable library (`config`, `data`, `models`, `preprocessing`, `training`, `inference`, `evaluation`, `utils`)
|
| 46 |
+
- `backend/app/` — FastAPI service (`api/routes.py`, `services/predictor_service.py`, `schemas/`, `core/`, `utils/`)
|
| 47 |
+
- `frontend/src/` — React UI (`components/`, `services/api.js`)
|
| 48 |
+
- `scripts/` — CLI entrypoints (`train.py`, `evaluate.py`, `predict.py`, etc.)
|
| 49 |
+
- `configs/` — YAML training/eval configs
|
| 50 |
+
- `models/vX.Y.Z/` — versioned model artifacts (`model.h5`, `vocab.json`)
|
| 51 |
+
- `tests/unit/` — pytest unit tests
|
| 52 |
+
- `notebooks/` — exploratory notebooks (not part of runtime)
|
| 53 |
+
- `docs/` — phase notes and runbooks
|
| 54 |
+
|
| 55 |
+
## Code Standards
|
| 56 |
+
|
| 57 |
+
- **Python:** type hints on all new/edited public functions; prefer `pathlib.Path` over string paths
|
| 58 |
+
- **Imports:** absolute imports from `captioning.*`; no relative imports across top-level packages
|
| 59 |
+
- **Determinism:** seed NumPy / TF / Python `random` whenever introducing stochastic code paths in training or evaluation
|
| 60 |
+
- **Configs:** never hardcode hyperparameters in scripts — extend `src/captioning/config/schema.py` and update the relevant YAML in `configs/`
|
| 61 |
+
- **Models / vocab:** never modify files under `models/vX.Y.Z/` in place; bump the version directory instead
|
| 62 |
+
- **Backend layering:** `api/routes.py` only orchestrates; inference logic stays in `backend/app/services/` and `src/captioning/inference/`
|
| 63 |
+
- **Schemas:** all FastAPI request/response bodies go through Pydantic schemas in `backend/app/schemas/`
|
| 64 |
+
- **Frontend:** functional components + hooks; keep API calls inside `frontend/src/services/api.js`
|
| 65 |
+
- **Tests:** new behavior gets a unit test under `tests/unit/`; keep tests CPU-only and offline (no network, no real model downloads)
|
| 66 |
+
|
| 67 |
+
## Working Style
|
| 68 |
+
|
| 69 |
+
- Plan before implementing for any non-trivial change (training loop, decoder, data pipeline, API contract)
|
| 70 |
+
- One module at a time, with tests
|
| 71 |
+
- Run `pytest` for touched areas before declaring a change done
|
| 72 |
+
- After making changes, summarize what you did so the user can review and commit
|
| 73 |
+
- If a change spans library + backend + frontend, list the affected files grouped by layer in the summary
|
backend/app/tests/__init__.py
ADDED
|
File without changes
|
backend/app/tests/conftest.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures for the backend test suite.
|
| 2 |
+
|
| 3 |
+
These tests deliberately avoid loading TensorFlow or any real model.
|
| 4 |
+
The route layer depends on ``PredictorService`` only through duck-typed
|
| 5 |
+
attributes (``model_version``, ``decode_strategy``, ``max_upload_bytes``,
|
| 6 |
+
``caption_image_bytes``), so a small fake stands in cleanly and keeps the
|
| 7 |
+
whole suite under one second.
|
| 8 |
+
|
| 9 |
+
We also bypass the FastAPI lifespan entirely. The lifespan builds a real
|
| 10 |
+
``CaptionPredictor`` from disk, which requires weights, a tokenizer, and a
|
| 11 |
+
TF graph build. Tests build a fresh ``FastAPI`` instance, wire the same
|
| 12 |
+
router and middleware, and stash the fake service directly on
|
| 13 |
+
``app.state.predictor_service`` — the exact slot the lifespan would have
|
| 14 |
+
populated in production.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from collections.abc import Callable, Iterator
|
| 20 |
+
|
| 21 |
+
import pytest
|
| 22 |
+
from fastapi import FastAPI
|
| 23 |
+
from fastapi.testclient import TestClient
|
| 24 |
+
|
| 25 |
+
from app.api.routes import router
|
| 26 |
+
from app.core.config import BackendSettings
|
| 27 |
+
from app.core.logging import RequestContextMiddleware, configure_app_logging
|
| 28 |
+
from app.utils.image import ImageDecodeError
|
| 29 |
+
|
| 30 |
+
configure_app_logging()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class FakePredictorService:
|
| 34 |
+
"""Duck-typed stand-in for ``PredictorService``."""
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
*,
|
| 39 |
+
caption: str = "a test caption",
|
| 40 |
+
latency_ms: float = 1.23,
|
| 41 |
+
decode_strategy: str = "greedy",
|
| 42 |
+
model_version: str = "test-v0",
|
| 43 |
+
max_upload_bytes: int = 1024,
|
| 44 |
+
raise_decode_error: bool = False,
|
| 45 |
+
) -> None:
|
| 46 |
+
self.model_version = model_version
|
| 47 |
+
self.decode_strategy = decode_strategy
|
| 48 |
+
self.max_upload_bytes = max_upload_bytes
|
| 49 |
+
self._caption = caption
|
| 50 |
+
self._latency_ms = latency_ms
|
| 51 |
+
self._raise = raise_decode_error
|
| 52 |
+
self.calls: list[bytes] = []
|
| 53 |
+
|
| 54 |
+
async def caption_image_bytes(self, image_bytes: bytes) -> tuple[str, float]:
|
| 55 |
+
self.calls.append(image_bytes)
|
| 56 |
+
if self._raise:
|
| 57 |
+
raise ImageDecodeError("synthetic decode failure")
|
| 58 |
+
return self._caption, self._latency_ms
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _build_app(service: FakePredictorService | None) -> FastAPI:
|
| 62 |
+
app = FastAPI()
|
| 63 |
+
app.state.backend_settings = BackendSettings()
|
| 64 |
+
app.state.predictor_service = service
|
| 65 |
+
app.add_middleware(RequestContextMiddleware)
|
| 66 |
+
app.include_router(router)
|
| 67 |
+
return app
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@pytest.fixture
|
| 71 |
+
def fake_service() -> FakePredictorService:
|
| 72 |
+
return FakePredictorService()
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@pytest.fixture
|
| 76 |
+
def client(fake_service: FakePredictorService) -> Iterator[TestClient]:
|
| 77 |
+
with TestClient(_build_app(fake_service)) as test_client:
|
| 78 |
+
yield test_client
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@pytest.fixture
|
| 82 |
+
def client_without_service() -> Iterator[TestClient]:
|
| 83 |
+
with TestClient(_build_app(None)) as test_client:
|
| 84 |
+
yield test_client
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
@pytest.fixture
|
| 88 |
+
def build_client() -> Callable[[FakePredictorService | None], TestClient]:
|
| 89 |
+
def _make(service: FakePredictorService | None) -> TestClient:
|
| 90 |
+
return TestClient(_build_app(service))
|
| 91 |
+
|
| 92 |
+
return _make
|
backend/app/tests/test_captions.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ``POST /v1/captions``.
|
| 2 |
+
|
| 3 |
+
Covers the route's status-code contract end-to-end:
|
| 4 |
+
|
| 5 |
+
* 200 — happy path, typed ``CaptionResponse`` body
|
| 6 |
+
* 400 — empty file upload
|
| 7 |
+
* 413 — payload above ``max_upload_bytes``
|
| 8 |
+
* 415 — disallowed content type
|
| 9 |
+
* 422 — bytes that the predictor cannot decode (synthetic)
|
| 10 |
+
* 503 — predictor not yet loaded
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from fastapi.testclient import TestClient
|
| 16 |
+
|
| 17 |
+
from app.tests.conftest import FakePredictorService
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _image_field(payload: bytes, content_type: str = "image/jpeg", name: str = "a.jpg"):
|
| 21 |
+
return {"image": (name, payload, content_type)}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_captions_happy_path_returns_typed_response(
|
| 25 |
+
client: TestClient, fake_service: FakePredictorService
|
| 26 |
+
) -> None:
|
| 27 |
+
response = client.post("/v1/captions", files=_image_field(b"\xff\xd8stub"))
|
| 28 |
+
assert response.status_code == 200
|
| 29 |
+
|
| 30 |
+
body = response.json()
|
| 31 |
+
assert body["caption"] == "a test caption"
|
| 32 |
+
assert body["model_version"] == "test-v0"
|
| 33 |
+
assert body["decode_strategy"] == "greedy"
|
| 34 |
+
assert body["latency_ms"] == 1.23
|
| 35 |
+
assert body["request_id"]
|
| 36 |
+
|
| 37 |
+
# Service actually received the upload payload.
|
| 38 |
+
assert fake_service.calls == [b"\xff\xd8stub"]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_captions_request_id_matches_response_header(client: TestClient) -> None:
|
| 42 |
+
response = client.post(
|
| 43 |
+
"/v1/captions",
|
| 44 |
+
files=_image_field(b"\xff\xd8stub"),
|
| 45 |
+
headers={"x-request-id": "trace-123"},
|
| 46 |
+
)
|
| 47 |
+
assert response.status_code == 200
|
| 48 |
+
assert response.headers.get("x-request-id") == "trace-123"
|
| 49 |
+
assert response.json()["request_id"] == "trace-123"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_captions_rejects_unsupported_content_type(client: TestClient) -> None:
|
| 53 |
+
response = client.post(
|
| 54 |
+
"/v1/captions",
|
| 55 |
+
files=_image_field(b"hello", content_type="text/plain", name="a.txt"),
|
| 56 |
+
)
|
| 57 |
+
assert response.status_code == 415
|
| 58 |
+
assert "Unsupported content type" in response.json()["detail"]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_captions_rejects_empty_upload(client: TestClient) -> None:
|
| 62 |
+
response = client.post("/v1/captions", files=_image_field(b""))
|
| 63 |
+
assert response.status_code == 400
|
| 64 |
+
assert "Empty" in response.json()["detail"]
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_captions_rejects_oversize_upload(client: TestClient) -> None:
|
| 68 |
+
# Fake service.max_upload_bytes = 1024
|
| 69 |
+
response = client.post("/v1/captions", files=_image_field(b"x" * 2048))
|
| 70 |
+
assert response.status_code == 413
|
| 71 |
+
assert "limit" in response.json()["detail"].lower()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_captions_returns_422_on_decode_failure(build_client) -> None:
|
| 75 |
+
bad_service = FakePredictorService(raise_decode_error=True)
|
| 76 |
+
with build_client(bad_service) as test_client:
|
| 77 |
+
response = test_client.post("/v1/captions", files=_image_field(b"\xff\xd8junk"))
|
| 78 |
+
assert response.status_code == 422
|
| 79 |
+
assert "synthetic decode failure" in response.json()["detail"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_captions_returns_503_when_predictor_not_loaded(
|
| 83 |
+
client_without_service: TestClient,
|
| 84 |
+
) -> None:
|
| 85 |
+
response = client_without_service.post("/v1/captions", files=_image_field(b"\xff\xd8stub"))
|
| 86 |
+
assert response.status_code == 503
|
| 87 |
+
assert "not ready" in response.json()["detail"].lower()
|
backend/app/tests/test_health.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for ``GET /healthz``.
|
| 2 |
+
|
| 3 |
+
The route reports liveness + readiness in the response body and always
|
| 4 |
+
returns 200; readiness is conveyed by ``model_loaded``.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
from app.tests.conftest import FakePredictorService
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_healthz_reports_ready_when_service_present(client: TestClient) -> None:
|
| 15 |
+
response = client.get("/healthz")
|
| 16 |
+
assert response.status_code == 200
|
| 17 |
+
|
| 18 |
+
body = response.json()
|
| 19 |
+
assert body["status"] == "ok"
|
| 20 |
+
assert body["model_loaded"] is True
|
| 21 |
+
assert body["model_version"] == "test-v0"
|
| 22 |
+
assert body["api_version"]
|
| 23 |
+
assert "timestamp" in body
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_healthz_reports_loading_when_service_missing(
|
| 27 |
+
client_without_service: TestClient,
|
| 28 |
+
) -> None:
|
| 29 |
+
response = client_without_service.get("/healthz")
|
| 30 |
+
assert response.status_code == 200
|
| 31 |
+
|
| 32 |
+
body = response.json()
|
| 33 |
+
assert body["status"] == "loading"
|
| 34 |
+
assert body["model_loaded"] is False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_healthz_echoes_request_id_header(client: TestClient) -> None:
|
| 38 |
+
response = client.get("/healthz", headers={"x-request-id": "deadbeef"})
|
| 39 |
+
assert response.status_code == 200
|
| 40 |
+
assert response.headers.get("x-request-id") == "deadbeef"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_healthz_generates_request_id_when_absent(client: TestClient) -> None:
|
| 44 |
+
response = client.get("/healthz")
|
| 45 |
+
assert response.status_code == 200
|
| 46 |
+
rid = response.headers.get("x-request-id")
|
| 47 |
+
assert rid and len(rid) >= 16
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_healthz_uses_overridden_model_version(
|
| 51 |
+
build_client,
|
| 52 |
+
) -> None:
|
| 53 |
+
service = FakePredictorService(model_version="v9.9.9")
|
| 54 |
+
with build_client(service) as test_client:
|
| 55 |
+
body = test_client.get("/healthz").json()
|
| 56 |
+
assert body["model_version"] == "v9.9.9"
|
pyproject.toml
CHANGED
|
@@ -211,6 +211,7 @@ ignore_missing_imports = true
|
|
| 211 |
[tool.pytest.ini_options]
|
| 212 |
minversion = "8.0"
|
| 213 |
testpaths = ["tests", "backend/app/tests"]
|
|
|
|
| 214 |
addopts = [
|
| 215 |
"-ra", # Show short summary for non-passing tests
|
| 216 |
"--strict-markers",
|
|
|
|
| 211 |
[tool.pytest.ini_options]
|
| 212 |
minversion = "8.0"
|
| 213 |
testpaths = ["tests", "backend/app/tests"]
|
| 214 |
+
pythonpath = ["backend"] # Lets `from app.* import ...` resolve in tests
|
| 215 |
addopts = [
|
| 216 |
"-ra", # Show short summary for non-passing tests
|
| 217 |
"--strict-markers",
|