RUST Contents

Contract Tests Basics

Use contract tests to protect service boundaries: validate request/response shapes, prevent breaking API changes, and keep integrations stable in production.

On this page

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.