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.
Table of Contents
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.
An API contract is the agreement between an API provider and the consumers of that API.
That contract can include:
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.
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:
API contract tests give teams a way to catch those changes before users, customers, partners, or internal systems discover them the hard way.
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:
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.”
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.
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:
This makes Bruno useful for catching breaking API changes before they affect frontend apps, SDKs, internal services, partners, or customers.
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:
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.”
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:
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.
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.
A strong API contract includes failure behavior.
Error responses matter because consumers often depend on them for:
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:
Many teams test happy paths first and failure paths later. For contract testing, the failure paths are part of the contract.
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-TypeCache-ControlETagRetry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-Request-IDHeaders 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.
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:
baseUrlauthTokenuserIdorganizationIdapiVersiontestEmailidempotencyKeyfeatureFlagworkspaceIdThis 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.
API contract tests become much more valuable when they run automatically.
A common workflow looks like this:
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.
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:
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.
Contract testing is most useful when tests are specific enough to catch real problems, but not so brittle that they fail on harmless changes.
Status codes matter, but they are not the full contract. Test response shape, field types, headers, and error formats too.
Consumers depend on predictable failures. Test validation errors, auth failures, not-found responses, and rate limits.
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/);
If consumers depend on pagination, test pagination metadata and edge cases like empty pages.
Error responses are often where contracts drift. Make sure your negative tests verify structure and codes.
Manual contract tests are helpful. Automated contract tests are much better.
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.
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.
Use this checklist before shipping API changes:
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.
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.