Schemathesis: Property-Based Testing That Finds Bugs You Didn't Think to Write
The hardest bugs to find with manual testing are the ones you don't think to test. You write tests for the happy path, a few expected error cases, and maybe some edge cases you've burned by before. But the input space of a real API is enormous — and somewhere in that space is the combination that crashes your server, corrupts your data, or triggers a 500 instead of a 400.
Schemathesis is a property-based testing tool for HTTP APIs. Instead of you writing test cases, Schemathesis generates them automatically from your OpenAPI or GraphQL schema. It uses the spec as a grammar for valid (and intentionally invalid) inputs, sends thousands of requests, and reports every case where your server's behavior violates what the spec promises.
The result: bugs you would never have written a test case for, caught automatically.
What "Property-Based" Means for APIs
Traditional testing: you pick specific inputs and assert specific outputs.
Property-based testing: you define properties that should hold for all inputs, and the framework generates inputs automatically to try to violate them.
For HTTP APIs, the foundational property is simple but powerful: for any request that conforms to the schema, the server should not return a 5xx error. If your spec says an endpoint accepts an integer between 0 and 100, your server should handle any integer in that range — including 0, 100, and anything in between — without crashing.
Schemathesis also checks:
- Response bodies match the documented schema
- Required headers are present in responses
- 4xx responses include the documented error format
- The server stays responsive after many requests
Installation
# Via pip (Python 3.8+)
pip install schemathesis
# Or with extras for specific features
pip install schemathesis[all]
# Verify
st --versionSchemathesis also ships as a Docker image:
docker pull schemathesis/schemathesis:stableBasic CLI Usage
The fastest path to results is pointing Schemathesis at a running server:
# Test against a live URL
st run https://api.example.com/openapi.json
# Test a local server
st run http://localhost:8080/openapi.yaml
# Test a file (still needs a running server for the URL)
st run openapi.yaml --url http://localhost:8080A typical output looks like:
================================ Schemathesis test session starts =================================
Schema location: http://localhost:8080/openapi.json
Base URL: http://localhost:8080
Specification version: Open API 3.0.3
Workers: 1
Collected API operations: 12
GET /api/users ✓ 100 samples
POST /api/users ✓ 100 samples
GET /api/users/{userId} ✓ 100 samples
PUT /api/users/{userId} ✓ 100 samples
DELETE /api/users/{userId} ✓ 100 samples
GET /api/orders ✓ 100 samples
POST /api/orders F
=================================================================================================
FAILED
POST /api/orders
1. Test Case ID: abcd1234
- Response violates schema
Check : response_schema_conformance
Request : POST /api/orders
Body: {"customerId": -1, "productId": " ", "quantity": 0}
Response :
Status code : 201
Body : {}
Response is missing required field: 'id'Schemathesis found that sending quantity: 0 created an order (201) with an empty response body, violating the documented response schema. That's a real bug — a zero-quantity order that shouldn't be created, and when it is, doesn't return the promised data.
Fuzzing Strategies
By default Schemathesis generates random valid inputs. But it also has specific strategies designed to probe for common vulnerability classes:
# Use the default strategy
st run http://localhost:8080/openapi.yaml
# Include negative testing — send deliberately invalid data
st run http://localhost:8080/openapi.yaml --checks all
# Focus on specific checks
st run http://localhost:8080/openapi.yaml \
--checks not_a_server_error \
--checks response_schema_conformance \
--checks content_type_conformance
# Increase sample count
st run http://localhost:8080/openapi.yaml --count 500
# Use multiple workers for parallel testing
st run http://localhost:8080/openapi.yaml --workers 4Available checks:
not_a_server_error— no 5xx responses (default)response_schema_conformance— response body matches documented schemacontent_type_conformance— Content-Type header matches specaccept_header_conformance— server handles Accept headers correctlyresponse_headers_conformance— required response headers are presentuse_after_free— stateful: resource operations after deletionensure_resource_availability— stateful: created resources can be retrievednegative_data_rejection— invalid input gets 4xx, not 5xx or 2xx
Authentication
Many endpoints require auth. Pass credentials on the command line:
# Bearer token
st run http://localhost:8080/openapi.yaml \
--auth-type bearer \
--auth "your-token-here"
# Basic auth
st run http://localhost:8080/openapi.yaml \
--auth-type basic \
--auth "username:password"
# Custom header
st run http://localhost:8080/openapi.yaml \
--header "Authorization: Bearer your-token" \
--header "X-API-Key: your-key"For OAuth flows or session-based auth, you need the Python API.
Python API: Full Control
The Python API gives you complete control over test generation, filtering, and assertions:
import schemathesis
from schemathesis import Case
import requests
# Load schema
schema = schemathesis.from_uri("http://localhost:8080/openapi.yaml")
# Basic test — all operations, default checks
@schema.parametrize()
def test_api(case: Case):
response = case.call()
case.validate_response(response)Run it with pytest:
pytest test_api.py -vCustom Authentication
import schemathesis
from schemathesis import Case
import pytest
import requests
@pytest.fixture(scope="session")
def auth_token():
response = requests.post(
"http://localhost:8080/auth/token",
json={
"client_id": "test-client",
"client_secret": "test-secret",
"grant_type": "client_credentials"
}
)
return response.json()["access_token"]
schema = schemathesis.from_uri("http://localhost:8080/openapi.yaml")
@schema.parametrize()
def test_api_authenticated(case: Case, auth_token):
# Add auth to every generated request
case.headers = case.headers or {}
case.headers["Authorization"] = f"Bearer {auth_token}"
response = case.call()
case.validate_response(response)Filtering Operations
Test only specific endpoints or methods:
# Test only POST endpoints
@schema.parametrize(method="POST")
def test_post_endpoints(case: Case):
response = case.call()
case.validate_response(response)
# Test specific paths
@schema.parametrize(endpoint="/api/orders.*")
def test_orders_api(case: Case):
response = case.call()
case.validate_response(response)
# Exclude specific operations
@schema.parametrize(
endpoint="^(?!.*/admin).*$" # Exclude /admin endpoints
)
def test_non_admin_api(case: Case):
response = case.call()
case.validate_response(response)Custom Assertions
Beyond the built-in checks, add domain-specific assertions:
@schema.parametrize()
def test_api_business_rules(case: Case):
response = case.call()
# Standard schema validation
case.validate_response(response)
# Custom assertions
if response.status_code == 200:
data = response.json()
# IDs should never be negative
if "id" in data:
assert int(data["id"]) > 0, f"ID {data['id']} is not positive"
# Timestamps should be valid ISO 8601
if "createdAt" in data:
from datetime import datetime
try:
datetime.fromisoformat(data["createdAt"].replace("Z", "+00:00"))
except ValueError:
raise AssertionError(f"Invalid timestamp: {data['createdAt']}")
if response.status_code == 422:
# Validation errors should have an 'errors' field
error_body = response.json()
assert "errors" in error_body or "detail" in error_body, \
f"422 response missing error detail: {error_body}"Stateful Testing
Standard property-based testing generates each request independently, which misses a whole class of bugs that only appear when operations are sequenced. Stateful testing uses links between operations to build realistic request chains.
Schemathesis uses OpenAPI's links feature to define these chains:
# openapi.yaml
paths:
/orders:
post:
operationId: createOrder
responses:
'201':
description: Order created
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
links:
getOrder:
operationId: getOrder
parameters:
orderId: '$response.body#/id'
cancelOrder:
operationId: cancelOrder
parameters:
orderId: '$response.body#/id'
/orders/{orderId}:
get:
operationId: getOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: string
delete:
operationId: cancelOrder
parameters:
- name: orderId
in: path
required: true
schema:
type: stringEnable stateful testing:
st run http://localhost:8080/openapi.yaml --stateful=links
# Increase the depth of state chains
st run http://localhost:8080/openapi.yaml \
--stateful=links \
--stateful-recursion-limit 5Or in Python:
schema = schemathesis.from_uri(
"http://localhost:8080/openapi.yaml",
stateful=schemathesis.Stateful.links
)
@schema.parametrize()
def test_stateful(case: Case):
response = case.call()
case.validate_response(response)Stateful testing is where Schemathesis often finds the most interesting bugs:
- Creating a resource returns an ID that, when used to fetch the resource, returns 404
- Deleting a resource, then fetching it returns 200 with stale data instead of 404
- A resource's state changes prevent subsequent valid operations from succeeding
GraphQL Testing
Schemathesis also supports GraphQL APIs:
st run http://localhost:8080/graphql --app=graphqlPython API:
schema = schemathesis.graphql.from_url("http://localhost:8080/graphql")
@schema.parametrize()
def test_graphql(case: Case):
response = case.call()
# GraphQL always returns 200, so check for errors in the body
if response.status_code == 200:
data = response.json()
assert "errors" not in data or case.data.get("expected_error"), \
f"Unexpected GraphQL errors: {data.get('errors')}"Schemathesis introspects the schema, generates valid and invalid queries, and tests that the server handles them without 5xx errors.
CI/CD Integration
GitHub Actions
name: Schemathesis API Tests
on:
push:
branches: [main, develop]
schedule:
- cron: '0 2 * * *' # Nightly full run
jobs:
schemathesis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
pip install schemathesis
pip install -r requirements.txt
- name: Start API server
run: |
python -m uvicorn app.main:app --host 0.0.0.0 --port 8080 &
python -c "
import urllib.request, time
for _ in range(30):
try:
urllib.request.urlopen('http://localhost:8080/health')
break
except:
time.sleep(1)
"
- name: Run Schemathesis (fast, PR checks)
if: github.event_name == 'pull_request'
env:
API_TOKEN: ${{ secrets.TEST_API_TOKEN }}
run: |
st run http://localhost:8080/openapi.json \
--checks all \
--count 50 \
--header "Authorization: Bearer $API_TOKEN" \
--junit-xml schemathesis-results.xml \
--workers 2
- name: Run Schemathesis (deep, nightly)
if: github.event_name == 'schedule'
env:
API_TOKEN: ${{ secrets.TEST_API_TOKEN }}
run: |
st run http://localhost:8080/openapi.json \
--checks all \
--count 1000 \
--stateful=links \
--header "Authorization: Bearer $API_TOKEN" \
--junit-xml schemathesis-results.xml \
--workers 4
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: schemathesis-results
path: schemathesis-results.xml
- name: Publish results
uses: mikepenz/action-junit-report@v4
if: always()
with:
report_paths: schemathesis-results.xml
check_name: Schemathesis API TestsDocker-Based CI
- name: Run Schemathesis via Docker
run: |
docker run --rm \
--network host \
-e SCHEMATHESIS_TOKEN=${{ secrets.TEST_API_TOKEN }} \
schemathesis/schemathesis:stable \
run http://localhost:8080/openapi.json \
--checks all \
--header "Authorization: Bearer $SCHEMATHESIS_TOKEN"Reproducing Failures
When Schemathesis finds a bug, it outputs the minimal reproducing case. Use the --seed flag to reproduce the same random sequence:
# Reproduce a specific failing run
st run http://localhost:8080/openapi.yaml --seed 12345The Python API also supports saving and replaying found issues as pytest fixtures, making it easy to add a non-property-based regression test for each bug found:
# After schemathesis finds: POST /orders with {"quantity": 0}
# Add a regression test:
def test_zero_quantity_order_rejected():
response = requests.post(
"http://localhost:8080/orders",
json={"customerId": "user-1", "productId": "prod-1", "quantity": 0},
headers={"Authorization": f"Bearer {auth_token}"}
)
assert response.status_code == 422, \
"Zero quantity order should be rejected with 422"Integrating with Hypothesis
Schemathesis is built on top of Hypothesis, Python's property-based testing framework. You can use Hypothesis directly for more advanced strategies:
from hypothesis import given, settings, HealthCheck
import schemathesis
from schemathesis.extra.pytest_plugin import schema
@given(case=schema.parametrize())
@settings(
max_examples=200,
suppress_health_check=[HealthCheck.too_slow],
deadline=5000 # 5 second deadline per example
)
def test_with_hypothesis(case):
response = case.call()
case.validate_response(response)
# Hypothesis shrinks failing examples automatically
# The simplest failing case is reported, not the firstHypothesis's shrinking means that when a bug is found, the framework automatically minimizes the input to the smallest case that reproduces it. Instead of {"name": "aGxS3kPqwZ", "quantity": 847, ...}, you get {"name": "a", "quantity": 0}.
What Schemathesis Won't Find
Property-based testing has limits. Schemathesis will tell you when your server violates its own spec. It won't tell you when the spec is wrong — when the documented behavior is itself incorrect.
It also won't verify business logic beyond what the spec encodes. If your spec says POST /transfers returns 201 and {"status": "pending"}, but the actual money movement fails silently, Schemathesis won't catch that. For business logic correctness, you need tests that understand your domain.
Think of Schemathesis as a first line of defense: it ensures your server is robust against the full input space your spec permits. Conventional tests handle intent. Together, they cover substantially more ground than either alone.
Summary
Schemathesis's core proposition is compelling: your API spec already describes what inputs your server should accept and what outputs it should produce. Property-based testing treats that spec as executable, generating thousands of test cases from it automatically.
The bugs it finds — malformed responses, missing required fields, 500s on edge-case inputs, state-dependent failures after deletion — are exactly the kind that slip past human-written test suites. Not because the tests are poorly written, but because no one thought to test those specific inputs.
Running Schemathesis in CI on every pull request, with a deeper nightly run for stateful chains, gives you a continuous automated safety net that scales with your API's complexity without requiring proportional growth in test maintenance.
HelpMeTest can complement this setup by providing continuous monitoring against your production API, catching regressions that only appear under real traffic conditions — but Schemathesis is the right tool for the pre-deploy phase where you want to probe the full input space before anything reaches users.