スキーマファースト開発によるContract Testingの実践
はじめに
マルチプロダクト連携やマイクロサービスアーキテクチャにおけるシステム間連携には、API仕様の変更検知、互換性の担保、統合テストの複雑化といった難しさがあります。AIエージェントによるコード生産性の向上でプロダクトの高速な立ち上げが可能になった今、これらの課題の顕在化はさらに早まり加速していると感じています。
Contract Testingは、この難しさを解消するアプローチであり、OpenAPIによるスキーマファースト開発で実践できます。また、OpenAPI仕様からTypeScriptコードを自動生成するOrvalでもContract Testingの概念を導入しています。
本記事では、Contract Testingの理論とOrvalでの実践方法を説明します。
なぜContract Testingが必要か
APIによるシステム境界を扱うシステム
APIによるシステム境界を扱うケースとして、次のようなものがあります。
- マイクロサービスアーキテクチャ: 複数のサービスがAPIを介して連携
-
SPA+ バックエンドAPI: フロントエンドとバックエンドを分離した構成 - マルチプロダクト連携: 複数のプロダクトがAPIを介してデータを共有
これらのシステムでは、API仕様が「契約」として機能します。しかし、契約が守られているかを検証する仕組みがなければ、統合時に問題が発覚し開発スピードの低下や品質の低下に繋がります。
また、AIエージェントによるコード生成は非決定的という特徴があるため、契約を明確に定義することで、決定的な部分を増やしシステム全体の一貫性を保つことができます。
AIエージェントを有効に活用する1つの方向として、決定的な要素を契約という形で増やしていくことも効果的だと考えています。
他のテスト手法との比較
統合テスト(E2Eテスト)は、すべてのサービスを起動する必要があり、実行が遅く環境構築が複雑です。ユニットテスト + モックは、手動で作成したモックが実際のAPIと乖離し、契約の変更を検出できないことがあります。Contract Testingは「契約」による前提を共有することでこれらの問題を解決します。
Contract Testingとは
Contract Testingは、サービス間の境界を「契約」として明確に定義し、その契約に基づいてテストを行うアプローチです。契約による制約を設けることで、統合時のエラーを未然に防ぎ、品質向上と開発速度の向上を実現します。
Martin FowlerはContractTestで、従来のテストの課題について以下のように説明しています。
testing against a double always raises the question of whether the double is indeed an accurate representation of the external service, and what happens if the external service changes its contract?
(Test Doubleに対するテストは、常に「そのTest Doubleが外部サービスの正確な表現であるか」「外部サービスが契約を変更した場合どうなるか」という疑問を生じさせる)
Contract Testingは、この課題を解決します。契約を明確に定義し、ProviderとConsumerの両方がその契約に準拠することで、統合時に差分がないことを担保できます。
Contract Testingの構成要素
Contract Testingは以下の要素で構成されます。
| 要素 | 説明 |
|---|---|
契約(Contract) |
サービス間で合意した仕様。リクエスト/レスポンスの形式、エンドポイント、ステータスコードなどを定義する。本記事ではOpenAPIを契約として使用する。 |
Provider |
APIを提供する側(サーバー、バックエンド)。契約に従ってレスポンスを返す。 |
Consumer |
APIを利用する側(クライアント、フロントエンド)。契約に従ってリクエストを送り、レスポンスを処理する。 |
ProviderとConsumerの両方が契約に準拠することで、統合時のエラーを防ぎます。
OrvalでのContract Testing
OrvalはOpenAPI仕様からTypeScriptコードを生成するツールです。Contract Testingを実現するために、以下の機能を提供しています。
Provider-side: Hono + Zod
Honoクライアントを使用すると、リクエスト/レスポンスを自動的にZodスキーマで検証し、Provider-side Contract Testingを実現します。
export default defineConfig({
petstore: {
output: {
client: 'hono',
+ override: {
+ hono: {
+ handlers: 'server/src/handlers',
+ },
+ },
},
},
});
生成されるハンドラー:
export const createPetsHandlers = factory.createHandlers(
zValidator('json', createPetsBody),
zValidator('response', createPetsResponse),
async (c: CreatePetsContext) => {
const body = c.req.valid('json');
const newPet = { id: generateId(), ...body };
return c.json(newPet);
},
);
契約違反があれば、TypeScriptエラーが発生します。
const user = { id: '123', name: 'Alice' }; // email が欠けている
return c.json(user); // ❌ Property 'email' is missing
Consumer-side: MSW(開発時)
OrvalはOpenAPI仕様からMSWのモックを自動生成します。このモックはスキーマに合致したレスポンスを返すため、Consumer側でContract Testingを実現できます。
export default defineConfig({
petstore: {
output: {
client: 'swr',
mock: true,
},
},
});
自動生成されるモック:
// ./gen/endpoints/pets.msw.ts(自動生成)
export const getCreatePetsMockHandler = () => {
return http.post('*/pets', () => {
return HttpResponse.json(getCreatePetsMock());
});
};
export const getCreatePetsMock = () => ({
id: faker.number.int(),
name: faker.word.sample(),
tag: faker.word.sample(),
});
使用例:
import { getCreatePetsMockHandler } from './gen/endpoints/pets.msw';
test('Consumer-side: ペット作成', async () => {
server.use(getCreatePetsMockHandler());
const pet = await createPet({ name: 'Taro', tag: 'dog' });
expect(pet).toMatchObject({ name: 'Taro', tag: 'dog' });
});
自動生成されたモックはスキーマに準拠しているため、Consumerのテストでモックと実際のAPIの乖離を防ぐことができます。
Consumer-Driven Contract Testing
Consumer-Drivenとは
Contract Testingの中でも、Consumer-Driven Contract Testingは特に重要な概念です。Ian RobinsonはConsumer-Driven Contracts: A Service Evolution Patternで詳しく解説しています。
provider contracts emerge to meet consumer expectations and demands. To reflect the derived nature of this new contractual arrangement, we call such provider contracts consumer-driven contracts
(Providerの契約はConsumerの期待と要求を満たすために生まれる。この新しい契約の派生的な性質を反映して、このようなProviderの契約をConsumer-Driven Contractsと呼ぶ)
Consumer-Driven Contract Testingでは、Consumerが自分の期待(Expectations)を定義し、それをProviderに伝えます。Providerはその期待を理解し、それを満たすようにサービスを進化させます。
なぜConsumer-Drivenが必要か
Contract Testingでは、ProviderとConsumerの両方が契約を前提にしています。しかし、実際の運用では、Providerが契約を変更した場合にConsumerが気づかないケースや、Consumerから見てProviderが契約に準拠しているか保証されないケースがあります。例えば、ProviderがemailフィールドをemailAddressに変更した場合、Consumerは気づかずにデプロイし、本番環境でエラーが発生します。
Consumer-Driven Contract Testingでは、この問題を解決するために、Consumer側で契約を検証します。Consumerが自分の期待を定義し、その期待が満たされているかをテストすることで、Providerの変更や契約違反を早期に検出できます。
OrvalのRuntimeValidationオプション
OrvalのFetchクライアントにはruntimeValidationオプションがあります。これを有効にすると、実際のAPIリクエスト時に自動的にZodスキーマで検証し、契約違反を即座に検出します。
export default defineConfig({
petstore: {
output: {
schemas: {
path: 'src/gen/models',
type: 'zod',
},
client: 'fetch',
+ override: {
+ fetch: {
+ runtimeValidation: true,
+ },
+ },
},
},
});
生成されるコード:
export const createPet = async (body: CreatePetsBody): Promise<Pet> => {
const res = await fetch('/pets', { method: 'POST', body: JSON.stringify(body) });
const data = await res.json();
return Pet.parse(data); // Zodスキーマでランタイムバリデーション
};
Providerが契約を変更した場合、Consumer側でZodのパースエラーが発生し、問題を早期に発見できます。これがConsumer-Driven Contract Testingの実現方法です。
まとめ
Contract Testingは、サービス間の境界を「契約」として明確に定義し、結合する前にシステム間の互換性を保証するアプローチです。
本記事では、OpenAPIによるスキーマファースト開発を前提に、Orvalの以下の機能を使ってContract Testingを実現する方法を紹介しました。
- Provider-side:
Hono+Zodでリクエスト/レスポンスを検証し、Providerが契約に準拠していることを担保 - Consumer-side(開発時):
MSWモックを自動生成し、スキーマに準拠したテストを実現 -
Consumer-Driven Contract Testing:RuntimeValidationでConsumer側から契約を検証し、Providerの変更や契約違反を早期に検出
Contract Testingの概念や実践方法について、参考になれば幸いです。
Discussion