Testing Python Apps
Test a Python web application (FastAPI, Flask, Django, and others) in-process with Schemathesis instead of sending requests over the network. Requests go straight to the ASGI or WSGI callable, so no server has to be running.
Prerequisites
- Schemathesis and pytest installed in the same environment as your application
- An application that serves its OpenAPI schema on a route
Which Frameworks Work
Schemathesis has exactly two in-process entry points, and they key off the calling convention, not the framework:
schemathesis.openapi.from_asgi(path, app)- anything that is an ASGI 3 callableschemathesis.openapi.from_wsgi(path, app)- anything that is a WSGI callable
path is where your application serves its schema, so it varies with the library that generates it.
| Framework | Loader | Schema path in a default setup |
|---|---|---|
| FastAPI | from_asgi |
/openapi.json |
| Starlette | from_asgi |
whatever route you register |
| Flask | from_wsgi |
whatever route you register |
| APIFlask | from_wsgi |
/openapi.json |
| Flask-RESTX | from_wsgi |
/swagger.json |
| flask-smorest | from_wsgi |
OPENAPI_URL_PREFIX + /openapi.json |
| Litestar | from_asgi |
/schema/openapi.json |
| Quart | from_asgi |
whatever route you register |
| Sanic | from_asgi |
whatever route you register |
| Falcon | from_wsgi or from_asgi |
whatever route you register |
| Connexion 3 | from_asgi |
the spec file you pass to add_api() |
| Django + DRF | from_wsgi or from_asgi |
wherever you route SpectacularAPIView |
| Django Ninja | from_wsgi |
/api/openapi.json |
Frameworks with no in-process path
aiohttp and Tornado expose neither a WSGI nor an ASGI callable, so there is nothing for from_asgi or from_wsgi to call. Run them over a real server instead:
uvx schemathesis run http://127.0.0.1:8080/openapi.json
Basic Setup
FastAPI (ASGI)
from fastapi import FastAPI
import schemathesis
app = FastAPI()
@app.get("/users")
async def get_users():
return [{"id": 1, "name": "Alice"}]
# Load schema directly from the app
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
@schema.parametrize()
def test_api(case):
case.call_and_validate()
Flask (WSGI)
from flask import Flask, jsonify
import schemathesis
app = Flask(__name__)
OPENAPI = {
"openapi": "3.0.0",
"info": {"title": "Users API", "version": "1.0"},
"paths": {
"/users": {
"get": {
"responses": {
"200": {
"description": "List of users",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
},
},
}
}
},
}
}
}
}
},
}
@app.route("/users")
def get_users():
return jsonify([{"id": 1, "name": "Alice"}])
@app.route("/openapi.json")
def openapi_spec():
return jsonify(OPENAPI)
schema = schemathesis.openapi.from_wsgi("/openapi.json", app)
@schema.parametrize()
def test_api(case):
case.call_and_validate()
Both loaders take the path that serves the schema and the application instance. Save either example as test_api.py and run pytest test_api.py:
test_api.py . [100%]
============================== 1 passed in 0.23s ===============================
Django and Django REST Framework
Point the loader at whatever URL serves your schema - with drf-spectacular that is wherever you routed SpectacularAPIView. Either callable works; the WSGI one is shown here:
import schemathesis
from myproject.wsgi import application
schema = schemathesis.openapi.from_wsgi("/schema/?format=json", application)
The ?format=json is drf-spectacular's content negotiation; without it the view returns YAML, which Schemathesis also accepts.
ALLOWED_HOSTS has to admit the host the in-process client uses for the schema and for every generated request - localhost for WSGI, testserver for ASGI - or Django's CommonMiddleware answers every request with 400 Bad Request. Loading names the rejected host:
Failed to load schema due to client error (HTTP 400 Bad Request)
Django rejected the request because its `Host` header is not in `ALLOWED_HOSTS`
Host: localhost
ALLOWED_HOSTS: ['example.com']
Add 'localhost' to ALLOWED_HOSTS in the Django settings you use for testing
# settings.py
ALLOWED_HOSTS = ["localhost", "testserver"]
The schema view is itself a documented operation, but Schemathesis recognises the endpoint that served the schema and leaves it out. Add an explicit filter selecting it if you do want it tested.
Database access under pytest-django
Requests run Django's full request lifecycle, so they reach the database. Two errors show up in order.
RuntimeError: Database access not allowed - the test needs pytest-django's db fixture.
FailedHealthCheck: ... uses a function-scoped fixture - db runs once per test, not once per generated input, and Hypothesis refuses that until you confirm it is intended:
from hypothesis import HealthCheck, settings
@schema.parametrize()
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_api(case, db):
case.call_and_validate()
Being per-test has consequences: one transaction wraps every generated input, so rows created by one input are still visible to the next, the rollback happens only after the last one, and transaction.on_commit() callbacks never run. Use transactional_db when the code under test depends on committed data.
Reusing Values From Your Source
When you load an app with from_asgi or from_wsgi, Schemathesis reads the request handlers' source code and reuses the literal values it finds - status codes, identifiers, enum members, magic strings - as candidate test data. Free-form parameters that random generation rarely satisfies then occasionally receive a value the application recognizes, reaching code paths behind those checks.
This is on by default. Disable it in the config file:
[analysis.constants]
enabled = false
Registering additional sources
App inspection reaches the request handlers' modules, plus - for Flask - the module the app is defined in. For Django, it walks the URL resolver and reaches every view it routes to. For literals defined elsewhere, or when the handler is a library view served over ASGI rather than your own code (for example GraphQL resolvers behind a Starlette/FastAPI mount), register the source explicitly:
import schemathesis
import myapp.resolvers
@schemathesis.python.constants
def _constants():
return myapp.resolvers
The decorated function returns what to inspect: a module, an application instance, a dotted module name, or an iterable of them. Registered sources are combined with the automatically inspected app; disabling analysis.constants turns off both.
Custom Test Clients
ASGI lifespan is handled for you: loading a schema with from_asgi starts the application's lifespan, every generated call reuses it, and shutdown runs at interpreter exit. You do not need a custom client to get startup and shutdown events.
Use one when requests need to share state that Schemathesis does not manage: a cookie jar carried across cases, a fixed header set, or a connection the surrounding test owns. Create the client once in a module-scoped fixture and pass it to every call:
import pytest
from fastapi import FastAPI
import schemathesis
from schemathesis.python.asgi import ASGIClient
app = FastAPI()
@app.get("/users")
async def get_users():
return [{"id": 1, "name": "Alice"}]
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
@pytest.fixture(scope="module")
def client():
with ASGIClient(app) as client:
yield client
@schema.parametrize()
def test_api_with_session(case, client):
case.call_and_validate(session=client)
A function-scoped fixture would be created once per test function rather than per generated case, and Hypothesis rejects it with a FailedHealthCheck; a module scope avoids that.
ASGIClient is a requests.Session subclass, so cookies, headers and adapters behave the way they do for any other session.
For WSGI applications the equivalent is a werkzeug.Client, passed the same way.
Integration with pytest Fixtures
Build the app, or the schema, from existing fixtures with schemathesis.pytest.from_fixture. Here users stands in for a fixture you already have, such as a database session:
import pytest
from fastapi import FastAPI
import schemathesis
@pytest.fixture
def users():
return [{"id": 1, "name": "Alice"}]
@pytest.fixture
def configured_app(users):
app = FastAPI()
@app.get("/users")
async def get_users():
return users
return app
@pytest.fixture
def api_schema(configured_app):
return schemathesis.openapi.from_asgi("/openapi.json", configured_app)
schema = schemathesis.pytest.from_fixture("api_schema")
@schema.parametrize()
def test_operations(case):
case.call_and_validate()
Authentication Integration
For scenarios where you need to dynamically obtain authentication tokens (login flows, OAuth), integrate with your app's auth system:
import schemathesis
from schemathesis.python.asgi import ASGIClient
from myapp import app
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
@schema.auth()
class AppAuth:
def get(self, case, context):
# Log in to get a fresh token
with ASGIClient(context.app) as client:
response = client.post(
"/auth/token",
json={"username": "test_user", "password": "test_password"},
)
return response.json()["access_token"]
def set(self, case, data, context):
case.headers["Authorization"] = f"Bearer {data}"
This pattern is for dynamic authentication (login flows, token refresh). For static authentication (API keys, fixed tokens), simply add headers directly to your test client or case objects.
Troubleshooting
FailedHealthCheck: ... uses a function-scoped fixture. A fixture passed to the test is created once per test, not once per generated case. Use a wider scope (module, session) or suppress the health check as shown in Database access under pytest-django.
Schema loading fails with 404. The first argument to from_asgi/from_wsgi must be the path your application serves the schema on; see the table in Which Frameworks Work.
Every request returns 400 under Django. Add localhost (WSGI) or testserver (ASGI) to ALLOWED_HOSTS.