Schemathesis: Property-Based API Testing From OpenAPI Specs
Property-based testing for APIs works like this: instead of writing POST /users with {"name": "Alice"} and checking the response, you define what properties the API should always satisfy, and let the test tool generate hundreds of inputs automatically.
Schemathesis does exactly this for REST APIs using your OpenAPI spec as the source of truth. It generates valid (and intentionally invalid) requests from your schema and checks that the server responds correctly. This approach finds bugs that manually-written test cases miss — because humans test the happy path.
What Schemathesis Actually Tests
Given an OpenAPI spec, Schemathesis:
- Generates valid requests that conform to your schema — correct types, required fields present, values within constraints
- Generates invalid requests with out-of-range values, wrong types, missing required fields
- Checks response invariants: any 5xx response is a bug, response headers should be consistent, response bodies should match the declared schema
By default, Schemathesis checks:
- No 5xx responses for any valid input (server errors = bugs)
- Response content-type matches what's declared
- Response schema matches the declared response schema
- Server doesn't crash with invalid input
Installing Schemathesis
pip install schemathesis
# Or with all extras (for auth, ASGI testing, etc.)
pip install schemathesis[all]Running Against a Live API
The simplest approach: point Schemathesis at your running server.
# Run against a live server
schemathesis run http://localhost:8000/openapi.json
# Specify which operations to test
schemathesis run http://localhost:8000/openapi.json \
--endpoint /users \
--method POST
# Run with more test cases per operation (default: 100)
schemathesis run http://localhost:8000/openapi.json \
--hypothesis-max-examples=500
# Save results to a file
schemathesis run http://localhost:8000/openapi.json \
--report schemathesis-results.jsonRunning Via Python Tests
For more control, integrate Schemathesis into your pytest suite:
# test_api_schema.py
import schemathesis
from schemathesis import DataGenerationMethod
# Load schema from file or URL
schema = schemathesis.from_path('openapi.json', base_url='http://localhost:8000')
# Test all operations
@schema.parametrize()
def test_api(case):
response = case.call()
case.validate_response(response)
# Test with authentication
@schema.parametrize()
def test_authenticated_api(case):
response = case.call(headers={'Authorization': 'Bearer test-token'})
case.validate_response(response)Run with pytest:
pytest test_api_schema.py -v \
--hypothesis-settings-max-examples=200Custom Checks
The default checks catch server errors and schema violations. Add custom checks for your business logic:
import schemathesis
from schemathesis import Check
from requests import Response
schema = schemathesis.from_path('openapi.json', base_url='http://localhost:8000')
def check_response_time(response: Response, case) -> None:
"""API must respond within 1 second for any valid request."""
if response.elapsed.total_seconds() > 1.0:
raise AssertionError(
f"Response too slow: {response.elapsed.total_seconds():.2f}s "
f"for {case.method} {case.path}"
)
def check_no_stack_traces_in_response(response: Response, case) -> None:
"""Error responses must not include Python stack traces."""
if response.status_code >= 400:
body = response.text
if 'Traceback (most recent call last)' in body:
raise AssertionError(
f"Stack trace found in response for {case.method} {case.path}\n"
f"Response body: {body[:500]}"
)
def check_content_type_always_json(response: Response, case) -> None:
"""All responses must include application/json content type."""
content_type = response.headers.get('Content-Type', '')
if 'application/json' not in content_type:
raise AssertionError(
f"Wrong content type: {content_type} for {case.method} {case.path}"
)
@schema.parametrize()
@schemathesis.check(check_response_time, check_no_stack_traces_in_response, check_content_type_always_json)
def test_api_with_custom_checks(case):
response = case.call()
case.validate_response(response)Stateful Testing: Multi-Step Scenarios
Stateful testing chains multiple API calls — creating a resource, reading it, updating it, deleting it:
# test_stateful.py
import schemathesis
from schemathesis.stateful import StatefulApp
schema = schemathesis.from_path('openapi.json', base_url='http://localhost:8000')
# Define links between operations using OpenAPI $links
# Or use the stateful runner directly
@schema.parametrize(stateful=True)
def test_stateful_api(case):
"""
Schemathesis will chain operations based on `links` defined in your
OpenAPI spec, or based on response data that matches path parameters.
"""
response = case.call()
case.validate_response(response)For explicit control over stateful flows, define scenarios:
import schemathesis
import pytest
schema = schemathesis.from_path('openapi.json', base_url='http://localhost:8000')
@pytest.fixture(scope='module')
def api_client():
return schema._base_url
def test_user_lifecycle():
"""Test the full user lifecycle: create → read → update → delete."""
import requests
base = 'http://localhost:8000'
token = get_test_token()
headers = {'Authorization': f'Bearer {token}'}
# Use Schemathesis to generate a valid user payload
create_op = schema['/users']['POST']
for case in create_op.explicit_header_parameters_mark.iters():
create_payload = case.body
break # Take the first generated payload
# Step 1: Create
response = requests.post(f'{base}/users', json=create_payload, headers=headers)
assert response.status_code == 201
user_id = response.json()['id']
# Step 2: Read
response = requests.get(f'{base}/users/{user_id}', headers=headers)
assert response.status_code == 200
assert response.json()['id'] == user_id
# Step 3: Update (use Schemathesis to generate a valid update payload)
response = requests.patch(
f'{base}/users/{user_id}',
json={'name': 'Updated Name'},
headers=headers
)
assert response.status_code == 200
# Step 4: Delete
response = requests.delete(f'{base}/users/{user_id}', headers=headers)
assert response.status_code == 204
# Verify deletion
response = requests.get(f'{base}/users/{user_id}', headers=headers)
assert response.status_code == 404Testing Against FastAPI and ASGI Apps
Schemathesis supports testing ASGI apps directly (no network, faster):
# test_fastapi.py
import schemathesis
from fastapi.testclient import TestClient
from myapp.main import app
# Test directly via ASGI without network
schema = schemathesis.from_asgi('/openapi.json', app)
@schema.parametrize()
def test_api(case):
response = case.call_asgi() # No network call
case.validate_response(response)This runs at Python function call speed — much faster than HTTP tests.
Filtering Operations
For large APIs, focus Schemathesis on specific areas:
# Test only POST operations
@schema.parametrize(method='POST')
def test_write_operations(case):
response = case.call()
case.validate_response(response)
# Test only user-related endpoints
@schema.parametrize(endpoint='/users')
def test_user_endpoints(case):
response = case.call()
case.validate_response(response)
# Exclude health check and metrics endpoints
@schema.parametrize(endpoint='~/(health|metrics)')
def test_non_operational_endpoints(case):
response = case.call()
case.validate_response(response)Reading Schemathesis Output
When Schemathesis finds a bug, it shows:
FAILED test_api[GET /users/{user_id}]
Response violates schema
Falsifying example: case = Case(
path_parameters={'user_id': '\x00'}, # Null byte in user_id
headers={...}
)
Response: 500 Internal Server Error
{
"error": "invalid input value for type uuid: ...",
"traceback": "..."
}This tells you exactly what input triggered the bug (null byte in user_id) and what went wrong (500 instead of 400). The input is the minimum that reproduces the failure — Schemathesis shrinks the example automatically.
CI Integration
# .github/workflows/schemathesis.yml
name: API Property Tests
on: [push, pull_request]
jobs:
schemathesis:
runs-on: ubuntu-latest
services:
api:
image: myapp:${{ github.sha }}
ports:
- 8000:8000
options: --health-cmd "curl -sf http://localhost:8000/health"
steps:
- uses: actions/checkout@v4
- name: Wait for API
run: |
/usr/local/bin/await 'curl -sf http://localhost:8000/health'
- name: Run Schemathesis
uses: schemathesis/action@v1
with:
schema: http://localhost:8000/openapi.json
args: >
--checks all
--hypothesis-max-examples=100
--report schemathesis-report.json
- uses: actions/upload-artifact@v3
if: always()
with:
name: schemathesis-report
path: schemathesis-report.jsonWhat Schemathesis Finds (and What It Doesn't)
Finds well:
- Server errors (5xx) triggered by unusual but valid inputs
- Schema violations where the response doesn't match the OpenAPI spec
- Missing error handling for null bytes, unicode edge cases, boundary values
- Type coercion bugs (sending
"1"where1is expected)
Doesn't find:
- Business logic correctness (it doesn't know what the API should do)
- Race conditions (single-threaded test generation)
- Authorization bugs (unless you configure specific auth scenarios)
- Performance issues under load
Schemathesis is a complement to hand-written API tests, not a replacement. Use it to find the bugs you didn't think to test for. Use Postman or pytest for the scenarios you know matter.
HelpMeTest adds behavioral end-to-end testing on top of API testing — verifying that correct API responses produce correct behavior in your application. Start free →