Validate API responses against OpenAPI/Swagger specifications, JSON Schema definitions, and consumer-driven contracts to prevent breaking changes
You are an expert QA engineer specializing in API contract validation. When the user asks you to write, review, or plan API contract tests, follow these detailed instructions to systematically verify that API responses conform to their published specifications, that backward compatibility is maintained across versions, and that consumer expectations are always met.
tests/
contracts/
openapi/
validate-responses.spec.ts # Validate responses against OpenAPI spec
validate-request.spec.ts # Validate request schemas
backward-compat.spec.ts # Backward compatibility checks
json-schema/
schema-validation.spec.ts # JSON Schema validation tests
schema-evolution.spec.ts # Schema change detection
consumer-driven/
consumer-contracts.spec.ts # Consumer-driven contract tests
pact-provider.spec.ts # Pact provider verification
graphql/
schema-validation.spec.ts # GraphQL schema validation
breaking-changes.spec.ts # GraphQL breaking change detection
fixtures/
api-client.ts # Typed API client helper
schema-loader.ts # Load and parse OpenAPI specs
contract-helpers.ts # Contract validation utilities
specs/
openapi.yaml # OpenAPI 3.x specification
schemas/ # JSON Schema definitions
user.schema.json
document.schema.json
error.schema.json
playwright.config.ts
// tests/contracts/fixtures/schema-loader.ts
import * as fs from 'fs';
import * as path from 'path';
import * as yaml from 'js-yaml';
export interface OpenAPISpec {
openapi: string;
info: { title: string; version: string };
paths: Record<string, Record<string, PathOperation>>;
components: { schemas: Record<string, JSONSchema> };
}
export interface PathOperation {
operationId: string;
summary?: string;
parameters?: ParameterObject[];
requestBody?: RequestBodyObject;
responses: Record<string, ResponseObject>;
}
export interface JSONSchema {
type?: string;
properties?: Record<string, JSONSchema>;
required?: string[];
items?: JSONSchema;
enum?: unknown[];
format?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
additionalProperties?: boolean | JSONSchema;
}
interface ParameterObject {
name: string;
in: string;
required?: boolean;
schema: JSONSchema;
}
interface RequestBodyObject {
required?: boolean;
content: Record<string, { schema: JSONSchema }>;
}
interface ResponseObject {
description: string;
content?: Record<string, { schema: JSONSchema }>;
headers?: Record<string, { schema: JSONSchema }>;
}
export function loadOpenAPISpec(specPath: string): OpenAPISpec {
const content = fs.readFileSync(specPath, 'utf-8');
if (specPath.endsWith('.yaml') || specPath.endsWith('.yml')) {
return yaml.load(content) as OpenAPISpec;
}
return JSON.parse(content);
}
export function loadJSONSchema(schemaPath: string): JSONSchema {
const content = fs.readFileSync(schemaPath, 'utf-8');
return JSON.parse(content);
}
export function getResponseSchema(
spec: OpenAPISpec,
path: string,
method: string,
statusCode: string
): JSONSchema | null {
const pathObj = spec.paths[path];
if (!pathObj) return null;
const operation = pathObj[method.toLowerCase()];
if (!operation) return null;
const response = operation.responses[statusCode] || operation.responses['default'];
if (!response?.content) return null;
const jsonContent = response.content['application/json'];
return jsonContent?.schema || null;
}
// tests/contracts/fixtures/contract-helpers.ts
import Ajv, { ErrorObject } from 'ajv';
import addFormats from 'ajv-formats';
import { JSONSchema } from './schema-loader';
const ajv = new Ajv({ allErrors: true, strict: false });
addFormats(ajv);
export interface ValidationResult {
valid: boolean;
errors: ErrorObject[] | null;
summary: string;
}
export function validateAgainstSchema(
data: unknown,
schema: JSONSchema
): ValidationResult {
const validate = ajv.compile(schema);
const valid = validate(data) as boolean;
return {
valid,
errors: validate.errors || null,
summary: valid
? 'Response matches schema'
: `Schema violations: ${(validate.errors || [])
.map((e) => `${e.instancePath} ${e.message}`)
.join('; ')}`,
};
}
export function checkBackwardCompatibility(
oldSchema: JSONSchema,
newSchema: JSONSchema
): { compatible: boolean; breakingChanges: string[] } {
const breakingChanges: string[] = [];
// Check for removed required fields
const oldRequired = new Set(oldSchema.required || []);
const newRequired = new Set(newSchema.required || []);
const oldProperties = oldSchema.properties || {};
const newProperties = newSchema.properties || {};
// Removed properties that were in old schema
for (const prop of Object.keys(oldProperties)) {
if (!(prop in newProperties)) {
breakingChanges.push(`Removed property: "${prop}"`);
}
}
// Type changes on existing properties
for (const [prop, oldPropSchema] of Object.entries(oldProperties)) {
if (prop in newProperties) {
const newPropSchema = newProperties[prop];
if (oldPropSchema.type !== newPropSchema.type) {
breakingChanges.push(
`Type changed for "${prop}": ${oldPropSchema.type} -> ${newPropSchema.type}`
);
}
}
}
// New required fields (breaking for existing consumers)
for (const field of newRequired) {
if (!oldRequired.has(field)) {
breakingChanges.push(`New required field added: "${field}"`);
}
}
// Enum value removal
for (const [prop, oldPropSchema] of Object.entries(oldProperties)) {
if (prop in newProperties && oldPropSchema.enum && newProperties[prop].enum) {
const removedValues = oldPropSchema.enum.filter(
(v) => !newProperties[prop].enum!.includes(v)
);
if (removedValues.length > 0) {
breakingChanges.push(
`Enum values removed from "${prop}": ${removedValues.join(', ')}`
);
}
}
}
return {
compatible: breakingChanges.length === 0,
breakingChanges,
};
}
// tests/contracts/openapi/validate-responses.spec.ts
import { test, expect } from '@playwright/test';
import { loadOpenAPISpec, getResponseSchema } from '../fixtures/schema-loader';
import { validateAgainstSchema } from '../fixtures/contract-helpers';
import * as path from 'path';
const spec = loadOpenAPISpec(path.resolve(__dirname, '../specs/openapi.yaml'));
test.describe('OpenAPI Response Validation', () => {
test('GET /api/users returns response matching spec', async ({ request }) => {
const response = await request.get('/api/users');
const status = response.status().toString();
const body = await response.json();
const schema = getResponseSchema(spec, '/api/users', 'get', status);
expect(schema, `No schema found for GET /api/users ${status}`).not.toBeNull();
const result = validateAgainstSchema(body, schema!);
expect(result.valid, result.summary).toBe(true);
});
test('GET /api/users/:id returns response matching spec', async ({ request }) => {
const response = await request.get('/api/users/1');
const status = response.status().toString();
const body = await response.json();
const schema = getResponseSchema(spec, '/api/users/{id}', 'get', status);
expect(schema).not.toBeNull();
const result = validateAgainstSchema(body, schema!);
expect(result.valid, result.summary).toBe(true);
});
test('POST /api/users error response matches error schema', async ({ request }) => {
// Send invalid data to trigger validation error
const response = await request.post('/api/users', {
data: { invalid: 'payload' },
});
const status = response.status().toString();
const body = await response.json();
const schema = getResponseSchema(spec, '/api/users', 'post', status);
if (schema) {
const result = validateAgainstSchema(body, schema);
expect(result.valid, result.summary).toBe(true);
}
// Verify standard error format
expect(body).toHaveProperty('error');
expect(typeof body.error).toBe('object');
if (body.error) {
expect(body.error).toHaveProperty('message');
expect(typeof body.error.message).toBe('string');
}
});
test('response content-type matches spec', async ({ request }) => {
const response = await request.get('/api/users');
const contentType = response.headers()['content-type'];
expect(contentType).toContain('application/json');
});
test('pagination response structure matches spec', async ({ request }) => {
const response = await request.get('/api/users?page=1&limit=10');
const body = await response.json();
// Standard pagination contract
expect(body).toHaveProperty('data');
expect(Array.isArray(body.data)).toBe(true);
expect(body).toHaveProperty('pagination');
expect(body.pagination).toHaveProperty('page');
expect(body.pagination).toHaveProperty('limit');
expect(body.pagination).toHaveProperty('total');
expect(body.pagination).toHaveProperty('totalPages');
expect(typeof body.pagination.page).toBe('number');
expect(typeof body.pagination.limit).toBe('number');
expect(typeof body.pagination.total).toBe('number');
expect(typeof body.pagination.totalPages).toBe('number');
});
test('validate all documented endpoints return conforming responses', async ({ request }) => {
const violations: string[] = [];
for (const [pathTemplate, pathObj] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(pathObj)) {
if (['get'].includes(method)) {
// Replace path parameters with test values
const resolvedPath = pathTemplate.replace(/{(\w+)}/g, '1');
try {
const response = await request.get(resolvedPath);
const status = response.status().toString();
const body = await response.json().catch(() => null);
if (body) {
const schema = getResponseSchema(spec, pathTemplate, method, status);
if (schema) {
const result = validateAgainstSchema(body, schema);
if (!result.valid) {
violations.push(
`${method.toUpperCase()} ${pathTemplate} (${status}): ${result.summary}`
);
}
}
}
} catch (error) {
// Skip unreachable endpoints
}
}
}
}
expect(
violations,
`Contract violations found:\n${violations.join('\n')}`
).toHaveLength(0);
});
});
// tests/contracts/json-schema/schema-validation.spec.ts
import { test, expect } from '@playwright/test';
import { loadJSONSchema } from '../fixtures/schema-loader';
import { validateAgainstSchema } from '../fixtures/contract-helpers';
import * as path from 'path';
const userSchema = loadJSONSchema(
path.resolve(__dirname, '../specs/schemas/user.schema.json')
);
const errorSchema = loadJSONSchema(
path.resolve(__dirname, '../specs/schemas/error.schema.json')
);
test.describe('JSON Schema Validation', () => {
test('user object conforms to user schema', async ({ request }) => {
const response = await request.get('/api/users/1');
expect(response.status()).toBe(200);
const user = await response.json();
const result = validateAgainstSchema(user, userSchema);
expect(result.valid, result.summary).toBe(true);
});
test('user list items all conform to user schema', async ({ request }) => {
const response = await request.get('/api/users');
expect(response.status()).toBe(200);
const body = await response.json();
const users = body.data || body;
for (let i = 0; i < users.length; i++) {
const result = validateAgainstSchema(users[i], userSchema);
expect(result.valid, `User at index ${i}: ${result.summary}`).toBe(true);
}
});
test('error responses conform to error schema', async ({ request }) => {
const response = await request.get('/api/users/nonexistent-id');
if (response.status() >= 400) {
const error = await response.json();
const result = validateAgainstSchema(error, errorSchema);
expect(result.valid, result.summary).toBe(true);
}
});
test('required fields are always present', async ({ request }) => {
const response = await request.get('/api/users/1');
const user = await response.json();
const requiredFields = userSchema.required || [];
for (const field of requiredFields) {
expect(
user,
`Required field "${field}" is missing from user response`
).toHaveProperty(field);
}
});
test('field types match schema definitions', async ({ request }) => {
const response = await request.get('/api/users/1');
const user = await response.json();
const properties = userSchema.properties || {};
for (const [field, fieldSchema] of Object.entries(properties)) {
if (user[field] !== undefined && user[field] !== null) {
switch (fieldSchema.type) {
case 'string':
expect(typeof user[field], `${field} should be string`).toBe('string');
break;
case 'number':
case 'integer':
expect(typeof user[field], `${field} should be number`).toBe('number');
break;
case 'boolean':
expect(typeof user[field], `${field} should be boolean`).toBe('boolean');
break;
case 'array':
expect(Array.isArray(user[field]), `${field} should be array`).toBe(true);
break;
case 'object':
expect(typeof user[field], `${field} should be object`).toBe('object');
break;
}
}
}
});
test('string format constraints are enforced', async ({ request }) => {
const response = await request.get('/api/users/1');
const user = await response.json();
const properties = userSchema.properties || {};
for (const [field, fieldSchema] of Object.entries(properties)) {
if (user[field] && fieldSchema.type === 'string') {
if (fieldSchema.format === 'email') {
expect(user[field]).toMatch(/^[^\s@]+@[^\s@]+\.[^\s@]+$/);
}
if (fieldSchema.format === 'date-time') {
expect(new Date(user[field]).toISOString()).toBeTruthy();
}
if (fieldSchema.format === 'uri') {
expect(() => new URL(user[field])).not.toThrow();
}
if (fieldSchema.minLength) {
expect(user[field].length).toBeGreaterThanOrEqual(fieldSchema.minLength);
}
if (fieldSchema.maxLength) {
expect(user[field].length).toBeLessThanOrEqual(fieldSchema.maxLength);
}
}
}
});
});
// tests/contracts/openapi/backward-compat.spec.ts
import { test, expect } from '@playwright/test';
import { loadOpenAPISpec } from '../fixtures/schema-loader';
import { checkBackwardCompatibility } from '../fixtures/contract-helpers';
import * as path from 'path';
test.describe('Backward Compatibility', () => {
test('current schema is backward compatible with previous version', () => {
const previousSpec = loadOpenAPISpec(
path.resolve(__dirname, '../specs/openapi-v1.yaml')
);
const currentSpec = loadOpenAPISpec(
path.resolve(__dirname, '../specs/openapi.yaml')
);
const schemasToCheck = ['User', 'Document', 'Error'];
for (const schemaName of schemasToCheck) {
const oldSchema = previousSpec.components.schemas[schemaName];
const newSchema = currentSpec.components.schemas[schemaName];
if (oldSchema && newSchema) {
const result = checkBackwardCompatibility(oldSchema, newSchema);
expect(
result.compatible,
`Breaking changes in ${schemaName}:\n${result.breakingChanges.join('\n')}`
).toBe(true);
}
}
});
test('API version header is present and correct', async ({ request }) => {
const response = await request.get('/api/users');
const apiVersion = response.headers()['api-version'] ||
response.headers()['x-api-version'];
expect(apiVersion).toBeDefined();
expect(apiVersion).toMatch(/^\d+\.\d+\.\d+$/);
});
test('deprecated fields still present but marked', async ({ request }) => {
const response = await request.get('/api/users/1');
const body = await response.json();
// If deprecated fields exist, they should still be present for backward compat
const spec = loadOpenAPISpec(path.resolve(__dirname, '../specs/openapi.yaml'));
const userSchema = spec.components.schemas['User'];
if (userSchema?.properties) {
for (const [field, fieldSchema] of Object.entries(userSchema.properties)) {
if ((fieldSchema as Record<string, unknown>).deprecated) {
// Deprecated fields should still be in the response
expect(
body,
`Deprecated field "${field}" removed before deprecation period ended`
).toHaveProperty(field);
}
}
}
});
test('new required fields are not added without version bump', async ({ request }) => {
const v1Response = await request.get('/api/v1/users/1');
const v2Response = await request.get('/api/v2/users/1');
if (v1Response.status() === 200 && v2Response.status() === 200) {
const v1Body = await v1Response.json();
const v2Body = await v2Response.json();
const v1Fields = new Set(Object.keys(v1Body));
const v2Fields = new Set(Object.keys(v2Body));
// All v1 fields must still exist in v2
for (const field of v1Fields) {
expect(
v2Fields.has(field),
`Field "${field}" from v1 is missing in v2`
).toBe(true);
}
}
});
});
// src/test/java/contracts/ApiContractTest.java
package contracts;
import io.restassured.RestAssured;
import io.restassured.module.jsv.JsonSchemaValidator;
import io.restassured.response.Response;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static io.restassured.RestAssured.*;
import static org.hamcrest.Matchers.*;
public class ApiContractTest {
@BeforeAll
static void setup() {
RestAssured.baseURI = System.getProperty("api.baseUrl", "http://localhost:3000");
}
@Test
@DisplayName("GET /api/users response matches JSON Schema")
void getUsersResponseMatchesSchema() {
given()
.header("Accept", "application/json")
.when()
.get("/api/users")
.then()
.statusCode(200)
.contentType("application/json")
.body(JsonSchemaValidator.matchesJsonSchemaInClasspath(
"schemas/users-list-response.json"
));
}
@Test
@DisplayName("GET /api/users/:id response matches User schema")
void getUserByIdMatchesSchema() {
given()
.header("Accept", "application/json")
.pathParam("id", 1)
.when()
.get("/api/users/{id}")
.then()
.statusCode(200)
.contentType("application/json")
.body(JsonSchemaValidator.matchesJsonSchemaInClasspath(
"schemas/user.schema.json"
))
.body("id", notNullValue())
.body("email", matchesPattern("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"))
.body("createdAt", matchesPattern(
"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}"
));
}
@Test
@DisplayName("Error responses follow standard error contract")
void errorResponseFollowsContract() {
given()
.header("Accept", "application/json")
.when()
.get("/api/users/nonexistent")
.then()
.statusCode(anyOf(is(404), is(400)))
.contentType("application/json")
.body("error", notNullValue())
.body("error.message", not(emptyOrNullString()))
.body("error.code", notNullValue());
}
@Test
@DisplayName("Pagination contract is consistent across endpoints")
void paginationContractConsistency() {
String[] paginatedEndpoints = {
"/api/users",
"/api/documents",
"/api/reports"
};
for (String endpoint : paginatedEndpoints) {
Response response = given()
.queryParam("page", 1)
.queryParam("limit", 10)
.when()
.get(endpoint);
if (response.statusCode() == 200) {
response.then()
.body("data", instanceOf(java.util.List.class))
.body("pagination.page", equalTo(1))
.body("pagination.limit", equalTo(10))
.body("pagination.total", instanceOf(Integer.class))
.body("pagination.totalPages", instanceOf(Integer.class));
}
}
}
@ParameterizedTest
@ValueSource(strings = {"application/json", "application/xml"})
@DisplayName("Content negotiation returns
<!-- Content truncated for initial SEO render. Open the source file tab for the full file. -->
npx skills add PramodDutta/API Contract Validator下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer
Tags:api-contract, openapi, swagger, json-schema, contract-testing, schema-validation, api-compatibility, breaking-changes