テスト戦略 — マイクロサービスのテスト
「テストが全部パスしてるのに本番で壊れた」
ケンジは悔しそうに言った。注文サービスのユニットテストはグリーン。商品サービスのユニットテストもグリーン。なのに、注文から商品情報を取得する部分で本番障害が起きた。
「モックが本物と乖離してた、ってこと?」とミサキが確認する。
「そうです。商品サービスのAPIレスポンスのフィールド名が変わってたのに、モックは古いまま...」
これがマイクロサービスのテストで最もよく起きる問題だ。
テストピラミッド(マイクロサービス版)
Loading diagram...
マイクロサービスのテスト構成:
70%: ユニットテスト
→ 各サービス内部のロジック
→ 高速、隔離、並列実行
20%: コントラクトテスト
→ サービス間のAPI契約
→ モックと本物のズレを防ぐ
8%: 統合テスト
→ サービス間の実際の通信
→ データベース含む
2%: E2Eテスト
→ ユーザー視点のシナリオ
→ 本番に近い環境で実行
ユニットテスト: サービス内部のテスト
# spec/services/order_service_spec.rb
RSpec.describe OrderService do
describe '#create' do
let(:user_id) { SecureRandom.uuid }
let(:items) { [{ product_id: 'prod-1', quantity: 2, price_cents: 1000 }] }
# 外部サービスはモック(コントラクトテストが保証する)
let(:product_service) { instance_double(ProductServiceClient) }
let(:inventory_service) { instance_double(InventoryServiceClient) }
before do
allow(product_service).to receive(:find_batch)
.with(['prod-1'])
.and_return([{ 'id' => 'prod-1', 'name' => 'テスト商品', 'price_cents' => 1000 }])
allow(inventory_service).to receive(:reserve)
.and_return({ 'reservation_id' => 'res-1' })
end
subject(:service) {
OrderService.new(
product_client: product_service,
inventory_client: inventory_service
)
}
it '注文を作成する' do
order = service.create(user_id: user_id, items: items)
expect(order.status).to eq 'pending'
expect(order.user_id).to eq user_id
end
it '注文アイテムに価格スナップショットを保存する' do
order = service.create(user_id: user_id, items: items)
expect(order.order_items.first.unit_price_snapshot_cents).to eq 1000
end
context '在庫不足の場合' do
before do
allow(inventory_service).to receive(:reserve)
.and_raise(InsufficientStockError, '在庫不足')
end
it 'エラーを発生させる' do
expect { service.create(user_id: user_id, items: items) }
.to raise_error(InsufficientStockError)
end
it '注文レコードを作成しない' do
expect {
service.create(user_id: user_id, items: items) rescue nil
}.not_to change(Order, :count)
end
end
end
endコントラクトテスト: Pact を使ったConsumer-Driven Contracts
コントラクトテストは、サービス間のAPI契約を自動検証する。
Loading diagram...
Consumer 側: 期待するAPIを定義
# Gemfile(注文サービス)
gem 'pact', group: :test
# spec/pact/consumer/product_service_consumer_spec.rb
require 'pact/consumer/rspec'
Pact.service_consumer 'order-service' do
has_pact_with 'product-service' do
mock_service :product_service do
port 8080
end
end
end
RSpec.describe 'Product Service Contract' do
include Pact::Consumer::RSpec
describe 'GET /api/v1/products/:id' do
before do
product_service
.given('商品ID prod-1 が存在する')
.upon_receiving('商品の詳細を取得する')
.with(
method: :get,
path: '/api/v1/products/prod-1',
headers: { 'Accept' => 'application/json' }
)
.will_respond_with(
status: 200,
headers: { 'Content-Type' => 'application/json' },
body: {
id: 'prod-1',
name: Pact.like('商品名'), # 型チェック
price_cents: Pact.like(1000), # 型チェック
stock_quantity: Pact.like(100) # 型チェック
}
)
end
it '商品サービスから商品情報を取得できる' do
client = ProductServiceClient.new('http://localhost:8080')
product = client.find('prod-1')
expect(product['id']).to eq 'prod-1'
expect(product['price_cents']).to be_a(Integer)
end
end
describe 'GET /api/v1/products/batch' do
before do
product_service
.given('複数の商品が存在する')
.upon_receiving('複数商品を一括取得する')
.with(
method: :post,
path: '/api/v1/products/batch',
body: { ids: ['prod-1', 'prod-2'] }
)
.will_respond_with(
status: 200,
body: {
products: Pact.each_like({
id: Pact.like('prod-1'),
name: Pact.like('商品名'),
price_cents: Pact.like(1000)
})
}
)
end
it '複数の商品を一括取得できる' do
client = ProductServiceClient.new('http://localhost:8080')
result = client.find_batch(['prod-1', 'prod-2'])
expect(result['products']).to be_an(Array)
end
end
endProvider 側: 契約を検証する
# spec/pact/provider/order_service_pact_spec.rb(商品サービス)
require 'pact/provider/rspec'
Pact.service_provider 'product-service' do
honours_pact_with 'order-service' do
pact_uri ENV.fetch('PACT_URL',
'http://pact-broker/pacts/provider/product-service/consumer/order-service/latest')
end
end
RSpec.describe 'Order Service Pact Verification', pact: true do
before(:each) do |example|
if example.metadata[:pact_interaction_description]&.include?('商品ID prod-1 が存在する')
# テスト状態のセットアップ
Product.create!(
id: 'prod-1',
name: 'テスト商品',
price_cents: 1000
)
StockItem.create!(product_id: 'prod-1', quantity: 100)
end
end
after(:each) do
Product.delete_all
StockItem.delete_all
end
end統合テスト: 実際のサービスと通信
# spec/integration/order_flow_spec.rb
# Docker Compose で依存サービスを起動してテスト
RSpec.describe 'Order Creation Integration', :integration do
before(:all) do
# Docker Composeで依存サービスを起動
system('docker-compose -f docker-compose.test.yml up -d')
wait_for_services_ready
end
after(:all) do
system('docker-compose -f docker-compose.test.yml down')
end
it '商品の注文から在庫減算まで一貫して動作する' do
# 商品を作成(実際の商品サービスAPIを呼ぶ)
product = ProductServiceClient.new('http://localhost:3001')
.create(name: '統合テスト商品', price_cents: 5000)
# 在庫を設定
InventoryServiceClient.new('http://localhost:3002')
.set_stock(product_id: product['id'], quantity: 10)
# 注文を作成
order = post('/api/v1/orders', {
user_id: 'test-user',
items: [{ product_id: product['id'], quantity: 3 }]
})
expect(order.status).to eq 201
# 在庫が減算されていることを確認
stock = InventoryServiceClient.new('http://localhost:3002')
.get_stock(product['id'])
expect(stock['quantity']).to eq 7 # 10 - 3
end
private
def wait_for_services_ready(timeout: 30)
deadline = Time.current + timeout
until Time.current > deadline
ready = [3001, 3002, 3003].all? { |port|
HTTP.timeout(1).get("http://localhost:#{port}/health").status.ok?
rescue StandardError
false
}
return if ready
sleep 1
end
raise 'Services did not become ready in time'
end
end# docker-compose.test.yml
version: '3.8'
services:
product-service:
image: shopnova/product-service:test
ports: ['3001:3000']
environment:
DATABASE_URL: postgres://test:test@postgres:5432/product_test
depends_on:
postgres:
condition: service_healthy
inventory-service:
image: shopnova/inventory-service:test
ports: ['3002:3000']
environment:
DATABASE_URL: postgres://test:test@postgres:5432/inventory_test
postgres:
image: postgres:15
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U test']
interval: 5s
retries: 5E2Eテスト: ユーザーシナリオのテスト
# spec/e2e/purchase_flow_spec.rb
RSpec.describe 'Purchase Flow E2E', :e2e do
it 'ユーザーが商品を購入できる' do
# E2Eはステージング環境で実行
api = ApiClient.new(ENV.fetch('STAGING_API_URL'))
# 1. ユーザー認証
auth = api.post('/api/v1/auth/login', {
email: 'e2e-test@shopnova.example.com',
password: ENV.fetch('E2E_TEST_PASSWORD')
})
token = auth.body['token']
# 2. 商品検索
search_result = api.get('/api/v1/search', {
q: 'E2Eテスト商品',
Authorization: "Bearer #{token}"
})
product = search_result.body['results'].first
expect(product).not_to be_nil
# 3. 注文作成
order_response = api.post(
'/api/v1/orders',
{ items: [{ product_id: product['id'], quantity: 1 }] },
headers: { Authorization: "Bearer #{token}" }
)
expect(order_response.status).to eq 201
order_id = order_response.body['order_id']
# 4. 注文ステータス確認
order_status = api.get(
"/api/v1/orders/#{order_id}",
headers: { Authorization: "Bearer #{token}" }
)
expect(order_status.body['status']).to be_in(['pending', 'processing'])
end
endテスト自動化: CI パイプライン
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- run: bundle exec rspec spec/unit spec/models spec/services --format progress
contract-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run Consumer Pact Tests
run: bundle exec rspec spec/pact/consumer --format documentation
- name: Publish Pacts
env:
PACT_BROKER_URL: ${{ secrets.PACT_BROKER_URL }}
PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
run: |
bundle exec pact-broker publish ./spec/pacts \
--consumer-app-version ${{ github.sha }} \
--branch ${{ github.ref_name }}
provider-verification:
runs-on: ubuntu-latest
needs: contract-tests
steps:
- name: Verify Provider Pacts
env:
PACT_URL: ${{ secrets.PACT_BROKER_URL }}/pacts/provider/product-service/consumer/order-service/latest
run: bundle exec rspec spec/pact/provider
integration-tests:
runs-on: ubuntu-latest
needs: contract-tests
steps:
- uses: actions/checkout@v4
- name: Start services
run: docker-compose -f docker-compose.test.yml up -d
- name: Run integration tests
run: bundle exec rspec spec/integration
- name: Cleanup
run: docker-compose -f docker-compose.test.yml downテストダブルの管理
# spec/support/service_doubles.rb
module ServiceDoubles
def stub_product_service
allow(ProductServiceClient).to receive(:new).and_return(
instance_double(ProductServiceClient).tap { |d|
allow(d).to receive(:find) { |id| product_fixture(id) }
allow(d).to receive(:find_batch) { |ids| ids.map { |id| product_fixture(id) } }
}
)
end
def product_fixture(id)
{
'id' => id,
'name' => "商品 #{id}",
'price_cents' => 1000,
'stock_quantity' => 100
}
end
end
RSpec.configure do |config|
config.include ServiceDoubles
endまとめ
「コントラクトテストを導入してから、サービス間の不整合が事前に発見できるようになった」とケンジが報告した。
テスト戦略のまとめ:
ユニットテスト: サービス内部のロジック → モックで高速に
コントラクトテスト: API契約の検証 → Pactで自動化
統合テスト: サービス間の実通信 → Docker Composeで再現
E2Eテスト: ユーザーシナリオ → ステージングで最小限
INFO
コントラクトテストは「ConsumerがProviderに何を期待しているか」を明文化する。Pact Brokerを使うと、契約がリポジトリ間で共有され、Provider側で自動的に検証される。これにより、APIの後方互換性を破るような変更をCIで自動検出できる。
次章(最終章)では、マイクロサービス化の旅を振り返り、「いつマイクロサービスを選ぶべきか」という根本的な問いに答える。