Skip to content

Commit 926f6e3

Browse files
authored
Merge pull request #15 from BarnabasG/initial
1.3.0 Add Django support
2 parents a27ba27 + a43f8df commit 926f6e3

11 files changed

Lines changed: 241 additions & 34 deletions

File tree

README.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# pytest-api-cov
22

3-
A **pytest plugin** that measures **API endpoint coverage** for FastAPI and Flask applications. Know which endpoints are tested and which are missing coverage.
3+
A **pytest plugin** that measures **API endpoint coverage** for FastAPI, Flask, and Django applications. Know which endpoints are tested and which are missing coverage.
44

55
## Features
66

7-
- **Zero Configuration**: Plug-and-play with Flask/FastAPI apps - just install and run
7+
- **Zero Configuration**: Plug-and-play with Flask/FastAPI/Django apps - just install and run
88
- **Client-Based Discovery**: Automatically extracts app from your existing test client fixtures
99
- **Terminal Reports**: Rich terminal output with detailed coverage information
1010
- **JSON Reports**: Export coverage data for CI/CD integration
@@ -385,7 +385,7 @@ pytest --api-cov-report --api-cov-openapi-spec=openapi.yaml
385385

386386
## Framework Support
387387

388-
Works automatically with FastAPI, Flask, and Flask-OpenAPI3 applications.
388+
Works automatically with FastAPI, Flask, Flask-OpenAPI3, and Django applications.
389389

390390
### FastAPI
391391

@@ -421,6 +421,15 @@ def test_get_user(coverage_client):
421421
assert response.status_code == 200
422422
```
423423

424+
### Django
425+
426+
```python
427+
# Tests automatically get a 'coverage_client' fixture that wraps django.test.Client
428+
def test_root_endpoint(coverage_client):
429+
response = coverage_client.get("/")
430+
assert response.status_code == 200
431+
```
432+
424433
## Parallel Testing
425434

426435
pytest-api-cov fully supports pytest-xdist for parallel test execution:
@@ -519,6 +528,7 @@ The plugin supports:
519528
- **FastAPI**: Detected by `FastAPI` class
520529
- **Flask**: Detected by `Flask` class
521530
- **FlaskOpenAPI3**: Detected by `OpenAPI` class (from `flask_openapi3` module)
531+
- **Django**: Detected by `django` module presence or `WSGIHandler` class
522532

523533
Other frameworks are not currently supported.
524534

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pytest-api-cov"
3-
version = "1.2.3"
3+
version = "1.3.0"
44
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
55
readme = "README.md"
66
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
@@ -33,6 +33,7 @@ dev = [
3333
"typeguard>=4.4.4",
3434
"vulture>=2.14",
3535
"types-PyYAML>=6.0",
36+
"django>=4.0.0",
3637
]
3738

3839
# API COVERAGE

src/pytest_api_cov/frameworks.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,57 @@ def send(self, *args: Any, **kwargs: Any) -> Any:
9898
return TrackingFastAPIClient(self.app)
9999

100100

101+
class DjangoAdapter(BaseAdapter):
102+
"""Adapter for Django applications."""
103+
104+
def get_endpoints(self) -> List[str]:
105+
"""Return list of 'METHOD /path' strings."""
106+
from django.urls import get_resolver # type: ignore[import-untyped]
107+
from django.urls.resolvers import URLPattern, URLResolver # type: ignore[import-untyped]
108+
109+
endpoints: List[str] = []
110+
111+
def _extract_patterns(patterns: List[Any], prefix: str = "") -> None:
112+
for pattern in patterns:
113+
if isinstance(pattern, URLPattern):
114+
route = str(pattern.pattern).strip("^$")
115+
full_path = f"/{prefix}{route}".replace("//", "/")
116+
117+
view = pattern.callback
118+
methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}
119+
120+
if hasattr(view, "view_class") and hasattr(view.view_class, "http_method_names"):
121+
methods = {m.upper() for m in view.view_class.http_method_names}
122+
123+
endpoints.extend(f"{method} {full_path}" for method in methods if method not in ("HEAD", "OPTIONS"))
124+
125+
elif isinstance(pattern, URLResolver):
126+
route = str(pattern.pattern).strip("^$")
127+
_extract_patterns(pattern.url_patterns, f"{prefix}{route}")
128+
129+
_extract_patterns(get_resolver().url_patterns)
130+
return sorted(endpoints)
131+
132+
def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
133+
"""Return a patched test client that records calls."""
134+
from django.test import Client # type: ignore[import-untyped]
135+
136+
if recorder is None:
137+
return Client()
138+
139+
class TrackingDjangoClient(Client): # type: ignore[misc]
140+
def request(self, **request: Any) -> Any:
141+
method = request.get("REQUEST_METHOD", "GET").upper()
142+
path = request.get("PATH_INFO", "/")
143+
144+
if recorder is not None:
145+
recorder.record_call(path, test_name, method)
146+
147+
return super().request(**request)
148+
149+
return TrackingDjangoClient()
150+
151+
101152
def get_framework_adapter(app: Any) -> BaseAdapter:
102153
"""Detect the framework and return the appropriate adapter."""
103154
app_type = type(app).__name__
@@ -108,4 +159,15 @@ def get_framework_adapter(app: Any) -> BaseAdapter:
108159
if module_name == "fastapi" and app_type == "FastAPI":
109160
return FastAPIAdapter(app)
110161

111-
raise TypeError(f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask and FastAPI.")
162+
# Django detection
163+
# Django apps are often WSGIHandlers or just the module 'django' is present
164+
if module_name == "django" or "django" in module_name:
165+
return DjangoAdapter(app)
166+
167+
# Check for Django WSGI handler specifically
168+
if app_type == "WSGIHandler" and module_name == "django.core.handlers.wsgi":
169+
return DjangoAdapter(app)
170+
171+
raise TypeError(
172+
f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask, FastAPI, and Django."
173+
)

src/pytest_api_cov/plugin.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def is_supported_framework(app: Any) -> bool:
6464
(module_name == "flask" and app_type == "Flask")
6565
or (module_name == "flask_openapi3" and app_type == "OpenAPI")
6666
or (module_name == "fastapi" and app_type == "FastAPI")
67+
or (module_name == "django.core.handlers.wsgi" and app_type == "WSGIHandler")
68+
or (module_name == "django" or "django" in module_name)
6769
)
6870

6971

@@ -84,11 +86,10 @@ def extract_app_from_client(client: Any) -> Optional[Any]:
8486
if hasattr(client, "_transport") and hasattr(client._transport, "app"):
8587
return client._transport.app
8688

87-
# Flask's test client may expose the application via "application" or "app"
8889
if hasattr(client, "_app"):
8990
return client._app
9091

91-
return None
92+
return getattr(client, "handler", None)
9293

9394

9495
def pytest_addoption(parser: pytest.Parser) -> None:
@@ -110,12 +111,12 @@ def pytest_configure(config: pytest.Config) -> None:
110111

111112
logger.setLevel(log_level)
112113

113-
if not logger.handlers:
114-
handler = logging.StreamHandler()
115-
handler.setLevel(log_level)
116-
formatter = logging.Formatter("%(message)s")
117-
handler.setFormatter(formatter)
118-
logger.addHandler(handler)
114+
# if not logger.handlers:
115+
# handler = logging.StreamHandler()
116+
# handler.setLevel(log_level)
117+
# formatter = logging.Formatter("%(message)s")
118+
# handler.setFormatter(formatter)
119+
# logger.addHandler(handler)
119120

120121
logger.info("Initializing API coverage plugin...")
121122

@@ -367,7 +368,7 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
367368
logger.info(f"> Found custom fixture '{fixture_name}', wrapping with coverage tracking")
368369
break
369370
except pytest.FixtureLookupError:
370-
logger.debug(f"> Custom fixture '{fixture_name}' not found, trying next one")
371+
logger.debug(f"> Custom fixture '{fixture_name}' not found")
371372
continue
372373

373374
if client is None:
@@ -399,7 +400,7 @@ def coverage_client(request: pytest.FixtureRequest) -> Any:
399400

400401
if not is_supported_framework(app):
401402
logger.warning(
402-
f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI."
403+
f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask, FastAPI, and Django."
403404
)
404405
return client
405406

src/pytest_api_cov/report.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ def print_endpoints(
142142
else:
143143
# Handle legacy format without method
144144
formatted_endpoint = endpoint
145-
console.print(f" {symbol} [{style}]{formatted_endpoint}[/]")
145+
console.print(f" {symbol}\t[{style}]{formatted_endpoint}[/]")
146146

147147

148148
def compute_coverage(covered_count: int, uncovered_count: int) -> float:
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import pytest
2+
3+
pytest_plugins = ["pytester"]
4+
5+
6+
def test_django_discovery(pytester):
7+
"""Test that Django endpoints are discovered and covered."""
8+
9+
# Create urls.py
10+
pytester.makepyfile(
11+
urls="""
12+
from django.http import JsonResponse
13+
from django.urls import path
14+
15+
def root_view(request):
16+
return JsonResponse({"message": "Hello Django"})
17+
18+
urlpatterns = [
19+
path("api/root/", root_view),
20+
]
21+
"""
22+
)
23+
24+
# Create a conftest.py that defines the app fixture
25+
pytester.makeconftest("""
26+
import pytest
27+
from django.conf import settings
28+
from django.core.handlers.wsgi import WSGIHandler
29+
30+
if not settings.configured:
31+
settings.configure(
32+
DEBUG=True,
33+
SECRET_KEY="secret",
34+
ROOT_URLCONF="urls",
35+
ALLOWED_HOSTS=["*"],
36+
INSTALLED_APPS=[],
37+
)
38+
import django
39+
django.setup()
40+
41+
@pytest.fixture
42+
def app():
43+
return WSGIHandler()
44+
""")
45+
46+
# Create a test file
47+
pytester.makepyfile("""
48+
def test_root(coverage_client):
49+
response = coverage_client.get("/api/root/")
50+
assert response.status_code == 200
51+
""")
52+
53+
# Run pytest with api-coverage enabled
54+
result = pytester.runpytest("--api-cov-report", "--api-cov-show-covered-endpoints", "-vv")
55+
56+
# Check output
57+
print(result.stdout.str())
58+
assert "API Coverage Report" in result.stdout.str()
59+
assert "Covered Endpoints:" in result.stdout.str()
60+
assert "GET /api/root/" in result.stdout.str()
61+
assert "Total API Coverage: 20.0%" in result.stdout.str()
62+
assert result.ret == 0

tests/integration/test_openapi_integration.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,18 @@ def test_dummy(coverage_client):
3535
""")
3636

3737
# Run pytest with the flag
38-
result = pytester.runpytest("--api-cov-report", "--api-cov-openapi-spec=openapi.json", "-vv")
38+
result = pytester.runpytest(
39+
"--api-cov-report",
40+
"--api-cov-openapi-spec=openapi.json",
41+
"-vv",
42+
"-o",
43+
"log_cli=true",
44+
"-o",
45+
"log_cli_level=INFO",
46+
)
3947

4048
# Check that endpoints were discovered
41-
result.stderr.fnmatch_lines(
49+
result.stdout.fnmatch_lines(
4250
[
4351
"*Discovered 3 endpoints from OpenAPI spec*",
4452
]

tests/integration/test_plugin_integration.py

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ def test_uncovered(coverage_client):
4545
assert "FAIL: Required coverage of 90.0% not met" in output
4646
assert "Actual coverage: 50.0%" in output
4747
assert "Covered Endpoints" in output
48-
assert "[.] GET" in output
48+
assert "[.] GET" in output
4949
assert "Uncovered Endpoints" in output
50-
assert "[X] GET" in output
50+
assert "[X] GET" in output
5151

5252

5353
def test_plugin_disabled_by_default(pytester):
@@ -108,9 +108,9 @@ def test_with_custom_client(coverage_client):
108108
assert "API Coverage Report" in output
109109
assert "Total API Coverage: 50.0%" in output
110110
assert "Covered Endpoints" in output
111-
assert "[.] GET" in output
111+
assert "[.] GET" in output
112112
assert "Uncovered Endpoints" in output
113-
assert "[X] GET" in output
113+
assert "[X] GET" in output
114114

115115

116116
def test_custom_fixture_wrapping_fastapi(pytester):
@@ -159,9 +159,9 @@ def test_with_custom_client(coverage_client):
159159
assert "API Coverage Report" in output
160160
assert "Total API Coverage: 50.0%" in output
161161
assert "Covered Endpoints" in output
162-
assert "[.] GET" in output
162+
assert "[.] GET" in output
163163
assert "Uncovered Endpoints" in output
164-
assert "[X] GET" in output
164+
assert "[X] GET" in output
165165

166166

167167
def test_custom_fixture_fallback_when_not_found(pytester):
@@ -243,9 +243,9 @@ def test_root_endpoint(coverage_client):
243243
assert "API Coverage Report" in output
244244
assert "Total API Coverage: 50.0%" in output
245245
assert "Covered Endpoints" in output
246-
assert "[.] GET" in output
246+
assert "[.] GET" in output
247247
assert "Uncovered Endpoints" in output
248-
assert "[X] GET" in output
248+
assert "[X] GET" in output
249249

250250

251251
def test_multiple_auto_discover_files_uses_first(pytester):
@@ -426,11 +426,11 @@ def test_admin(coverage_client):
426426
assert "API Coverage Report" in output
427427

428428
assert "GET /users/bob" in output
429-
assert "[.] GET /users/bob" in output
429+
assert "[.] GET /users/bob" in output
430430

431431
assert "GET /users/alice" in output
432432
assert "GET /users/charlie" in output
433-
assert "[-] GET /users/alice" in output
434-
assert "[-] GET /users/charlie" in output
433+
assert "[-] GET /users/alice" in output
434+
assert "[-] GET /users/charlie" in output
435435

436436
assert "Total API Coverage:" in output

tests/unit/test_frameworks.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,12 +192,22 @@ class MockWSGIHandler:
192192
mock_fastapi_app.__class__.__module__ = "fastapi.applications"
193193
mock_fastapi_app.__class__.__name__ = "FastAPI"
194194

195+
mock_django_app = Mock()
196+
mock_django_app.__class__.__module__ = "django.core.handlers.wsgi"
197+
mock_django_app.__class__.__name__ = "WSGIHandler"
198+
195199
mock_unsupported_app = Mock()
196-
mock_unsupported_app.__class__.__module__ = "django.core.handlers.wsgi"
197-
mock_unsupported_app.__class__.__name__ = "WSGIHandler"
200+
mock_unsupported_app.__class__.__module__ = "bottle"
201+
mock_unsupported_app.__class__.__name__ = "Bottle"
198202

199203
assert isinstance(get_framework_adapter(mock_flask_app), FlaskAdapter)
200204
assert isinstance(get_framework_adapter(mock_fastapi_app), FastAPIAdapter)
205+
206+
# Django is now supported
207+
from pytest_api_cov.frameworks import DjangoAdapter
208+
209+
assert isinstance(get_framework_adapter(mock_django_app), DjangoAdapter)
210+
201211
with pytest.raises(TypeError, match="Unsupported application type"):
202212
get_framework_adapter(mock_unsupported_app)
203213

tests/unit/test_plugin.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,18 @@ def test_is_supported_framework_fastapi(self):
4040
mock_app.__class__.__module__ = "fastapi.applications"
4141
assert is_supported_framework(mock_app) is True
4242

43-
def test_is_supported_framework_unsupported(self):
44-
"""Test framework detection with unsupported framework."""
43+
def test_is_supported_framework_django(self):
44+
"""Test framework detection with Django app."""
4545
mock_app = Mock()
4646
mock_app.__class__.__name__ = "Django"
4747
mock_app.__class__.__module__ = "django.core"
48+
assert is_supported_framework(mock_app) is True
49+
50+
def test_is_supported_framework_unsupported(self):
51+
"""Test framework detection with unsupported framework."""
52+
mock_app = Mock()
53+
mock_app.__class__.__name__ = "Bottle"
54+
mock_app.__class__.__module__ = "bottle"
4855
assert is_supported_framework(mock_app) is False
4956

5057

0 commit comments

Comments
 (0)