mybook

テスト戦略 — マイクロサービスのテスト

「テストが全部パスしてるのに本番で壊れた」

ケンジは悔しそうに言った。注文サービスのユニットテストはグリーン。商品サービスのユニットテストもグリーン。なのに、注文から商品情報を取得する部分で本番障害が起きた。

「モックが本物と乖離してた、ってこと?」とミサキが確認する。

「そうです。商品サービスの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
end

Provider 側: 契約を検証する

# 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: 5

E2Eテスト: ユーザーシナリオのテスト

# 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で自動検出できる。

次章(最終章)では、マイクロサービス化の旅を振り返り、「いつマイクロサービスを選ぶべきか」という根本的な問いに答える。