Property-Based Testing for API Contracts
APIs are one of the highest-leverage targets for property-based testing. A REST API has a large input space (any valid HTTP request), a defined contract (the API spec), and invariants that must hold across all inputs (status codes, response shapes, business rules). Example-based API tests verify that specific requests produce specific responses. Property-based API tests verify that all valid requests produce well-formed responses — a fundamentally stronger guarantee.
This guide covers the key patterns for applying property-based testing to REST APIs: invariant properties, roundtrip tests for serialization, schema compliance, consumer-driven contracts, and stateful API testing.
Why APIs Benefit from Property-Based Testing
Consider what a typical example-based API test looks like:
test('GET /users/:id returns user', async () => {
const response = await fetch('/api/users/123');
expect(response.status).toBe(200);
const body = await response.json();
expect(body.id).toBe(123);
expect(body.name).toBeDefined();
});This test verifies one specific user ID. But what about user ID 0? What about very large IDs? What about IDs with leading zeros? What about non-numeric characters in the URL segment? What about concurrent requests for the same user?
Property-based testing answers all of these by generating a wide variety of inputs and checking that the invariants hold — not that specific values match, but that the response is always well-formed, always has the right structure, always returns a consistent status code for a given class of input.
Pattern 1: Status Code Invariants
The most basic API property: the status code should be predictable based on the class of request.
import fc from 'fast-check';
// Valid user IDs always return 200 or 404 — never 500
test('GET /users/:id never returns 500 for any integer ID', async () => {
await fc.assert(
fc.asyncProperty(fc.integer({ min: 1, max: 2147483647 }), async (userId) => {
const response = await fetch(`/api/users/${userId}`);
expect([200, 404]).toContain(response.status);
}),
{ numRuns: 50 } // reduce for network tests
);
});
// Any authenticated request always gets a valid status
test('POST /users always returns 2xx or 4xx for valid JSON', async () => {
const userPayload = fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
age: fc.integer({ min: 0, max: 150 }),
});
await fc.assert(
fc.asyncProperty(userPayload, async (payload) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${TEST_TOKEN}` },
body: JSON.stringify(payload),
});
expect(response.status).toBeGreaterThanOrEqual(200);
expect(response.status).toBeLessThan(500);
}),
{ numRuns: 100 }
);
});The key property here: a well-implemented API should never return a 5xx error for any valid client input. 4xx errors are expected (invalid data, missing resources), but 5xx errors indicate a server bug.
Pattern 2: Roundtrip Properties (Create → Read)
One of the strongest API properties is the create-read roundtrip: if you POST a resource and then GET it back by the returned ID, the data should match.
test('create user then read it back returns the same data', async () => {
const userPayload = fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
role: fc.constantFrom('admin', 'user', 'viewer'),
});
await fc.assert(
fc.asyncProperty(userPayload, async (payload) => {
// Create the user
const createResponse = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_TOKEN}`,
},
body: JSON.stringify(payload),
});
expect(createResponse.status).toBe(201);
const created = await createResponse.json();
expect(created.id).toBeDefined();
// Read it back
const readResponse = await fetch(`/api/users/${created.id}`, {
headers: { 'Authorization': `Bearer ${TEST_TOKEN}` },
});
expect(readResponse.status).toBe(200);
const read = await readResponse.json();
// Invariant: what we wrote should match what we read
expect(read.name).toBe(payload.name);
expect(read.email).toBe(payload.email);
expect(read.role).toBe(payload.role);
}),
{ numRuns: 20 } // fewer runs for integration tests hitting a real server
);
});This roundtrip test will catch bugs in data storage, serialization, encoding (especially for non-ASCII names), and field mapping. It's much stronger than a test that only checks one or two hardcoded examples.
Pattern 3: Serialization/Deserialization Properties
For APIs that accept and return structured data, the serialization pipeline is a rich source of bugs. Property tests verify that data survives the full pipeline: client → JSON → server → database → server → JSON → client.
from hypothesis import given, settings
from hypothesis import strategies as st
import requests
# Strategy for API-safe strings (no null bytes, reasonable length)
api_string = st.text(
alphabet=st.characters(blacklist_categories=('Cs',)), # no surrogates
min_size=1,
max_size=255,
)
product_payload = st.fixed_dictionaries({
'name': api_string,
'description': st.text(max_size=1000),
'price': st.decimals(min_value='0.01', max_value='9999.99', places=2),
'currency': st.sampled_from(['USD', 'EUR', 'GBP', 'JPY']),
'tags': st.lists(api_string, max_size=10),
'metadata': st.dictionaries(
api_string,
st.one_of(st.text(max_size=100), st.integers(), st.booleans()),
max_size=5,
),
})
@given(product_payload)
@settings(max_examples=30)
def test_product_serialization_roundtrip(payload):
"""Creating and reading a product should preserve all fields."""
response = requests.post(
'https://api.example.com/products',
json=payload,
headers={'Authorization': f'Bearer {TEST_TOKEN}'},
)
assert response.status_code == 201, f"Expected 201, got {response.status_code}: {response.text}"
created = response.json()
product_id = created['id']
# Clean up later
try:
read_response = requests.get(
f'https://api.example.com/products/{product_id}',
headers={'Authorization': f'Bearer {TEST_TOKEN}'},
)
assert read_response.status_code == 200
read = read_response.json()
assert read['name'] == payload['name']
assert read['description'] == payload['description']
assert float(read['price']) == float(payload['price'])
assert read['currency'] == payload['currency']
assert set(read['tags']) == set(payload['tags'])
finally:
requests.delete(
f'https://api.example.com/products/{product_id}',
headers={'Authorization': f'Bearer {TEST_TOKEN}'},
)Note the finally block — property tests that hit real APIs should always clean up after themselves, since they may create many resources across many runs.
Pattern 4: Schema Compliance
Your API probably has a defined schema (OpenAPI/Swagger, JSON Schema, or a TypeScript interface). Property tests can verify that every response matches the schema, regardless of what input generated it.
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import fc from 'fast-check';
const ajv = new Ajv();
addFormats(ajv);
const userSchema = {
type: 'object',
required: ['id', 'name', 'email', 'createdAt'],
properties: {
id: { type: 'integer', minimum: 1 },
name: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email' },
role: { type: 'string', enum: ['admin', 'user', 'viewer'] },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
additionalProperties: false,
};
const validateUser = ajv.compile(userSchema);
test('GET /users/:id response always matches schema', async () => {
await fc.assert(
fc.asyncProperty(fc.integer({ min: 1, max: 10000 }), async (userId) => {
const response = await fetch(`/api/users/${userId}`, {
headers: { 'Authorization': `Bearer ${TEST_TOKEN}` },
});
if (response.status === 200) {
const body = await response.json();
const valid = validateUser(body);
if (!valid) {
throw new Error(
`Response schema validation failed for user ${userId}:\n` +
ajv.errorsText(validateUser.errors) + '\n' +
'Response: ' + JSON.stringify(body, null, 2)
);
}
}
// 404 is acceptable — schema check doesn't apply
}),
{ numRuns: 50 }
);
});Schema compliance testing is particularly valuable when:
- Multiple teams contribute to the same API
- The API has been through multiple refactors
- You're generating clients from the schema and need to guarantee it's accurate
Pattern 5: Idempotency Properties
Many operations should be idempotent — running them multiple times should have the same effect as running them once. Property-based testing can verify this:
test('PUT /users/:id is idempotent', async () => {
const updatePayload = fc.record({
name: fc.string({ minLength: 1, maxLength: 100 }),
email: fc.emailAddress(),
});
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 1, max: 1000 }),
updatePayload,
async (userId, payload) => {
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${TEST_TOKEN}`,
};
// Skip if user doesn't exist
const checkResponse = await fetch(`/api/users/${userId}`, { headers });
if (checkResponse.status === 404) return;
// Apply the update twice
const first = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers,
body: JSON.stringify(payload),
});
const second = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers,
body: JSON.stringify(payload),
});
expect(first.status).toBe(second.status);
if (first.status === 200) {
const firstBody = await first.json();
const secondBody = await second.json();
expect(firstBody.name).toBe(secondBody.name);
expect(firstBody.email).toBe(secondBody.email);
}
}
),
{ numRuns: 20 }
);
});Pattern 6: Pagination Invariants
List endpoints with pagination should satisfy several invariants:
from hypothesis import given, settings, assume
from hypothesis import strategies as st
import requests
@given(
page=st.integers(min_value=1, max_value=100),
page_size=st.sampled_from([10, 25, 50, 100]),
)
@settings(max_examples=30)
def test_pagination_invariants(page, page_size):
"""Pagination should be consistent and complete."""
response = requests.get(
'https://api.example.com/products',
params={'page': page, 'per_page': page_size},
headers={'Authorization': f'Bearer {TEST_TOKEN}'},
)
assert response.status_code in (200, 404), f"Unexpected status: {response.status_code}"
if response.status_code == 200:
body = response.json()
# Invariant 1: Never return more items than requested
assert len(body['items']) <= page_size
# Invariant 2: Pagination metadata is consistent
assert body['page'] == page
assert body['per_page'] == page_size
assert body['total'] >= 0
# Invariant 3: If total < page_size, this must be the only page
if body['total'] < page_size:
assert body['total_pages'] == 1
# Invariant 4: Items on a page are unique (no duplicates)
item_ids = [item['id'] for item in body['items']]
assert len(item_ids) == len(set(item_ids))Pattern 7: Consumer-Driven Contracts with Property Tests
Consumer-driven contract testing verifies that a provider API satisfies the expectations of each consumer. Property-based testing adds an extra dimension: instead of verifying specific request/response pairs, it verifies that the provider satisfies the consumer's expectations across all valid inputs.
The pattern:
- Consumer defines the properties it depends on (not specific examples)
- Provider runs the consumer's property tests against its implementation
- Any failure means the provider has broken the consumer's contract
// consumer-contract.test.js (runs against the provider)
describe('User API Contract (consumed by Billing Service)', () => {
// The billing service depends on these properties of the User API:
test('any existing user has a non-null account_id', async () => {
await fc.assert(
fc.asyncProperty(fc.integer({ min: 1, max: 10000 }), async (userId) => {
const response = await fetch(`${PROVIDER_URL}/api/users/${userId}`);
if (response.status === 200) {
const user = await response.json();
// CRITICAL: billing service requires account_id to be non-null
expect(user.account_id).toBeDefined();
expect(user.account_id).not.toBeNull();
}
}),
{ numRuns: 50 }
);
});
test('user status is always one of the known values', async () => {
const KNOWN_STATUSES = ['active', 'suspended', 'pending', 'cancelled'];
await fc.assert(
fc.asyncProperty(fc.integer({ min: 1, max: 10000 }), async (userId) => {
const response = await fetch(`${PROVIDER_URL}/api/users/${userId}`);
if (response.status === 200) {
const user = await response.json();
expect(KNOWN_STATUSES).toContain(user.status);
}
}),
{ numRuns: 50 }
);
});
});These contract property tests can be run in CI against any build of the provider, giving early warning when a change would break a downstream consumer.
Pattern 8: Stateful API Testing
Stateful property tests model a sequence of API calls as a state machine and verify that invariants hold at every step. This catches bugs that only emerge from specific sequences of operations.
from hypothesis.stateful import RuleBasedStateMachine, rule, invariant
from hypothesis import strategies as st
import requests
class APIStateMachine(RuleBasedStateMachine):
"""Model a sequence of API operations and verify invariants."""
def __init__(self):
super().__init__()
self.created_ids = set()
self.session = requests.Session()
self.session.headers['Authorization'] = f'Bearer {TEST_TOKEN}'
@rule(payload=st.fixed_dictionaries({
'name': st.text(min_size=1, max_size=100),
'email': st.emails(),
}))
def create_user(self, payload):
response = self.session.post('https://api.example.com/users', json=payload)
if response.status_code == 201:
self.created_ids.add(response.json()['id'])
@rule()
def delete_random_user(self):
if not self.created_ids:
return
user_id = next(iter(self.created_ids))
response = self.session.delete(f'https://api.example.com/users/{user_id}')
if response.status_code == 204:
self.created_ids.discard(user_id)
@invariant()
def deleted_users_are_gone(self):
"""After deletion, a GET should return 404."""
# We can't easily check all deleted users here, but we can check
# that all known created users return 200
for user_id in list(self.created_ids):
response = self.session.get(f'https://api.example.com/users/{user_id}')
assert response.status_code in (200, 404), \
f"User {user_id} returned unexpected status {response.status_code}"
def teardown(self):
"""Clean up all created users after the test."""
for user_id in self.created_ids:
self.session.delete(f'https://api.example.com/users/{user_id}')
TestAPIBehavior = APIStateMachine.TestCasePerformance Considerations
Property tests that hit real APIs are slower than unit property tests. A few techniques to manage this:
Reduce numRuns/max_examples for integration tests. 20-50 runs is often sufficient for API properties, versus 100-1000 for pure unit tests.
Use test databases. Run property tests against a dedicated test database that can be reset between runs, rather than against production data.
Parallelize carefully. Property tests that create resources can interfere with each other if run in parallel. Use unique prefixes or isolated namespaces.
Cache authentication tokens. Don't authenticate inside the property test function — authenticate once before the test suite and reuse the token.
Profile your test suite. Most slow API property test suites have one or two tests that are much slower than the others. Identify and optimize those first.
Using HelpMeTest for API Property Testing
HelpMeTest provides a cloud environment for running API tests, including property-based tests, at scale. Its Robot Framework integration supports testing REST APIs with properties that verify invariants across generated inputs. HelpMeTest's AI-powered test generation can identify API invariants worth testing from your OpenAPI spec or existing example-based tests. The platform's test history tracking shows you when an API property that was passing starts failing — often the first indication that a breaking change has been introduced. Usage-based pricing, $0.003/run, no base fee.
Conclusion
Property-based testing is a natural fit for API testing because APIs have well-defined contracts that should hold for all valid inputs. The patterns covered here — status code invariants, roundtrip tests, schema compliance, idempotency, pagination consistency, consumer-driven contracts, and stateful testing — each target a different class of API bugs that example-based tests routinely miss.
Start with the simplest pattern: verify that your API never returns a 500 error for any valid input. This one property, run with 50-100 random IDs or payloads, will find bugs in most production APIs. From there, add roundtrip tests for your most important resources, and schema validation to protect against unexpected response shape changes.
The combination of property-based and example-based API testing gives you both comprehensive coverage of the input space and clear documentation of specific known behaviors — a significantly stronger guarantee than either approach alone.