From 7326154baa77214189e3435c97aa3d41b331ccca Mon Sep 17 00:00:00 2001 From: BarnabasG Date: Sun, 9 Nov 2025 20:39:54 +0000 Subject: [PATCH 1/3] 1.2.0 migrating from app fixture to test client extraction --- README.md | 189 ++++++--------- example/conftest.py | 12 +- pyproject.toml | 4 +- src/pytest_api_cov/cli.py | 212 +++++------------ src/pytest_api_cov/config.py | 4 +- src/pytest_api_cov/plugin.py | 357 ++++++++++++++--------------- src/pytest_api_cov/pytest_flags.py | 7 +- tests/unit/test_plugin.py | 8 - uv.lock | 4 +- 9 files changed, 309 insertions(+), 488 deletions(-) diff --git a/README.md b/README.md index b6b2ffe..dc8c2b0 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,9 @@ A **pytest plugin** that measures **API endpoint coverage** for FastAPI and Flas ## Features - **Zero Configuration**: Plug-and-play with Flask/FastAPI 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 -- **Setup Wizard**: Interactive setup wizard for complex projects ## Quick Start @@ -28,25 +28,13 @@ pytest --api-cov-report ### App Location Flexibility -**Zero Config**: Works automatically if your app is in `app.py`, `main.py`, or `server.py` +Discovery in this plugin is client-based: the plugin extracts the application instance from your test client fixtures, or from an `app` fixture when present. This means the plugin integrates with the test clients or fixtures you already use in your tests rather than relying on background file scanning. -**Any Location**: Place your app anywhere in your project - just create a `conftest.py`: +How discovery works (in order): -```python -import pytest -from my_project.backend.api import my_app # Any import path! - -@pytest.fixture -def app(): - return my_app -``` - -The plugin will automatically discover your Flask/FastAPI app if it's in common locations: -- `app.py` (with variable `app`, `application`, or `main`) -- `main.py` (with variable `app`, `application`, or `main`) -- `server.py` (with variable `app`, `application`, or `server`) - -**Your app can be located anywhere!** If it's not in a standard location, just create a `conftest.py` file to tell the plugin where to find it. +1. If you configure one or more candidate client fixture names (see configuration below), the plugin will try each in order and wrap the first matching fixture it finds. +2. If no configured client fixture is found, the plugin will look for a standard `app` fixture and use that to create a tracked client. +3. If neither a client fixture nor an `app` fixture is available (or the plugin cannot extract an app from the client), coverage tracking will be skipped and a helpful message is shown. ### Example @@ -97,7 +85,7 @@ API Coverage Report Uncovered Endpoints: ❌ GET /health -Total API Coverage: 66.67% +Total API Coverage: 75.0% ``` Or running with advanced options: @@ -120,6 +108,16 @@ Total API Coverage: 50.0% JSON report saved to api_coverage.json ``` +### See examples + +```bash +# Print an example pyproject.toml configuration snippet +pytest-api-cov show-pyproject + +# Print an example conftest.py for a known app module +pytest-api-cov show-conftest FastAPI src.main app +``` + ## HTTP Method-Aware Coverage By default, pytest-api-cov tracks coverage for **each HTTP method separately**. This means `GET /users` and `POST /users` are treated as different endpoints for coverage purposes. @@ -160,19 +158,6 @@ Total API Coverage: 100.0% # All endpoints have at least one method tested ## Advanced Configuration -### Setup Wizard - -If auto-discovery doesn't work for your project, use the interactive setup wizard: - -```bash -pytest-api-cov init -``` - -This will: -- Detect your framework and app location -- Create a `conftest.py` fixture if needed -- Generate suggested `pyproject.toml` configuration - ### Manual Configuration Create a `conftest.py` file to specify your app location (works with **any** file path or structure): @@ -194,7 +179,15 @@ This approach works with any project structure - the plugin doesn't care where y ### Custom Test Client Fixtures -You have several options for using custom client fixtures: +The plugin can wrap existing test client fixtures automatically. Recent changes allow you to specify one or more candidate fixture names (the plugin will try them in order) instead of a single configured name. + +Default client fixture names the plugin will look for (in order): +- `client` +- `test_client` +- `api_client` +- `app_client` + +If you use a different fixture name, you can provide one or more names via the CLI flag `--api-cov-client-fixture-names` (repeatable) or in `pyproject.toml` under `[tool.pytest_api_cov]` as `client_fixture_names` (a list). #### Option 1: Helper Function @@ -226,34 +219,28 @@ def test_with_flask_client(flask_client): assert response.status_code == 200 ``` -#### Option 2: Configuration-Based +The helper returns a pytest fixture you can assign to a name in `conftest.py`. -Configure an existing fixture to be wrapped automatically: +#### Option 2: Configuration-Based (recommended for most users) -```python -import pytest -from fastapi.testclient import TestClient -from your_app import app +Configure one or more existing fixture names to be discovered and wrapped automatically by the plugin. -@pytest.fixture -def my_custom_client(): - """Custom test client with authentication.""" - client = TestClient(app) - client.headers.update({"Authorization": "Bearer test-token"}) - return client - -def test_endpoint(coverage_client): - response = coverage_client.get("/protected-endpoint") - assert response.status_code == 200 -``` - -Configure it in `pyproject.toml`: +Example `pyproject.toml`: ```toml [tool.pytest_api_cov] -client_fixture_name = "my_custom_client" +# Provide a list of candidate fixture names the plugin should try (order matters) +client_fixture_names = ["my_custom_client"] ``` +Or use the CLI flag multiple times: + +```bash +pytest --api-cov-report --api-cov-client-fixture-names=my_custom_client --api-cov-client-fixture-names=another_fixture +``` + +If the configured fixture(s) are not found, the plugin will try to use an `app` fixture (if present) to create a tracked client. If neither is available or the plugin cannot extract the app from a discovered client fixture, the tests will still run — coverage will simply be unavailable and a warning will be logged. + ### Configuration Options Add configuration to your `pyproject.toml`: @@ -272,13 +259,11 @@ show_excluded_endpoints = false # Use * for wildcard matching, all other characters are matched literally # Use ! at the start to negate a pattern (include what would otherwise be excluded) exclusion_patterns = [ - "/health", # Exact match - "/metrics", # Exact match - "/docs/*", # Wildcard: matches /docs/swagger, /docs/openapi, etc. - "/admin/*", # Wildcard: matches all admin endpoints - "!/admin/public", # Negation: include /admin/public even though /admin/* excludes it - "/api/v1.0/*", # Exact version match (won't match /api/v1x0/*) - "!/api/v1.0/health" # Negation: include /api/v1.0/health even though /api/v1.0/* excludes it + "/health", + "/metrics", + "/docs/*", + "/admin/*", + "!/admin/public", ] # Save detailed JSON report @@ -290,13 +275,12 @@ force_sugar = true # Force no Unicode symbols in output force_sugar_disabled = true -# Wrap an existing custom test client fixture with coverage tracking -client_fixture_name = "my_custom_client" +# Provide candidate fixture names (in priority order). +client_fixture_names = ["my_custom_client"] # Group HTTP methods by endpoint for legacy behavior (default: false) -# When true: treats GET /users and POST /users as one "/users" endpoint -# When false: treats them as separate "GET /users" and "POST /users" endpoints (recommended) group_methods_by_endpoint = false + ``` ### Command Line Options @@ -323,8 +307,8 @@ pytest --api-cov-report --api-cov-report-path=api_coverage.json # Exclude specific endpoints (supports wildcards and negation) pytest --api-cov-report --api-cov-exclusion-patterns="/health" --api-cov-exclusion-patterns="/docs/*" -# Exclude with negation (exclude all admin except admin/public) -pytest --api-cov-report --api-cov-exclusion-patterns="/admin/*" --api-cov-exclusion-patterns="!/admin/public" +# Specify one or more existing client fixture names (repeatable) +pytest --api-cov-report --api-cov-client-fixture-names=my_custom_client --api-cov-client-fixture-names=another_fixture # Verbose logging (shows discovery process) pytest --api-cov-report -v @@ -450,74 +434,31 @@ jobs: ### No App Found -If you see "No API app found", you have several options: - -**Option 1 - Auto-discovery (Zero Config)** -Place your app in a standard location with a standard name: -- Files: `app.py`, `main.py`, `server.py`, `wsgi.py`, `asgi.py` -- Variable names: `app`, `application`, `main`, `server` - -**Option 2 - Custom Location (Any File/Path)** -Create a `conftest.py` file to specify your app location: - -```python -import pytest -from my_project.api.server import my_flask_app # Any import path -# or from src.backend.main import fastapi_instance -# or from anywhere import your_app - -@pytest.fixture -def app(): - return my_flask_app # Return your app instance -``` - -**Option 3 - Override Auto-discovery** -If you have multiple auto-discoverable files or want to use a different app: - -```python -# Even if you have app.py, you can override it -import pytest -from main import my_real_app # Use this instead of app.py - -@pytest.fixture -def app(): - return my_real_app -``` - -**Option 4 - Setup Wizard** -Run the interactive setup: `pytest-api-cov init` - -The plugin will automatically find your app using the `app` fixture first, then fall back to auto-discovery in common locations. This means you can place your app **anywhere** as long as you create the fixture. - -### Multiple App Files - -If you have multiple files that could be auto-discovered (e.g., both `app.py` and `main.py`), the plugin will use the **first valid app it finds** in this priority order: - -1. `app.py` -2. `main.py` -3. `server.py` -4. `wsgi.py` -5. `asgi.py` +If coverage is not running because the plugin could not locate an app, check the following: -To use a specific app when multiple exist, create a `conftest.py` with an `app` fixture pointing to your preferred app. +- Ensure you are running pytest with `--api-cov-report` enabled. +- Confirm you have a test client fixture (e.g. `client`, `test_client`, `api_client`) or an `app` fixture in your test suite. +- If you use a custom client fixture, add its name to `client_fixture_names` in `pyproject.toml` or pass it via the CLI using `--api-cov-client-fixture-names` (repeatable) so the plugin can find and wrap it. +- If the plugin finds the client fixture but cannot extract the underlying app (for example the client type is not supported or wrapped in an unexpected way), you will see a message like "Could not extract app from client" — in that case either provide an `app` fixture directly or wrap your existing client using `create_coverage_fixture`. -### No Endpoints Discovered +### No endpoints Discovered -If you see "No endpoints discovered": +If you still see no endpoints discovered: -1. Check that your app is properly instantiated -2. Verify your routes/endpoints are defined -3. Ensure the `coverage_client` fixture is working in your tests -4. Use `-v` or `-vv` for debug information +1. Check that your app is properly instantiated inside the fixture or client. +2. Verify your routes/endpoints are defined and reachable by the test client. +3. Ensure the `coverage_client` fixture is being used in your tests (or that your configured client fixture is listed and discovered). +4. Use `-v` or `-vv` for debug logging to see why the plugin skipped discovery or wrapping. ### Framework Not Detected The plugin supports: -- **FastAPI**: Detected by `from fastapi import` or `import fastapi` -- **Flask**: Detected by `from flask import` or `import flask` +- **FastAPI**: Detected by `FastAPI` class +- **Flask**: Detected by `Flask` class +- **FlaskOpenAPI3**: Detected by `FlaskOpenAPI3` class Other frameworks are not currently supported. ## License -This project is licensed under the Apache License 2.0. \ No newline at end of file +This project is licensed under the Apache License 2.0. diff --git a/example/conftest.py b/example/conftest.py index da67b45..70b6b72 100644 --- a/example/conftest.py +++ b/example/conftest.py @@ -1,10 +1,16 @@ """example/conftest.py""" import pytest +from fastapi.testclient import TestClient + from example.src.main import app as fastapi_app @pytest.fixture -def app(): - """FastAPI app fixture for testing.""" - return fastapi_app +def client(): + """Standard FastAPI test client fixture. + + The pytest-api-cov plugin will automatically discover this fixture, + extract the app from it, and wrap it with coverage tracking. + """ + return TestClient(fastapi_app) diff --git a/pyproject.toml b/pyproject.toml index f372c6f..be96254 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytest-api-cov" -version = "1.1.5" +version = "1.2.0" description = "Pytest Plugin to provide API Coverage statistics for Python Web Frameworks" readme = "README.md" authors = [{ name = "Barnaby Gill", email = "barnabasgill@gmail.com" }] @@ -24,7 +24,7 @@ Source = "https://github.com/BarnabasG/api-coverage" [dependency-groups] dev = [ "mypy>=1.17.0", - "path>=17.1.1", + "path>=16.0.0", "pytest-cov>=6.2.1", "pytest-sugar>=1.0.0", "pytest-xdist>=3.8.0", diff --git a/src/pytest_api_cov/cli.py b/src/pytest_api_cov/cli.py index 7590fa2..1e73811 100644 --- a/src/pytest_api_cov/cli.py +++ b/src/pytest_api_cov/cli.py @@ -6,81 +6,47 @@ from typing import Optional, Tuple -def detect_framework_and_app() -> Optional[Tuple[str, str, str]]: - """Detect framework and app location. +def generate_conftest_content(framework: str, file_path: str, app_variable: str) -> str: + """Generate conftest.py content based on provided framework/module/app variable. - Returns (framework, file_path, app_variable) or None. + This is a non-interactive helper that returns example content — the project + no longer performs automatic file-scanning. Use this helper to bootstrap a + `conftest.py` if desired. """ - # Search for app files at any depth in current directory - app_patterns = ["app.py", "main.py", "server.py", "wsgi.py", "asgi.py"] - common_vars = ["app", "application", "main", "server"] - - # Find all matching files recursively - found_files = [file_path for pattern in app_patterns for file_path in Path().rglob(pattern)] - - # Sort by depth (shallowest first) and then by filename priority - found_files.sort(key=lambda p: (len(p.parts), app_patterns.index(p.name))) - - for file_path in found_files: - try: - content = file_path.read_text() - - if "from fastapi import" in content or "import fastapi" in content: - framework = "FastAPI" - elif "from flask import" in content or "import flask" in content: - framework = "Flask" - else: - continue # Not a framework file we care about - - for var_name in common_vars: - if f"{var_name} = " in content: - return framework, file_path.as_posix(), var_name - - except (IOError, UnicodeDecodeError): - continue - - return None - - -def generate_conftest_content(framework: str, file_path: str, app_variable: str) -> str: - """Generate conftest.py content based on detected framework.""" - # Convert file path to import path (e.g., "src/main.py" -> "src.main") module_path = file_path.replace("/", ".").replace("\\", ".").replace(".py", "") - return f'''"""conftest.py - Auto-generated by pytest-api-cov init""" + if framework == "FastAPI": + test_client_import = "from fastapi.testclient import TestClient" + client_creation = "return TestClient(app)" + elif framework == "Flask": + test_client_import = "from flask.testing import FlaskClient" + client_creation = "return FlaskClient(app)" + else: + test_client_import = "" + client_creation = "# Create and return a test client for your framework" + + return f'''"""conftest.py - Example generated by pytest-api-cov CLI (non-interactive)""" import pytest +{test_client_import} -# Import your {framework} app from anywhere in your project -from {module_path} import {app_variable} +from {module_path} import {app_variable} as app @pytest.fixture -def app(): - """Provide the {framework} app for API coverage testing. +def client(): + """Standard test client fixture for {framework}. - You can import from any location - just change the import path above - to match your project structure. + The pytest-api-cov plugin can extract the app from your client fixture + and wrap it with coverage tracking when enabled. """ - return {app_variable} - - -# The plugin will automatically create a 'coverage_client' fixture that uses your 'app' fixture -# You can use either: -# - def test_endpoint(app): ... # Direct app access -# - def test_endpoint(coverage_client): ... # Test client with API coverage tracking -# -# To wrap an existing custom fixture instead, specify the fixture name in pyproject.toml: -# [tool.pytest_api_cov] -# client_fixture_name = "my_custom_client" -# -# Example custom fixture: -# @pytest.fixture -# def my_custom_client(app): -# client = app.test_client() # Flask -# # or client = TestClient(app) # FastAPI -# # Add custom setup here (auth headers, etc.) -# return client + {client_creation} + + +# Use the coverage_client fixture in your tests: +# def test_endpoint(coverage_client): +# response = coverage_client.get("/endpoint") +# assert response.status_code == 200 ''' @@ -99,9 +65,9 @@ def generate_pyproject_config() -> str: # Exclude endpoints matching these patterns (optional) # exclusion_patterns = [ -# "/health", -# "/metrics", -# "/docs", +# "*/health", +# "*/metrics", +# "*/docs/*", # ] # Save JSON report to file (optional) @@ -110,108 +76,46 @@ def generate_pyproject_config() -> str: # Force Unicode symbols in terminal output (optional) # force_sugar = true -# Wrap an existing custom test client fixture with coverage tracking (optional) -# client_fixture_name = "my_custom_client" +# Specify custom client fixture names to discover (optional) +# client_fixture_names = ["client", "test_client", "my_custom_client"] # Group HTTP methods by endpoint for legacy behavior (optional) # group_methods_by_endpoint = false """ -def cmd_init() -> int: - """Initialize pytest-api-cov setup in current directory.""" - print("🚀 pytest-api-cov Setup Wizard") - print("=" * 40) - - detection_result = detect_framework_and_app() - - if detection_result: - framework, file_path, app_variable = detection_result - print(f"✅ Detected {framework} app in {file_path} (variable: {app_variable})") - - conftest_exists = Path("conftest.py").exists() - if conftest_exists: - print("âš ī¸ conftest.py already exists") - create_conftest = input("Do you want to overwrite it? (y/N): ").lower().startswith("y") - else: - create_conftest = True - - if create_conftest: - conftest_content = generate_conftest_content(framework, file_path, app_variable) - with Path("conftest.py").open("w") as f: - f.write(conftest_content) - print("✅ Created conftest.py") - - pyproject_exists = Path("pyproject.toml").exists() - if pyproject_exists: - print("â„šī¸ pyproject.toml already exists") # noqa: RUF001 - print("Add this configuration to your pyproject.toml:") - print(generate_pyproject_config()) - else: - create_pyproject = input("Create pyproject.toml with pytest-api-cov config? (Y/n): ").lower() - if not create_pyproject.startswith("n"): - pyproject_content = f"""[project] -name = "your-project" -version = "0.1.0" - -{generate_pyproject_config()} - -[tool.pytest.ini_options] -testpaths = ["tests"] -""" - with Path("pyproject.toml").open("w") as f: - f.write(pyproject_content) - print("✅ Created pyproject.toml") - - print() - print("🎉 Setup complete!") - print() - print("Next steps:") - print("1. Write your tests using the 'coverage_client' fixture") - print("2. Run: pytest --api-cov-report") - print() - print("Example test:") - print(""" -def test_root_endpoint(coverage_client): - response = coverage_client.get("/") - assert response.status_code == 200 -""") - - else: - print("❌ No FastAPI or Flask app detected in common locations") - print() - print("Please ensure you have one of these files with a Flask/FastAPI app:") - print("â€ĸ app.py, main.py, server.py, wsgi.py, or asgi.py") - print("â€ĸ Files can be in the current directory or any subdirectory") - print("â€ĸ The file must contain a variable named 'app', 'application', 'main', or 'server'") - print() - print("Example app.py:") - print(""" -from fastapi import FastAPI - -app = FastAPI() - -@app.get("/") -def read_root(): - return {"message": "Hello World"} -""") - return 1 - - return 0 - - def main() -> int: - """Run the main CLI entry point.""" + """Run the main CLI entry point. + + Note: the previous interactive "init" wizard was removed. This CLI + provides programmatic helpers to generate example `conftest.py` and + `pyproject.toml` content; use those functions or create a manual + `conftest.py`/`pyproject.toml` as described in the README. + """ parser = argparse.ArgumentParser(prog="pytest-api-cov", description="pytest API coverage plugin CLI tools") subparsers = parser.add_subparsers(dest="command", help="Available commands") - subparsers.add_parser("init", help="Initialize pytest-api-cov setup") + # Keep a non-interactive 'show-config' command for convenience + subparsers.add_parser("show-pyproject", help="Print example pyproject.toml configuration") + show_conftest = subparsers.add_parser("show-conftest", help="Print example conftest content") + show_conftest.add_argument("framework", nargs=1, help="Framework name (FastAPI|Flask)") + show_conftest.add_argument("module_path", nargs=1, help="Module path of your app, e.g. src.main") + show_conftest.add_argument("app_variable", nargs=1, help="App variable name in the module, e.g. app") args = parser.parse_args() - if args.command == "init": - return cmd_init() + if args.command == "show-pyproject": + print(generate_pyproject_config()) + return 0 + + if args.command == "show-conftest": + framework = args.framework[0] + module_path = args.module_path[0] + app_variable = args.app_variable[0] + print(generate_conftest_content(framework, module_path + ".py", app_variable)) + return 0 + parser.print_help() return 1 diff --git a/src/pytest_api_cov/config.py b/src/pytest_api_cov/config.py index 8a19d33..0e1b9a6 100644 --- a/src/pytest_api_cov/config.py +++ b/src/pytest_api_cov/config.py @@ -21,7 +21,7 @@ class ApiCoverageReportConfig(BaseModel): report_path: Optional[str] = Field(None, alias="api-cov-report-path") force_sugar: bool = Field(default=False, alias="api-cov-force-sugar") force_sugar_disabled: bool = Field(default=False, alias="api-cov-force-sugar-disabled") - client_fixture_name: str = Field("coverage_client", alias="api-cov-client-fixture-name") + client_fixture_names: List[str] = Field(["client", "test_client", "api_client", "app_client"], alias="api-cov-client-fixture-names") group_methods_by_endpoint: bool = Field(default=False, alias="api-cov-group-methods-by-endpoint") @@ -46,7 +46,7 @@ def read_session_config(session_config: Any) -> Dict[str, Any]: "api-cov-report-path": "report_path", "api-cov-force-sugar": "force_sugar", "api-cov-force-sugar-disabled": "force_sugar_disabled", - "api-cov-client-fixture-name": "client_fixture_name", + "api-cov-client-fixture-names": "client_fixture_names", "api-cov-group-methods-by-endpoint": "group_methods_by_endpoint", } config = {} diff --git a/src/pytest_api_cov/plugin.py b/src/pytest_api_cov/plugin.py index 5b33dff..46428dd 100644 --- a/src/pytest_api_cov/plugin.py +++ b/src/pytest_api_cov/plugin.py @@ -1,10 +1,7 @@ """pytest plugin for API coverage tracking.""" -import importlib -import importlib.util import logging -import os -from typing import Any, Generator, Optional +from typing import Any, Optional import pytest @@ -30,114 +27,29 @@ def is_supported_framework(app: Any) -> bool: or (module_name == "fastapi" and app_type == "FastAPI") ) +def extract_app_from_client(client: Any) -> Optional[Any]: + """Extract app from various client types.""" + # Typical attributes used by popular clients + if client is None: + return None -def auto_discover_app() -> Optional[Any]: - """Automatically discover Flask/FastAPI apps in common locations.""" - logger.debug("> Auto-discovering app in common locations...") + # common attribute for requests-like test clients + if hasattr(client, "app"): + return client.app - common_patterns = [ - ("app.py", ["app", "application", "main"]), - ("main.py", ["app", "application", "main"]), - ("server.py", ["app", "application", "server"]), - ("wsgi.py", ["app", "application"]), - ("asgi.py", ["app", "application"]), - ] + if hasattr(client, "application"): + return client.application - found_apps = [] # Track all discovered apps - found_files = [] # Track all files that exist + # Starlette/requests transport internals + if hasattr(client, "_transport") and hasattr(client._transport, "app"): + return client._transport.app - for filename, attr_names in common_patterns: - if os.path.exists(filename): # noqa: PTH110 - found_files.append(filename) - logger.debug(f"> Found {filename}, checking for app variables...") - try: - module_name = filename[:-3] # .py extension - spec = importlib.util.spec_from_file_location(module_name, filename) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - for attr_name in attr_names: - if hasattr(module, attr_name): - app = getattr(module, attr_name) - if is_supported_framework(app): - found_apps.append((filename, attr_name, type(app).__name__)) - if len(found_apps) == 1: - logger.info( - f"✅ Auto-discovered {type(app).__name__} app in {filename} as '{attr_name}'" - ) - remaining_files = [ - f - for f in [ - p[0] - for p in common_patterns[common_patterns.index((filename, attr_names)) :] - ] - if os.path.exists(f) and f != filename # noqa: PTH110 - ] - if remaining_files: - logger.debug( - f"> Note: Also found files {remaining_files} but using first discovered app" - ) - logger.debug( - "> To use a different app, create a conftest.py with an 'app' fixture" - ) - return app - else: - logger.debug(f"> Found '{attr_name}' in {filename} but it's not a supported framework") - - except Exception as e: # noqa: BLE001 - logger.debug(f"> Could not import {filename}: {e}") - continue - - if found_files: - logger.debug(f"> Found files {found_files} but no supported Flask/FastAPI apps in them") - logger.debug("> If your app is in one of these files with a different variable name,") - logger.debug("> create a conftest.py with an 'app' fixture to specify it") + # Flask's test client may expose the application via "application" or "app" + if hasattr(client, "_app"): + return getattr(client, "_app") - logger.debug("> No app auto-discovered") return None - -def get_helpful_error_message() -> str: - """Generate a helpful error message for setup guidance.""" - return """ -đŸšĢ No API app found! - -Quick Setup Options: - -Option 1 - Auto-discovery (Zero Config): - Place your FastAPI/Flask app in one of these files: - â€ĸ app.py (with variable named 'app', 'application', or 'main') - â€ĸ main.py (with variable named 'app', 'application', or 'main') - â€ĸ server.py (with variable named 'app', 'application', or 'server') - - Example app.py: - from fastapi import FastAPI - app = FastAPI() # <- Plugin will auto-discover this - -Option 2 - Custom Location or Override Auto-discovery: - Create conftest.py to specify exactly which app to use: - - import pytest - from my_project.api.server import my_app # Any import path! - # or from app import my_real_app # Override auto-discovery - - @pytest.fixture - def app(): - return my_app - - This works for: - â€ĸ Apps in custom locations - â€ĸ Multiple app files (specify which one to use) - â€ĸ Different variable names in standard files - -Option 3 - Setup Wizard: - Run: pytest-api-cov init - -Then run: pytest --api-cov-report -""" - - def pytest_addoption(parser: pytest.Parser) -> None: """Add API coverage flags to the pytest parser.""" add_pytest_api_cov_flags(parser) @@ -202,22 +114,68 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: """Coverage-enabled client fixture.""" session = request.node.session - if not session.config.getoption("--api-cov-report"): - pytest.skip("API coverage not enabled. Use --api-cov-report flag.") + # Do not skip tests; if coverage is disabled or not initialized, try to return an existing client + coverage_enabled = bool(session.config.getoption("--api-cov-report")) coverage_data = getattr(session, "api_coverage_data", None) - if coverage_data is None: - pytest.skip("API coverage data not initialized. This should not happen.") + # Try to obtain an existing client if requested existing_client = None if existing_fixture_name: try: existing_client = request.getfixturevalue(existing_fixture_name) logger.debug(f"> Found existing '{existing_fixture_name}' fixture, wrapping with coverage") - except pytest.FixtureLookupError as e: - raise RuntimeError(f"Existing fixture '{existing_fixture_name}' not found") from e + except pytest.FixtureLookupError: + logger.warning(f"> Existing fixture '{existing_fixture_name}' not found when creating '{fixture_name}'") + + # If coverage is not enabled or recorder not available, return existing client (if any) + if not coverage_enabled or coverage_data is None: + if existing_client is not None: + yield existing_client + return + # Try to fall back to an app fixture to construct a client + try: + app = request.getfixturevalue("app") + except pytest.FixtureLookupError: + logger.warning(f"> Coverage not enabled and no existing fixture available for '{fixture_name}', returning None") + yield None + return + # if we have an app, attempt to create a tracked client using adapter without recorder + try: + from .frameworks import get_framework_adapter + + adapter = get_framework_adapter(app) + client = adapter.get_tracked_client(None, request.node.name) + yield client + return + except Exception: + yield existing_client + return + + # At this point coverage is enabled and coverage_data exists + if existing_client is None: + # Try to find a client fixture by common names + config = get_pytest_api_cov_report_config(request.config) + for name in config.client_fixture_names: + try: + existing_client = request.getfixturevalue(name) + logger.info(f"> Found client fixture '{name}' while creating '{fixture_name}'") + break + except pytest.FixtureLookupError: + continue + + app = None + if existing_client is not None: + app = extract_app_from_client(existing_client) + + if app is None: + # Try to get an app fixture + try: + app = request.getfixturevalue("app") + logger.debug("> Found 'app' fixture while creating coverage fixture") + except pytest.FixtureLookupError: + app = None - app = get_app_from_fixture_or_auto_discover(request) if app and is_supported_framework(app): try: from .frameworks import get_framework_adapter @@ -229,25 +187,30 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: for endpoint_method in endpoints: method, path = endpoint_method.split(" ", 1) coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter") - logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.") - logger.debug(f"> Discovered endpoints: {endpoints}") + logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints when creating '{fixture_name}'.") except Exception as e: # noqa: BLE001 logger.warning(f"> pytest-api-coverage: Could not discover endpoints from app. Error: {e}") - if existing_client: - client = existing_client - elif app and is_supported_framework(app): - from .frameworks import get_framework_adapter - - adapter = get_framework_adapter(app) - client = adapter.get_tracked_client(coverage_data.recorder, request.node.name) - yield client + # If we have an existing client, wrap it; otherwise try to create a tracked client from app + if existing_client is not None: + wrapped = wrap_client_with_coverage(existing_client, coverage_data.recorder, request.node.name) + yield wrapped return - else: - pytest.skip("No existing fixture specified and no valid app for creating new client") - wrapped_client = wrap_client_with_coverage(client, coverage_data.recorder, request.node.name) - yield wrapped_client + if app is not None: + try: + from .frameworks import get_framework_adapter + + adapter = get_framework_adapter(app) + client = adapter.get_tracked_client(coverage_data.recorder, request.node.name) + yield client + return + except Exception as e: # noqa: BLE001 + logger.warning(f"> Failed to create tracked client for '{fixture_name}': {e}") + + # Last resort: yield None but do not skip + logger.warning(f"> create_coverage_fixture('{fixture_name}') could not provide a client; tests will run without API coverage for this fixture.") + yield None fixture_func.__name__ = fixture_name return pytest.fixture(fixture_func) @@ -255,53 +218,79 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: def wrap_client_with_coverage(client: Any, recorder: Any, test_name: str) -> Any: """Wrap an existing test client with coverage tracking.""" + if client is None or recorder is None: + return client class CoverageWrapper: def __init__(self, wrapped_client: Any) -> None: self._wrapped = wrapped_client + def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> Optional[tuple]: + # Try several strategies to obtain a path and method + path = None + method = None + + # First, if args[0] looks like a string path + if args: + first = args[0] + if isinstance(first, str): + path = first.partition("?")[0] + method = name.upper() + return path, method + + # For starlette/requests TestClient, args[0] may be a Request or PreparedRequest + if hasattr(first, "url") and hasattr(first.url, "path"): + try: + path = first.url.path + method = getattr(first, "method", name).upper() + return path, method + except Exception: + pass + + # Try kwargs-based FlaskClient open signature + if kwargs: + path_kw = kwargs.get("path") or kwargs.get("url") or kwargs.get("uri") + if isinstance(path_kw, str): + path = path_kw.partition("?")[0] + method = kwargs.get("method", name).upper() + return path, method + + return None + def __getattr__(self, name: str) -> Any: attr = getattr(self._wrapped, name) if name in ["get", "post", "put", "delete", "patch", "head", "options"]: def tracked_method(*args: Any, **kwargs: Any) -> Any: response = attr(*args, **kwargs) - # Extract path from args[0] and method from function name - if args and recorder is not None: - path = args[0] - # Clean up the path to match endpoint format - if isinstance(path, str): - # Remove query parameters - path = path.partition("?")[0] - method = name.upper() + if recorder is not None: + pm = self._extract_path_and_method(name, args, kwargs) + if pm: + path, method = pm recorder.record_call(path, test_name, method) return response return tracked_method - return attr - return CoverageWrapper(client) + elif name == "open": + def tracked_open(*args: Any, **kwargs: Any) -> Any: + response = attr(*args, **kwargs) + if recorder is not None: + pm = self._extract_path_and_method("OPEN", args, kwargs) + if pm: + path, method = pm + recorder.record_call(path, test_name, method) + return response + return tracked_open -def get_app_from_fixture_or_auto_discover(request: pytest.FixtureRequest) -> Any: - """Get app from fixture or auto-discovery.""" - app = None - try: - app = request.getfixturevalue("app") - logger.debug("> Found 'app' fixture") - except pytest.FixtureLookupError: - logger.debug("> No 'app' fixture found, trying auto-discovery...") - app = auto_discover_app() - return app + return attr + return CoverageWrapper(client) @pytest.fixture -def coverage_client(request: pytest.FixtureRequest) -> Generator[Any, Any, None]: - """Smart auto-discovering test coverage_client that records API calls for coverage. - - Tries to find an 'app' fixture first, then auto-discovers apps in common locations. - Can also wrap existing custom fixtures if configured. - """ +def coverage_client(request: pytest.FixtureRequest) -> Any: + """Smart client fixture that wrap's user's existing test client with coverage tracking.""" session = request.node.session if not session.config.getoption("--api-cov-report"): @@ -311,58 +300,45 @@ def coverage_client(request: pytest.FixtureRequest) -> Generator[Any, Any, None] coverage_data = getattr(session, "api_coverage_data", None) if coverage_data is None: pytest.skip("API coverage data not initialized. This should not happen.") - - if config.client_fixture_name != "coverage_client": + + client = None + for fixture_name in config.client_fixture_names: try: - existing_client = request.getfixturevalue(config.client_fixture_name) - logger.info(f"> Found custom fixture '{config.client_fixture_name}', wrapping with coverage tracking") - - app = get_app_from_fixture_or_auto_discover(request) - if app and is_supported_framework(app): - try: - from .frameworks import get_framework_adapter - - adapter = get_framework_adapter(app) - if not coverage_data.discovered_endpoints.endpoints: - endpoints = adapter.get_endpoints() - framework_name = type(app).__name__ - for endpoint_method in endpoints: - method, path = endpoint_method.split(" ", 1) - coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter") - logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints.") - logger.debug(f"> Discovered endpoints: {endpoints}") - except Exception as e: # noqa: BLE001 - logger.warning(f"> pytest-api-coverage: Could not discover endpoints from app. Error: {e}") - - wrapped_client = wrap_client_with_coverage(existing_client, coverage_data.recorder, request.node.name) - yield wrapped_client - + client = request.getfixturevalue(fixture_name) + logger.info(f"> Found custom fixture '{fixture_name}', wrapping with coverage tracking") + break except pytest.FixtureLookupError: - logger.warning(f"> Custom fixture '{config.client_fixture_name}' not found, falling back to auto-discovery") - - else: - return + logger.warning(f"> Custom fixture '{fixture_name}' not found, trying next one") + continue + + if client is None: + logger.warning("> No test client fixture found, skipping coverage tracking") + return None - app = get_app_from_fixture_or_auto_discover(request) + app = extract_app_from_client(client) + logger.debug(f"> Extracted app from client: {app}, app type: {type(app).__name__ if app else None}") if app is None: - helpful_msg = get_helpful_error_message() - print(helpful_msg) - pytest.skip("No API app found. See error message above for setup guidance.") + logger.warning("> No app found, skipping coverage tracking") + return client if not is_supported_framework(app): - pytest.skip(f"Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI.") + logger.warning(f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI.") + return client try: from .frameworks import get_framework_adapter adapter = get_framework_adapter(app) + logger.debug(f"> Got adapter: {adapter}, adapter type: {type(adapter).__name__ if adapter else None}") except TypeError as e: - pytest.skip(f"Framework detection failed: {e}") + logger.warning(f"> Framework detection failed: {e}") + return client if not coverage_data.discovered_endpoints.endpoints: try: endpoints = adapter.get_endpoints() + logger.debug(f"> Adapter returned {len(endpoints)} endpoints") framework_name = type(app).__name__ for endpoint_method in endpoints: method, path = endpoint_method.split(" ", 1) @@ -371,9 +347,12 @@ def coverage_client(request: pytest.FixtureRequest) -> Generator[Any, Any, None] logger.debug(f"> Discovered endpoints: {endpoints}") except Exception as e: # noqa: BLE001 logger.warning(f"> pytest-api-coverage: Could not discover endpoints. Error: {e}") + return client + + else: + logger.debug(f"> Endpoints already discovered: {len(coverage_data.discovered_endpoints.endpoints)}") - client = adapter.get_tracked_client(coverage_data.recorder, request.node.name) - yield client + return wrap_client_with_coverage(client, coverage_data.recorder, request.node.name) def pytest_sessionfinish(session: pytest.Session) -> None: diff --git a/src/pytest_api_cov/pytest_flags.py b/src/pytest_api_cov/pytest_flags.py index 1692cb6..2f23e96 100644 --- a/src/pytest_api_cov/pytest_flags.py +++ b/src/pytest_api_cov/pytest_flags.py @@ -61,13 +61,12 @@ def add_pytest_api_cov_flags(parser: pytest.Parser) -> None: default=False, help="Disable use of API coverage sugar in console report.", ) - parser.addoption( - "--api-cov-client-fixture-name", - action="store", + "--api-cov-client-fixture-names", + action="append", type=str, default=None, - help="Name of existing test client fixture to wrap with coverage tracking", + help="Name of existing test client fixture(s) to wrap. Use multiple times for multiple fixtures.", ) parser.addoption( "--api-cov-group-methods-by-endpoint", diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index fed3aa4..3c921f1 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -9,7 +9,6 @@ from pytest_api_cov.plugin import ( DeferXdistPlugin, auto_discover_app, - get_helpful_error_message, is_supported_framework, pytest_addoption, pytest_configure, @@ -60,13 +59,6 @@ def test_auto_discover_app_import_error(self, mock_spec_from_file): result = auto_discover_app() assert result is None - def test_get_helpful_error_message(self): - """Test that helpful error message is generated.""" - message = get_helpful_error_message() - assert "No API app found" in message - assert "Quick Setup Options" in message - assert "pytest-api-cov init" in message - class TestPluginHooks: """Tests for pytest plugin hooks.""" diff --git a/uv.lock b/uv.lock index 399d89f..0141c5b 100644 --- a/uv.lock +++ b/uv.lock @@ -661,7 +661,7 @@ wheels = [ [[package]] name = "pytest-api-cov" -version = "1.1.5" +version = "1.2.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, @@ -701,7 +701,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.17.0" }, - { name = "path", specifier = ">=17.1.1" }, + { name = "path", specifier = ">=16.0.0" }, { name = "pytest-cov", specifier = ">=6.2.1" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, From 271572e0e44262469a7c42b0d7e04794ec90440a Mon Sep 17 00:00:00 2001 From: BarnabasG Date: Sun, 9 Nov 2025 22:22:02 +0000 Subject: [PATCH 2/3] 1.2.0 tests, cleanup, formatting --- example/conftest.py | 2 +- src/pytest_api_cov/cli.py | 2 - src/pytest_api_cov/config.py | 4 +- src/pytest_api_cov/plugin.py | 75 +++-- tests/integration/test_plugin_integration.py | 14 +- tests/unit/test_cli.py | 308 ++----------------- tests/unit/test_plugin.py | 15 - 7 files changed, 101 insertions(+), 319 deletions(-) diff --git a/example/conftest.py b/example/conftest.py index 70b6b72..b0b2ab2 100644 --- a/example/conftest.py +++ b/example/conftest.py @@ -9,7 +9,7 @@ @pytest.fixture def client(): """Standard FastAPI test client fixture. - + The pytest-api-cov plugin will automatically discover this fixture, extract the app from it, and wrap it with coverage tracking. """ diff --git a/src/pytest_api_cov/cli.py b/src/pytest_api_cov/cli.py index 1e73811..3430486 100644 --- a/src/pytest_api_cov/cli.py +++ b/src/pytest_api_cov/cli.py @@ -2,8 +2,6 @@ import argparse import sys -from pathlib import Path -from typing import Optional, Tuple def generate_conftest_content(framework: str, file_path: str, app_variable: str) -> str: diff --git a/src/pytest_api_cov/config.py b/src/pytest_api_cov/config.py index 0e1b9a6..133a359 100644 --- a/src/pytest_api_cov/config.py +++ b/src/pytest_api_cov/config.py @@ -21,7 +21,9 @@ class ApiCoverageReportConfig(BaseModel): report_path: Optional[str] = Field(None, alias="api-cov-report-path") force_sugar: bool = Field(default=False, alias="api-cov-force-sugar") force_sugar_disabled: bool = Field(default=False, alias="api-cov-force-sugar-disabled") - client_fixture_names: List[str] = Field(["client", "test_client", "api_client", "app_client"], alias="api-cov-client-fixture-names") + client_fixture_names: List[str] = Field( + ["client", "test_client", "api_client", "app_client"], alias="api-cov-client-fixture-names" + ) group_methods_by_endpoint: bool = Field(default=False, alias="api-cov-group-methods-by-endpoint") diff --git a/src/pytest_api_cov/plugin.py b/src/pytest_api_cov/plugin.py index 46428dd..330e987 100644 --- a/src/pytest_api_cov/plugin.py +++ b/src/pytest_api_cov/plugin.py @@ -1,7 +1,7 @@ """pytest plugin for API coverage tracking.""" import logging -from typing import Any, Optional +from typing import Any, Optional, Tuple import pytest @@ -27,6 +27,7 @@ def is_supported_framework(app: Any) -> bool: or (module_name == "fastapi" and app_type == "FastAPI") ) + def extract_app_from_client(client: Any) -> Optional[Any]: """Extract app from various client types.""" # Typical attributes used by popular clients @@ -46,10 +47,11 @@ def extract_app_from_client(client: Any) -> Optional[Any]: # Flask's test client may expose the application via "application" or "app" if hasattr(client, "_app"): - return getattr(client, "_app") + return client._app return None + def pytest_addoption(parser: pytest.Parser) -> None: """Add API coverage flags to the pytest parser.""" add_pytest_api_cov_flags(parser) @@ -137,7 +139,9 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: try: app = request.getfixturevalue("app") except pytest.FixtureLookupError: - logger.warning(f"> Coverage not enabled and no existing fixture available for '{fixture_name}', returning None") + logger.warning( + f"> Coverage not enabled and no existing fixture available for '{fixture_name}', returning None" + ) yield None return # if we have an app, attempt to create a tracked client using adapter without recorder @@ -146,11 +150,12 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: adapter = get_framework_adapter(app) client = adapter.get_tracked_client(None, request.node.name) - yield client - return - except Exception: + except Exception: # noqa: BLE001 yield existing_client return + else: + yield client + return # At this point coverage is enabled and coverage_data exists if existing_client is None: @@ -187,7 +192,9 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: for endpoint_method in endpoints: method, path = endpoint_method.split(" ", 1) coverage_data.add_discovered_endpoint(path, method, f"{framework_name.lower()}_adapter") - logger.info(f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints when creating '{fixture_name}'.") + logger.info( + f"> pytest-api-coverage: Discovered {len(endpoints)} endpoints when creating '{fixture_name}'." + ) except Exception as e: # noqa: BLE001 logger.warning(f"> pytest-api-coverage: Could not discover endpoints from app. Error: {e}") @@ -203,13 +210,17 @@ def fixture_func(request: pytest.FixtureRequest) -> Any: adapter = get_framework_adapter(app) client = adapter.get_tracked_client(coverage_data.recorder, request.node.name) - yield client - return except Exception as e: # noqa: BLE001 logger.warning(f"> Failed to create tracked client for '{fixture_name}': {e}") + else: + yield client + return # Last resort: yield None but do not skip - logger.warning(f"> create_coverage_fixture('{fixture_name}') could not provide a client; tests will run without API coverage for this fixture.") + logger.warning( + f"> create_coverage_fixture('{fixture_name}') could not provide a client; " + "tests will run without API coverage for this fixture." + ) yield None fixture_func.__name__ = fixture_name @@ -225,7 +236,7 @@ class CoverageWrapper: def __init__(self, wrapped_client: Any) -> None: self._wrapped = wrapped_client - def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> Optional[tuple]: + def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> Optional[Tuple[str, str]]: # Try several strategies to obtain a path and method path = None method = None @@ -243,9 +254,10 @@ def _extract_path_and_method(self, name: str, args: Any, kwargs: Any) -> Optiona try: path = first.url.path method = getattr(first, "method", name).upper() - return path, method - except Exception: + except Exception: # noqa: BLE001 pass + else: + return path, method # Try kwargs-based FlaskClient open signature if kwargs: @@ -272,7 +284,8 @@ def tracked_method(*args: Any, **kwargs: Any) -> Any: return tracked_method - elif name == "open": + if name == "open": + def tracked_open(*args: Any, **kwargs: Any) -> Any: response = attr(*args, **kwargs) if recorder is not None: @@ -285,6 +298,7 @@ def tracked_open(*args: Any, **kwargs: Any) -> Any: return tracked_open return attr + return CoverageWrapper(client) @@ -300,7 +314,7 @@ def coverage_client(request: pytest.FixtureRequest) -> Any: coverage_data = getattr(session, "api_coverage_data", None) if coverage_data is None: pytest.skip("API coverage data not initialized. This should not happen.") - + client = None for fixture_name in config.client_fixture_names: try: @@ -308,22 +322,40 @@ def coverage_client(request: pytest.FixtureRequest) -> Any: logger.info(f"> Found custom fixture '{fixture_name}', wrapping with coverage tracking") break except pytest.FixtureLookupError: - logger.warning(f"> Custom fixture '{fixture_name}' not found, trying next one") + logger.debug(f"> Custom fixture '{fixture_name}' not found, trying next one") continue - + + if client is None: + # Try to fallback to an 'app' fixture and create a tracked client + try: + app = request.getfixturevalue("app") + logger.info("> Found 'app' fixture, creating tracked client from app") + from .frameworks import get_framework_adapter + + adapter = get_framework_adapter(app) + client = adapter.get_tracked_client(coverage_data.recorder, request.node.name) + except pytest.FixtureLookupError: + logger.warning("> No test client fixture found and no 'app' fixture available. Falling back to None") + client = None + except Exception as e: # noqa: BLE001 + logger.warning(f"> Failed to create tracked client from 'app' fixture: {e}") + client = None + if client is None: - logger.warning("> No test client fixture found, skipping coverage tracking") + logger.warning("> Coverage client could not be created; tests will run without API coverage for this session.") return None app = extract_app_from_client(client) logger.debug(f"> Extracted app from client: {app}, app type: {type(app).__name__ if app else None}") if app is None: - logger.warning("> No app found, skipping coverage tracking") + logger.warning("> No app found, returning client without coverage tracking") return client if not is_supported_framework(app): - logger.warning(f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI.") + logger.warning( + f"> Unsupported framework: {type(app).__name__}. pytest-api-coverage supports Flask and FastAPI." + ) return client try: @@ -348,9 +380,6 @@ def coverage_client(request: pytest.FixtureRequest) -> Any: except Exception as e: # noqa: BLE001 logger.warning(f"> pytest-api-coverage: Could not discover endpoints. Error: {e}") return client - - else: - logger.debug(f"> Endpoints already discovered: {len(coverage_data.discovered_endpoints.endpoints)}") return wrap_client_with_coverage(client, coverage_data.recorder, request.node.name) diff --git a/tests/integration/test_plugin_integration.py b/tests/integration/test_plugin_integration.py index 7e8ea4a..393869a 100644 --- a/tests/integration/test_plugin_integration.py +++ b/tests/integration/test_plugin_integration.py @@ -99,7 +99,7 @@ def test_with_custom_client(coverage_client): result = pytester.runpytest( "--api-cov-report", - "--api-cov-client-fixture-name=my_custom_client", + "--api-cov-client-fixture-names=my_custom_client", "--api-cov-show-covered-endpoints", ) @@ -150,7 +150,7 @@ def test_with_custom_client(coverage_client): result = pytester.runpytest( "--api-cov-report", - "--api-cov-client-fixture-name=my_api_client", + "--api-cov-client-fixture-names=my_api_client", "--api-cov-show-covered-endpoints", ) @@ -188,7 +188,7 @@ def test_root(coverage_client): result = pytester.runpytest( "--api-cov-report", - "--api-cov-client-fixture-name=nonexistent_fixture", + "--api-cov-client-fixture-names=nonexistent_fixture", ) assert result.ret == 0 @@ -268,6 +268,14 @@ def from_app(): def from_main(): return "From main.py" """, + "conftest.py": """ + import pytest + from app import app as app_instance + + @pytest.fixture + def app(): + return app_instance + """, "test_multiple.py": """ def test_endpoint(coverage_client): response = coverage_client.get("/from-app-py") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index a906ece..5cad875 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -6,146 +6,12 @@ import pytest from pytest_api_cov.cli import ( - cmd_init, - detect_framework_and_app, generate_conftest_content, generate_pyproject_config, main, ) -class TestDetectFrameworkAndApp: - """Tests for detect_framework_and_app function.""" - - @patch("pathlib.Path.rglob", return_value=[]) - def test_no_files_exist(self, mock_rglob): - """Test detection when no app files exist.""" - result = detect_framework_and_app() - assert result is None - mock_rglob.assert_called() - - @pytest.mark.parametrize( - ("framework", "import_stmt", "var_name", "expected_file", "expected_var"), - [ - ("FastAPI", "from fastapi import FastAPI", "app", "app.py", "app"), - ("Flask", "from flask import Flask", "application", "app.py", "application"), - ("FastAPI", "import fastapi\nfrom fastapi import FastAPI", "main", "main.py", "main"), - ("FastAPI", "from fastapi import FastAPI", "app", "src/main.py", "app"), - ("Flask", "from flask import Flask", "app", "example/src/main.py", "app"), - ], - ) - def test_framework_app_detection(self, framework, import_stmt, var_name, expected_file, expected_var): - """Test detection of various framework apps.""" - if framework == "FastAPI": - app_content = f""" -{import_stmt} - -{var_name} = FastAPI() - -@{var_name}.get("/") -def root(): - return {{"message": "hello"}} -""" - else: - app_content = f""" -{import_stmt} - -{var_name} = Flask(__name__) - -@{var_name}.route("/") -def root(): - return "hello" -""" - - mock_file = Mock(spec=Path) - mock_file.name = Path(expected_file).name - mock_file.parts = Path(expected_file).parts - mock_file.read_text.return_value = app_content - mock_file.as_posix.return_value = expected_file - - def rglob_side_effect(pattern): - if pattern == mock_file.name: - yield mock_file - else: - yield from [] - - with patch("pathlib.Path.rglob", side_effect=rglob_side_effect): - result = detect_framework_and_app() - assert result == (framework, expected_file, expected_var) - - def test_framework_but_no_app_variable(self): - """Test when framework is imported but no app variable found.""" - app_content = """ -from fastapi import FastAPI - -# No app variable defined -""" - mock_file = Mock(spec=Path) - mock_file.name = "app.py" - mock_file.parts = ("app.py",) - mock_file.read_text.return_value = app_content - mock_file.as_posix.return_value = "app.py" - - def rglob_side_effect(pattern): - if pattern == "app.py": - yield mock_file - else: - yield from [] - - with patch("pathlib.Path.rglob", side_effect=rglob_side_effect): - result = detect_framework_and_app() - assert result is None - - def test_file_read_exception(self): - """Test handling of file read exceptions.""" - # 1. Create the mock file - mock_file = Mock(spec=Path) - mock_file.name = "app.py" - mock_file.parts = ("app.py",) - mock_file.read_text.side_effect = IOError("Cannot read file") - mock_file.as_posix.return_value = "app.py" - - def rglob_side_effect(pattern): - if pattern == "app.py": - yield mock_file - else: - yield from [] - - with patch("pathlib.Path.rglob", side_effect=rglob_side_effect): - result = detect_framework_and_app() - assert result is None - - def test_multiple_files_checked(self): - """Test that multiple files are checked in order.""" - flask_content = """ -from flask import Flask -server = Flask(__name__) -""" - mock_app_file = Mock(spec=Path) - mock_app_file.name = "app.py" - mock_app_file.parts = ("app.py",) - mock_app_file.read_text.return_value = "# foobar" - mock_app_file.as_posix.return_value = "app.py" - - mock_server_file = Mock(spec=Path) - mock_server_file.name = "server.py" - mock_server_file.parts = ("server.py",) - mock_server_file.read_text.return_value = flask_content - mock_server_file.as_posix.return_value = "server.py" - - def rglob_side_effect(pattern): - if pattern == "app.py": - return [mock_app_file] - if pattern == "server.py": - return [mock_server_file] - return [] - - with patch("pathlib.Path.rglob", side_effect=rglob_side_effect): - result = detect_framework_and_app() - - assert result == ("Flask", "server.py", "server") - - class TestGenerateConftestContent: """Tests for generate_conftest_content function.""" @@ -154,20 +20,22 @@ def test_fastapi_conftest(self): content = generate_conftest_content("FastAPI", "app.py", "app") assert "import pytest" in content + assert "from fastapi.testclient import TestClient" in content assert "from app import app" in content - assert "def app():" in content - assert "Provide the FastAPI app" in content - assert "return app" in content + assert "def client():" in content + assert "The pytest-api-cov plugin can extract the app from your client fixture" in content + assert "return TestClient(app)" in content def test_flask_conftest(self): """Test generating conftest for Flask.""" content = generate_conftest_content("Flask", "main.py", "application") assert "import pytest" in content + assert "from flask.testing import FlaskClient" in content assert "from main import application" in content - assert "def app():" in content - assert "Provide the Flask app" in content - assert "return application" in content + assert "def client():" in content + assert "The pytest-api-cov plugin can extract the app from your client fixture" in content + assert "return FlaskClient(app)" in content def test_subdirectory_conftest(self): """Test generating conftest for app in subdirectory.""" @@ -175,9 +43,8 @@ def test_subdirectory_conftest(self): assert "import pytest" in content assert "from src.main import app" in content - assert "def app():" in content - assert "Provide the FastAPI app" in content - assert "return app" in content + assert "def client():" in content + assert "return TestClient(app)" in content def test_nested_subdirectory_conftest(self): """Test generating conftest for app in nested subdirectory.""" @@ -185,9 +52,8 @@ def test_nested_subdirectory_conftest(self): assert "import pytest" in content assert "from example.src.main import app" in content - assert "def app():" in content - assert "Provide the Flask app" in content - assert "return app" in content + assert "def client():" in content + assert "return FlaskClient(app)" in content class TestGeneratePyprojectConfig: @@ -207,147 +73,41 @@ def test_pyproject_config_structure(self): assert "# force_sugar" in config -class TestCmdInit: - """Tests for cmd_init function.""" - - @patch("pytest_api_cov.cli.detect_framework_and_app") - @patch("builtins.input") - @patch("pytest_api_cov.cli.Path.exists") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("builtins.print") - def test_init_success_no_existing_files(self, mock_print, mock_file, mock_exists, mock_input, mock_detect): - """Test successful init with no existing files.""" - mock_detect.return_value = ("FastAPI", "app.py", "app") - mock_exists.return_value = False # No existing files - mock_input.return_value = "y" # User agrees to create pyproject.toml - - result = cmd_init() - - assert result == 0 - # conftest.py and pyproject.toml - assert mock_file.call_count == 2 - mock_print.assert_any_call("✅ Created conftest.py") - mock_print.assert_any_call("✅ Created pyproject.toml") - - @patch("pytest_api_cov.cli.detect_framework_and_app") - @patch("builtins.input") - @patch("pytest_api_cov.cli.Path.exists") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("builtins.print") - def test_init_with_existing_conftest(self, mock_print, mock_file, mock_exists, mock_input, mock_detect): - """Test init with existing conftest.py.""" - mock_detect.return_value = ("Flask", "app.py", "app") - - mock_exists.side_effect = [True, False] - mock_input.side_effect = ["y", "n"] # Overwrite conftest, don't create pyproject - - result = cmd_init() - - assert result == 0 - mock_print.assert_any_call("âš ī¸ conftest.py already exists") - mock_print.assert_any_call("✅ Created conftest.py") - - @patch("pytest_api_cov.cli.detect_framework_and_app") - @patch("builtins.input") - @patch("pytest_api_cov.cli.Path.exists") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("builtins.print") - def test_init_with_existing_pyproject(self, mock_print, mock_file, mock_exists, mock_input, mock_detect): - """Test init with existing pyproject.toml.""" - mock_detect.return_value = ("FastAPI", "main.py", "main") - - mock_exists.side_effect = [False, True] - - result = cmd_init() - - assert result == 0 - mock_print.assert_any_call("â„šī¸ pyproject.toml already exists") - mock_print.assert_any_call("Add this configuration to your pyproject.toml:") - - @patch("pytest_api_cov.cli.detect_framework_and_app") - @patch("builtins.input") - @patch("pytest_api_cov.cli.Path.exists") - @patch("pathlib.Path.open", new_callable=mock_open) - @patch("builtins.print") - def test_init_user_declines_conftest_overwrite(self, mock_print, mock_file, mock_exists, mock_input, mock_detect): - """Test when user declines to overwrite existing conftest.""" - mock_detect.return_value = ("FastAPI", "app.py", "app") - mock_exists.return_value = True # conftest.py exists - mock_input.side_effect = ["n", "n"] # Don't overwrite conftest or create pyproject - - result = cmd_init() - - assert result == 0 - mock_print.assert_any_call("âš ī¸ conftest.py already exists") - - @patch("pytest_api_cov.cli.detect_framework_and_app") - @patch("builtins.open", new_callable=mock_open) - @patch("builtins.print") - def test_init_no_app_detected(self, mock_print, mock_file, mock_detect): # noqa: ARG002 - """Test init when no app is detected.""" - mock_detect.return_value = None - - result = cmd_init() - - assert result == 1 - mock_print.assert_any_call("❌ No FastAPI or Flask app detected in common locations") - mock_print.assert_any_call("Example app.py:") - - @patch("pytest_api_cov.cli.detect_framework_and_app") - @patch("builtins.print") - def test_init_prints_next_steps(self, mock_print, mock_detect): - """Test that init prints helpful next steps.""" - mock_detect.return_value = ("FastAPI", "app.py", "app") - - with ( - patch("pytest_api_cov.cli.Path.exists", return_value=False), - patch("builtins.input", return_value="n"), - patch("pathlib.Path.open", mock_open()), - ): - result = cmd_init() - - assert result == 0 - mock_print.assert_any_call("🎉 Setup complete!") - mock_print.assert_any_call("Next steps:") - mock_print.assert_any_call("1. Write your tests using the 'coverage_client' fixture") - mock_print.assert_any_call("2. Run: pytest --api-cov-report") - - class TestMain: """Tests for main function.""" - @patch("pytest_api_cov.cli.cmd_init") - @patch("sys.argv", ["pytest-api-cov", "init"]) - def test_main_init_command(self, mock_cmd_init): - """Test main with init command.""" - mock_cmd_init.return_value = 0 - - result = main() + def test_main_show_pyproject(self, monkeypatch): + """Test main prints pyproject snippet for show-pyproject.""" + monkeypatch.setattr("sys.argv", ["pytest-api-cov", "show-pyproject"]) + with patch("builtins.print") as mock_print: + result = main() + assert result == 0 + mock_print.assert_called() + def test_main_show_conftest(self, monkeypatch): + """Test main prints conftest snippet for show-conftest.""" + monkeypatch.setattr("sys.argv", ["pytest-api-cov", "show-conftest", "FastAPI", "src.main", "app"]) + with patch("builtins.print") as mock_print: + result = main() assert result == 0 - mock_cmd_init.assert_called_once() + mock_print.assert_called() - @patch("sys.argv", ["pytest-api-cov"]) - def test_main_no_command(self): + def test_main_no_command(self, monkeypatch): """Test main with no command (should show help).""" + monkeypatch.setattr("sys.argv", ["pytest-api-cov"]) result = main() assert result == 1 - @patch("sys.argv", ["pytest-api-cov", "unknown"]) def test_main_unknown_command(self): """Test main with unknown command.""" with pytest.raises(SystemExit) as exc_info: - main() + monkeypatch = pytest.MonkeyPatch() + try: + monkeypatch.setenv("DUMMY", "1") # noop to obtain monkeypatch object + monkeypatch.setattr("sys.argv", ["pytest-api-cov", "unknown"]) + main() + finally: + monkeypatch.undo() assert exc_info.value.code == 2 - - @patch("pytest_api_cov.cli.cmd_init") - @patch("sys.argv", ["pytest-api-cov", "init"]) - def test_main_init_command_failure(self, mock_cmd_init): - """Test main when init command fails.""" - mock_cmd_init.return_value = 1 - - result = main() - - assert result == 1 diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index 3c921f1..b7004be 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -8,7 +8,6 @@ from pytest_api_cov.models import SessionData from pytest_api_cov.plugin import ( DeferXdistPlugin, - auto_discover_app, is_supported_framework, pytest_addoption, pytest_configure, @@ -45,20 +44,6 @@ def test_is_supported_framework_unsupported(self): mock_app.__class__.__module__ = "django.core" assert is_supported_framework(mock_app) is False - @patch("os.path.exists", return_value=False) - def test_auto_discover_app_no_files(self, mock_exists): - """Test auto-discovery when no app files exist.""" - result = auto_discover_app() - assert result is None - mock_exists.assert_called() - - @patch("importlib.util.spec_from_file_location") - def test_auto_discover_app_import_error(self, mock_spec_from_file): - """Test auto-discovery when import fails.""" - mock_spec_from_file.return_value = None - result = auto_discover_app() - assert result is None - class TestPluginHooks: """Tests for pytest plugin hooks.""" From 43a8243ccb5f72bcc16335d76c7c0ead1ac5f026 Mon Sep 17 00:00:00 2001 From: BarnabasG Date: Sun, 9 Nov 2025 22:33:01 +0000 Subject: [PATCH 3/3] 1.2.0 Add additional tests for plugin file --- tests/unit/test_config.py | 2 +- tests/unit/test_plugin.py | 139 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index a240133..f97f0b5 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -5,7 +5,7 @@ import pytest import tomli -from path import Path +from pathlib import Path from pydantic import ValidationError from pytest_api_cov.config import ( diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py index b7004be..afd1a6f 100644 --- a/tests/unit/test_plugin.py +++ b/tests/unit/test_plugin.py @@ -13,6 +13,9 @@ pytest_configure, pytest_sessionfinish, pytest_sessionstart, + extract_app_from_client, + wrap_client_with_coverage, + create_coverage_fixture, ) @@ -350,3 +353,139 @@ def test_pytest_testnodedown_with_existing_worker_data(self): assert "/new" in worker_data assert "existing_test" in worker_data["/existing"] assert "new_test" in worker_data["/new"] + + +def test_extract_app_from_client_variants(): + """Extract app from different client shapes.""" + app = object() + + class A: + def __init__(self): + self.app = app + + class B: + def __init__(self): + self.application = app + + class Transport: + def __init__(self): + self.app = app + + class C: + def __init__(self): + self._transport = Transport() + + class D: + def __init__(self): + self._app = app + + assert extract_app_from_client(A()) is app + assert extract_app_from_client(B()) is app + assert extract_app_from_client(C()) is app + assert extract_app_from_client(D()) is app + assert extract_app_from_client(None) is None + + +def test_wrap_client_with_coverage_records_various_call_patterns(): + """Tracked client records calls for string path, request-like, and kwargs.""" + recorder = Mock() + + class DummyReq: + def __init__(self, path, method="GET"): + class URL: + def __init__(self, p): + self.path = p + + self.url = URL(path) + self.method = method + + class Client: + def get(self, *args, **kwargs): + return "GET-OK" + + def open(self, *args, **kwargs): + return "OPEN-OK" + + client = Client() + wrapped = wrap_client_with_coverage(client, recorder, "test_fn") + + assert wrapped.get("/foo") == "GET-OK" + recorder.record_call.assert_called_with("/foo", "test_fn", "GET") + + recorder.reset_mock() + + req = DummyReq("/bar", method="POST") + assert wrapped.get(req) == "GET-OK" + recorder.record_call.assert_called_with("/bar", "test_fn", "POST") + + recorder.reset_mock() + + assert wrapped.open(path="/baz", method="PUT") == "OPEN-OK" + recorder.record_call.assert_called_with("/baz", "test_fn", "PUT") + + +def test_create_coverage_fixture_returns_existing_client_when_coverage_disabled(): + """create_coverage_fixture yields existing fixture when coverage disabled.""" + fixture = create_coverage_fixture("my_client", existing_fixture_name="existing") + + class SimpleSession: + def __init__(self): + self.config = Mock() + self.config.getoption.return_value = False + + session = SimpleSession() + + class Req: + def __init__(self): + self.node = Mock() + self.node.session = session + + def getfixturevalue(self, name): + if name == "existing": + return "I-AM-EXISTING-CLIENT" + raise pytest.FixtureLookupError(name) + + req = Req() + raw_fixture = getattr(fixture, "__wrapped__", fixture) + gen = raw_fixture(req) + got = next(gen) + assert got == "I-AM-EXISTING-CLIENT" + with pytest.raises(StopIteration): + next(gen) + + +@patch("pytest_api_cov.frameworks.get_framework_adapter") +def test_create_coverage_fixture_falls_back_to_app_when_no_existing_and_coverage_disabled(mock_get_adapter): + """When no existing client but an app fixture exists and coverage disabled, create tracked client.""" + fixture = create_coverage_fixture("my_client", existing_fixture_name=None) + + class SimpleSession: + def __init__(self): + self.config = Mock() + self.config.getoption.return_value = False + + session = SimpleSession() + + class Req: + def __init__(self): + self.node = Mock() + self.node.session = session + + def getfixturevalue(self, name): + if name == "app": + return "APP-OBJ" + raise pytest.FixtureLookupError(name) + + adapter = Mock() + adapter.get_tracked_client.return_value = "CLIENT-FROM-APP" + mock_get_adapter.return_value = adapter + + req = Req() + # Unwrap pytest.fixture wrapper to call the inner generator directly + raw_fixture = getattr(fixture, "__wrapped__", fixture) + gen = raw_fixture(req) + got = next(gen) + assert got == "CLIENT-FROM-APP" + mock_get_adapter.assert_called_once_with("APP-OBJ") + with pytest.raises(StopIteration): + next(gen)