← Concepts & practices
Technique Testing, debugging, and measurement

Contract and integration testing

How do you know two components still agree?

A consumer can pass every unit test and still fail when a provider renames a field or changes its type. This procedure turns a shared payload into explicit evidence: define what the consumer needs, check the provider, cross the wire, and interpret the first failure before changing code.

The procedure to keep

Reproduce one boundary, name the observable contract, verify both sides against it, and leave with a focused regression case.

TypeScriptGo Prove the agreement before guessing at the fix.
Start with one boundary

A 200 response can still be wrong.

The order page loads order-42 from an order service. The consumer needs a success response with an order ID, a known status, a non-negative integer total in cents, and a currency code. A successful transport status is necessary, but it is not the whole agreement.

We will hold that consumer need fixed while the provider changes one fixture at a time: an aligned payload, a renamed ID field, a string total, a serializer that turns the total into text only on the wire, and a temporary 503. The goal is not to test every endpoint. It is to learn what this check establishes and what it leaves for another test.

Boundary caseGET /orders/order-42

Consumer and provider can deploy independently.

Required body
orderId, status, totalCents, and currency.
Allowed status
pending, ready, or cancelled.
Unknown fields
Allowed, because the consumer does not rely on them yet.
Evidence
A precise field failure, a passing contract, or a transport failure.
Follow the same order each time

From symptom to evidence.

Each step leaves an artifact for the next one. If the expected observation does not appear, stop and revise the question instead of widening the fix.

  1. 01
    Reproduce

    Fix one request and one response.

    Use order-42 and the smallest provider fixture that shows the problem. Keep the consumer expectation visible.

    Leave with A repeatable boundary case and the behavior to preserve.
  2. 02
    Specify

    Write the consumer contract.

    List required fields, allowed values, units, and whether extra fields are tolerated. Keep this close to the consumer’s real use.

    Leave with A fixture and assertions that could fail for a plausible drift.
  3. 03
    Verify provider

    Run the provider against the agreement.

    Feed the provider response to the same contract check. A renamed field should identify body.orderId, not merely say “request failed.”

    Leave with A passing provider check or a field-level mismatch to send to the owner.
  4. 04
    Cross the wire

    Exercise serialization and decoding.

    Encode the response and decode it as the consumer would. This catches drift that exists only on the wire, such as a serializer that writes the total as text, while keeping network timing out of the claim.

    Leave with Evidence that the contract survives the boundary, or a smaller integration failure.
  5. 05
    Interpret and replay

    Classify the first failure.

    A 503 is availability evidence; a 200 with a string total is payload drift. Turn the mismatch into a regression fixture and change one owner at a time.

    Leave with A checked behavior, a named uncertainty, and the next investigation if needed.
Hold the contract fixed

Run the boundary, then read what it proved.

The lab runs the displayed TypeScript implementation. Select a provider fixture and a check scope, then inspect the output and the observed wire. The result is behavior evidence from this bounded case, not a claim about a real network or every provider endpoint.

Evidence lab

Can the consumer still trust the response?

Keep the consumer contract fixed. Change the provider fixture or the boundary checked.

Provider fixture
Check scope
Current fixtureRenamed order ID

The provider sends id instead of orderId.

Choose a provider fixture and check scope, then run the check.

When the expected failure does not appearCheck the instrument before the fix

If a renamed field passes, the contract may not be asserting the field the consumer uses, or the fixture may not be the response that crossed the boundary. Inspect the exact payload and the assertion path. If the contract passes but the page still fails, move to consumer mapping, rendering, or a different integration condition rather than weakening this check without evidence.

Separate the two comparisons

Hold the contract steady. Change the language.

Read the boundary check in TypeScript and Go.

The examples check the same order response. The browser lab runs TypeScript; the panes show how each language preserves the field-level contract.

Define the fixture and assertions

TypeScriptContract inputs
orders.ts · fixture
export function providerResponse(version: ProviderVersion): ProviderResponse {
	switch (version) {
		case 'aligned':
			return { status: 200, body: { ...consumerContract } };
		case 'renamed-field':
			return {
				status: 200,
				body: { id: consumerContract.orderId, status: 'ready', totalCents: 1299, currency: 'USD' }
			};
		case 'wrong-type':
			return {
				status: 200,
				body: { orderId: 'order-42', status: 'ready', totalCents: '1299', currency: 'USD' }
			};
		case 'serializer-drift':
			// In memory the total is still a number; the provider's serializer writes it as text.
			return {
				status: 200,
				body: {
					...consumerContract,
					toJSON: () => ({ ...consumerContract, totalCents: String(consumerContract.totalCents) })
				}
			};
		case 'server-error':
			return { status: 503, body: { error: 'temporarily unavailable' } };
	}
}
GoContract inputs
orders.go · fixture
func ProviderResponseFor(version ProviderVersion) ProviderResponse {
	switch version {
	case Aligned:
		return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
	case RenamedField:
		return ProviderResponse{200, map[string]any{"id": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
	case WrongType:
		return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": "1299", "currency": "USD"}}
	case SerializerDrift:
		// In memory the total is still an int; the ",string" option writes it as text.
		return ProviderResponse{200, providerOrder{OrderID: "order-42", Status: "ready", TotalCents: 1299, Currency: "USD"}}
	case ServerError:
		return ProviderResponse{503, map[string]any{"error": "temporarily unavailable"}}
	default:
		return ProviderResponse{500, map[string]any{"error": "unknown fixture"}}
	}
}

// providerOrder is the provider's own response model.
type providerOrder struct {
	OrderID    string `json:"orderId"`
	Status     string `json:"status"`
	TotalCents int    `json:"totalCents,string"`
	Currency   string `json:"currency"`
}

The contract says what the consumer relies on and allows extra fields it does not yet use.

Verify provider and wire behavior

TypeScriptContract checks
orders.ts · verification
export function validateOrderResponse(response: ProviderResponse): string[] {
	const failures: string[] = [];
	if (response.status !== 200) {
		failures.push(`status: expected 200, received ${response.status}`);
		return failures;
	}
	if (!isRecord(response.body)) return ['body: expected an object'];
	if (typeof response.body.orderId !== 'string') failures.push('body.orderId: expected a string');
	if (!['pending', 'ready', 'cancelled'].includes(String(response.body.status))) {
		failures.push('body.status: expected pending, ready, or cancelled');
	}
	if (
		typeof response.body.totalCents !== 'number' ||
		!Number.isInteger(response.body.totalCents) ||
		response.body.totalCents < 0
	) {
		failures.push('body.totalCents: expected a non-negative integer');
	}
	if (typeof response.body.currency !== 'string') failures.push('body.currency: expected a string');
	return failures;
}
// Checks the provider's response object as the provider built it, before it is serialized.
export function runConsumerContract(version: ProviderVersion): ContractRun {
	const response = providerResponse(version);
	const wire = JSON.stringify(response);
	const failures = validateOrderResponse(response);
	return { ok: failures.length === 0, failures, response, wire };
}

// Checks what the consumer decodes after the response crosses the JSON boundary.
export function runIntegrationCheck(version: ProviderVersion): ContractRun {
	const response = providerResponse(version);
	const wire = JSON.stringify(response);
	const received = JSON.parse(wire) as ProviderResponse;
	const failures = validateOrderResponse(received);
	return { ok: failures.length === 0, failures, response: received, wire };
}

export function runCheck(scope: CheckScope, version: ProviderVersion): ContractRun {
	return scope === 'consumer' ? runConsumerContract(version) : runIntegrationCheck(version);
}
GoContract checks
orders.go · verification
// VerifyOrderContract checks the provider's response object as the provider built it.
func VerifyOrderContract(provider ProviderResponse) ContractResult {
	result := ContractResult{OK: true, Response: provider}
	fail := func(message string) {
		result.OK = false
		result.Failures = append(result.Failures, message)
	}
	if provider.Status != 200 {
		fail(fmt.Sprintf("status: expected 200, received %d", provider.Status))
		return result
	}
	body, ok := fieldsOf(provider.Body)
	if !ok {
		fail("body: expected an object")
		return result
	}
	if _, ok := body["orderId"].(string); !ok {
		fail("body.orderId: expected a string")
	}
	status, ok := body["status"].(string)
	if !ok || (status != "pending" && status != "ready" && status != "cancelled") {
		fail("body.status: expected pending, ready, or cancelled")
	}
	if !nonNegativeInteger(body["totalCents"]) {
		fail("body.totalCents: expected a non-negative integer")
	}
	if _, ok := body["currency"].(string); !ok {
		fail("body.currency: expected a string")
	}
	return result
}

// RunIntegrationCheck checks what the consumer decodes after the response crosses the JSON boundary.
func RunIntegrationCheck(version ProviderVersion) ContractResult {
	provider := ProviderResponseFor(version)
	wire, err := json.Marshal(provider)
	if err != nil {
		return ContractResult{OK: false, Failures: []string{"transport: could not encode response"}, Response: provider}
	}
	var received ProviderResponse
	if err := json.Unmarshal(wire, &received); err != nil {
		return ContractResult{OK: false, Failures: []string{"transport: could not decode response"}, Response: provider, Wire: wire}
	}
	result := VerifyOrderContract(received)
	result.Wire = wire
	return result
}

func fieldsOf(body any) (map[string]any, bool) {
	switch value := body.(type) {
	case map[string]any:
		return value, true
	case providerOrder:
		return map[string]any{"orderId": value.OrderID, "status": value.Status, "totalCents": value.TotalCents, "currency": value.Currency}, true
	default:
		return nil, false
	}
}

func nonNegativeInteger(value any) bool {
	switch number := value.(type) {
	case int:
		return number >= 0
	case float64: // what encoding/json decodes a JSON number into
		return number >= 0 && number == float64(int(number))
	default:
		return false
	}
}

Provider verification and a local round trip answer related but distinct questions: payload agreement and serialization-path agreement. The serializer fixture passes the first and fails the second.

See the consumer call siteOnly use a response after checking it
TypeScriptChecked consumer call
orders.ts · caller
export function loadOrder(version: ProviderVersion, scope: CheckScope = 'integration') {
	const result = runCheck(scope, version);
	if (!result.ok)
		return { ok: false as const, error: result.failures[0], failures: result.failures };
	return { ok: true as const, order: result.response.body as OrderSummary };
}
GoChecked consumer call
orders.go · caller
func LoadOrder(version ProviderVersion) (OrderSummary, error) {
	result := RunIntegrationCheck(version)
	if !result.OK {
		return OrderSummary{}, fmt.Errorf("contract failed: %s", result.Failures[0])
	}
	var decoded struct {
		Body OrderSummary `json:"body"`
	}
	if err := json.Unmarshal(result.Wire, &decoded); err != nil {
		return OrderSummary{}, err
	}
	return decoded.Body, nil
}

The caller receives a typed order only after the boundary check passes.

Copy the complete examplesStandard library only

These files are complete and copyable, and use only the standard library.

TypeScriptComplete contract example
orders.ts
export type OrderStatus = 'pending' | 'ready' | 'cancelled';
export type ProviderVersion =
	'aligned' | 'renamed-field' | 'wrong-type' | 'serializer-drift' | 'server-error';
export type CheckScope = 'consumer' | 'integration';

export type OrderSummary = {
	orderId: string;
	status: OrderStatus;
	totalCents: number;
	currency: string;
};

export type ProviderResponse = { status: number; body: unknown };

export type ContractRun = {
	ok: boolean;
	failures: string[];
	response: ProviderResponse;
	wire: string;
};

export const consumerContract: OrderSummary = {
	orderId: 'order-42',
	status: 'ready',
	totalCents: 1299,
	currency: 'USD'
};

function isRecord(value: unknown): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null && !Array.isArray(value);
}

export function providerResponse(version: ProviderVersion): ProviderResponse {
	switch (version) {
		case 'aligned':
			return { status: 200, body: { ...consumerContract } };
		case 'renamed-field':
			return {
				status: 200,
				body: { id: consumerContract.orderId, status: 'ready', totalCents: 1299, currency: 'USD' }
			};
		case 'wrong-type':
			return {
				status: 200,
				body: { orderId: 'order-42', status: 'ready', totalCents: '1299', currency: 'USD' }
			};
		case 'serializer-drift':
			// In memory the total is still a number; the provider's serializer writes it as text.
			return {
				status: 200,
				body: {
					...consumerContract,
					toJSON: () => ({ ...consumerContract, totalCents: String(consumerContract.totalCents) })
				}
			};
		case 'server-error':
			return { status: 503, body: { error: 'temporarily unavailable' } };
	}
}


export function validateOrderResponse(response: ProviderResponse): string[] {
	const failures: string[] = [];
	if (response.status !== 200) {
		failures.push(`status: expected 200, received ${response.status}`);
		return failures;
	}
	if (!isRecord(response.body)) return ['body: expected an object'];
	if (typeof response.body.orderId !== 'string') failures.push('body.orderId: expected a string');
	if (!['pending', 'ready', 'cancelled'].includes(String(response.body.status))) {
		failures.push('body.status: expected pending, ready, or cancelled');
	}
	if (
		typeof response.body.totalCents !== 'number' ||
		!Number.isInteger(response.body.totalCents) ||
		response.body.totalCents < 0
	) {
		failures.push('body.totalCents: expected a non-negative integer');
	}
	if (typeof response.body.currency !== 'string') failures.push('body.currency: expected a string');
	return failures;
}
// Checks the provider's response object as the provider built it, before it is serialized.
export function runConsumerContract(version: ProviderVersion): ContractRun {
	const response = providerResponse(version);
	const wire = JSON.stringify(response);
	const failures = validateOrderResponse(response);
	return { ok: failures.length === 0, failures, response, wire };
}

// Checks what the consumer decodes after the response crosses the JSON boundary.
export function runIntegrationCheck(version: ProviderVersion): ContractRun {
	const response = providerResponse(version);
	const wire = JSON.stringify(response);
	const received = JSON.parse(wire) as ProviderResponse;
	const failures = validateOrderResponse(received);
	return { ok: failures.length === 0, failures, response: received, wire };
}

export function runCheck(scope: CheckScope, version: ProviderVersion): ContractRun {
	return scope === 'consumer' ? runConsumerContract(version) : runIntegrationCheck(version);
}

export function loadOrder(version: ProviderVersion, scope: CheckScope = 'integration') {
	const result = runCheck(scope, version);
	if (!result.ok)
		return { ok: false as const, error: result.failures[0], failures: result.failures };
	return { ok: true as const, order: result.response.body as OrderSummary };
}

export function example() {
	return loadOrder('aligned');
}

console.log(example());
GoComplete contract example
orders.go
package main

import (
	"encoding/json"
	"fmt"
)

type ProviderVersion string

const (
	Aligned         ProviderVersion = "aligned"
	RenamedField    ProviderVersion = "renamed-field"
	WrongType       ProviderVersion = "wrong-type"
	SerializerDrift ProviderVersion = "serializer-drift"
	ServerError     ProviderVersion = "server-error"
)

type OrderSummary struct {
	OrderID    string `json:"orderId"`
	Status     string `json:"status"`
	TotalCents int    `json:"totalCents"`
	Currency   string `json:"currency"`
}

// ProviderResponse is the response as the provider built it, before serialization.
type ProviderResponse struct {
	Status int `json:"status"`
	Body   any `json:"body"`
}

type ContractResult struct {
	OK       bool
	Failures []string
	Response ProviderResponse
	Wire     []byte
}

func ProviderResponseFor(version ProviderVersion) ProviderResponse {
	switch version {
	case Aligned:
		return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
	case RenamedField:
		return ProviderResponse{200, map[string]any{"id": "order-42", "status": "ready", "totalCents": 1299, "currency": "USD"}}
	case WrongType:
		return ProviderResponse{200, map[string]any{"orderId": "order-42", "status": "ready", "totalCents": "1299", "currency": "USD"}}
	case SerializerDrift:
		// In memory the total is still an int; the ",string" option writes it as text.
		return ProviderResponse{200, providerOrder{OrderID: "order-42", Status: "ready", TotalCents: 1299, Currency: "USD"}}
	case ServerError:
		return ProviderResponse{503, map[string]any{"error": "temporarily unavailable"}}
	default:
		return ProviderResponse{500, map[string]any{"error": "unknown fixture"}}
	}
}

// providerOrder is the provider's own response model.
type providerOrder struct {
	OrderID    string `json:"orderId"`
	Status     string `json:"status"`
	TotalCents int    `json:"totalCents,string"`
	Currency   string `json:"currency"`
}


// VerifyOrderContract checks the provider's response object as the provider built it.
func VerifyOrderContract(provider ProviderResponse) ContractResult {
	result := ContractResult{OK: true, Response: provider}
	fail := func(message string) {
		result.OK = false
		result.Failures = append(result.Failures, message)
	}
	if provider.Status != 200 {
		fail(fmt.Sprintf("status: expected 200, received %d", provider.Status))
		return result
	}
	body, ok := fieldsOf(provider.Body)
	if !ok {
		fail("body: expected an object")
		return result
	}
	if _, ok := body["orderId"].(string); !ok {
		fail("body.orderId: expected a string")
	}
	status, ok := body["status"].(string)
	if !ok || (status != "pending" && status != "ready" && status != "cancelled") {
		fail("body.status: expected pending, ready, or cancelled")
	}
	if !nonNegativeInteger(body["totalCents"]) {
		fail("body.totalCents: expected a non-negative integer")
	}
	if _, ok := body["currency"].(string); !ok {
		fail("body.currency: expected a string")
	}
	return result
}

// RunIntegrationCheck checks what the consumer decodes after the response crosses the JSON boundary.
func RunIntegrationCheck(version ProviderVersion) ContractResult {
	provider := ProviderResponseFor(version)
	wire, err := json.Marshal(provider)
	if err != nil {
		return ContractResult{OK: false, Failures: []string{"transport: could not encode response"}, Response: provider}
	}
	var received ProviderResponse
	if err := json.Unmarshal(wire, &received); err != nil {
		return ContractResult{OK: false, Failures: []string{"transport: could not decode response"}, Response: provider, Wire: wire}
	}
	result := VerifyOrderContract(received)
	result.Wire = wire
	return result
}

func fieldsOf(body any) (map[string]any, bool) {
	switch value := body.(type) {
	case map[string]any:
		return value, true
	case providerOrder:
		return map[string]any{"orderId": value.OrderID, "status": value.Status, "totalCents": value.TotalCents, "currency": value.Currency}, true
	default:
		return nil, false
	}
}

func nonNegativeInteger(value any) bool {
	switch number := value.(type) {
	case int:
		return number >= 0
	case float64: // what encoding/json decodes a JSON number into
		return number >= 0 && number == float64(int(number))
	default:
		return false
	}
}


func LoadOrder(version ProviderVersion) (OrderSummary, error) {
	result := RunIntegrationCheck(version)
	if !result.OK {
		return OrderSummary{}, fmt.Errorf("contract failed: %s", result.Failures[0])
	}
	var decoded struct {
		Body OrderSummary `json:"body"`
	}
	if err := json.Unmarshal(result.Wire, &decoded); err != nil {
		return OrderSummary{}, err
	}
	return decoded.Body, nil
}


func main() {
	order, err := LoadOrder(Aligned)
	if err != nil {
		panic(err)
	}
	fmt.Printf("%s: %d cents\n", order.OrderID, order.TotalCents)
}

TypeScriptnode --experimental-strip-types orders.ts

Gogo run orders.go

Know what the evidence leaves open

A contract is a boundary check, not a whole system test.

This procedure establishes that the chosen consumer fields and meanings match the chosen provider fixture and, in the integration mode, survive local JSON encoding and decoding. It does not establish network availability over time, authentication, retry behavior, database correctness, latency, or every business rule.

Keep those questions separate. Add a failure-path test for a retry contract, an end-to-end test for wiring and authentication, or a domain test for totals. A contract failure should make the next evidence smaller, not invite a broad rewrite.

Build UIs?See where this shows up in your components.

The frontend can own the consumer side.

A fetch wrapper can decode the response once and expose a checked order to components. A page test can then verify loading, empty, and error rendering separately. The component should not silently rename or coerce fields just to make a drifting endpoint look compatible.

Practice the next move

Respond to evidence, not just symptoms.

Use the field-level failure as your clue. Choose the next piece of evidence that can distinguish provider drift from a consumer mistake.

Next-evidence practice

The contract check fails: body.totalCents is a string, not an integer.

The provider team says the endpoint is returning 200. What is the most useful next move?

Leave an evidence note

Make the next replay cheaper.

A passing contract check is useful only when someone can tell what it covered. Leave a note that says why the check exists, what it checks, what it cannot see, what happens when it fails, and when to revisit it.

Why
The order page and the order service deploy independently and share one payload, so each side’s unit tests can pass while the pair disagrees.
What
A consumer contract for GET /orders/order-42, checked against the provider fixture and again after a JSON round trip, with a field path in every failure.
Constraint
It covers only the fields the consumer reads and the local wire path. Availability, auth, retries, latency, and persistence need tests of their own.
Fallback
A field-level failure goes to the side that owns it as a regression fixture. A 503 is availability, not drift. A needed rename gets a compatibility window.
Reconsider when
The consumer starts reading a new field, the provider changes its serializer or units, or a second consumer comes to depend on the same response.

A procedure note to adapt to your own boundary. Nothing here is saved to an account.

Explore more concepts & practices →