By Lucas Yoris · Performate
Contract Tests vs Performance Tests: How to Keep Both Without Duplicating Effort
Separate schema correctness from concurrency behavior—share artifacts, not duplicate suites that drift.
Your Pact suite is green, OpenAPI diff is clean, and staging deploys every hour—then checkout p99 doubles under a modest sale because the new serializer allocates twice per request. Contract tests proved the message shape; they never proved parallel behavior on connection pools, caches, or GC.
Contract and performance testing answer different risks. Duplicating entire request catalogs in separate repos guarantees drift within two sprints. In this guide you will learn why schema green is not SLO green, how one k6 script reuses the same payloads your contract suite validates, and which decision rules keep both gates without doubling maintenance.
Why contract green is not performance green
Contract tests (Pact, JSON Schema, protobuf compatibility) prove producers and consumers agree on message shapes across versions. Performance tests prove latency, throughput, and failure behavior when many clients hit those shapes at once (k6 introduction).
- Contracts are point samples: one request/response pair does not exercise pool exhaustion or lock contention.
- Performance needs realistic mix: read/write ratios, auth refresh, and think time change tail latency under the same schema.
- Version splits matter:
/v1and/v2may both pass schema checks while routing sends them through different clusters (API versioning under load). - Duplicate suites drift: two teams maintaining separate Postman folders for "contract" vs "load" diverge on headers, auth, and field defaults within weeks.
Think of contracts as verifying the blueprint; performance tests stress-test the building when every elevator runs at once.
When CI passes both suites but production still hurts
Teams often run contract tests in pull requests and load tests manually before major releases—then wonder why incidents still cluster on checkout. The gap is usually artifact divergence: the load script still posts { "sku": 123 } while Pact expects { "sku": "SKU-123" } string IDs, or the perf run skips the OAuth handshake your consumers perform. Share one OpenAPI spec or Postman collection as source, then derive both gates (Postman to k6).
Practical k6 implementation: one payload, two gates
Load tests should reuse the same JSON bodies and headers your contract suite validates—then add concurrency, percentile thresholds, and tagged routes. The script below posts checkout payloads that match a fictional Pact contract while measuring tail latency under parallel traffic.
Example script (illustrative—not a production-ready test). The snippet below uses fictional URLs, tokens, and SLO numbers. Adapt base URL, auth, payloads, and thresholds to your environment.
What this example demonstrates:
- Shared contract shape: checkout body fields (
sku,qty,currency) mirror what schema tests assert—perf failures are not mystery 400s from drift. - Contract checks + perf thresholds: lightweight
check()on status and required JSON keys; SLO gates onp(95)/p(99)and error rate. - Tagged routes:
route:checkoutseparates perf summaries from future read-heavy scenarios in the same script. - Env-driven base URL: same script runs against contract stub, staging, and perf env without edits.
import http from 'k6/http';
import { check, sleep } from 'k6';
const BASE = __ENV.API_BASE || 'https://staging.example.com';
const TOKEN = __ENV.TOKEN || 'staging-token-replace-me';
// Body shape validated by contract tests (Pact / JSON Schema)
function checkoutPayload(sku) {
return JSON.stringify({
sku: String(sku),
qty: 1,
currency: 'USD',
});
}
export const options = {
scenarios: {
checkout_contract_shape: {
executor: 'constant-arrival-rate',
rate: Number(__ENV.CHECKOUT_RPS || 10),
timeUnit: '1s',
duration: '6m',
preAllocatedVUs: 12,
maxVUs: 50,
tags: { route: 'checkout', gate: 'perf' },
exec: 'checkoutUnderLoad',
},
},
thresholds: {
// Performance gate — not covered by contract CI
'http_req_duration{route:checkout}': ['p(95)<750', 'p(99)<1100'],
http_req_failed: ['rate<0.01'],
// Custom check metric from contract-style assertions
'checks{route:checkout}': ['rate>0.99'],
},
};
export function checkoutUnderLoad() {
const sku = `SKU-${100 + (__VU % 50)}`;
const res = http.post(`${BASE}/checkout`, checkoutPayload(sku), {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
Accept: 'application/json',
},
tags: { route: 'checkout', gate: 'perf' },
});
// Contract-adjacent checks at load — schema presence, not full Pact replay
check(res, {
'status 2xx': (r) => r.status >= 200 && r.status < 300,
'has orderId': (r) => {
try {
const body = r.json();
return typeof body.orderId === 'string' && body.orderId.length > 0;
} catch {
return false;
}
},
'currency echoed': (r) => {
try {
return r.json('currency') === 'USD';
} catch {
return false;
}
},
});
sleep(0.4);
}
Patterns that work
- Generate k6 from OpenAPI or import Postman once; export contract examples from the same folder (parameterization).
- Run contract CI on every PR; run perf smoke at low RPS on merge—full soak stays scheduled (CI/CD load testing).
- Tag
gate:contractvsgate:perfwhen one repo hosts both smoke types for clarity in dashboards. - Pair with versioning tests when multiple API families coexist (version mix scenarios).
Anti-patterns to avoid
- Maintaining two Postman collections that diverge on auth headers and field types.
- Asserting every response field in high-throughput runs—reserve deep schema validation for contract CI.
- Skipping perf because "Pact is green"—schema correctness does not predict tail latency.
- Load-testing mock servers that return instant 200s with no backend—hide real pool behavior.
Pro tip (example command): the line below is an example of how to fail CI on perf thresholds while still printing check rates—useful when contract and perf share one nightly job.
k6 run checkout-perf.js --summary-export=summary.json --thresholds-on-summary
What this command demonstrates: exported JSON captures both http_req_duration percentiles and checks pass rates—release reviewers see schema-adjacent failures and SLO breaches in one artifact.
Decision framework: contract-only vs perf-only vs both
| Situation | Recommended action |
|---|---|
| New endpoint, schema still changing | Contract tests in PR CI; defer perf until shape stabilizes |
| Schema frozen, traffic unknown | Contract green + low-RPS perf smoke with percentile thresholds |
| High-risk checkout/payment paths | Both gates required; shared payload fixtures from OpenAPI examples |
| Internal service with no external consumers | JSON Schema or protobuf compat in CI; perf via tagged k6 scenarios |
| Deprecation window with two API versions | Contract per version + version-mix perf (versioning guide) |
Use contract tests alone if the integration is early-stage and breaking changes are expected daily—perf numbers would churn meaningless.
Use performance tests alone if schema is enforced elsewhere (strong typing, codegen) and your risk is concurrency—not field renames.
Use both with shared artifacts if external consumers exist and the path carries revenue or SLA penalties—most product APIs land here.
Observability, documentation, and next steps
Dual gates only help when teams know which failure mode they are fixing. Before you scale traffic:
- Document which OpenAPI examples or Pact fixtures map to each k6 scenario payload.
- Separate dashboards for contract CI (PR) vs perf smoke (merge/nightly)—different cadence, different thresholds.
- Alert on perf
p(99)divergence even when contract suites pass on the same commit. - Correlate tagged k6 routes with APM during perf failures (correlation IDs).
- Archive contract publish version + k6 script hash per release so regressions trace to schema vs infra.
How Performate keeps contract and perf aligned
Forking collections for "QA contracts" and "perf scripts" is how drift starts. Below is a concrete workflow example for the checkout API this article uses.
Example: one collection, two gates
- Import the same Postman collection (or OpenAPI) your contract team already maintains—folder
checkoutwith example bodies. Problem solved: one source of truth; perf and Pact pull identical field shapes. - Create a low-RPS perf scenario in the visual editor—e.g. 10 req/s constant arrival for six minutes—with the checkout request. Problem solved: perf smoke without rewriting HTTP from scratch.
- Add checks in the scenario panel for
orderIdpresence and 2xx status—mirror contract assertions lightly. Problem solved: load runs catch gross schema drift before Pact nightly jobs. - Set p95/p99 thresholds on the checkout scenario; keep contract deep validation in existing CI. Problem solved: clear separation—shape vs concurrency—without duplicate repos.
- Export k6 for merge pipeline at reduced rate; full soak stays on calendar schedule. Problem solved: GitOps perf gate matches what engineers tuned locally.
- Compare runs across releases in the integrated report when contract version bumps—watch latency, not just green Pact badges. Problem solved: finance and backend debate one export when schema and perf move together.
That workflow maps to the cta: faster performance confidence without maintaining parallel script forests.
Closing takeaway
Contract tests protect integration correctness; performance tests protect customer experience under parallel load. Share artifacts, split gates, and never treat a green Pact publish as proof your checkout survives a sale.
Import one collection, add percentile thresholds to the same payloads your schema suite validates, and schedule a low-RPS smoke on every merge—then note which failures are shape problems vs concurrency problems.
Ready to optimize your API performance?
Explore how Performate simplifies k6 load testing—from imports to results—so your team ships performance confidence faster.