{"id":2772,"date":"2026-06-08T04:28:01","date_gmt":"2026-06-08T04:28:01","guid":{"rendered":"https:\/\/tucumandevelopers.com\/index.php\/2026\/06\/08\/contract-testing-catch-breaking-api-changes-before-your-consumers-do\/"},"modified":"2026-06-08T04:28:01","modified_gmt":"2026-06-08T04:28:01","slug":"contract-testing-catch-breaking-api-changes-before-your-consumers-do","status":"publish","type":"post","link":"https:\/\/tucumandevelopers.com\/index.php\/2026\/06\/08\/contract-testing-catch-breaking-api-changes-before-your-consumers-do\/","title":{"rendered":"Contract Testing: Catch Breaking API Changes Before Your Consumers Do"},"content":{"rendered":"<div>\n<div>\n<div data-article-id=\"3845072\" id=\"article-body\">\n<p>Ever shipped a backend change that passed every test, only to wake up to a frontend on fire? The API still returned <code>200 OK<\/code> \u2014 it just renamed <code>user_name<\/code> to <code>username<\/code>, and three consumers broke silently. Unit tests didn&#8217;t catch it. Integration tests didn&#8217;t catch it. This is exactly the gap <strong>contract testing<\/strong> fills.<\/p>\n<h2> <a name=\"what-a-contract-actually-is\" href=\"#what-a-contract-actually-is\"> <\/a> What a contract actually is <\/h2>\n<p>A contract is a machine-readable agreement about the shape of requests and responses between a <strong>consumer<\/strong> (the client) and a <strong>provider<\/strong> (the API). Instead of testing the provider in isolation and <em>hoping<\/em> clients agree, the consumer declares what it needs, and the provider proves it still delivers.<\/p>\n<p>In <strong>consumer-driven<\/strong> contract testing, the consumer owns the contract. The provider&#8217;s job is to never break it without a conversation.<\/p>\n<h2> <a name=\"a-minimal-contract-test\" href=\"#a-minimal-contract-test\"> <\/a> A minimal contract test <\/h2>\n<p>You don&#8217;t need a heavyweight framework to start. Here&#8217;s a contract expressed as a plain JSON schema that both sides can share. <\/p>\n<div>\n<pre><code><span>{<\/span><span> <\/span><span>\"request\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"method\"<\/span><span>:<\/span><span> <\/span><span>\"GET\"<\/span><span>,<\/span><span> <\/span><span>\"path\"<\/span><span>:<\/span><span> <\/span><span>\"\/users\/42\"<\/span><span> <\/span><span>},<\/span><span> <\/span><span>\"response\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"status\"<\/span><span>:<\/span><span> <\/span><span>200<\/span><span>,<\/span><span> <\/span><span>\"body\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"type\"<\/span><span>:<\/span><span> <\/span><span>\"object\"<\/span><span>,<\/span><span> <\/span><span>\"required\"<\/span><span>:<\/span><span> <\/span><span>[<\/span><span>\"id\"<\/span><span>,<\/span><span> <\/span><span>\"username\"<\/span><span>,<\/span><span> <\/span><span>\"email\"<\/span><span>],<\/span><span> <\/span><span>\"properties\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"id\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"type\"<\/span><span>:<\/span><span> <\/span><span>\"integer\"<\/span><span> <\/span><span>},<\/span><span> <\/span><span>\"username\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"type\"<\/span><span>:<\/span><span> <\/span><span>\"string\"<\/span><span> <\/span><span>},<\/span><span> <\/span><span>\"email\"<\/span><span>:<\/span><span> <\/span><span>{<\/span><span> <\/span><span>\"type\"<\/span><span>:<\/span><span> <\/span><span>\"string\"<\/span><span>,<\/span><span> <\/span><span>\"format\"<\/span><span>:<\/span><span> <\/span><span>\"email\"<\/span><span> <\/span><span>}<\/span><span> <\/span><span>}<\/span><span> <\/span><span>}<\/span><span> <\/span><span>}<\/span><span> <\/span><span>}<\/span><span> <\/span><\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>The consumer verifies it can rely on these fields. The provider verifies its real output still satisfies them.<\/p>\n<h2> <a name=\"verifying-on-the-provider-side\" href=\"#verifying-on-the-provider-side\"> <\/a> Verifying on the provider side <\/h2>\n<p>Run the contract against the live provider response in CI using <a href=\"https:\/\/ajv.js.org\/\" target=\"_blank\" rel=\"noopener noreferrer\">Ajv<\/a>, a fast JSON Schema validator: <\/p>\n<div>\n<pre><code><span>import<\/span> <span>Ajv<\/span> <span>from<\/span> <span>\"<\/span><span>ajv<\/span><span>\"<\/span><span>;<\/span> <span>import<\/span> <span>addFormats<\/span> <span>from<\/span> <span>\"<\/span><span>ajv-formats<\/span><span>\"<\/span><span>;<\/span> <span>import<\/span> <span>contract<\/span> <span>from<\/span> <span>\"<\/span><span>.\/contracts\/get-user.json<\/span><span>\"<\/span> <span>assert<\/span> <span>{<\/span> <span>type<\/span><span>:<\/span> <span>\"<\/span><span>json<\/span><span>\"<\/span> <span>};<\/span> <span>const<\/span> <span>ajv<\/span> <span>=<\/span> <span>addFormats<\/span><span>(<\/span><span>new<\/span> <span>Ajv<\/span><span>({<\/span> <span>allErrors<\/span><span>:<\/span> <span>true<\/span> <span>}));<\/span> <span>const<\/span> <span>validate<\/span> <span>=<\/span> <span>ajv<\/span><span>.<\/span><span>compile<\/span><span>(<\/span><span>contract<\/span><span>.<\/span><span>response<\/span><span>.<\/span><span>body<\/span><span>);<\/span> <span>test<\/span><span>(<\/span><span>\"<\/span><span>GET \/users\/:id honors the consumer contract<\/span><span>\"<\/span><span>,<\/span> <span>async <\/span><span>()<\/span> <span>=&gt;<\/span> <span>{<\/span> <span>const<\/span> <span>res<\/span> <span>=<\/span> <span>await<\/span> <span>fetch<\/span><span>(<\/span><span>\"<\/span><span>http:\/\/localhost:3000\/users\/42<\/span><span>\"<\/span><span>);<\/span> <span>expect<\/span><span>(<\/span><span>res<\/span><span>.<\/span><span>status<\/span><span>).<\/span><span>toBe<\/span><span>(<\/span><span>contract<\/span><span>.<\/span><span>response<\/span><span>.<\/span><span>status<\/span><span>);<\/span> <span>const<\/span> <span>body<\/span> <span>=<\/span> <span>await<\/span> <span>res<\/span><span>.<\/span><span>json<\/span><span>();<\/span> <span>const<\/span> <span>ok<\/span> <span>=<\/span> <span>validate<\/span><span>(<\/span><span>body<\/span><span>);<\/span> <span>if <\/span><span>(<\/span><span>!<\/span><span>ok<\/span><span>)<\/span> <span>console<\/span><span>.<\/span><span>error<\/span><span>(<\/span><span>validate<\/span><span>.<\/span><span>errors<\/span><span>);<\/span> <span>expect<\/span><span>(<\/span><span>ok<\/span><span>).<\/span><span>toBe<\/span><span>(<\/span><span>true<\/span><span>);<\/span> <span>});<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Now rename <code>username<\/code> back to <code>user_name<\/code> on the provider and this test fails <em>before<\/em> the change merges \u2014 not after a consumer pages you at 2 a.m.<\/p>\n<h2> <a name=\"why-this-beats-a-shared-staging-environment\" href=\"#why-this-beats-a-shared-staging-environment\"> <\/a> Why this beats a shared staging environment <\/h2>\n<p>The classic alternative is end-to-end tests against a shared environment. They&#8217;re slow, flaky, and only catch breakage <em>after<\/em> both services deploy together. Contract tests run in milliseconds, in isolation, and pin down responsibility: if the contract test goes red, the provider broke it.<\/p>\n<p>Critically, contracts catch <strong>additive-but-breaking<\/strong> changes that loose typing hides \u2014 a field changing from <code>integer<\/code> to <code>string<\/code>, a <code>required<\/code> field going optional, an enum dropping a value. Your provider&#8217;s own tests pass because the provider is internally consistent. The contract is the only thing watching the boundary.<\/p>\n<h2> <a name=\"make-breaking-changes-a-deliberate-act\" href=\"#make-breaking-changes-a-deliberate-act\"> <\/a> Make breaking changes a deliberate act <\/h2>\n<p>The real payoff is workflow. Commit contracts to a repo both teams can see. Wire provider verification into the provider&#8217;s pipeline. When a provider change violates a contract, CI fails and forces the question: <em>do we version this, or coordinate the migration?<\/em> <\/p>\n<div>\n<pre><code><span># .github\/workflows\/contracts.yml<\/span> <span>name<\/span><span>:<\/span> <span>contract-tests<\/span> <span>on<\/span><span>:<\/span> <span>[<\/span><span>pull_request<\/span><span>]<\/span> <span>jobs<\/span><span>:<\/span> <span>verify<\/span><span>:<\/span> <span>runs-on<\/span><span>:<\/span> <span>ubuntu-latest<\/span> <span>steps<\/span><span>:<\/span> <span>-<\/span> <span>uses<\/span><span>:<\/span> <span>actions\/checkout@v4<\/span> <span>-<\/span> <span>uses<\/span><span>:<\/span> <span>actions\/setup-node@v4<\/span> <span>with<\/span><span>:<\/span> <span>{<\/span> <span>node-version<\/span><span>:<\/span> <span>20<\/span> <span>}<\/span> <span>-<\/span> <span>run<\/span><span>:<\/span> <span>npm ci<\/span> <span>-<\/span> <span>run<\/span><span>:<\/span> <span>npm run start:provider &amp;<\/span> <span>-<\/span> <span>run<\/span><span>:<\/span> <span>npx wait-on http:\/\/localhost:3000\/health<\/span> <span>-<\/span> <span>run<\/span><span>:<\/span> <span>npm test -- contracts\/<\/span> <\/code><\/pre>\n<div>\n<\/p><\/div>\n<\/p><\/div>\n<p>Breaking the contract becomes a conscious decision with a paper trail, not an accident.<\/p>\n<h2> <a name=\"getting-started-without-the-ceremony\" href=\"#getting-started-without-the-ceremony\"> <\/a> Getting started without the ceremony <\/h2>\n<p>You can adopt this incrementally: start with your two or three most depended-on endpoints, write a schema for each, and fail the build when reality drifts. Add more as confidence grows. Tools like Pact add a broker and versioning once you outgrow plain schemas \u2014 but the principle is the same at every scale.<\/p>\n<p>If you&#8217;d rather not hand-roll the schemas and CI glue, <a href=\"https:\/\/apikumo.com\/\" target=\"_blank\" rel=\"noopener noreferrer\">APIKumo<\/a> lets you capture request\/response shapes from your existing collections, keep them versioned alongside live docs, and run them as checks \u2014 so the contract stays in lockstep with the API your consumers actually call. Whichever route you take, the goal is the same: catch the break before your users do.<\/p>\n<\/p><\/div>\n<\/div>\n<\/div>\n<\/div>\n<p>Fuente: <a href=\"https:\/\/dev.to\/apikumo\/contract-testing-catch-breaking-api-changes-before-your-consumers-do-13h\">Art\u00edculo original<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Ever shipped a backend change that passed every test, only to wake up to a frontend on fire? The API still returned 200 OK \u2014 it just renamed user_name to username, and three consumers broke silently. Unit tests didn&#8217;t catch it. Integration tests didn&#8217;t catch it. This is exactly the gap contract testing fills. What [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":2648,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":true,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2}},"categories":[41],"tags":[],"class_list":["post-2772","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devto"],"jetpack_publicize_connections":[],"_links":{"self":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/2772","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/comments?post=2772"}],"version-history":[{"count":0,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/posts\/2772\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media\/2648"}],"wp:attachment":[{"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/media?parent=2772"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/categories?post=2772"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/tucumandevelopers.com\/index.php\/wp-json\/wp\/v2\/tags?post=2772"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}