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
endJSONスキーマバリデーション
レスポンスの構造をスキーマで検証する。
// 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')
endVCRで外部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
endContract 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
endINFO
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を使いやすいドキュメントで公開する方法を学ぶ。