Contract Testing Builder
Ensure API contracts don't break consumers.
Contract Testing Concepts
Consumer → Defines expected contract → Provider must satisfy
Benefits:
- Catch breaking changes early
- Independent development
- Fast feedback (no integration env needed)
- Documentation as code
Pact Setup (Consumer Side)
// consumer/tests/pacts/user-api.pact.test.ts import { PactV3 } from "@pact-foundation/pact"; import { userApi } from "../api/userApi";
const provider = new PactV3({ consumer: "UserWebApp", provider: "UserAPI", dir: path.resolve(__dirname, "../../pacts"), });
describe("User API Contract", () => { it("should get user by ID", async () => { // Define expected interaction await provider .given("user 123 exists") .uponReceiving("a request for user 123") .withRequest({ method: "GET", path: "/api/users/123", headers: { Authorization: "Bearer token123", }, }) .willRespondWith({ status: 200, headers: { "Content-Type": "application/json", }, body: { id: "123", email: "john@example.com", name: "John Doe", role: "USER", createdAt: like("2024-01-01T00:00:00Z"), }, }) .executeTest(async (mockServer) => { // Make actual API call against mock server const user = await userApi.getUser("123", mockServer.url);
// Verify consumer can handle response
expect(user.id).toBe("123");
expect(user.email).toBe("john@example.com");
});
});
it("should return 404 when user not found", async () => { await provider .given("user 999 does not exist") .uponReceiving("a request for non-existent user") .withRequest({ method: "GET", path: "/api/users/999", }) .willRespondWith({ status: 404, headers: { "Content-Type": "application/json", }, body: { error: "User not found", }, }) .executeTest(async (mockServer) => { await expect(userApi.getUser("999", mockServer.url)).rejects.toThrow( "User not found" ); }); }); });
Pact Verification (Provider Side)
// provider/tests/pacts/verify.test.ts import { Verifier } from "@pact-foundation/pact"; import { app } from "../src/app";
describe("Pact Verification", () => { let server: Server;
beforeAll(async () => { server = app.listen(3000); });
afterAll(() => { server.close(); });
it("should validate consumer contracts", async () => { const verifier = new Verifier({ provider: "UserAPI", providerBaseUrl: "http://localhost:3000",
// Fetch pacts from broker or local files
pactUrls: [
path.resolve(__dirname, "../../pacts/UserWebApp-UserAPI.json"),
],
// Provider states setup
stateHandlers: {
"user 123 exists": async () => {
// Seed database with user 123
await db.user.create({
id: "123",
email: "john@example.com",
name: "John Doe",
role: "USER",
});
},
"user 999 does not exist": async () => {
// Ensure user 999 doesn't exist
await db.user.deleteMany({ where: { id: "999" } });
},
},
// Teardown after each test
afterEach: async () => {
await db.$executeRaw`TRUNCATE TABLE users CASCADE`;
},
});
await verifier.verifyProvider();
}); });
OpenAPI Contract Testing
contracts/user-api.yaml
openapi: 3.0.0 info: title: User API version: 1.0.0
paths: /api/users/{id}: get: parameters: - name: id in: path required: true schema: type: string responses: "200": description: User found content: application/json: schema: $ref: "#/components/schemas/User" "404": description: User not found content: application/json: schema: $ref: "#/components/schemas/Error"
components: schemas: User: type: object required: - id - email - name - role properties: id: type: string email: type: string format: email name: type: string role: type: string enum: [USER, ADMIN] createdAt: type: string format: date-time
Contract Validation (OpenAPI)
// tests/contract-validation.test.ts import * as OpenAPIValidator from "express-openapi-validator"; import * as fs from "fs"; import * as yaml from "js-yaml";
describe("API Contract Validation", () => { it("should match OpenAPI spec", async () => { const spec = yaml.load( fs.readFileSync("./contracts/user-api.yaml", "utf8") );
app.use(
OpenAPIValidator.middleware({
apiSpec: spec,
validateRequests: true,
validateResponses: true,
})
);
// Valid request - should pass
await request(app)
.get("/api/users/123")
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty("id");
expect(res.body).toHaveProperty("email");
expect(res.body).toHaveProperty("name");
expect(res.body).toHaveProperty("role");
});
});
it("should reject invalid responses", async () => { // Mock endpoint that returns invalid data app.get("/api/invalid", (req, res) => { res.json({ id: "123", // Missing required fields! }); });
// Should fail validation
await request(app).get("/api/invalid").expect(500);
}); });
JSON Schema Validation
// schemas/user.schema.ts export const userSchema = { type: "object", required: ["id", "email", "name", "role"], properties: { id: { type: "string" }, email: { type: "string", format: "email" }, name: { type: "string", minLength: 1 }, role: { type: "string", enum: ["USER", "ADMIN"] }, createdAt: { type: "string", format: "date-time" }, }, additionalProperties: false, };
// tests/schema-validation.test.ts import Ajv from "ajv"; import addFormats from "ajv-formats";
const ajv = new Ajv(); addFormats(ajv);
describe("User Schema Validation", () => { const validate = ajv.compile(userSchema);
it("should validate correct user object", () => { const user = { id: "123", email: "john@example.com", name: "John Doe", role: "USER", createdAt: "2024-01-01T00:00:00Z", };
expect(validate(user)).toBe(true);
});
it("should reject missing required fields", () => { const user = { id: "123", email: "john@example.com", // Missing name and role };
expect(validate(user)).toBe(false);
expect(validate.errors).toContainEqual(
expect.objectContaining({
message: "must have required property 'name'",
})
);
});
it("should reject invalid email format", () => { const user = { id: "123", email: "invalid-email", name: "John Doe", role: "USER", };
expect(validate(user)).toBe(false);
}); });
CI Integration
.github/workflows/contract-tests.yml
name: Contract Tests
on: [push, pull_request]
jobs: consumer-tests: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Run consumer tests
run: npm run test:pact
- name: Publish pacts
run: |
npx pact-broker publish \
./pacts \
--consumer-app-version=${{ github.sha }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
provider-tests: runs-on: ubuntu-latest needs: consumer-tests steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4
- name: Verify provider
run: npm run test:pact:verify
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
Breaking Change Detection
// tests/breaking-changes.test.ts describe("Breaking Change Detection", () => { it("should not remove required fields", async () => { const v1Response = { id: "123", email: "john@example.com", name: "John Doe", role: "USER", };
const v2Response = {
id: "123",
email: "john@example.com",
// Missing 'name' - BREAKING CHANGE!
role: "USER",
};
// Validate v2 still has all v1 required fields
const v1Keys = Object.keys(v1Response);
const v2Keys = Object.keys(v2Response);
const missingFields = v1Keys.filter((key) => !v2Keys.includes(key));
expect(missingFields).toHaveLength(0);
});
it("should not change field types", async () => { const v1Response = { id: "123", // string age: 25, // number };
const v2Response = {
id: 123, // number - BREAKING CHANGE!
age: "25", // string - BREAKING CHANGE!
};
expect(typeof v2Response.id).toBe(typeof v1Response.id);
expect(typeof v2Response.age).toBe(typeof v1Response.age);
}); });
Contract Documentation
API Contract Documentation
User API Contract
Consumer: UserWebApp
Provider: UserAPI
Interactions
Get User by ID
Request:
GET /api/users/{id}
Authorization: Bearer {token}
Response (200):
{ "id": "string", "email": "string (email format)", "name": "string", "role": "USER | ADMIN", "createdAt": "string (ISO 8601)" }
Response (404):
{ "error": "User not found" }
Provider States
-
user {id} exists: User with given ID exists in database
-
user {id} does not exist: User with given ID does not exist
Breaking Change Policy
-
Cannot remove required fields
-
Cannot change field types
-
Cannot remove enum values
-
Can add optional fields
-
Can deprecate with 6-month notice
Best Practices
- Consumer-driven: Consumers define expectations
- Test early: Run in CI on every commit
- Use Pact Broker: Central contract repository
- Provider states: Setup test data properly
- Version contracts: Track API versions
- Document changes: Clear migration guides
- Monitor compliance: Track contract violations
Output Checklist
- Contract test framework chosen (Pact/OpenAPI)
- Consumer tests written
- Provider verification configured
- Provider states implemented
- Schema validation added
- Breaking change detection
- CI integration configured
- Contract documentation
- Pact Broker setup (if using Pact)
- Versioning strategy defined