Generate API contracts from real service interactions and test that services still match those contracts.
uv venv
uv syncThe recorder acts as a proxy between a client (Service A) and the target service (Service B). It records all API interactions to generate contracts.
-
Set up: Place Service B on port
7777(configurable inconf.tomlastarget_service_url) -
Start the recorder:
uv run -m contractest.recorder
-
Send requests through the recorder from Service A (frontend, webapp, etc.)
-
The recorder automatically records all contracts during interactions
-
Stop the recorder (Ctrl+C) and contracts are saved to
./contracts(configurable inconf.toml)
Flows let you chain API calls together by extracting values from one response and injecting them into the next request (e.g., login → save token → use token in next call).
-
Record contracts with the recorder (creates basic empty flows)
-
List recorded contracts:
uv run contractest/common/flow_helper.py
-
Inspect a contract to see request/response:
uv run contractest/common/flow_helper.py inspect <contract_hash>
-
Edit
contracts/flow.yamlmanually to define data flows:- path: /auth/login method: POST contract_hash: "a1b2c3d4e5f6g7h8" store: - key: "auth_token" parameter_position: "body" parameter_name: "access_token" use: [] - path: /api/users/me method: GET contract_hash: "b2c3d4e5f6g7h8i9" store: [] use: - key: "auth_token" parameter_position: "header" parameter_name: "Authorization"
-
store — Extract values from response to use in later calls
key— Variable name to save asparameter_position— Where to find it (body,header,cookie)parameter_name— JSON path or field name
-
use — Inject stored values into request
key— Which variable to useparameter_position— Where to put it (body,header,path,query,cookie)parameter_name— Target location (field name, header name, or{placeholder}for path)
# Step 1: Login and extract token
- path: /auth/login
method: POST
contract_hash: "hash1"
store:
- key: "access_token"
parameter_position: "body"
parameter_name: "token"
use: []
# Step 2: Get user (using token)
- path: /users/me
method: GET
contract_hash: "hash2"
store:
- key: "user_id"
parameter_position: "body"
parameter_name: "id"
use:
- key: "access_token"
parameter_position: "header"
parameter_name: "Authorization"
# Step 3: Update user (using token and user_id)
- path: /users/{user_id}
method: PATCH
contract_hash: "hash3"
store: []
use:
- key: "access_token"
parameter_position: "header"
parameter_name: "Authorization"
- key: "user_id"
parameter_position: "path"
parameter_name: "{user_id}"The validator tests that a service conforms to previously recorded contracts. Use this to ensure API compatibility hasn't broken.
-
Start the service under test (Service B) on the configured URL in
conf.tomlasservice_under_test_url -
Run the validator:
uv run -m contractest.validator
The validator will test all recorded contracts against the live service and report any discrepancies.
Use the mock server to test your frontend against recorded API contracts without needing the real backend running. Perfect for Playwright or other browser automation tests.
uv run -m contractest.mock_serverThe mock server will:
- Load recorded contracts from
./contracts(configurable inconf.toml) - Start on
localhost:8080(configurable ashostandport) - Serve responses matching recorded API behavior
Example output:
Mock server started on localhost:8080
Serving 5 contracts
Available endpoints:
GET /api/users
POST /api/users
GET /api/orders
DELETE /api/orders/123
PATCH /api/orders/123
Configure your frontend tests to point to the mock server:
// Playwright example
const BASE_URL = 'http://localhost:8080';
test('should fetch users', async ({ page }) => {
await page.goto(`${BASE_URL}/users`);
// ... your test assertions
});The mock server will serve the exact responses that were recorded from the real backend, allowing your frontend to test against realistic data.
APIs often contain fields that change over time or are unique per request (timestamps, IDs, tokens, etc.). Contractest supports ignoring these fields in both request and response validation.
Edit conf.toml to ignore dynamic fields:
# Ignore fields in REQUEST bodies (checked only for structure/type)
[request_body_comparison.ignore_fields_by_path]
"/delivery" = ["delivery_date", "order_id"]
"/users" = ["id", "created_at"]
# Ignore fields in RESPONSE bodies (checked with full validation)
[response_body_comparison.ignore_fields]
# Globally ignored fields in all responses
# ignore_fields = ["timestamp", "nonce", "correlation_id"]
[response_body_comparison.ignore_fields_by_path]
"/login" = ["session_token", "auth_token"]
"/orders" = ["order_id"]Request fields (typically ignored by structure only):
delivery_date,start_date- dates that must be currentorder_id,user_id- IDs that vary per requesttimestamp,request_time- request timestamps
Response fields (typically ignored globally):
id,uuid- generated identifierscreated_at,updated_at- automatic timestampssession_token,auth_token- authentication tokensnonce,correlation_id- one-time valuesexpires_at- expiration times
Control how deeply the validator checks responses:
[response_body_comparison]
strict_match = false # Allow flexible matching
value_match = true # Check field values match
structure_match = true # Check fields exist (types/names)
array_order_match = true # Arrays must be in same order
array_length_match = false # Allow different array lengthsInstead of ignoring request fields, you can generate dynamic values using Python expressions. This is useful when the API requires current dates, unique IDs, or other dynamic values.
Available functions and modules:
datetime-datetime.date.today(),datetime.datetime.now(), etc.uuid-uuid.uuid4(),uuid.uuid1(), etc.random-random.randint(),random.choice(), etc.str,int,float- type conversion functions
Configuration:
[request_field_generators]
# Format: "/path" = { field_name = "python_expression" }
"/delivery" = { delivery_date = "datetime.date.today().isoformat()" }
"/orders" = { order_id = "str(uuid.uuid4())", item_count = "random.randint(1, 10)" }
"/users" = { created_at = "datetime.datetime.now().isoformat()" }Examples:
[request_field_generators]
# Current date (e.g., "2024-01-15")
"/api/delivery" = { delivery_date = "datetime.date.today().isoformat()" }
# Unique ID (e.g., "550e8400-e29b-41d4-a716-446655440000")
"/api/orders" = { order_id = "str(uuid.uuid4())" }
# Random value (e.g., 5)
"/api/items" = { quantity = "random.randint(1, 10)" }
# ISO format timestamp (e.g., "2024-01-15T10:30:45.123456")
"/api/events" = { timestamp = "datetime.datetime.now().isoformat()" }When validation runs, these expressions are evaluated fresh for each test, so you always get current/unique values.