Contract Tests Basics
Contract Tests: Guardrails for Integrations
Unit tests protect internal logic. Integration tests validate wiring. Contract tests protect something different: the agreement between components. In production, many incidents come from silent API drift: one service changes a field name or behavior and another service breaks at runtime.
Contract tests make those agreements explicit and testable.
What Is a Contract?
A contract is a stable definition of how two systems interact. Common contracts:
- HTTP request and response schemas (JSON shape, required fields)
- Status codes and error formats
- Message schemas in queues
- Event payload fields
Production rule: treat external contracts like public APIs. They evolve slowly and intentionally.
Provider vs Consumer Perspective
There are two sides:
- Consumer: expects a certain shape/behavior from the provider
- Provider: must continue to satisfy consumer expectations
Contract testing aims to ensure changes in the provider do not break consumers.
A Minimal Contract Test Approach (No Extra Framework)
At a minimal level, you can write a test that asserts the JSON schema or key fields.
Example response shape:
{
"id": 1,
"email": "a@example.com",
"created_at": "2026-01-01T10:00:00Z"
}
Rust test that validates required fields exist:
use serde_json::Value;
fn assert_user_contract(v: &Value) {
assert!(v.get("id").is_some());
assert!(v.get("email").is_some());
assert!(v.get("created_at").is_some());
}
#[test]
fn user_response_contract() {
let body = r#"{"id":1,"email":"a@example.com","created_at":"2026-01-01T10:00:00Z"}"#;
let v: Value = serde_json::from_str(body).unwrap();
assert_user_contract(&v);
}
Production note: this catches accidental field removal or renaming.
Typed Contract Tests With Serde
A stronger approach is to define a struct that represents the contract and deserialize into it. If the provider breaks the schema, deserialization fails.
use serde::Deserialize;
#[derive(Deserialize)]
struct UserResponse {
id: u64,
email: String,
created_at: String,
}
#[test]
fn typed_contract() {
let body = r#"{"id":1,"email":"a@example.com","created_at":"2026-01-01T10:00:00Z"}"#;
let parsed: UserResponse = serde_json::from_str(body).unwrap();
assert_eq!(parsed.id, 1);
}
Production rule: contract structs should match the externally visible API, not internal domain models.
Error Contract: Keep It Stable
Most integration failures happen in error handling. Define a stable error format and contract-test it.
use serde::Deserialize;
#[derive(Deserialize)]
struct ErrorResponse {
code: String,
message: String,
}
#[test]
fn error_contract() {
let body = r#"{"code":"VALIDATION","message":"invalid email"}"#;
let e: ErrorResponse = serde_json::from_str(body).unwrap();
assert_eq!(e.code, "VALIDATION");
}
Production benefit: ensures all clients can parse and display errors consistently.
Backward Compatibility Rules (Practical)
To keep integrations stable, adopt simple rules:
- Adding optional fields is usually safe
- Removing fields is breaking
- Renaming fields is breaking unless versioned
- Changing types (string to number) is breaking
- Changing error format is often breaking
Contract tests enforce these rules automatically by failing when the schema changes unexpectedly.
Where to Run Contract Tests
Production-friendly strategy:
- Consumers run tests against recorded provider responses (fixtures)
- Providers run tests to ensure they still satisfy the contract fixtures
This can be as simple as checking JSON samples into the repo under tests/fixtures and validating both sides against them.
Fixture-Based Contract Testing
Store example payloads:
tests/
fixtures/
user_ok.json
user_not_found.json
error_validation.json
Then in tests, load and validate them:
use std::fs;
#[test]
fn fixture_contract_user_ok() {
let body = fs::read_to_string("tests/fixtures/user_ok.json").unwrap();
let _: UserResponse = serde_json::from_str(&body).unwrap();
}
Production note: fixtures become living documentation and prevent accidental drift.
When You Need a Full Contract Framework
As systems grow, teams may adopt dedicated frameworks (consumer-driven contracts, pact-style workflows). But a minimal fixture + serde approach already catches most breaking changes for small-to-medium systems.
Common Production Pitfalls
- Only contract testing success responses, ignoring errors
- Using internal domain structs as external contracts (coupling)
- Not versioning breaking changes
- Allowing optional fields to become required without coordination
Production Checklist
- Define contract structs separate from domain models
- Contract test both success and error shapes
- Use fixtures as stable shared examples
- Enforce backward compatibility rules
- Run contract tests in CI to block breaking changes
Contract tests turn integration stability into something measurable. They are one of the most cost-effective production practices for preventing cross-service incidents.