Skip to content

Commit 5bdd2f6

Browse files
committed
test: add pytest tooling and full-coverage suite for core and decorator
1 parent d902049 commit 5bdd2f6

11 files changed

Lines changed: 1169 additions & 3 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,7 @@ wheels/
88

99
# Virtual environments
1010
.venv
11+
12+
# Test artifacts
13+
.coverage
14+
.pytest_cache/

CLAUDE.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Public PyPI package: typed HTTP errors for FastAPI — exact `Literal` error cod
66

77
## Status
88

9-
Draft. The `core` and `decorator` layers are implemented. Linters/type checker are configured (see Conventions); tests are not set up yet (verification via ad-hoc smoke scripts outside the repo). Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
9+
Draft. The `core` and `decorator` layers are implemented. Linters/type checker and pytest are configured (see Conventions). Licensed under MIT (`LICENSE` + PEP 639 metadata in `pyproject.toml`).
1010

1111
## Architecture — three independent layers
1212

@@ -69,7 +69,12 @@ src-layout, package `src/fastapi_typed_errors/`:
6969
- Type checker: **ty**, max strictness (`[tool.ty.rules] all = "error"`). Suppressions use ty-style comments (`# ty: ignore[rule]`); ty does not recognize mypy rule codes inside `# type: ignore[...]`.
7070
- Linter/formatter: **ruff** with `select = ["ALL"]` + `preview = true`, ignoring only the `TC` and `CPY` modules and `missing-trailing-comma` (COM812, formatter conflict); pydocstyle convention = google. Formatter: double quotes, `line-length = 120`. Import order: ruff isort defaults (stdlib `import` then `from` imports, third-party, local) — exactly the preferred style, no extra config needed.
7171
- Ruff suppressions use the new `# ruff:ignore[rule-name]` syntax — the preview rule `noqa-comments` forbids legacy `# noqa` comments. Single-rule entries in `lint.ignore` must use rule *names*, not codes (preview rule `rule-codes-in-selectors`).
72-
- Both tools are pinned exactly (`==`) in the `dev` dependency group; bump deliberately (`uv add --dev --bounds exact ty ruff`).
72+
- ruff and ty are pinned exactly (`==`) in the `dev` dependency group; bump deliberately (`uv add --dev --bounds exact ty ruff`). Test tooling (`pytest`, `pytest-cov`, `httpx2` for `TestClient`) uses compatible-release pins (`~=`).
73+
- Tests live in `tests/`, mirroring the package structure (`tests/core/test_base.py``src/.../core/base.py`); the directory is not a package (INP001 ignored, S101 too: pytest asserts; EM101 ignored — literal details in `raise` are the package's own user-facing pattern). Run: `just test` (`uv run pytest --cov`); pytest config and coverage live in `pyproject.toml`. **Coverage bar: `fail_under = 100`** (branch coverage on). Test-writing rules are introduced incrementally by the user — check recent test files for the current style before writing new ones.
74+
- Async tests are plain `async def` — the anyio pytest plugin picks them up via `tests/conftest.py` (an `anyio_backend` fixture + a `pytest_collection_modifyitems` hook auto-marking coroutine tests; anyio has no pytest-asyncio-style "auto" mode).
75+
- Registration-only stub endpoints in tests end with `raise NotImplementedError` (ty `all = "error"` rejects `...` bodies with non-`None` return annotations outside stubs/protocols).
76+
- Test structure: **AAA** (Arrange / Act / Assert), the blocks separated by blank lines; single-expression tests may collapse to one line (e.g. `assert error_models(X) is X.model`). Every test opens with a short one-line docstring describing the case; test functions are annotated `-> None`.
77+
- Prefer pytest **fixtures** for reusable arrange values (request objects, configured apps, built artifacts) — typed, with a docstring and a `Returns:` section. Module level is only for error classes/enums that must exist at import time (they are used in type positions). Don't name a fixture `request` — it clashes with pytest's built-in.
7378
- Lint everything: `just lint` (ruff format --check, ruff check, ty check). Run `uv run ruff format` to apply formatting.
7479
- Environment and build: uv (`uv sync`, `uv run python ...`), build backend `uv_build`.
75-
- Run smoke checks against a live FastAPI app: `uv run --with httpx python <script>` (httpx is needed by `TestClient` and is not a package dependency).
80+
- Run smoke checks against a live FastAPI app: `uv run python <script>` (`httpx2` for `TestClient` is in the dev group, so no `--with` is needed anymore).

Justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ lint:
77
uv run ruff format --check
88
uv run ruff check
99
uv run ty check
10+
11+
# Run the test suite with coverage.
12+
test:
13+
uv run pytest --cov

pyproject.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,34 @@ ignore = [
3838
[tool.ruff.lint.pydocstyle]
3939
convention = "google"
4040

41+
[tool.ruff.lint.per-file-ignores]
42+
"tests/**" = [
43+
"assert", # S101: pytest is built on assert
44+
"implicit-namespace-package", # INP001: the tests directory is not a package
45+
"raw-string-in-exception", # EM101: raising errors with a literal detail is the package's own user-facing pattern
46+
]
47+
4148
[tool.ty.rules]
4249
all = "error"
4350

51+
[tool.pytest.ini_options]
52+
testpaths = ["tests"]
53+
addopts = "--strict-config --strict-markers"
54+
55+
[tool.coverage.run]
56+
source = ["fastapi_typed_errors"]
57+
branch = true
58+
59+
[tool.coverage.report]
60+
show_missing = true
61+
fail_under = 100
62+
4463
[dependency-groups]
4564
dev = [
65+
"anyio~=4.14.2",
66+
"httpx2~=2.7.0",
67+
"pytest~=9.1.1",
68+
"pytest-cov~=7.1.0",
4669
"ruff==0.15.22",
4770
"ty==0.0.61",
4871
]

tests/conftest.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
"""Shared pytest configuration: anyio-powered ``async def`` tests."""
2+
3+
import inspect
4+
5+
import pytest
6+
7+
8+
@pytest.fixture
9+
def anyio_backend() -> str:
10+
"""Run async tests on the asyncio backend.
11+
12+
Returns:
13+
str: The anyio backend name.
14+
"""
15+
return "asyncio"
16+
17+
18+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
19+
"""Mark every ``async def`` test with the anyio marker automatically.
20+
21+
Args:
22+
items: Collected test items, mutated in place.
23+
"""
24+
for item in items:
25+
if isinstance(item, pytest.Function) and inspect.iscoroutinefunction(item.function):
26+
item.add_marker(pytest.mark.anyio)

tests/core/test_base.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Tests for ``BaseError`` and the ``BaseErrorMeta`` metaclass."""
2+
3+
from enum import StrEnum
4+
from http import HTTPStatus
5+
from typing import Literal
6+
7+
import pytest
8+
9+
from fastapi_typed_errors import BaseError, ErrorResponse
10+
from fastapi_typed_errors.core.base import BaseErrorMeta
11+
12+
13+
class Code(StrEnum):
14+
"""Error codes used by the test error classes."""
15+
16+
NOT_FOUND = "NOT_FOUND"
17+
FORBIDDEN = "FORBIDDEN"
18+
19+
20+
class NotFoundError(BaseError[Literal[Code.NOT_FOUND]]):
21+
"""Test error with a description."""
22+
23+
http_status = HTTPStatus.NOT_FOUND
24+
description = "Entity does not exist"
25+
26+
27+
class ForbiddenError(BaseError[Literal[Code.FORBIDDEN]]):
28+
"""Test error without a description."""
29+
30+
http_status = HTTPStatus.FORBIDDEN
31+
32+
33+
class WideResponse[T: str](ErrorResponse[T]):
34+
"""Custom response base with an extra field."""
35+
36+
extra: str = "x"
37+
38+
39+
class AppError[T: str](BaseError[T]):
40+
"""Intermediate generic base carrying a custom ``response_base``."""
41+
42+
response_base = WideResponse
43+
44+
45+
class CustomError(AppError[Literal[Code.FORBIDDEN]]):
46+
"""Concrete error inheriting the custom response base."""
47+
48+
http_status = HTTPStatus.FORBIDDEN
49+
50+
51+
def test_code_extracted_from_enum_literal() -> None:
52+
"""The metaclass pulls the code out of the ``Literal`` generic parameter."""
53+
assert NotFoundError.error_code is Code.NOT_FOUND
54+
55+
56+
def test_code_extracted_from_bare_string_literal() -> None:
57+
"""A plain string ``Literal`` works as the error code too."""
58+
59+
class BareError(BaseError[Literal["BARE"]]):
60+
"""Error declared without an enum."""
61+
62+
http_status = HTTPStatus.CONFLICT
63+
64+
assert BareError.error_code == "BARE"
65+
66+
67+
def test_error_classes_use_the_metaclass() -> None:
68+
"""Concrete errors are instances of the public ``BaseErrorMeta``."""
69+
assert type(NotFoundError) is BaseErrorMeta
70+
71+
72+
def test_model_is_parametrized_response() -> None:
73+
"""The derived model validates the exact declared code."""
74+
instance = NotFoundError.model(code=Code.NOT_FOUND, detail="gone")
75+
76+
assert isinstance(instance, ErrorResponse)
77+
assert instance.code is Code.NOT_FOUND
78+
79+
80+
def test_model_title_is_clean() -> None:
81+
"""OpenAPI titles render the code value, not the enum ``repr()``."""
82+
assert NotFoundError.model.model_json_schema()["title"] == "ErrorResponse[NOT_FOUND]"
83+
84+
85+
def test_unparametrized_model_title_is_class_name() -> None:
86+
"""The generic base keeps its plain class name as the title."""
87+
assert ErrorResponse.model_json_schema()["title"] == "ErrorResponse"
88+
89+
90+
def test_intermediate_base_has_no_code() -> None:
91+
"""A generic base parametrized with a ``TypeVar`` declares no code."""
92+
with pytest.raises(AttributeError, match="error_code is not defined"):
93+
_ = AppError.error_code
94+
95+
96+
def test_intermediate_base_has_no_model() -> None:
97+
"""A generic base parametrized with a ``TypeVar`` declares no model."""
98+
with pytest.raises(AttributeError, match="model is not defined"):
99+
_ = AppError.model
100+
101+
102+
def test_non_literal_parametrization_rejected() -> None:
103+
"""``BaseError[str]`` fails at class definition time, not at request time."""
104+
with pytest.raises(TypeError, match="exactly one string code"):
105+
106+
class _Bad(BaseError[str]):
107+
http_status = HTTPStatus.BAD_REQUEST
108+
109+
110+
def test_enum_class_parametrization_rejected() -> None:
111+
"""Passing the whole enum instead of ``Literal[member]`` is rejected."""
112+
with pytest.raises(TypeError, match="exactly one string code"):
113+
114+
class _Bad(BaseError[Code]):
115+
http_status = HTTPStatus.BAD_REQUEST
116+
117+
118+
def test_multi_literal_parametrization_rejected() -> None:
119+
"""A ``Literal`` with several codes is rejected."""
120+
with pytest.raises(TypeError, match="exactly one string code"):
121+
122+
class _Bad(BaseError[Literal[Code.NOT_FOUND, Code.FORBIDDEN]]):
123+
http_status = HTTPStatus.BAD_REQUEST
124+
125+
126+
def test_response_base_inherited_from_generic_base() -> None:
127+
"""A concrete error parametrizes the ``response_base`` of its base class."""
128+
response = CustomError("no").to_response()
129+
130+
assert isinstance(response, WideResponse)
131+
assert response.extra == "x"
132+
133+
134+
def test_response_base_declared_in_own_namespace() -> None:
135+
"""A class declaring both code and ``response_base`` uses its own base."""
136+
137+
class InlineError(BaseError[Literal["INLINE"]]):
138+
"""Error overriding the response base in place."""
139+
140+
http_status = HTTPStatus.CONFLICT
141+
response_base = WideResponse
142+
143+
assert isinstance(InlineError("x").to_response(), WideResponse)
144+
145+
146+
def test_detail_defaults_to_description() -> None:
147+
"""Without an explicit detail the ``description`` is used."""
148+
assert NotFoundError().detail == "Entity does not exist"
149+
150+
151+
def test_detail_falls_back_to_status_phrase() -> None:
152+
"""Without a description the HTTP status phrase is used."""
153+
assert ForbiddenError().detail == HTTPStatus.FORBIDDEN.phrase
154+
155+
156+
def test_explicit_detail_wins() -> None:
157+
"""An explicit detail overrides all defaults."""
158+
assert NotFoundError("gone").detail == "gone"
159+
160+
161+
def test_headers_are_stored() -> None:
162+
"""Extra headers are passed through to ``HTTPException``."""
163+
assert NotFoundError(headers={"WWW-Authenticate": "Bearer"}).headers == {"WWW-Authenticate": "Bearer"}
164+
165+
166+
def test_status_code_comes_from_http_status() -> None:
167+
"""The ``HTTPException`` status code mirrors ``http_status``."""
168+
assert NotFoundError().status_code == HTTPStatus.NOT_FOUND
169+
170+
171+
def test_to_response_carries_code_and_detail() -> None:
172+
"""``to_response()`` builds the parametrized model from the instance."""
173+
response = NotFoundError("gone").to_response()
174+
175+
assert type(response) is NotFoundError.model
176+
assert response.code is Code.NOT_FOUND
177+
assert response.detail == "gone"
178+
179+
180+
def test_metaclass_standalone_defaults_to_error_response() -> None:
181+
"""The metaclass works without ``BaseError``, falling back to ``ErrorResponse``."""
182+
183+
class RogueBase[T: str](metaclass=BaseErrorMeta):
184+
"""Generic base using the metaclass directly."""
185+
186+
class RogueError(RogueBase[Literal["ROGUE"]]):
187+
"""Concrete error outside the BaseError hierarchy."""
188+
189+
assert issubclass(RogueError.model, ErrorResponse)
190+
191+
192+
def test_two_parameter_generic_base_declares_no_code() -> None:
193+
"""A base parametrized with two arguments is skipped by code extraction."""
194+
195+
class TwoParam[T: str, U: str](BaseError[T]):
196+
"""Base carrying an extra, unrelated type parameter."""
197+
198+
class Odd(TwoParam[Literal["ODD"], Literal["X"]]):
199+
"""Parametrization the extractor must skip."""
200+
201+
http_status = HTTPStatus.BAD_REQUEST
202+
203+
with pytest.raises(AttributeError, match="error_code is not defined"):
204+
_ = Odd.error_code

0 commit comments

Comments
 (0)