contract-testing
サービス間のAPI互換性を検証するコントラクトテストの構築・改善の際に使用します。Pact(コンシューマー駆動型コントラクト)、PactFlow(双方向コントラクト)、Spring Cloud Contract、Pact Broker、can-i-deployの各ツールと、コントラクトテスト手法の選定戦略に対応しています。 使用対象:Pact、PactFlow、Spring Cloud Contract、コンシューマー駆動型コントラクト、双方向コントラクト、API互換性検証、can-i-deploy、Pact Broker 使用非対象:ユーザーフロー全体のテスト(e2e-testingを使用)、API機能テスト(api-testingを使用)、ユニットテスト(unit-testingを使用)
description の原文を見る
Use when setting up or improving contract tests that verify API compatibility between services. Covers Pact (consumer-driven contracts), PactFlow (bi-directional contracts), Spring Cloud Contract, Pact Broker, can-i-deploy, and strategies for choosing between contract testing approaches. USE FOR: Pact, PactFlow, Spring Cloud Contract, consumer-driven contracts, bi-directional contracts, API compatibility verification, can-i-deploy, Pact Broker DO NOT USE FOR: full user flow tests (use e2e-testing), API functional testing (use api-testing), unit testing (use unit-testing)
SKILL.md 本文
コントラクトテスト — サービス間の API 互換性の検証
概要
コントラクトテストは、2 つのサービス(コンシューマーとプロバイダー)が両方とも同時に実行されていなくても正しく通信できることを検証します。全体の統合テストではなく、各側がコントラクトに対して検証します。コントラクトは、期待されるリクエスト/レスポンスのやり取りの正式な仕様です。
E2E テストの代わりにコントラクトテストを使用する場合: マイクロサービスアーキテクチャでは E2E テストが遅くなり、不安定になり、数十のサービスにわたって維持するのが高くつきます。
コントラクトテストのアプローチ
| アプローチ | 仕組み | 最適な用途 |
|---|---|---|
| コンシューマー主導 | コンシューマーがコントラクトを作成し、プロバイダーが検証 | 最も一般的 — コンシューマーが必要な内容を認識 |
| プロバイダー主導 | プロバイダーが API 仕様を公開し、コンシューマーが対して検証 | 多くの未知のコンシューマーがいる API |
| 双方向 | 両方とも共有の仕様(OpenAPI など)に対して検証 | 既に OpenAPI 仕様を保守しているチーム |
コンシューマー主導コントラクトテスト(Pact ワークフロー)
Consumer Pact Broker Provider
│ │ │
├─ 1. Write consumer test ──►│ │
│ (generates pact file) │ │
│ │ │
├─ 2. Publish pact ─────────►│ │
│ │ │
│ │◄── 3. Fetch pact ──────────┤
│ │ │
│ │ 4. Provider verifies ───►│
│ │ against pact │
│ │ │
│ │◄── 5. Publish result ──────┤
│ │ │
├── 6. can-i-deploy? ──────►│ │
│ (checks compatibility) │ │
│ │ │
Pact
Pact は最も広く使用されているコントラクトテストフレームワークで、多くの言語にわたってコンシューマー主導のコントラクトをサポートしています。
JavaScript — コンシューマーテスト
// consumer/user-client.pact.test.ts
import { PactV4, MatchersV3 } from '@pact-foundation/pact';
import { UserClient } from './user-client';
const { like, eachLike, regex, integer, string } = MatchersV3;
const provider = new PactV4({
consumer: 'UserWebApp',
provider: 'UserService',
logLevel: 'warn',
});
describe('UserClient', () => {
it('should fetch a user by ID', async () => {
// Define the expected interaction
await provider
.addInteraction()
.given('user 123 exists')
.uponReceiving('a request for user 123')
.withRequest('GET', '/api/users/123', (builder) => {
builder.headers({ Accept: 'application/json' });
})
.willRespondWith(200, (builder) => {
builder
.headers({ 'Content-Type': 'application/json' })
.jsonBody({
id: integer(123),
name: string('Alice'),
email: regex('alice@example.com', '^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$'),
role: string('admin'),
});
})
.executeTest(async (mockServer) => {
// Point the client at the mock server
const client = new UserClient(mockServer.url);
// Call the client — it talks to the Pact mock
const user = await client.getUser(123);
// Verify the client handles the response correctly
expect(user.id).toBe(123);
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
});
});
it('should fetch a list of users', async () => {
await provider
.addInteraction()
.given('users exist')
.uponReceiving('a request for all users')
.withRequest('GET', '/api/users', (builder) => {
builder.headers({ Accept: 'application/json' });
})
.willRespondWith(200, (builder) => {
builder
.headers({ 'Content-Type': 'application/json' })
.jsonBody(
eachLike({
id: integer(1),
name: string('Alice'),
email: string('alice@example.com'),
})
);
})
.executeTest(async (mockServer) => {
const client = new UserClient(mockServer.url);
const users = await client.listUsers();
expect(users).toHaveLength(1);
expect(users[0].name).toBe('Alice');
});
});
it('should return 404 for non-existent user', async () => {
await provider
.addInteraction()
.given('user 999 does not exist')
.uponReceiving('a request for user 999')
.withRequest('GET', '/api/users/999')
.willRespondWith(404, (builder) => {
builder.jsonBody({
error: string('User not found'),
});
})
.executeTest(async (mockServer) => {
const client = new UserClient(mockServer.url);
await expect(client.getUser(999)).rejects.toThrow('User not found');
});
});
});
JavaScript — プロバイダー検証
// provider/pact-verification.test.ts
import { Verifier } from '@pact-foundation/pact';
import { createApp } from '../src/app';
describe('Provider Verification', () => {
let server: any;
beforeAll(async () => {
const app = await createApp();
server = app.listen(0);
});
afterAll(() => {
server.close();
});
it('should validate the expectations of UserWebApp', async () => {
const port = server.address().port;
await new Verifier({
providerBaseUrl: `http://localhost:${port}`,
provider: 'UserService',
pactBrokerUrl: process.env.PACT_BROKER_URL || 'http://localhost:9292',
pactBrokerToken: process.env.PACT_BROKER_TOKEN,
publishVerificationResult: process.env.CI === 'true',
providerVersion: process.env.GIT_SHA || '1.0.0',
providerVersionBranch: process.env.GIT_BRANCH || 'main',
stateHandlers: {
'user 123 exists': async () => {
// Seed the database with user 123
await seedUser({ id: 123, name: 'Alice', email: 'alice@example.com', role: 'admin' });
},
'user 999 does not exist': async () => {
// Ensure user 999 does not exist
await deleteUser(999);
},
'users exist': async () => {
await seedUser({ id: 1, name: 'Alice', email: 'alice@example.com' });
},
},
}).verifyProvider();
});
});
C# — コンシューマーテスト(PactNet)
using PactNet;
using PactNet.Matchers;
using Xunit;
public class UserClientPactTests
{
private readonly IPactBuilderV4 _pactBuilder;
public UserClientPactTests()
{
var pact = Pact.V4("UserWebApp", "UserService", new PactConfig
{
PactDir = "../../../pacts",
LogLevel = PactLogLevel.Warning,
});
_pactBuilder = pact.WithHttpInteractions();
}
[Fact]
public async Task GetUser_WhenUserExists_ReturnsUser()
{
_pactBuilder
.UponReceiving("a request for user 123")
.Given("user 123 exists")
.WithRequest(HttpMethod.Get, "/api/users/123")
.WithHeader("Accept", "application/json")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithHeader("Content-Type", "application/json")
.WithJsonBody(new
{
id = Match.Integer(123),
name = Match.Type("Alice"),
email = Match.Regex("alice@example.com", @"^[\w.-]+@[\w.-]+\.[a-z]{2,}$"),
role = Match.Type("admin"),
});
await _pactBuilder.VerifyAsync(async ctx =>
{
var client = new UserClient(ctx.MockServerUri.ToString());
var user = await client.GetUserAsync(123);
Assert.Equal(123, user.Id);
Assert.Equal("Alice", user.Name);
});
}
[Fact]
public async Task GetUser_WhenUserDoesNotExist_Returns404()
{
_pactBuilder
.UponReceiving("a request for non-existent user")
.Given("user 999 does not exist")
.WithRequest(HttpMethod.Get, "/api/users/999")
.WillRespond()
.WithStatus(HttpStatusCode.NotFound)
.WithJsonBody(new
{
error = Match.Type("User not found"),
});
await _pactBuilder.VerifyAsync(async ctx =>
{
var client = new UserClient(ctx.MockServerUri.ToString());
await Assert.ThrowsAsync<UserNotFoundException>(
() => client.GetUserAsync(999));
});
}
}
C# — プロバイダー検証(PactNet)
using PactNet;
using PactNet.Verifier;
using Xunit;
public class ProviderPactTests : IClassFixture<CustomWebApplicationFactory>
{
private readonly CustomWebApplicationFactory _factory;
public ProviderPactTests(CustomWebApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public void VerifyPacts()
{
var config = new PactVerifierConfig
{
LogLevel = PactLogLevel.Warning,
};
using var server = _factory.Server;
var verifier = new PactVerifier("UserService", config);
verifier
.WithHttpEndpoint(server.BaseAddress)
.WithPactBrokerSource(new Uri("http://localhost:9292"), options =>
{
options
.ConsumerVersionSelectors(
new ConsumerVersionSelector { MainBranch = true },
new ConsumerVersionSelector { DeployedOrReleased = true }
)
.PublishResults(
Environment.GetEnvironmentVariable("GIT_SHA") ?? "1.0.0",
options => options.ProviderBranch(
Environment.GetEnvironmentVariable("GIT_BRANCH") ?? "main"));
})
.WithProviderStateUrl(new Uri(server.BaseAddress, "/provider-states"))
.Verify();
}
}
Java — コンシューマーテスト(Pact JVM)
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.core.model.V4Pact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static au.com.dius.pact.consumer.dsl.LambdaDsl.newJsonBody;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "UserService")
class UserClientPactTest {
@Pact(consumer = "UserWebApp")
V4Pact getUserPact(PactDslWithProvider builder) {
return builder
.given("user 123 exists")
.uponReceiving("a request for user 123")
.path("/api/users/123")
.method("GET")
.headers("Accept", "application/json")
.willRespondWith()
.status(200)
.headers(Map.of("Content-Type", "application/json"))
.body(newJsonBody(body -> {
body.integerType("id", 123);
body.stringType("name", "Alice");
body.stringMatcher("email", "^[\\w.-]+@[\\w.-]+\\.[a-z]{2,}$", "alice@example.com");
body.stringType("role", "admin");
}).build())
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "getUserPact")
void getUser_whenUserExists_returnsUser(MockServer mockServer) {
var client = new UserClient(mockServer.getUrl());
var user = client.getUser(123);
assertEquals(123, user.getId());
assertEquals("Alice", user.getName());
}
}
Pact Broker と can-i-deploy
Pact Broker
Pact Broker はコントラクトの中央リポジトリで、チーム間でのコントラクトテストワークフロー全体を実現します。
# Pact Broker をローカルで実行
docker run -d \
--name pact-broker \
-p 9292:9292 \
-e PACT_BROKER_DATABASE_URL=sqlite:////tmp/pact_broker.sqlite3 \
pactfoundation/pact-broker
# Pact をブローカーに公開
npx pact-broker publish ./pacts \
--consumer-app-version=$(git rev-parse --short HEAD) \
--branch=$(git branch --show-current) \
--broker-base-url=http://localhost:9292
# 環境用にバージョンをタグ付け
npx pact-broker create-version-tag \
--pacticipant=UserWebApp \
--version=$(本git rev-parse --short HEAD) \
--tag=main
can-i-deploy
can-i-deploy は重要な安全チェックです。ターゲット環境にデプロイされているすべてのものとサービスバージョンが互換性があるかどうかを教えてくれます。
# コンシューマーが本番環境にデプロイできるかを確認
npx pact-broker can-i-deploy \
--pacticipant=UserWebApp \
--version=$(git rev-parse --short HEAD) \
--to-environment=production \
--broker-base-url=http://localhost:9292
# プロバイダーがデプロイできるかを確認
npx pact-broker can-i-deploy \
--pacticipant=UserService \
--version=$(git rev-parse --short HEAD) \
--to-environment=production \
--broker-base-url=http://localhost:9292
# デプロイを記録
npx pact-broker record-deployment \
--pacticipant=UserService \
--version=$(git rev-parse --short HEAD) \
--environment=production \
--broker-base-url=http://localhost:9292
PactFlow(双方向コントラクトテスト)
PactFlow は Pact を双方向コントラクトテストで拡張します — プロバイダーが OpenAPI 仕様を公開し、コンシューマーが Pact コントラクトを公開します。PactFlow は 2 つの間の互換性を検証します。
ワークフロー
Consumer PactFlow Provider
│ │ │
├─ 1. Publish pact ─────────►│ │
│ │ │
│ │◄── 2. Publish OpenAPI ─────┤
│ │ │
│ │── 3. Cross-validate ──────►│
│ │ (pact vs OpenAPI spec) │
│ │ │
├── 4. can-i-deploy? ──────►│ │
# プロバイダーが OpenAPI 仕様を公開
npx pactflow publish-provider-contract \
openapi.yaml \
--provider=UserService \
--provider-app-version=$(git rev-parse --short HEAD) \
--branch=$(git branch --show-current) \
--content-type=application/yaml \
--verification-exit-code=0 \
--verification-results=oas-report.txt \
--verification-results-content-type=text/plain \
--broker-base-url=https://your-org.pactflow.io \
--broker-token=$PACTFLOW_TOKEN
Spring Cloud Contract
Spring Cloud Contract は Groovy DSL または Kotlin DSL を使用して JVM アプリケーション向けのコントラクトテストを提供します。
Groovy DSL コントラクト
// src/test/resources/contracts/user/get_user.groovy
package contracts.user
import org.springframework.cloud.contract.spec.Contract
Contract.make {
description "should return user by ID"
request {
method GET()
url "/api/users/123"
headers {
accept(applicationJson())
}
}
response {
status OK()
headers {
contentType(applicationJson())
}
body([
id: 123,
name: "Alice",
email: "alice@example.com",
role: "admin"
])
}
}
Kotlin DSL コントラクト
// src/test/resources/contracts/user/get_user.kts
import org.springframework.cloud.contract.spec.ContractDsl.Companion.contract
contract {
description = "should return user by ID"
request {
method = GET
url = url("/api/users/123")
headers {
accept = APPLICATION_JSON
}
}
response {
status = OK
headers {
contentType = APPLICATION_JSON
}
body = body(mapOf(
"id" to 123,
"name" to "Alice",
"email" to "alice@example.com",
"role" to "admin"
))
}
}
プロバイダーベーステスト
import io.restassured.module.mockmvc.RestAssuredMockMvc;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
public abstract class BaseContractTest {
@Autowired
private UserController userController;
@BeforeEach
void setUp() {
RestAssuredMockMvc.standaloneSetup(userController);
// Seed test data
seedUser(new User(123, "Alice", "alice@example.com", "admin"));
}
}
# コントラクトテストを生成して実行
./gradlew contractTest
# スタブを Maven リポジトリに公開
./gradlew publishStubsPublicationToMavenLocal
# コンシューマーがスタブを使用
./gradlew test -Dstubrunner.ids=com.example:user-service:+:stubs:8080
コントラクトテスト vs E2E テストを使用する場合
| シナリオ | コントラクトテストを使用 | E2E テストを使用 |
|---|---|---|
| マイクロサービス API 境界 | はい | いいえ(サービスが多い) |
| 破壊的な API 変更検出 | はい | 部分的(遅く、不安定) |
| 全体的なユーザーフローの検証 | いいえ | はい |
| デプロイ安全チェック | はい(can-i-deploy) | いいえ(CI 用に遅い) |
| UI 動作の検証 | いいえ | はい |
| プロバイダー API の進化 | はい | いいえ |
| フロントエンド + バックエンド統合 | 場合によって(API レイヤー) | はい(ユーザーに見える動作) |
| サードパーティ API 互換性 | はい(コントラクトを提供する場合) | いいえ(制御なし) |
判定チェックリスト
- コントラクトテストを使用する場合: API 経由で通信する複数のサービスがあり、高速で信頼性の高い互換性チェックが必要な場合。
- E2E テストを使用する場合: 実際の UI を通じたユーザー体験全体を検証する必要がある場合。
- 両方を使用する場合: マイクロサービスアーキテクチャと Web フロントエンドがある場合 — サービス境界用のコントラクトテスト、重要なユーザーフロー用の E2E テスト。
CI 統合
GitHub Actions — Pact ワークフロー
name: Contract Tests
on: [push, pull_request]
jobs:
consumer-contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Run Consumer Contract Tests
run: npx vitest run --project contracts
- name: Publish Pacts
if: github.ref == 'refs/heads/main' || github.event_name == 'pull_request'
run: |
npx pact-broker publish ./pacts \
--consumer-app-version=${{ github.sha }} \
--branch=${{ github.head_ref || github.ref_name }} \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
- name: Can I Deploy?
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant=UserWebApp \
--version=${{ github.sha }} \
--to-environment=production \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
provider-verification:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Verify Provider Against Pacts
run: npx vitest run --project provider-verification
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
GIT_SHA: ${{ github.sha }}
GIT_BRANCH: ${{ github.head_ref || github.ref_name }}
CI: true
- name: Can I Deploy?
if: github.ref == 'refs/heads/main'
run: |
npx pact-broker can-i-deploy \
--pacticipant=UserService \
--version=${{ github.sha }} \
--to-environment=production \
--broker-base-url=${{ secrets.PACT_BROKER_URL }} \
--broker-token=${{ secrets.PACT_BROKER_TOKEN }}
クロスプラットフォームツール一覧
| ツール | 対応言語 | コントラクト形式 |
|---|---|---|
| Pact | JS/TS、Java、C#、Python、Go、Ruby、Rust | コンシューマー主導 |
| PactFlow | 任意(OpenAPI 経由) | 双方向 |
| Spring Cloud Contract | Java、Kotlin、Groovy | プロバイダー主導 / コンシューマースタブ |
| Pact Broker | 言語非依存 | コントラクトリポジトリ |
| can-i-deploy | CLI ツール | デプロイ安全ゲート |
ベストプラクティス
- コンシューマー主導のコントラクト(Pact)で開始します — コンシューマーが必要な内容を最も良く知っています。
- Pact Broker を使用してチーム間でコントラクトを共有し、検証ステータスを追跡します。
- 本番環境にデプロイする前に常に
can-i-deployを実行します — これはセーフティネットです。 - プロバイダー状態(
given(...))を使用してプロバイダー内の特定のテストシナリオを設定します。 - 正確な値ではなく Pact マッチャー(
like、eachLike、regex)を使用します — コントラクトは柔軟であるべきです。 - コンシューマーが実際に使用する最小限のフィールドセットをテストします。全体の API レスポンスではなく。
- git SHA とブランチでコントラクトのバージョン管理を行い、トレーサビリティを確保します。
- すべてのコミットでコンシューマーコントラクトテストを実行します — 実サービスが不要なので高速です。
- すべての PR でプロバイダー検証を実行します — マージ前に破壊的な変更を検出します。
- 既に OpenAPI 仕様を保守している場合は、双方向コントラクトテスト(PactFlow)を使用します。
- コントラクトテストを機能的な API テストの代替として使用しないでください — これらは形状を検証するもので、ビジネスロジックは検証しません。
- 環境ごとに何が実行されているかを
can-i-deployが認識するように、Pact Broker にデプロイを記録します。
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- Tyler-R-Kendrick
- ライセンス
- MIT
- 最終更新
- 2026/2/11
Source: https://github.com/Tyler-R-Kendrick/agent-skills / ライセンス: MIT