mybook

API テスト戦略 — 品質を担保する

バグが本番に流れた日

「ナツミさん、PATCH /properties/:id が500エラーになってます!」

本番リリースから2時間後。林さんからの緊急メッセージ。

原因は単純だった。ナツミが property_params に新しいフィールドを追加したとき、許可リストへの追加を忘れていた。テストがなかったから気づけなかった。

「テストを書こう」

ナツミは決意した。「どんなテストを、どう書けばいい?」


テストピラミッド

Loading diagram...

APIの場合は特にIntegration Tests(リクエスト仕様)が重要。


セットアップ

# Gemfile
group :test do
  gem 'rspec-rails'
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'shoulda-matchers'
  gem 'vcr'          # 外部APIのレスポンス記録・再生
  gem 'webmock'      # HTTP通信のモック
  gem 'json_matchers'  # JSONスキーマバリデーション
end
# spec/rails_helper.rb
RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
  config.include RequestSpecHelper, type: :request
  config.include AuthHelper, type: :request
 
  config.before(:each, type: :request) do
    host! 'api.livly.test'
  end
end

ファクトリーの定義

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    name { Faker::Name.full_name }
    password { 'password123' }
    password_confirmation { 'password123' }
 
    trait :admin do
      admin { true }
    end
  end
end
 
# spec/factories/properties.rb
FactoryBot.define do
  factory :property do
    association :user
    name { Faker::Address.community }
    description { Faker::Lorem.paragraph }
    price { Faker::Number.between(from: 50_000, to: 500_000) }
    area { Faker::Number.decimal(l_digits: 2, r_digits: 1) }
    prefecture { '東京都' }
    city { '渋谷区' }
    published { false }
 
    trait :published do
      published { true }
      published_at { Time.current }
    end
 
    trait :with_photos do
      after(:create) do |property|
        create_list(:photo, 3, property: property)
      end
    end
  end
end

リクエストスペック(メインのAPIテスト)

# spec/requests/api/v1/properties_spec.rb
require 'rails_helper'
 
RSpec.describe 'Api::V1::Properties', type: :request do
  let(:user) { create(:user) }
  let(:headers) { auth_headers(user) }
 
  describe 'GET /api/v1/properties' do
    before { create_list(:property, 5, :published) }
 
    it '公開物件の一覧を返す' do
      get '/api/v1/properties', headers: headers
 
      expect(response).to have_http_status(:ok)
      expect(json_response['data'].length).to eq(5)
    end
 
    it 'ページネーションのメタ情報を含む' do
      create_list(:property, 20, :published)
      get '/api/v1/properties', params: { page: 2, per_page: 10 }, headers: headers
 
      meta = json_response['meta']
      expect(meta['current_page']).to eq(2)
      expect(meta['total_pages']).to eq(3)  # 25件 / 10 = 3ページ
    end
 
    it '都道府県でフィルタリングできる' do
      create(:property, :published, prefecture: '大阪府')
      get '/api/v1/properties', params: { prefecture: '大阪府' }, headers: headers
 
      expect(json_response['data'].length).to eq(1)
      expect(json_response['data'][0]['attributes']['location']).to include('大阪府')
    end
 
    context '未認証の場合' do
      it '401を返す' do
        get '/api/v1/properties'
        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
 
  describe 'GET /api/v1/properties/:id' do
    let(:property) { create(:property, :published, user: user) }
 
    it '物件の詳細を返す' do
      get "/api/v1/properties/#{property.id}", headers: headers
 
      expect(response).to have_http_status(:ok)
      data = json_response['data']
      expect(data['id']).to eq(property.id.to_s)
      expect(data['attributes']['name']).to eq(property.name)
    end
 
    it '存在しない物件は404を返す' do
      get '/api/v1/properties/99999', headers: headers
 
      expect(response).to have_http_status(:not_found)
      expect(json_response['title']).to eq('Not Found')
    end
  end
 
  describe 'POST /api/v1/properties' do
    let(:valid_params) do
      {
        property: {
          name: '新しいマンション',
          price: 120_000,
          area: 30.5,
          prefecture: '東京都',
          city: '新宿区'
        }
      }
    end
 
    it '物件を作成し201を返す' do
      expect {
        post '/api/v1/properties', params: valid_params, headers: headers
      }.to change(Property, :count).by(1)
 
      expect(response).to have_http_status(:created)
      expect(json_response['data']['attributes']['name']).to eq('新しいマンション')
    end
 
    it 'バリデーションエラーは422を返す' do
      invalid_params = { property: { name: '', price: -1 } }
      post '/api/v1/properties', params: invalid_params, headers: headers
 
      expect(response).to have_http_status(:unprocessable_entity)
      expect(json_response['errors']).not_to be_empty
    end
 
    it '許可されていないフィールドは無視される' do
      params_with_extra = valid_params.deep_merge(property: { admin_approved: true })
      post '/api/v1/properties', params: params_with_extra, headers: headers
 
      expect(response).to have_http_status(:created)
      expect(Property.last.admin_approved).to be_falsy
    end
  end
 
  describe 'PATCH /api/v1/properties/:id' do
    let(:property) { create(:property, user: user) }
 
    it '物件を更新できる' do
      patch "/api/v1/properties/#{property.id}",
            params: { property: { price: 200_000 } },
            headers: headers
 
      expect(response).to have_http_status(:ok)
      expect(property.reload.price).to eq(200_000)
    end
 
    it '他のユーザーの物件は更新できない(403)' do
      other_user = create(:user)
      other_property = create(:property, user: other_user)
 
      patch "/api/v1/properties/#{other_property.id}",
            params: { property: { price: 200_000 } },
            headers: headers
 
      expect(response).to have_http_status(:forbidden)
    end
  end
end

ヘルパーモジュール

# spec/support/auth_helper.rb
module AuthHelper
  def auth_headers(user)
    token = JsonWebToken.encode(user_id: user.id)
    { 'Authorization' => "Bearer #{token}", 'Content-Type' => 'application/json' }
  end
end
 
# spec/support/request_spec_helper.rb
module RequestSpecHelper
  def json_response
    JSON.parse(response.body)
  end
end

JSONスキーマバリデーション

レスポンスの構造をスキーマで検証する。

// spec/support/json_schemas/property.json
{
  "$schema": "http://json-schema.org/draft-07/schema",
  "type": "object",
  "required": ["data"],
  "properties": {
    "data": {
      "type": "object",
      "required": ["id", "type", "attributes"],
      "properties": {
        "id": { "type": "string" },
        "type": { "type": "string", "const": "properties" },
        "attributes": {
          "type": "object",
          "required": ["name", "price", "createdAt"],
          "properties": {
            "name": { "type": "string" },
            "price": { "type": "integer" },
            "area": { "type": ["number", "null"] },
            "createdAt": { "type": "string", "format": "date-time" }
          }
        }
      }
    }
  }
}
# スペックでのJSONスキーマ検証
it 'JSON:API形式のレスポンスを返す' do
  get "/api/v1/properties/#{property.id}", headers: headers
 
  expect(response).to have_http_status(:ok)
  expect(response.body).to match_json_schema('property')
end

VCRで外部APIをモック

FCMやStripeなど外部APIへのリクエストを記録・再生する。

# spec/support/vcr.rb
VCR.configure do |config|
  config.cassette_library_dir = 'spec/vcr_cassettes'
  config.hook_into :webmock
  config.configure_rspec_metadata!
 
  # APIキーなどの機密情報を隠す
  config.filter_sensitive_data('<FCM_KEY>') { ENV['FCM_SERVER_KEY'] }
end
# spec/services/push_notification_service_spec.rb
RSpec.describe PushNotificationService, type: :service do
  describe '.send' do
    let(:user) { create(:user, fcm_token: 'test_token_abc123') }
 
    it '通知を送信する', :vcr do
      # 初回実行時にFCM APIへのリクエストを記録
      # 2回目以降は記録されたレスポンスを再生
      result = PushNotificationService.send(
        token: user.fcm_token,
        title: 'テスト通知',
        body: '物件が更新されました'
      )
 
      expect(result.success?).to be true
    end
  end
end

Contract Testing(Pact)

フロントエンドとバックエンドの契約をテストする。

Loading diagram...
# Gemfile(コンシューマー側、iOSは別リポジトリ)
gem 'pact'
gem 'pact_broker-client'
# spec/service_consumers/property_api_spec.rb(プロバイダーテスト)
require 'pact/provider/rspec'
 
Pact.service_provider 'PropertyAPI' do
  honours_pact_with 'LivlyiOSApp' do
    pact_uri 'https://pact-broker.livly.internal/pacts/provider/PropertyAPI/consumer/LivlyiOSApp/latest'
  end
end
 
RSpec.describe 'Pact Property API', type: :pact do
  before do
    # コントラクトテスト用のセットアップ
    @user = create(:user)
    @property = create(:property, :published, user: @user)
  end
end

INFO

Contract Testingの効果:iOSチームが「このレスポンスフォーマットを期待する」というコントラクトを作成し、バックエンドがそれを満たすかをCIで自動検証する。APIの破壊的変更を事前に検知できる。


CI/CDでのテスト自動化

# .github/workflows/api-test.yml
name: API Test
 
on: [push, pull_request]
 
jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        ports: ['5432:5432']
      redis:
        image: redis:7
        ports: ['6379:6379']
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
 
      - name: Setup database
        run: |
          bundle exec rails db:create db:schema:load RAILS_ENV=test
 
      - name: Run RSpec
        run: bundle exec rspec spec/requests --format progress --format RspecJunitFormatter --out tmp/rspec.xml
 
      - name: Upload test results
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: tmp/rspec.xml

テストカバレッジ

# Gemfile
group :test do
  gem 'simplecov', require: false
end
 
# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
  add_filter '/spec/'
  add_filter '/config/'
  minimum_coverage 80  # 80%以下はCI失敗
end

まとめ

  • リクエストスペックがAPIテストの中心。正常系・異常系・権限エラーを網羅する
  • FactoryBot + Faker でテストデータを簡潔に生成する
  • JSONスキーマバリデーションでレスポンス構造を自動検証する
  • VCR で外部APIの通信を記録・再生し、テストを高速・安定させる
  • Pact によるContract TestingでiOSチームとの暗黙の約束を明示化する
  • CI/CDに組み込み、プルリクエスト時に自動でテストを実行する

次章では、作ったAPIを使いやすいドキュメントで公開する方法を学ぶ。