mcpField
C
contract-testing-builder

contract-testing-builder

github.com/patricio0312rev测试与质量63253 次安装41 周前更新
代码仓库

关于 contract-testing-builder

contract-testing-builder 是用于检查 API 变更是否仍兼容消费者的测试技能,涵盖 Pact 消费者驱动测试、提供方验证、OpenAPI 与 JSON Schema 校验及 CI 检查。

主要功能

  1. 01消费者驱动的 Pact 测试
  2. 02提供方契约验证
  3. 03OpenAPI 契约校验
  4. 04JSON Schema 校验
  5. 05破坏性变更检查
  6. 06CI 契约测试

适用场景

  1. 01检查团队间的 API 兼容性
  2. 02按消费者预期验证提供方
  3. 03依据 OpenAPI 或 JSON Schema 校验响应
  4. 04在 CI 中检测破坏性变更

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/patricio0312rev/skills --skill contract-testing-builder

安装后在 Claude Code 中输入/contract-testing-builder确认已加载。

详细介绍

契约测试构建器

确保 API 契约不会破坏消费者。

契约测试概念

Consumer → Defines expected contract → Provider must satisfy

Benefits:
- Catch breaking changes early
- Independent development
- Fast feedback (no integration env needed)
- Documentation as code

Pact 设置(消费者端)

// 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: "[email protected]",
          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("[email protected]");
      });
  });

  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 验证(提供方端)

// 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: "[email protected]",
            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 契约测试

# 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

契约验证(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 验证

// 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: "[email protected]",
      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: "[email protected]",
      // 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 集成

# .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 }}

破坏性变更检测

// tests/breaking-changes.test.ts
describe("Breaking Change Detection", () => {
  it("should not remove required fields", async () => {
    const v1Response = {
      id: "123",
      email: "[email protected]",
      name: "John Doe",
      role: "USER",
    };

    const v2Response = {
      id: "123",
      email: "[email protected]",
      // 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);
  });
});

契约文档

# API Contract Documentation

## User API Contract

### Consumer: UserWebApp

### Provider: UserAPI

### Interactions

#### Get User by ID

**Request:**

```http
GET /api/users/{id}
Authorization: Bearer {token}
```

响应(200):

{
  "id": "string",
  "email": "string (email format)",
  "name": "string",
  "role": "USER | ADMIN",
  "createdAt": "string (ISO 8601)"
}

响应(404):

{
  "error": "User not found"
}
提供方状态
  • 用户 {id} 存在:数据库中存在指定 ID 的用户
  • 用户 {id} 不存在:数据库中不存在指定 ID 的用户
破坏性变更政策
  1. 不得移除必填字段
  2. 不得更改字段类型
  3. 不得移除枚举值
  4. 可以添加可选字段
  5. 可提前 6 个月通知后弃用

## Best Practices

1. **Consumer-driven**: Consumers define expectations
2. **Test early**: Run in CI on every commit
3. **Use Pact Broker**: Central contract repository
4. **Provider states**: Setup test data properly
5. **Version contracts**: Track API versions
6. **Document changes**: Clear migration guides
7. **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

评论

支持 Markdown