Files
gh-tachyon-beep-skillpacks-…/skills/contract-testing/SKILL.md
2025-11-30 08:59:43 +08:00

525 lines
13 KiB
Markdown

---
name: contract-testing
description: Use when implementing Pact contracts, choosing consumer-driven vs provider-driven approaches, handling breaking API changes, setting up contract brokers, or preventing service integration issues - provides tool selection, anti-patterns, and workflow patterns
---
# Contract Testing
## Overview
**Core principle:** Test the contract, not the implementation. Verify integration points independently.
**Rule:** Contract tests catch breaking changes before deployment, not in production.
## Tool Selection Decision Tree
| Your Stack | Team Structure | Use | Why |
|-----------|----------------|-----|-----|
| Polyglot microservices | Multiple teams | **Pact** | Language-agnostic, mature broker |
| Java Spring ecosystem | Coordinated teams | **Spring Cloud Contract** | Spring integration, code-first |
| GraphQL APIs | Known consumers | **Pact + GraphQL** | Query validation |
| OpenAPI/REST | Public/many consumers | **OpenAPI Spec Testing** | Schema-first, documentation |
**First choice:** Pact (most mature ecosystem, widest language support)
**Why contract testing:** Catches API breaking changes in CI, not production. Teams test independently without running dependencies.
## Contract Type Decision Framework
| Scenario | Approach | Tools |
|----------|----------|-------|
| **Internal microservices, known consumers** | Consumer-Driven (CDC) | Pact, Spring Cloud Contract |
| **Public API, many unknown consumers** | Provider-Driven (Schema-First) | OpenAPI validation, Spectral |
| **Both internal and external consumers** | Bi-Directional | Pact + OpenAPI |
| **Event-driven/async messaging** | Message Pact | Pact (message provider/consumer) |
**Default:** Consumer-driven for internal services, schema-first for public APIs
## Anti-Patterns Catalog
### ❌ Over-Specification
**Symptom:** Contract tests verify exact response format, including fields consumer doesn't use
**Why bad:** Brittle tests, provider can't evolve API, false positives
**Fix:** Only specify what consumer actually uses
```javascript
// ❌ Bad - over-specified
.willRespondWith({
status: 200,
body: {
id: 123,
name: 'John',
email: 'john@example.com',
created_at: '2023-01-01',
updated_at: '2023-01-02',
phone: '555-1234',
address: {...} // Consumer doesn't use these
}
})
// ✅ Good - specify only what's used
.willRespondWith({
status: 200,
body: {
id: Matchers.integer(123),
name: Matchers.string('John')
}
})
```
---
### ❌ Testing Implementation Details
**Symptom:** Contract tests verify database queries, internal logic, or response timing
**Why bad:** Couples tests to implementation, not contract
**Fix:** Test only request/response contract, not how provider implements it
```javascript
// ❌ Bad - testing implementation
expect(provider.database.queryCalled).toBe(true)
// ✅ Good - testing contract only
expect(response.status).toBe(200)
expect(response.body.name).toBe('John')
```
---
### ❌ Brittle Provider States
**Symptom:** Provider states hardcode IDs, dates, or specific data that changes
**Why bad:** Tests fail randomly, high maintenance
**Fix:** Use matchers, generate data in state setup
```javascript
// ❌ Bad - hardcoded state
.given('user 123 exists')
.uponReceiving('request for user 123')
.withRequest({ path: '/users/123' })
// ✅ Good - flexible state
.given('a user exists')
.uponReceiving('request for user')
.withRequest({ path: Matchers.regex('/users/\\d+', '/users/123') })
.willRespondWith({
body: {
id: Matchers.integer(123),
name: Matchers.string('John')
}
})
```
---
### ❌ No Contract Versioning
**Symptom:** Breaking changes deployed without consumer coordination
**Why bad:** Runtime failures, production incidents
**Fix:** Use can-i-deploy, tag contracts by environment
```bash
# ✅ Good - check before deploying
pact-broker can-i-deploy \
--pacticipant UserService \
--version 2.0.0 \
--to production
```
---
### ❌ Missing Can-I-Deploy
**Symptom:** Deploying without checking if all consumers compatible
**Why bad:** Deploy provider changes that break consumers
**Fix:** Run can-i-deploy in CI before deployment
## Pact Broker Workflow
**Core workflow:**
1. **Consumer:** Write contract test → Generate pact file
2. **Consumer CI:** Publish pact to broker with version tag
3. **Provider CI:** Fetch contracts → Verify → Publish results
4. **Provider CD:** Run can-i-deploy → Deploy if compatible
### Publishing Contracts
```bash
# Consumer publishes pact with version and branch
pact-broker publish pacts/ \
--consumer-app-version ${GIT_SHA} \
--branch ${GIT_BRANCH} \
--tag ${ENV}
```
### Verifying Contracts
```javascript
// Provider verifies against broker
const { Verifier } = require('@pact-foundation/pact')
new Verifier({
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_URL,
provider: 'UserService',
publishVerificationResult: true,
providerVersion: process.env.GIT_SHA,
consumerVersionSelectors: [
{ mainBranch: true }, // Latest from main
{ deployed: 'production' }, // Currently in production
{ deployed: 'staging' } // Currently in staging
]
}).verifyProvider()
```
### Can-I-Deploy Check
```yaml
# CI/CD pipeline (GitHub Actions example)
- name: Check if can deploy
run: |
pact-broker can-i-deploy \
--pacticipant UserService \
--version ${{ github.sha }} \
--to-environment production
```
**Rule:** Never deploy without can-i-deploy passing
## Breaking Change Taxonomy
| Change Type | Breaking? | Migration Strategy |
|-------------|-----------|-------------------|
| Add optional field | No | Deploy provider first |
| Add required field | Yes | Use expand/contract pattern |
| Remove field | Yes | Deprecate → verify no consumers use → remove |
| Change field type | Yes | Add new field → migrate consumers → remove old |
| Rename field | Yes | Add new → deprecate old → remove old |
| Change status code | Yes | Version API or expand responses |
### Expand/Contract Pattern
**For adding required field:**
**Expand (Week 1-2):**
```javascript
// Provider adds NEW field (optional), keeps OLD field
{
user_name: "John", // Old field (deprecated)
name: "John" // New field
}
```
**Migrate (Week 3-4):**
- Consumers update to use new field
- Update contracts
- Verify all consumers migrated
**Contract (Week 5):**
```javascript
// Provider removes old field
{
name: "John" // Only new field remains
}
```
## Provider State Patterns
**Purpose:** Set up test data before verification
**Pattern:** Use state handlers to create/clean up data
```javascript
// Provider state setup
const { Verifier } = require('@pact-foundation/pact')
new Verifier({
stateHandlers: {
'a user exists': async () => {
// Setup: Create test user
await db.users.create({
id: 123,
name: 'John Doe'
})
},
'no users exist': async () => {
// Setup: Clear users
await db.users.deleteAll()
}
},
afterEach: async () => {
// Cleanup after each verification
await db.users.deleteAll()
}
}).verifyProvider()
```
**Best practices:**
- States should be independent
- Clean up after each verification
- Use transactions for database tests
- Don't hardcode IDs (use matchers)
## Async/Event-Driven Messaging Contracts
**For Kafka, RabbitMQ, SNS/SQS:** Use Message Pact (different API than HTTP Pact)
### Consumer Message Contract
```javascript
const { MessageConsumerPact, MatchersV3 } = require('@pact-foundation/pact')
describe('User Event Consumer', () => {
const messagePact = new MessageConsumerPact({
consumer: 'NotificationService',
provider: 'UserService'
})
it('processes user created events', () => {
return messagePact
.expectsToReceive('user created event')
.withContent({
userId: MatchersV3.integer(123),
email: MatchersV3.string('user@example.com'),
eventType: 'USER_CREATED'
})
.withMetadata({
'content-type': 'application/json'
})
.verify((message) => {
processUserCreatedEvent(message.contents)
})
})
})
```
### Provider Message Verification
```javascript
// Provider verifies it can produce matching messages
const { MessageProviderPact } = require('@pact-foundation/pact')
describe('User Event Producer', () => {
it('publishes user created events matching contracts', () => {
return new MessageProviderPact({
messageProviders: {
'user created event': () => ({
contents: {
userId: 123,
email: 'test@example.com',
eventType: 'USER_CREATED'
},
metadata: {
'content-type': 'application/json'
}
})
}
}).verify()
})
})
```
### Key Differences from HTTP Contracts
- **No request/response:** Only message payload
- **Metadata:** Headers, content-type, message keys
- **Ordering:** Don't test message ordering in contracts (infrastructure concern)
- **Delivery:** Don't test delivery guarantees (wrong layer)
**Workflow:** Same as HTTP (publish pact → verify → can-i-deploy)
## CI/CD Integration Quick Reference
### GitHub Actions
```yaml
# Consumer publishes contracts
- name: Run Pact tests
run: npm test
- name: Publish pacts
run: |
npm run pact:publish
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
# Provider verifies and checks deployment
- name: Verify contracts
run: npm run pact:verify
- name: Can I deploy?
run: |
pact-broker can-i-deploy \
--pacticipant UserService \
--version ${{ github.sha }} \
--to-environment production
```
### GitLab CI
```yaml
pact_test:
script:
- npm test
- npm run pact:publish
pact_verify:
script:
- npm run pact:verify
- pact-broker can-i-deploy --pacticipant UserService --version $CI_COMMIT_SHA --to-environment production
```
## Your First Contract Test
**Goal:** Prevent breaking changes between two services in one week
**Day 1-2: Consumer Side**
```javascript
// Install Pact
npm install --save-dev @pact-foundation/pact
// Consumer contract test (order-service)
const { PactV3, MatchersV3 } = require('@pact-foundation/pact')
const { getUserById } = require('./userClient')
describe('User API', () => {
const provider = new PactV3({
consumer: 'OrderService',
provider: 'UserService'
})
it('gets user by id', () => {
provider
.given('a user exists')
.uponReceiving('a request for user')
.withRequest({
method: 'GET',
path: '/users/123'
})
.willRespondWith({
status: 200,
body: {
id: MatchersV3.integer(123),
name: MatchersV3.string('John')
}
})
return provider.executeTest(async (mockServer) => {
const user = await getUserById(mockServer.url, 123)
expect(user.name).toBe('John')
})
})
})
```
**Day 3-4: Set Up Pact Broker**
```bash
# Docker Compose
docker-compose up -d
# Or use hosted Pactflow (SaaS)
# https://pactflow.io
```
**Day 5-6: Provider Side**
```javascript
// Provider verification (user-service)
const { Verifier } = require('@pact-foundation/pact')
const app = require('./app')
describe('Pact Verification', () => {
it('validates contracts from broker', () => {
return new Verifier({
provider: 'UserService',
providerBaseUrl: 'http://localhost:8080',
pactBrokerUrl: process.env.PACT_BROKER_URL,
publishVerificationResult: true,
providerVersion: '1.0.0',
stateHandlers: {
'a user exists': async () => {
await db.users.create({ id: 123, name: 'John' })
}
}
}).verifyProvider()
})
})
```
**Day 7: Add to CI**
```yaml
# Add can-i-deploy before deployment
- pact-broker can-i-deploy --pacticipant UserService --version $VERSION --to production
```
## Common Mistakes
### ❌ Testing Business Logic in Contracts
**Fix:** Contract tests verify integration only. Test business logic separately.
---
### ❌ Not Using Matchers
**Fix:** Use `Matchers.string()`, `Matchers.integer()` for flexible matching
---
### ❌ Skipping Can-I-Deploy
**Fix:** Always run can-i-deploy before deployment. Automate in CI.
---
### ❌ Hardcoding Test Data
**Fix:** Generate data in provider states, use matchers in contracts
## Quick Reference
**Tool Selection:**
- Polyglot/multiple teams: Pact
- Java Spring only: Spring Cloud Contract
- Public API: OpenAPI validation
**Contract Type:**
- Internal services: Consumer-driven (Pact)
- Public API: Provider-driven (OpenAPI)
- Both: Bi-directional
**Pact Broker Workflow:**
1. Consumer publishes pact
2. Provider verifies
3. Can-i-deploy checks compatibility
4. Deploy if compatible
**Breaking Changes:**
- Add optional field: Safe
- Add required field: Expand/contract pattern
- Remove/rename field: Deprecate → migrate → remove
**Provider States:**
- Set up test data
- Clean up after each test
- Use transactions for DB
- Don't hardcode IDs
**CI/CD:**
- Consumer: Test → publish pacts
- Provider: Verify → can-i-deploy → deploy
## Bottom Line
**Contract testing prevents API breaking changes by testing integration points independently. Use Pact for internal microservices, publish contracts to broker, run can-i-deploy before deployment.**
Test the contract (request/response), not the implementation. Use consumer-driven contracts for known consumers, schema-first for public APIs.