Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# pytest-api-cov

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

## Features

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

## Framework Support

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

### FastAPI

Expand Down Expand Up @@ -421,6 +421,15 @@ def test_get_user(coverage_client):
assert response.status_code == 200
```

### Django

```python
# Tests automatically get a 'coverage_client' fixture that wraps django.test.Client
def test_root_endpoint(coverage_client):
response = coverage_client.get("/")
assert response.status_code == 200
```

## Parallel Testing

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

Other frameworks are not currently supported.

Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "pytest-api-cov"
version = "1.2.3"
version = "1.3.0"
description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks"
readme = "README.md"
authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }]
Expand Down Expand Up @@ -33,6 +33,7 @@ dev = [
"typeguard>=4.4.4",
"vulture>=2.14",
"types-PyYAML>=6.0",
"django>=4.0.0",
]

# API COVERAGE
Expand Down
64 changes: 63 additions & 1 deletion src/pytest_api_cov/frameworks.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,57 @@ def send(self, *args: Any, **kwargs: Any) -> Any:
return TrackingFastAPIClient(self.app)


class DjangoAdapter(BaseAdapter):
"""Adapter for Django applications."""

def get_endpoints(self) -> List[str]:
"""Return list of 'METHOD /path' strings."""
from django.urls import get_resolver # type: ignore[import-untyped]
from django.urls.resolvers import URLPattern, URLResolver # type: ignore[import-untyped]

endpoints: List[str] = []

def _extract_patterns(patterns: List[Any], prefix: str = "") -> None:
for pattern in patterns:
if isinstance(pattern, URLPattern):
route = str(pattern.pattern).strip("^$")
full_path = f"/{prefix}{route}".replace("//", "/")

view = pattern.callback
methods = {"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"}

if hasattr(view, "view_class") and hasattr(view.view_class, "http_method_names"):
methods = {m.upper() for m in view.view_class.http_method_names}

endpoints.extend(f"{method} {full_path}" for method in methods if method not in ("HEAD", "OPTIONS"))

elif isinstance(pattern, URLResolver):
route = str(pattern.pattern).strip("^$")
_extract_patterns(pattern.url_patterns, f"{prefix}{route}")

_extract_patterns(get_resolver().url_patterns)
return sorted(endpoints)

def get_tracked_client(self, recorder: Optional["ApiCallRecorder"], test_name: str) -> Any:
"""Return a patched test client that records calls."""
from django.test import Client # type: ignore[import-untyped]

if recorder is None:
return Client()

class TrackingDjangoClient(Client): # type: ignore[misc]
def request(self, **request: Any) -> Any:
method = request.get("REQUEST_METHOD", "GET").upper()
path = request.get("PATH_INFO", "/")

if recorder is not None:
recorder.record_call(path, test_name, method)

return super().request(**request)

return TrackingDjangoClient()


def get_framework_adapter(app: Any) -> BaseAdapter:
"""Detect the framework and return the appropriate adapter."""
app_type = type(app).__name__
Expand All @@ -108,4 +159,15 @@ def get_framework_adapter(app: Any) -> BaseAdapter:
if module_name == "fastapi" and app_type == "FastAPI":
return FastAPIAdapter(app)

raise TypeError(f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask and FastAPI.")
# Django detection
# Django apps are often WSGIHandlers or just the module 'django' is present
if module_name == "django" or "django" in module_name:
return DjangoAdapter(app)

# Check for Django WSGI handler specifically
if app_type == "WSGIHandler" and module_name == "django.core.handlers.wsgi":
return DjangoAdapter(app)

raise TypeError(
f"Unsupported application type: {app_type}. pytest-api-coverage supports Flask, FastAPI, and Django."
)
21 changes: 11 additions & 10 deletions src/pytest_api_cov/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ def is_supported_framework(app: Any) -> bool:
(module_name == "flask" and app_type == "Flask")
or (module_name == "flask_openapi3" and app_type == "OpenAPI")
or (module_name == "fastapi" and app_type == "FastAPI")
or (module_name == "django.core.handlers.wsgi" and app_type == "WSGIHandler")
or (module_name == "django" or "django" in module_name)
)


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

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

return None
return getattr(client, "handler", None)


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

logger.setLevel(log_level)

if not logger.handlers:
handler = logging.StreamHandler()
handler.setLevel(log_level)
formatter = logging.Formatter("%(message)s")
handler.setFormatter(formatter)
logger.addHandler(handler)
# if not logger.handlers:
# handler = logging.StreamHandler()
# handler.setLevel(log_level)
# formatter = logging.Formatter("%(message)s")
# handler.setFormatter(formatter)
# logger.addHandler(handler)

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

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

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

if not is_supported_framework(app):
logger.warning(
f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI."
f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask, FastAPI, and Django."
)
return client

Expand Down
2 changes: 1 addition & 1 deletion src/pytest_api_cov/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def print_endpoints(
else:
# Handle legacy format without method
formatted_endpoint = endpoint
console.print(f" {symbol} [{style}]{formatted_endpoint}[/]")
console.print(f" {symbol}\t[{style}]{formatted_endpoint}[/]")


def compute_coverage(covered_count: int, uncovered_count: int) -> float:
Expand Down
62 changes: 62 additions & 0 deletions tests/integration/test_django_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import pytest

pytest_plugins = ["pytester"]


def test_django_discovery(pytester):
"""Test that Django endpoints are discovered and covered."""

# Create urls.py
pytester.makepyfile(
urls="""
from django.http import JsonResponse
from django.urls import path

def root_view(request):
return JsonResponse({"message": "Hello Django"})

urlpatterns = [
path("api/root/", root_view),
]
"""
)

# Create a conftest.py that defines the app fixture
pytester.makeconftest("""
import pytest
from django.conf import settings
from django.core.handlers.wsgi import WSGIHandler

if not settings.configured:
settings.configure(
DEBUG=True,
SECRET_KEY="secret",
ROOT_URLCONF="urls",
ALLOWED_HOSTS=["*"],
INSTALLED_APPS=[],
)
import django
django.setup()

@pytest.fixture
def app():
return WSGIHandler()
""")

# Create a test file
pytester.makepyfile("""
def test_root(coverage_client):
response = coverage_client.get("/api/root/")
assert response.status_code == 200
""")

# Run pytest with api-coverage enabled
result = pytester.runpytest("--api-cov-report", "--api-cov-show-covered-endpoints", "-vv")

# Check output
print(result.stdout.str())
assert "API Coverage Report" in result.stdout.str()
assert "Covered Endpoints:" in result.stdout.str()
assert "GET /api/root/" in result.stdout.str()
assert "Total API Coverage: 20.0%" in result.stdout.str()
assert result.ret == 0
12 changes: 10 additions & 2 deletions tests/integration/test_openapi_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,18 @@ def test_dummy(coverage_client):
""")

# Run pytest with the flag
result = pytester.runpytest("--api-cov-report", "--api-cov-openapi-spec=openapi.json", "-vv")
result = pytester.runpytest(
"--api-cov-report",
"--api-cov-openapi-spec=openapi.json",
"-vv",
"-o",
"log_cli=true",
"-o",
"log_cli_level=INFO",
)

# Check that endpoints were discovered
result.stderr.fnmatch_lines(
result.stdout.fnmatch_lines(
[
"*Discovered 3 endpoints from OpenAPI spec*",
]
Expand Down
22 changes: 11 additions & 11 deletions tests/integration/test_plugin_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ def test_uncovered(coverage_client):
assert "FAIL: Required coverage of 90.0% not met" in output
assert "Actual coverage: 50.0%" in output
assert "Covered Endpoints" in output
assert "[.] GET" in output
assert "[.] GET" in output
assert "Uncovered Endpoints" in output
assert "[X] GET" in output
assert "[X] GET" in output


def test_plugin_disabled_by_default(pytester):
Expand Down Expand Up @@ -108,9 +108,9 @@ def test_with_custom_client(coverage_client):
assert "API Coverage Report" in output
assert "Total API Coverage: 50.0%" in output
assert "Covered Endpoints" in output
assert "[.] GET" in output
assert "[.] GET" in output
assert "Uncovered Endpoints" in output
assert "[X] GET" in output
assert "[X] GET" in output


def test_custom_fixture_wrapping_fastapi(pytester):
Expand Down Expand Up @@ -159,9 +159,9 @@ def test_with_custom_client(coverage_client):
assert "API Coverage Report" in output
assert "Total API Coverage: 50.0%" in output
assert "Covered Endpoints" in output
assert "[.] GET" in output
assert "[.] GET" in output
assert "Uncovered Endpoints" in output
assert "[X] GET" in output
assert "[X] GET" in output


def test_custom_fixture_fallback_when_not_found(pytester):
Expand Down Expand Up @@ -243,9 +243,9 @@ def test_root_endpoint(coverage_client):
assert "API Coverage Report" in output
assert "Total API Coverage: 50.0%" in output
assert "Covered Endpoints" in output
assert "[.] GET" in output
assert "[.] GET" in output
assert "Uncovered Endpoints" in output
assert "[X] GET" in output
assert "[X] GET" in output


def test_multiple_auto_discover_files_uses_first(pytester):
Expand Down Expand Up @@ -426,11 +426,11 @@ def test_admin(coverage_client):
assert "API Coverage Report" in output

assert "GET /users/bob" in output
assert "[.] GET /users/bob" in output
assert "[.] GET /users/bob" in output

assert "GET /users/alice" in output
assert "GET /users/charlie" in output
assert "[-] GET /users/alice" in output
assert "[-] GET /users/charlie" in output
assert "[-] GET /users/alice" in output
assert "[-] GET /users/charlie" in output

assert "Total API Coverage:" in output
14 changes: 12 additions & 2 deletions tests/unit/test_frameworks.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,22 @@ class MockWSGIHandler:
mock_fastapi_app.__class__.__module__ = "fastapi.applications"
mock_fastapi_app.__class__.__name__ = "FastAPI"

mock_django_app = Mock()
mock_django_app.__class__.__module__ = "django.core.handlers.wsgi"
mock_django_app.__class__.__name__ = "WSGIHandler"

mock_unsupported_app = Mock()
mock_unsupported_app.__class__.__module__ = "django.core.handlers.wsgi"
mock_unsupported_app.__class__.__name__ = "WSGIHandler"
mock_unsupported_app.__class__.__module__ = "bottle"
mock_unsupported_app.__class__.__name__ = "Bottle"

assert isinstance(get_framework_adapter(mock_flask_app), FlaskAdapter)
assert isinstance(get_framework_adapter(mock_fastapi_app), FastAPIAdapter)

# Django is now supported
from pytest_api_cov.frameworks import DjangoAdapter

assert isinstance(get_framework_adapter(mock_django_app), DjangoAdapter)

with pytest.raises(TypeError, match="Unsupported application type"):
get_framework_adapter(mock_unsupported_app)

Expand Down
11 changes: 9 additions & 2 deletions tests/unit/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,18 @@ def test_is_supported_framework_fastapi(self):
mock_app.__class__.__module__ = "fastapi.applications"
assert is_supported_framework(mock_app) is True

def test_is_supported_framework_unsupported(self):
"""Test framework detection with unsupported framework."""
def test_is_supported_framework_django(self):
"""Test framework detection with Django app."""
mock_app = Mock()
mock_app.__class__.__name__ = "Django"
mock_app.__class__.__module__ = "django.core"
assert is_supported_framework(mock_app) is True

def test_is_supported_framework_unsupported(self):
"""Test framework detection with unsupported framework."""
mock_app = Mock()
mock_app.__class__.__name__ = "Bottle"
mock_app.__class__.__module__ = "bottle"
assert is_supported_framework(mock_app) is False


Expand Down
Loading
Loading