mybook

テストとモニタリング — APIの品質を保つ

本番障害の教訓

「SLAが99.9%のはずなのに、今月3回ダウンした」

パートナー企業C社からのクレームが届いた。確認すると、デプロイのたびにバグが混入し、エンドポイントが落ちていた。

「テストがない。モニタリングもない。気づくのはいつもユーザーからの報告だ」

サクラはCTOに宣言した。「テストとモニタリングを整備しないと、APIの信頼性は担保できません」

テストピラミッド

Loading diagram...
レイヤーツール速度コスト
静的解析RuboCop, Brakeman最速最低
ユニットRSpec (model specs)速い
統合RSpec (request specs)中程度
E2EPostman 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
end

INFO

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
  )
end

SLOと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
end

CI/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-warn

WARNING

テストのないAPIリリースは時限爆弾です。CI/CDでテストが通らなければデプロイできない仕組みを必ず整備してください。

サクラの成果

3ヶ月後、パートナー企業C社から連絡が来た。

「今月はダウンタイムがゼロでした。SLA達成できています」

テストカバレッジ92%、CloudWatchのダッシュボードはすべてグリーン。サクラは画面を見つめて、静かに満足した。

「品質は運ではなく、設計と計測の結果だ」

次章では、RESTを超えた「GraphQLとgRPC」の世界を探索する。