テストとモニタリング — APIの品質を保つ
本番障害の教訓
「SLAが99.9%のはずなのに、今月3回ダウンした」
パートナー企業C社からのクレームが届いた。確認すると、デプロイのたびにバグが混入し、エンドポイントが落ちていた。
「テストがない。モニタリングもない。気づくのはいつもユーザーからの報告だ」
サクラはCTOに宣言した。「テストとモニタリングを整備しないと、APIの信頼性は担保できません」
テストピラミッド
Loading diagram...
| レイヤー | ツール | 速度 | コスト |
|---|---|---|---|
| 静的解析 | RuboCop, Brakeman | 最速 | 最低 |
| ユニット | RSpec (model specs) | 速い | 低 |
| 統合 | RSpec (request specs) | 中程度 | 中 |
| E2E | Postman Newman | 遅い | 高 |
RSpecのセットアップ
# Gemfile
group :development, :test do
gem 'rspec-rails'
gem 'factory_bot_rails'
gem 'faker'
gem 'shoulda-matchers'
gem 'database_cleaner-active_record'
gem 'webmock'
end
# spec/rails_helper.rb
require "spec_helper"
require "support/factory_bot"
require "support/request_helpers"
require "support/database_cleaner"
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
config.include RequestHelpers, type: :request
end
Shoulda::Matchers.configure do |config|
config.integrate do |with|
with.test_framework :rspec
with.library :rails
end
end
# spec/support/request_helpers.rb
module RequestHelpers
def json_response
JSON.parse(response.body)
end
def auth_headers(api_key = nil)
api_key ||= create(:api_key)
{ "X-API-Key" => api_key.key, "Content-Type" => "application/json" }
end
def authenticated_get(path, api_key: nil, **params)
get path, headers: auth_headers(api_key), params: params
end
def authenticated_post(path, body: {}, api_key: nil)
post path, headers: auth_headers(api_key), params: body.to_json
end
endモデルスペック
# spec/models/user_spec.rb
RSpec.describe User, type: :model do
describe "validations" do
subject { build(:user) }
it { should validate_presence_of(:name) }
it { should validate_presence_of(:email) }
it { should validate_uniqueness_of(:email).case_insensitive }
it { should validate_length_of(:name).is_at_least(2).is_at_most(50) }
it { should allow_value("user@example.com").for(:email) }
it { should_not allow_value("invalid").for(:email) }
end
describe "associations" do
it { should have_many(:articles).dependent(:destroy) }
it { should belong_to(:partner).optional }
end
describe ".active" do
it "activeなユーザーのみ返す" do
active = create(:user, status: :active)
create(:user, status: :inactive)
expect(User.active).to contain_exactly(active)
end
end
endリクエストスペック(APIテスト)
# spec/requests/api/v2/users_spec.rb
RSpec.describe "API V2 Users", type: :request do
describe "GET /api/v2/users" do
context "認証済み" do
let!(:users) { create_list(:user, 3) }
it "ユーザー一覧を返す" do
authenticated_get "/api/v2/users"
expect(response).to have_http_status(:ok)
expect(json_response["data"].length).to eq(3)
end
it "ページネーションメタ情報を返す" do
authenticated_get "/api/v2/users", per_page: 2
meta = json_response["meta"]["pagination"]
expect(meta["per_page"]).to eq(2)
expect(meta["total_count"]).to eq(3)
expect(meta["total_pages"]).to eq(2)
end
it "statusでフィルタリングできる" do
active = create(:user, status: :active)
create(:user, status: :inactive)
authenticated_get "/api/v2/users", status: "active"
ids = json_response["data"].map { |u| u["id"] }
expect(ids).to contain_exactly(active.id)
end
end
context "未認証" do
it "401を返す" do
get "/api/v2/users"
expect(response).to have_http_status(:unauthorized)
expect(json_response["status"]).to eq(401)
end
end
context "無効なAPIキー" do
it "401を返す" do
get "/api/v2/users",
headers: { "X-API-Key" => "invalid_key" }
expect(response).to have_http_status(:unauthorized)
end
end
end
describe "POST /api/v2/users" do
let(:valid_params) { { name: "田中太郎", email: "tanaka@example.com" } }
context "有効なパラメーター" do
it "ユーザーを作成する" do
expect {
authenticated_post "/api/v2/users", body: { user: valid_params }
}.to change(User, :count).by(1)
expect(response).to have_http_status(:created)
expect(json_response["data"]["attributes"]["name"]).to eq("田中太郎")
end
it "Locationヘッダーを返す" do
authenticated_post "/api/v2/users", body: { user: valid_params }
expect(response.headers["Location"]).to match(%r{/api/v2/users/\d+})
end
end
context "無効なパラメーター" do
it "422とエラー詳細を返す" do
authenticated_post "/api/v2/users",
body: { user: { name: "", email: "invalid" } }
expect(response).to have_http_status(:unprocessable_entity)
errors = json_response["errors"]
fields = errors.map { |e| e["field"] }
expect(fields).to include("name", "email")
end
end
end
describe "DELETE /api/v2/users/:id" do
let!(:user) { create(:user) }
it "ユーザーを削除する" do
expect {
delete "/api/v2/users/#{user.id}", headers: auth_headers
}.to change(User, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
it "存在しないIDで404を返す" do
delete "/api/v2/users/99999", headers: auth_headers
expect(response).to have_http_status(:not_found)
end
end
endファクトリー
# spec/factories/users.rb
FactoryBot.define do
factory :user do
name { Faker::Name.name }
email { Faker::Internet.unique.email }
status { :active }
trait :inactive do
status { :inactive }
end
trait :with_articles do
after(:create) do |user|
create_list(:article, 3, user: user)
end
end
end
factory :api_key do
partner
name { "Test API Key" }
active { true }
end
factory :partner do
name { Faker::Company.name }
email { Faker::Internet.email }
api_plan { :starter }
end
endパフォーマンステスト
# spec/support/performance_helper.rb
module PerformanceHelper
def expect_query_count(count, &block)
query_count = 0
counter = ->(*, **) { query_count += 1 }
ActiveSupport::Notifications.subscribed(counter, "sql.active_record", &block)
expect(query_count).to eq(count)
end
end
# spec/requests/api/v2/users_spec.rb
it "N+1クエリが発生しない" do
create_list(:user, 10, :with_articles)
expect_query_count(3) do # users + articles + count
authenticated_get "/api/v2/users"
end
endINFO
N+1クエリはAPIパフォーマンスの最大の敵です。テストでクエリ数を検証することで、意図せずN+1が発生するのを防げます。
CloudWatchによるモニタリング
# config/initializers/cloudwatch_metrics.rb
class CloudWatchMetrics
def self.record_api_request(path:, method:, status:, duration_ms:)
client = Aws::CloudWatch::Client.new
client.put_metric_data(
namespace: "TechBridge/API",
metric_data: [
{
metric_name: "RequestCount",
value: 1,
dimensions: [
{ name: "Path", value: normalize_path(path) },
{ name: "Method", value: method },
{ name: "StatusCode", value: status.to_s }
]
},
{
metric_name: "ResponseTime",
value: duration_ms,
unit: "Milliseconds",
dimensions: [
{ name: "Path", value: normalize_path(path) }
]
}
]
)
end
def self.normalize_path(path)
# /api/v2/users/123 → /api/v2/users/{id}
path.gsub(/\/\d+/, "/{id}")
end
end
# app/controllers/application_controller.rb
around_action :record_metrics
def record_metrics
start = Time.current
yield
ensure
CloudWatchMetrics.record_api_request(
path: request.path,
method: request.method,
status: response.status,
duration_ms: ((Time.current - start) * 1000).round
)
endSLOとSLAの設計
# docs/slo.yaml
slo:
availability:
target: 99.9%
measurement_window: 30_days
error_budget: 43.8_minutes_per_month
latency:
p50: 100ms
p95: 300ms
p99: 1000ms
error_rate:
target: < 0.1% # 5xx errors / total requests# CloudFormation - CloudWatchアラーム
AvailabilityAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: API-Availability-Low
MetricName: 5xxErrorRate
Namespace: AWS/ApiGateway
Statistic: Average
Period: 300
EvaluationPeriods: 3
Threshold: 1 # 1%以上の5xxエラー
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref PagerDutySNSTopic
LatencyAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: API-P99-Latency-High
MetricName: IntegrationLatency
Namespace: AWS/ApiGateway
ExtendedStatistic: p99
Period: 300
EvaluationPeriods: 2
Threshold: 1000
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref PagerDutySNSTopicヘルスチェックエンドポイント
# config/routes.rb
get "/health", to: "health#show"
get "/health/ready", to: "health#readiness"
# app/controllers/health_controller.rb
class HealthController < ActionController::API
def show
render json: {
status: "ok",
timestamp: Time.current.iso8601,
version: ENV.fetch("APP_VERSION", "unknown")
}
end
def readiness
checks = {
database: check_database,
redis: check_redis,
external_api: check_external_api
}
all_healthy = checks.values.all? { |c| c[:status] == "ok" }
status = all_healthy ? :ok : :service_unavailable
render json: {
status: all_healthy ? "ready" : "not_ready",
checks: checks
}, status: status
end
private
def check_database
ActiveRecord::Base.connection.execute("SELECT 1")
{ status: "ok", latency_ms: benchmark { ActiveRecord::Base.connection.execute("SELECT 1") } }
rescue => e
{ status: "error", message: e.message }
end
def check_redis
Rails.cache.write("health_check", "ok", expires_in: 5.seconds)
value = Rails.cache.read("health_check")
{ status: value == "ok" ? "ok" : "error" }
rescue => e
{ status: "error", message: e.message }
end
def check_external_api
# 外部依存サービスのチェック
{ status: "ok" }
end
def benchmark
start = Time.current
yield
((Time.current - start) * 1000).round
end
endCI/CDパイプライン
# .github/workflows/api-tests.yml
name: API Tests
on:
push:
branches: [main, develop]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
steps:
- uses: actions/checkout@v4
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Setup database
run: |
bundle exec rails db:create db:schema:load
- name: Run tests
run: |
bundle exec rspec --format progress --format RspecJunitFormatter \
--out tmp/test-results/rspec.xml
- name: Upload results
uses: dorny/test-reporter@v1
if: always()
with:
name: RSpec Tests
path: tmp/test-results/rspec.xml
reporter: java-junit
- name: Security scan
run: |
bundle exec brakeman --exit-on-warnWARNING
テストのないAPIリリースは時限爆弾です。CI/CDでテストが通らなければデプロイできない仕組みを必ず整備してください。
サクラの成果
3ヶ月後、パートナー企業C社から連絡が来た。
「今月はダウンタイムがゼロでした。SLA達成できています」
テストカバレッジ92%、CloudWatchのダッシュボードはすべてグリーン。サクラは画面を見つめて、静かに満足した。
「品質は運ではなく、設計と計測の結果だ」
次章では、RESTを超えた「GraphQLとgRPC」の世界を探索する。