Like most developers, I spent quite a long time suffering from integration issues that inevitably appear in any reasonably serious project: the backend changes a field, adds a new one, changes its format — and the frontend finds out only after something breaks.
So, some time ago, I decided I had had enough and built my own API contract testing framework for the project I was working on. In practice, the result turned out even better than I expected: after fully implementing it, the number of integration issues between the frontend and backend dropped to almost zero.
In this article, I want to share this framework with you: how I designed it, what problems I wanted to solve, how I organized schemas and contracts, and why it eventually became much more than just a collection of API tests.
The stack in my case is fairly standard for a modern frontend project: JavaScript, TypeScript, React, and Zod. But the idea itself is not tied to any particular library — Zod is simply a very convenient tool for implementing it.
Before building anything, I defined the primary goal of the framework: automatically validate real API responses and guarantee that the backend returns exactly what the frontend expects.
That means catching changes in field types and formats, missing required fields, newly introduced unexpected fields, validation rule violations, and changes to stable response structures.
And that is where the entire framework architecture begins.
QA Engineer vs. Software Developer
Before we begin, I want to address what I believe is a fairly common misconception about API contract testing: that it should be done by developers rather than QA engineers.
This framework can be implemented and successfully used by both a QA engineer and a software developer. Its basic implementation does not require deep knowledge of the application architecture: a QA engineer can describe API contracts, build validation schemas, and use them to automatically validate API responses. In this form, the framework already fully performs its primary task — detecting contract violations between the backend and its consumer.
However, when a software developer integrates this framework directly into the application architecture, the same resources can provide significantly more value. Properly designed schemas effectively become a single source of truth for several parts of the application:
- TypeScript types;
- contract tests;
- runtime validation;
- form validation;
- rules and constraints for individual fields;
- frontend elements that depend on those constraints, such as validation errors or character counters;
- API documentation.
So instead of independently describing the same structure in types, tests, forms, UI, and documentation, we define it once and reuse it everywhere it is needed.
That is why, for QA, this framework can be a powerful API contract testing tool, while for a developer it can also become part of the application architecture itself.
Framework Organization
src/api/
├── endpoints # Resources required for API interaction
| └── users # Business-entity folder grouping related endpoints
| └── getProductById/ # Named endpoint folder
| ├── getProductById.api.ts # Bare reusable API function
| ├── getProductById.hook.ts # UI-ready hook
| └── getProductById.schemas.ts # Validation schemas and types
└── schemas # Business-entity definitions
├── common.schemas.ts # Schemas and types reused across business entities
└── product.schemas.ts # Schemas and types for a specific business entity
Pay attention to how the files inside each endpoint are organized. There are two reasons for this.
The first is that I build this framework as an AI-native architecture and try to make it as predictable and convenient for AI agents as possible. The more clearly responsibilities are separated and the more stable the naming patterns are, the easier it is for an agent to understand the structure, locate the correct file, and extend the existing implementation consistently.
The second reason is separation of concerns. I intentionally split the implementation into several suffix files, each with one clearly defined responsibility:
-
*.api.ts— a minimal function responsible for interacting with the API. It does not depend on React and contains only the logic required to make a request and receive a response. Because of this, it can be used independently in React hooks, contract tests, server-side code, or any other consumer. -
*.hook.ts— the React layer on top of the API function. This is where loading and error states, caching, state management, transformations, and other UI-specific logic can live. -
*.schemas.ts— the contract of a specific endpoint: Zod schemas for runtime validation and the TypeScript types associated with them.
This separation is particularly important for contract tests. A test does not need to initialize React or pull UI-specific logic along with it just to validate an API. It can call a function from *.api.ts directly and validate the response using a schema from *.schemas.ts.
Naming Conventions
I personally consider semantics one of the most underrated parts of software development, and I take it very seriously. A good name should immediately tell you what entity you are looking at, what role it performs, and where it is used, even before you open the file.
This becomes especially important in AI-native repositories: consistent naming allows AI to infer relationships between entities more accurately, anticipate their purpose, and find the right context. That is why naming conventions in this framework are not cosmetic — they are part of the architecture.
Endpoints
For endpoints, I use a simple rule:
<operation><entity><qualifier?>
The operation comes first, followed by the business entity, and then, when necessary, an additional qualifier describing the request.
For example:
getProductByIdcreateProductmodifyProductdeleteProductgetUserProduct
The same naming is used for the endpoint folder and its related files:
getProductById/
├── getProductById.api.ts
├── getProductById.hook.ts
└── getProductById.schemas.ts
This way, all parts of a particular API request can easily be found using the same name.
Schemas & Types
For schemas and types, I use a separate set of standard suffixes describing the role of the data in a particular request:
QueryParamsRequestPayloadResponsePayload
If the entity is a Zod schema, I simply add Schema to the name.
For example:
GetProductById_QueryParamsCreateProduct_RequestPayloadGetProductById_ResponsePayloadGetProductById_ResponsePayloadSchema
As a result, the name itself describes the structure:
<endpoint>_<data role><Schema?>
So when you see GetProductById_ResponsePayloadSchema, you immediately know three things:
- which endpoint it belongs to;
- which part of the API contract it describes;
- that you are looking at a runtime schema rather than a TypeScript type.
Getting Started
Common Schemas
Let us start with the simplest file — common.schemas.ts.
Its purpose is to store small but reusable schemas that appear across many business entities. These are not standalone application models, but rather building blocks: IDs, timestamps, tracker fields, UUIDs, emails, and other standardized primitives.
For example, a minimal common.schemas.ts might look like this:
import { z } from 'zod'
// Unified ID type used across the application
export const Id_Schema = z.string().uuid()
// Unified set of fields used to track entity creation and modification
export const Tracker_Schema = z.object({
createdOn: z.string().datetime(),
modifiedOn: z.string().datetime(),
})
These schemas are not duplicated in every business entity. Instead, they are reused through extend() or directly as individual fields.
This allows us to change a rule in one place and automatically propagate it throughout the application.
Business Entity
Now let us move to product.schemas.ts — the file describing the Product business entity. For schemas like this, I use a simple naming rule: the entity name plus the Schema suffix — in our case, Product_Schema.
There is one fundamentally important point here: we describe the entity as the frontend sees it, not as it exists internally on the backend. The same entity can have different representations on the two sides of an API.
For example, the backend model of a product might contain a stock property with the exact number of units remaining in the warehouse. The store owners probably do not want to expose that information publicly, so instead of stock: 12345, the frontend might receive only inStock: true.
That frontend representation is what our contract should describe.
import { z } from 'zod'
import { Id_Schema, Tracker_Schema } from './common.schemas'
// Product business-entity schema for runtime validation.
// Extends the shared tracker fields.
export const Product_Schema = Tracker_Schema.extend({
id: Id_Schema,
name: z.string(),
description: z.string().min(5).max(500),
inStock: z.boolean(),
})
// Product type automatically inferred from the runtime validation schema.
export type Product = z.infer<typeof Product_Schema>
// Generic Response Schema for endpoints that return the same object shape.
export const Product_GenericResponseSchema = Product_Schema
An important detail here is that we do not define the Product type separately. It is automatically inferred from the same Zod schema through z.infer.
As a result, Product_Schema becomes both a runtime representation of the contract and the source of compile-time typing. If we change the structure or validation rules of Product_Schema, the TypeScript Product type changes with it.
Runtime validation and typing remain synchronized by design.
Also note Product_GenericResponseSchema. In real-world APIs, several endpoints often return the same representation of an entity.
Instead of describing that shape again in every endpoint-specific file, I suggest defining it at the higher business-entity level and reusing it from there.
One more important clarification about Product_Schema: a business-level schema should contain every field that may appear for this entity across any endpoint.
This is not an endpoint-specific contract. It is an entity-specific contract — effectively a single source of truth for all fields of the entity and their validation rules.
Individual endpoints then build their own contracts on top of this base schema using pick, omit, extend, or reusable subschemas.
API Contract
Once we have a canonical business entity, it is time to describe the contract of a specific endpoint. For this example, let us use createProduct.schemas.ts.
Suppose the get, create, and update endpoints all return Product in the same representation. In that case, there is no reason to redefine the schema every time.
We simply import the prepared Product_GenericResponseSchema from product.schemas.ts and use it as the basis for CreateProduct_ResponsePayloadSchema.
import { z } from 'zod'
import {
Product_GenericResponseSchema,
type Product,
} from '@/api/schemas/'
// Runtime validation schema for the Create Product response payload.
export const CreateProduct_ResponsePayloadSchema =
Product_GenericResponseSchema
export type CreateProduct_ResponsePayload = z.infer<
typeof CreateProduct_ResponsePayloadSchema
>
// Request payload type for Create Product.
// A Zod schema is intentionally not created because it is not used.
export type CreateProduct_RequestPayload = Pick<
Product,
'name' | 'description'
>
There is another important principle here: do not create a Zod schema just because you can.
If some data never goes through runtime validation and the schema is not used anywhere, it only creates additional code and another entity that must be maintained.
That is why a Zod schema makes sense for ResponsePayload, which we are going to validate in contract tests. But if compile-time typing is enough for RequestPayload, we can simply derive the required type from the existing Product type using Pick.
This way, even endpoint-specific contracts continue to derive from the same source of truth instead of creating parallel descriptions of the same structure.
Contract Tests
Once the business entities and endpoint contracts have been described, the tests themselves become almost boring.
All you need to do is call the API, receive the response, and validate it against the appropriate Zod schema.
import { getProductById_API } from '@/api/endpoints'
import { GetProductById_ResponsePayloadSchema } from '@/api/schemas'
describe('API contracts', () => {
describe('Products', () => {
it('GET product by ID', async () => {
const data = await getProductById_API(12345)
expect(() => {
GetProductById_ResponsePayloadSchema.parse(data)
}).not.toThrow()
})
})
})
All the complexity is already contained in properly designed schemas, so adding another check usually takes only a few lines of code.
Important Clarification: These Are Not API Unit Tests
It is important to understand the purpose of contract testing correctly.
We are not trying to test the API itself or verify all of its business logic. This is not the place for edge cases, attempts to “break” an endpoint, or checks of how the backend behaves when given invalid data.
Our task is much simpler: make a valid request, receive a successful response, and verify that it matches the contract our application relies on.
So keep contract tests as simple as possible:
- call the API;
- make sure the request succeeds;
- validate the response against the appropriate schema.
Do not turn contract tests into a duplicate of the backend unit test suite — you do have one, right?
There is only one thing we care about here:
Does the API return what its consumer expects?
Pro Tips
#1. Array Validation
Keep in mind that Zod successfully validates an empty array even when a specific schema is defined for its elements:
z.array(Product_Schema)
For [], validation passes successfully because there is simply no element to which Product_Schema can be applied.
So if your test is supposed to validate not only the presence of an array but also the shape of its elements, make sure your test data contains at least one item.
For example:
expect(data.length).toBeGreaterThanOrEqual(1)
Otherwise, you risk getting a false positive: the test is green, but the structure of the objects inside the array has never actually been validated.
#2. Catching New Fields
In contract tests, it is important to detect not only missing or invalid fields, but also new unexpected fields appearing in the response.
That is why schemas should be validated in strict mode. Otherwise, the backend may add a new field and your test will never notice it.
There are two ways to solve this.
The first is to explicitly enable strict validation in every Zod object schema:
const Product_Schema = z
.object({
id: Id_Schema,
name: z.string(),
})
.strict()
The second option, which I personally prefer, is to create a dedicated utility for contract tests.
It recursively traverses the entire provided Zod schema and applies strict behavior to all object schemas, including objects nested inside arrays, other objects, unions, and so on.
This keeps the schemas themselves universal while centralizing strict behavior exactly where it is needed — in contract tests.
#3. Show the Received Value
Another small detail that has a surprisingly large impact on debugging: the validation error should show the actual value that failed validation.
A message such as “the field does not match the expected format” is often not enough, especially when tests operate on large responses or deeply nested structures.
I recommend configuring error reporting so that, along with the field path and the reason for the failure, it also prints the value that triggered the error.
Instead of something abstract like:
Invalid format at products[3].id
it is much more useful to get something like:
Invalid format at products[3].id
Received: "abc-123"
It may seem like a small detail, but in practice it significantly reduces the time required to investigate failing tests.
#4. Stable Data
Sometimes an API contains endpoints that return stable data — values that are not expected to change regularly.
A typical example is a list of statuses for a business entity.
In this case, it can be useful not only to validate the general response shape but also to explicitly lock down the expected set of values using literals.
For example:
const ProductStatus_Schema = z.enum([
'Pending',
'In Progress',
'Done',
])
At first glance, this may look like unnecessary overhead. In practice, however, it can be extremely useful — especially when frontend behavior depends directly on specific statuses.
For example, different statuses may display different UI, enable different actions, or trigger different business logic.
In such a case, introducing a new status or renaming an existing one is no longer just a data change. It is a contract change that can potentially affect the frontend.
If you cannot be completely sure that the backend team will always notify you about such changes in time, contract tests can serve as an additional safety net.
Bonuses
At the beginning of the article, I mentioned that a software developer can get much more from this framework than contract tests alone.
Since the Zod schemas have already become our single source of truth, we can reuse them directly in application code.
Here are a few practical examples.
#1. API Typing
Since TypeScript types are automatically inferred from our Zod schemas, the same types can be used directly in the API layer:
export const getProductByIdApi = async (
id: string,
): Promise<GetProductById_ResponsePayload> => {
return api.get(`/products/${id}`)
}
This means API typing and API validation are based on the same source of truth.
Change the schema, and the type used by the application code changes with it.
#2. Runtime Validation of Local Storage Data
Suppose a user is creating a product and we store a draft of the form in localStorage.
After the page reloads, we want to restore it — but before putting the data back into the form, we want to make sure it still conforms to our contract.
const rawDraft = localStorage.getItem('productDraft')
if (rawDraft) {
try {
const parsedDraft = JSON.parse(rawDraft)
const result = ProductDraft_Schema.safeParse(parsedDraft)
if (result.success) {
form.reset(result.data)
}
} catch {
// Invalid JSON — ignore the stored draft
}
}
This gives the data from localStorage two validation layers: first, we ensure it is valid JSON; then Zod checks its structure and values before it is allowed back into the form.
#3. Form Validation
The same Zod schemas can be integrated directly with popular form libraries.
For example, React Hook Form provides a Zod resolver:
const form = useForm({
resolver: zodResolver(Product_Schema),
})
As a result, rules such as:
description: z.string().min(5).max(500)
do not need to be described again in the form.
The schema that defines the API contract and TypeScript type also defines the form validation rules.
#4. Using Schema Constraints in the UI
A Zod schema contains not only information about the field type, but also its rules:
description: z.string().min(5).max(500)
If the UI displays a character counter:
127 / 500
there is no reason to hardcode 500 separately inside the React component.
We can get it directly from the schema:
const maxLength = Product_Schema.shape.description.maxLength
and use that value directly in the UI:
<CharacterCounter
current={description.length}
max={maxLength}
/>
This means 500 exists in exactly one place — Product_Schema.
Form validation, runtime validation, and the UI all use the same rule.
#5. Third-Party Service Integration
But why are we talking only about the classic frontend ↔ backend integration?
Let us look at this framework from another angle.
Suppose your application integrates with a third-party API. Your code directly depends on the responses returned by an external service, but you have neither a direct communication channel with its team nor any guarantee that you will receive timely notifications about API changes.
In this scenario, contract testing can become even more valuable.
Your tests effectively turn into an early-warning system: if the external service changes the response structure, removes a field, adds a new one, or changes its format, you can find out automatically — before that change turns into a problem for your users.
In other words, the same framework that helps reduce communication and integration issues in an internal frontend ↔ backend setup can, in the case of a third-party API, partially compensate for the complete absence of that communication.
#6. AI Compatibility
I put a lot of effort into standardization, structural consistency, and semantics while designing this framework.
And this is where we get to reap the benefits.
The more predictable the repository architecture is, the easier it is for AI to understand its patterns and extend them correctly.
If the framework is implemented consistently, AI no longer has to decide every time where to create files, how to name schemas, how to group endpoints, or how to structure contract tests.
The repository already contains a pattern that the agent only needs to recognize and reproduce.
In practice, this means that when a new API module appears, I can simply give the AI a link to the Swagger specification.
From there, it can parse the API specification, identify business entities and endpoints, and generate the module according to the existing structure: schemas, types, abstractions, and contract tests.
And yes, this has been tested in practice.
Implementing a new API module with around 10 endpoints, including testing, takes about 16 minutes.
So standardization here improves more than developer experience.
It makes the repository genuinely AI-native: instead of repeatedly explaining to an agent how another module should be implemented, we give it an architecture where the correct implementation pattern is already encoded in the structure and semantics of the code.
Conclusion
A few numbers.
While working with a large API containing more than 100 endpoints, this framework allowed me to reduce structural integration bugs to almost zero, cut the reaction time to API changes to within a single day, and catch around 100 integration issues in just three months — without requiring additional communication with the backend team.
I consider that a fairly strong result.
At this point, the framework solves one of the classic problems of software development quite well: the constant need to stay synchronized with API changes and detect situations where the actual contract starts diverging from what the application expects.
Overall, I am happy with the result.
The framework solves all the core problems I originally designed it for, requires relatively little additional code, and provides significantly more value than API validation alone.
I would be very interested to hear your thoughts.
Do you use anything similar in your projects?
Where do you think this approach works well — and where do you think it starts to break down?
Top comments (0)