Skip to content

API Contract Testing with Bruno: Catch Breaking Changes

 

APIs rarely break in the most obvious way. The server is still running. The endpoint still returns a response. The deployment looks healthy. Your monitoring dashboard is green.

Then a frontend page fails to load. A mobile app crashes. A customer integration starts throwing errors. A partner reports that a field they depend on disappeared.

The API was technically “up,” but the contract changed.

That is the problem API contract testing is designed to catch. Contract testing helps teams verify that an API still behaves the way its consumers expect. Instead of only checking whether an endpoint returns a 200 OK, contract tests validate the response shape, required fields, status codes, headers, error formats, authentication behavior, and assumptions that real applications rely on.

With Bruno, you can turn those expectations into repeatable API checks that live alongside your collection, run locally during development, and run automatically in CI before changes reach production.

Quick answer · API contract testing

API contract testing verifies that an API continues to follow the agreement its consumers depend on. That agreement includes endpoints, request formats, response shapes, required fields, status codes, headers, error formats, and authentication behavior. Contract tests help catch breaking API changes before they reach production.

Bruno executable API contracts

Bruno helps teams turn API expectations into executable checks. You can save requests, add assertions, organize tests into collections, use environments, store everything in Git, and run contract tests automatically with the Bruno CLI.

What is API contract testing?

An API contract is the agreement between an API provider and the consumers of that API.

That contract can include:

  • Available endpoints
  • HTTP methods
  • Required parameters
  • Request body structure
  • Response body structure
  • Status codes
  • Error formats
  • Headers
  • Authentication behavior
  • Pagination behavior
  • Field types and naming conventions

API contract testing verifies that the API still follows that agreement. For example, imagine consumers expect this response from GET /users/{id}:

{
  "id": "user_123",
  "email": "alex@example.com",
  "created_at": "2026-06-29T12:00:00Z"
}

If a backend change removes created_at, renames email to emailAddress, or changes id from a string to a number, the endpoint may still return 200 OK.

But for consumers, the contract has broken.

A contract test makes that expectation explicit:

test("returns the expected user contract", function () {
  const body = res.getBody();

  expect(res.getStatus()).to.equal(200);
  expect(body).to.have.property("id");
  expect(body).to.have.property("email");
  expect(body).to.have.property("created_at");
  expect(body.id).to.be.a("string");
  expect(body.email).to.be.a("string");
});

Now the API does not just need to respond. It needs to respond in the way consumers expect.

That example is a test, but Bruno actually gives you two complementary ways to validate a response, and good contract tests use both: assertions and tests. They check the same kinds of things, but they trade off simplicity for power.

Assertions are declarative, no-code checks. In the Assert tab of a request you fill in an expression, an operator, and an expected value, and Bruno saves them to the request file as a runtime.assertions block (in Bruno's YAML format, the default since v3.1). They are quick to write, easy to read, and ideal for straightforward single-value guarantees:

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "200"
    - expression: res.body.id
      operator: isString
    - expression: res.body.email
      operator: isString

Tests are JavaScript. You write them in the Tests tab using test() blocks, and you make the actual checks with the built-in Chai assertion library, the expressive expect(...).to.be... syntax you see in the example above. Because they are real code wrapped around a rich assertion library, they give you conditional logic, loops, and the ability to group several related checks under one named, reportable result. Reach for a test whenever a single expression cannot capture the contract, for example, validating the shape of items inside an array, checking a field only when it is present, or asserting nested structure.

Reach for an assertion when Reach for a test when
You are checking a single value, like a status code, one field's type, or a single nested leaf via its path You need to loop over or conditionally check items, like every entry in a variable-length array
You want a readable, no-code check anyone on the team can edit You need logic, like only asserting on an item when the array is non-empty
A few independent checks are enough You want several related checks grouped under one named result

A common pattern is to use assertions for the simple guarantees and tests for everything structural. You will see both throughout the steps that follow.

Why API contract testing matters

APIs often break quietly. The endpoint may still exist, the service may still be healthy, and the deployment may still pass basic checks. But if the contract changes unexpectedly, anything consuming that API can fail.

That might mean:

  • A frontend app cannot render a page because a field disappeared
  • A mobile app crashes because a value changed type
  • An SDK breaks because an error response changed shape
  • A partner integration fails because pagination metadata changed
  • An internal service retries incorrectly because rate limit headers disappeared

API contract tests give teams a way to catch those changes before users, customers, partners, or internal systems discover them the hard way.

Why status code tests are not enough

A lot of API tests start and end with a simple 200 OK status code check:

test("returns 200", function () {
  expect(res.getStatus()).to.equal(200);
});

That is a useful starting point, but it is not enough for API contract testing.

An API can return 200 OK and still break consumers because:

  • A required field disappeared
  • A field changed type
  • A date format changed
  • An array became an object
  • Pagination metadata changed
  • Error responses changed shape
  • A header was removed
  • A response still “works,” but means something different

For example, this response might be perfectly valid from the server’s perspective:

{
  "userId": 123,
  "emailAddress": "alex@example.com"
}

But it is breaking if consumers expect this:

{
  "id": "user_123",
  "email": "alex@example.com"
}

A status code test says, “The endpoint responded fine.”

A contract test says, “The endpoint responded with specific structure, fields, and so on.”

What should API contract tests check?

API contract tests should focus on the parts of your API that consumers actually rely on.

Contract area What to verify
Status codes Expected success and failure codes
Required fields Fields consumers depend on are present
Field types Strings stay strings, arrays stay arrays, objects stay objects
Response shape Object nesting and structure remain stable
Headers Content type, request IDs, caching, and rate limit headers
Error format Validation, auth, and permission errors are predictable
Pagination Cursors, limits, totals, and next-page behavior remain consistent
Authentication Missing, invalid, and insufficient credentials behave correctly

A good contract test does not need to verify every field in every response. It should verify the fields and behavior that would break a consumer if they changed unexpectedly.

How Bruno helps with API contract testing

Bruno helps teams turn API expectations into repeatable contract tests. Instead of manually checking an endpoint after every change, you can save requests, add assertions, organize tests into collections, and run those checks locally or in CI.

With Bruno, teams can:

  • Import or create API requests
  • Add assertions for status codes, response bodies, headers, and field types
  • Test both successful and failing API responses
  • Use environments for local, staging, and production-like testing
  • Store API tests in Git with the rest of the project
  • Run contract tests automatically with Bruno CLI

This makes Bruno useful for catching breaking API changes before they affect frontend apps, SDKs, internal services, partners, or customers.

Step 1: Start with your API contract

The easiest place to start is with your existing API contract.

For many teams, that means an OpenAPI specification. If your team already maintains an OpenAPI spec, you can import it into Bruno and use it as the starting point for contract tests. OpenAPI gives you the documented contract. Bruno helps you turn that contract into executable checks.

A typical workflow looks like this:

  1. Import your OpenAPI spec into Bruno.
  2. Generate requests from documented endpoints.
  3. Send those requests against a local, development, or staging environment.
  4. Add assertions for the behavior consumers rely on.
  5. Save those assertions as part of the collection.
  6. Run the collection before releasing API changes.

When you reach the assertion step, pick the tool that fits each check: lightweight assertions for single-value checks like status codes, field types, and individual nested fields, and JavaScript tests when a check needs logic, loops, or several related checks grouped under one named result. The two are interchangeable for simple checks, so start with assertions and graduate a check to a test the moment it needs that extra power (see assertions vs. tests).

You can also start from an existing Bruno collection. If your team already uses Bruno for manual API testing, identify the most important requests and start adding contract assertions to them.

You might also use AI to generate a Bruno collection for you. You can even have it generate the appropriate tests.

The key is to move from “this request works when I click Send” to “this request verifies the API behavior our consumers depend on.”

Step 2: Prioritize consumer-critical API workflows

Not every endpoint needs the same level of contract coverage.

Start with the workflows where a breaking change would cause real damage. Good candidates include endpoints that:

  • Power production user flows
  • Are used by external customers
  • Are used by partner integrations
  • Are used by internal services
  • Handle billing or account state
  • Handle authentication or permissions
  • Return complex nested data
  • Have broken before
  • Are hard to roll back

For example, a basic collection structure might look like this:

API Contract Tests
├── Auth
├── Users
├── Organizations
├── Billing
├── Projects
├── Webhooks
└── Error Handling

A workflow-based structure can be even more useful:

API Contract Tests
├── Create Account Flow
├── Invite Team Member Flow
├── Upgrade Plan Flow
├── Generate Report Flow
└── Delete Resource Flow

Workflow-based contract tests are helpful because API consumers rarely use a single endpoint in isolation. They log in, create something, fetch it, update it, list it, and handle failures along the way.

Your contract tests should reflect those real usage patterns.

Because these workflows chain requests, passing an ID from a create response into a later fetch, for example, they lean on tests rather than standalone assertions. An assertion validates a single response in place; a test can capture a value, save it to a variable, and carry it across the steps of a flow:

test("create returns an id we can reuse downstream", function () {
  const body = res.getBody();

  expect(res.getStatus()).to.equal(201);
  expect(body).to.have.property("id");

  bru.setVar("createdUserId", body.id);
});

A later request in the same flow can then assert against . Within each individual step, still use plain assertions for the simple single-value checks.

Step 3: Add Bruno assertions and tests for response shape

The most common contract failures happen in the response body.

A field gets renamed. A nested object moves. A value changes type. An array comes back empty or disappears.

Many of these checks are simple, single-value guarantees, so you can start with assertions and only upgrade to a test when a check needs logic, grouping, or conditional handling (see assertions vs. tests).

Start with the fields your consumers rely on. Each one is an independent presence-or-type check, so assertions handle them directly:

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "200"
    - expression: res.body.id
      operator: isString
    - expression: res.body.email
      operator: isString
    - expression: res.body.created_at
      operator: isString

If you would rather keep those guarantees together and report them under one named result, the same checks become a single test:

test("returns required user fields", function () {
  const body = res.getBody();

  expect(res.getStatus()).to.equal(200);
  expect(body).to.have.property("id");
  expect(body).to.have.property("email");
  expect(body).to.have.property("created_at");

  expect(body.id).to.be.a("string");
  expect(body.email).to.be.a("string");
  expect(body.created_at).to.be.a("string");
});

Arrays are where you usually must move up to a test. The top-level type is still a single assertion:

runtime:
  assertions:
    - expression: res.body.users
      operator: isArray

But verifying the shape of the items inside the array means guarding against an empty list first, and that conditional logic is something only a test can express:

test("returns a list of users", function () {
  const body = res.getBody();

  expect(res.getStatus()).to.equal(200);
  expect(body.users).to.be.an("array");

  if (body.users.length > 0) {
    expect(body.users[0]).to.have.property("id");
    expect(body.users[0]).to.have.property("email");
    expect(body.users[0].id).to.be.a("string");
    expect(body.users[0].email).to.be.a("string");
  }
});

Nested objects can go either way. Because an assertion expression can navigate a path, each leaf is still a single assertion:

runtime:
  assertions:
    - expression: res.body.organization.id
      operator: isString
    - expression: res.body.organization.name
      operator: isString

Reach for a test when you would rather report them together as one "organization details" contract check:

test("returns organization details for the user", function () {
  const body = res.getBody();

  expect(body).to.have.property("organization");
  expect(body.organization).to.have.property("id");
  expect(body.organization).to.have.property("name");

  expect(body.organization.id).to.be.a("string");
  expect(body.organization.name).to.be.a("string");
});

These checks catch accidental contract changes before they break consumers.

Step 4: Test API error contracts

A strong API contract includes failure behavior.

Error responses matter because consumers often depend on them for:

  • Form validation
  • Retry logic
  • Authentication refresh flows
  • User-facing messages
  • Debugging
  • Support tickets
  • Agent or automation recovery

A vague error like this is hard for consumers to act on:

{
  "error": "Invalid request"
}

A structured error is much more useful:

{
  "error": {
    "code": "missing_required_field",
    "message": "Email is required.",
    "field": "email",
    "request_id": "req_123"
  }
}

Error contracts are a good place to use both. Assertions are the quickest way to pin down the status code and any known, specific values, like the error code or the offending field:

runtime:
  assertions:
    - expression: res.status
      operator: eq
      value: "400"
    - expression: res.body.error.code
      operator: eq
      value: missing_required_field
    - expression: res.body.error.field
      operator: eq
      value: email

Reach for a test when you want to verify the whole error object's shape at once, that code, message, and field are all present together, and report it as one named contract check:

test("returns a structured validation error", function () {
  const body = res.getBody();

  expect(res.getStatus()).to.equal(400);
  expect(body).to.have.property("error");
  expect(body.error).to.have.property("code");
  expect(body.error).to.have.property("message");
  expect(body.error).to.have.property("field");

  expect(body.error.code).to.equal("missing_required_field");
  expect(body.error.field).to.equal("email");
});

Negative test cases are especially valuable for API contract testing. Add requests for:

  • Missing required fields
  • Invalid field types
  • Invalid enum values
  • Missing auth tokens
  • Expired auth tokens
  • Insufficient permissions
  • Resource not found
  • Duplicate resource creation
  • Rate limit exceeded
  • Malformed JSON

Many teams test happy paths first and failure paths later. For contract testing, the failure paths are part of the contract.

Step 5: Validate headers, pagination, and metadata

Some of the most important API behavior lives outside the response body.

Headers can define content type, caching behavior, rate limits, tracing, retries, and pagination behavior. If clients depend on those headers, they should be tested.

Useful headers to verify include:

  • Content-Type
  • Cache-Control
  • ETag
  • Retry-After
  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-Request-ID

Headers are flat, single values, so assertions are usually the cleanest tool here, no code, and easy for the whole team to scan. For example, you can validate that your API is returning JSON:

runtime:
  assertions:
    - expression: res.headers['content-type']
      operator: contains
      value: application/json

Pagination metadata is a set of nested fields. You could assert each one individually by its path (res.body.pagination.next_cursor, and so on), but grouping them in a test keeps the whole pagination contract together under one named check:

test("returns pagination metadata", function () {
  const body = res.getBody();

  expect(body).to.have.property("data");
  expect(body).to.have.property("pagination");
  expect(body.pagination).to.have.property("next_cursor");
  expect(body.pagination).to.have.property("limit");

  expect(body.data).to.be.an("array");
});

If your consumers rely on pagination metadata, test it directly.

Step 6: Use Bruno environments for safe contract testing

API contract tests should usually run against local, development, preview, or staging environments.

Production contract tests can be useful, but they should generally be read-only and carefully scoped.

A practical environment setup might look like this:

Local
Development
Staging
Production Read-Only

Your requests can stay portable by using variables:

/users/

And the matching environment file defines those values. In Bruno's YAML format, the file has a name and a variables array, where each variable carries a value plus enabled and secret flags:

name: Staging
variables:
  - name: baseUrl
    value: https://staging-api.example.internal
    enabled: true
    secret: false
    type: text
  - name: userId
    value: user_123
    enabled: true
    secret: false
    type: text
  - name: authToken
    value: ""
    enabled: true
    secret: true
    type: text

Variables marked secret: true (like authToken) are stored securely rather than written to the file, so real tokens never land in Git.

Useful contract testing variables include:

  • baseUrl
  • authToken
  • userId
  • organizationId
  • apiVersion
  • testEmail
  • idempotencyKey
  • featureFlag
  • workspaceId

This lets you run the same collection against different environments without rewriting every request.

Both assertions and tests can reference these variables, assertions through -style interpolation in their values, and tests through bru.getEnvVar("userId"), so you write a check once and it travels across local, staging, and production read-only.

Tip: Keep production contract tests read-only unless you are intentionally testing a safe production workflow. For write operations, use staging, sandbox, preview environments, or dedicated test accounts.

Step 7: Run API contract tests in CI with Bruno CLI

API contract tests become much more valuable when they run automatically.

A common workflow looks like this:

  1. A developer opens a pull request.
  2. The API is deployed to a preview or staging environment.
  3. Bruno CLI runs the contract test collection.
  4. CI fails if a contract-breaking change is detected.
  5. The team fixes the change or intentionally updates the contract.

Bruno CLI runs your assertions and your tests together, and the run fails if either one fails. So the choice between them is purely about readability, not about what CI can catch, both are first-class in an automated run.

The simplest setup is the official Bruno CLI GitHub Action, which installs the CLI, runs your command, and exposes pass/fail counts as step outputs. A basic workflow might look like this:

name: API Contract Tests

on:
  pull_request:
    branches: [main]

jobs:
  contract-tests:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Run contract tests
        uses: usebruno/bruno-cli-action@v1
        with:
          working-directory: ./collections/contract-tests
          command: run --env Staging

You can also pass environment variables at runtime:

- name: Run contract tests
  uses: usebruno/bruno-cli-action@v1
  with:
    working-directory: ./collections/contract-tests
    command: >-
      run --env Staging
      --env-var API_TOKEN=$
      --env-var BASE_URL=$

If you are not on GitHub Actions, the official Bruno CLI Docker image (usebruno/cli) runs the same collection anywhere, GitLab CI, Jenkins, Azure DevOps, or a local pre-push hook. Mount your collection into /bruno and forward any secrets with -e:

docker run --rm \
  -v $(pwd)/collections/contract-tests:/bruno \
  -e API_TOKEN \
  usebruno/cli run --env Staging --reporter-junit results.xml

Pinning a version tag like usebruno/cli:3.3.0 instead of latest keeps CI runs reproducible.

This is where contract testing starts to feel like a safety net.

Instead of relying on manual checks or hoping downstream consumers notice a problem, you can catch breaking changes during the pull request process.

Handling intentional breaking API changes

Not every contract test failure means something went wrong.

Sometimes the API contract should change. Maybe you are removing an old field. Maybe you are renaming a property for consistency. Maybe you are changing an error format to make it more useful. Maybe you are releasing a new API version.

The important thing is that breaking changes should be intentional, visible, and communicated.

When a contract test fails, ask:

  • Did we mean to change this behavior?
  • Which consumers depend on the old behavior?
  • Is this backward compatible?
  • Do we need a new API version?
  • Do we need a deprecation period?
  • Should old and new fields exist temporarily?
  • Have docs and examples been updated?

For example, during a transition, you might support both fields:

{
  "id": "user_123",
  "email": "alex@example.com",
  "created_at": "2026-06-29T12:00:00Z",
  "createdAt": "2026-06-29T12:00:00Z"
}

Then your contract tests can verify both the old behavior and the new behavior until consumers have migrated.

The goal of API contract testing is not to prevent change. The goal is to prevent accidental breakage.

Common API contract testing mistakes

Contract testing is most useful when tests are specific enough to catch real problems, but not so brittle that they fail on harmless changes.

Only checking status codes

Status codes matter, but they are not the full contract. Test response shape, field types, headers, and error formats too.

Only testing happy paths

Consumers depend on predictable failures. Test validation errors, auth failures, not-found responses, and rate limits.

Testing unstable data

Avoid assertions against values that change frequently unless that change is part of what you are testing.

For example, this may be too brittle:

expect(body.updated_at).to.equal("2026-06-29T12:00:00Z");

This is usually better:

expect(body.updated_at).to.be.a("string");
expect(body.updated_at).to.match(/^\d{4}-\d{2}-\d{2}T/);

Ignoring pagination

If consumers depend on pagination, test pagination metadata and edge cases like empty pages.

Ignoring errors

Error responses are often where contracts drift. Make sure your negative tests verify structure and codes.

Forgetting CI

Manual contract tests are helpful. Automated contract tests are much better.

Treating every failure as noise

A failing contract test is feedback. Either the API broke unexpectedly, or the test needs to be updated because the contract changed intentionally.

Both are useful signals.

Suggested Bruno collection structure

Here is a practical starting structure for a contract testing collection:

API Contract Tests
├── 01 Health & Version
│   ├── GET API health
│   └── GET API version
├── 02 Auth
│   ├── Missing token
│   ├── Invalid token
│   └── Valid token
├── 03 Users
│   ├── List users
│   ├── Get user
│   ├── Create user
│   └── Invalid create user
├── 04 Organizations
│   ├── List organizations
│   ├── Get organization
│   └── Missing organization
├── 05 Pagination
│   ├── Default pagination
│   └── Cursor pagination
├── 06 Error Format
│   ├── Validation error
│   ├── Not found error
│   └── Permission error
└── 07 Breaking Change Checks
    ├── Required fields
    ├── Deprecated fields
    └── Response shape

You do not need to build all of this at once.

Start with one critical workflow. Add assertions for the fields that matter. Add one or two negative tests. Then expand coverage over time.

The best API contract testing setup is the one your team will actually maintain.

API contract testing checklist

Use this checklist before shipping API changes:

  • Test critical API workflows
  • Check response shape, not just status codes
  • Assert required response fields
  • Assert important field types
  • Test validation errors
  • Test authentication and permission failures
  • Validate pagination behavior
  • Validate important response headers
  • Use stable test data
  • Run tests against local, preview, or staging environments
  • Run contract tests automatically in CI
  • Document or version intentional breaking changes

FAQ: API contract testing with Bruno

What is API contract testing?

API contract testing checks whether an API still follows the agreement its consumers depend on. That agreement can include request formats, response fields, status codes, headers, authentication behavior, error formats, and pagination structure.

What is the difference between API contract testing and integration testing?

API contract testing focuses on whether an API follows its expected interface. Integration testing checks whether multiple systems work together correctly. Contract tests are usually narrower and are designed to catch breaking API changes that could affect consumers.

Can Bruno be used for API contract testing?

Yes. Bruno can be used to create API requests, add assertions, test response bodies and headers, organize contract tests into collections, and run those tests locally or in CI with Bruno CLI.

What should an API contract test check?

An API contract test should check the parts of the API that consumers depend on, including required fields, field types, response shape, status codes, headers, error formats, pagination metadata, and authentication behavior.

Are status code checks enough for API contract testing?

No. Status code checks are useful, but they do not confirm that the response body, field names, field types, headers, or error formats are still compatible with API consumers.

Should API contract tests run in CI?

Yes. Running API contract tests in CI helps teams catch breaking changes during pull requests or release workflows, before those changes reach production.

Contract tests turn API expectations into safety checks

APIs can break even when they are still online.

A response can return 200 OK and still be unusable. A field can disappear silently. An error format can change without warning. A pagination object can shift just enough to break a frontend, SDK, internal service, or customer integration.

API contract testing helps catch those changes before they reach production.

With Bruno, you can define those expectations as real API requests, add assertions for the behavior consumers rely on, organize tests by workflow, use environments for safe testing, and run the same collection in CI with Bruno CLI.

Start small.

Pick one workflow your users or customers depend on. Add contract assertions for the response body, headers, and error cases. Run it before your next release.

That one workflow can become the beginning of a stronger API safety net.

Next step: Create a Bruno collection for one critical API workflow, add assertions for the fields consumers depend on, and run it locally before advanced testing logic and wiring it into CI.