mybook

Stage 8: テスト戦略 — テストピラミッドと品質保証

なぜテストが設計の話なのか

「マイさん、テストって設計とは別の話じゃないですか?」

「逆。テストが書きにくいコードは、設計が悪いコード。SOLIDを学んで、依存性逆転を使えるようになったのも、テストのためでもあった。」

「テストは後付けではなく、コードの品質を測るセンサー。書きにくければ、設計を見直すサインだ。」

テストピラミッド

Loading diagram...

「テストは3層のピラミッドで考える。底辺の単体テストが一番多く、頂点のE2Eは少なくていい。多くのチームが逆をやって、テストが遅くなる。」

種類対象速度量の目安
単体テスト1クラス・1メソッドミリ秒70%
統合テスト複数コンポーネント20%
E2Eテストユーザーシナリオ全体10%

単体テスト: 一点集中で速く

# spec/models/user_spec.rb
RSpec.describe User, type: :model do
  describe "バリデーション" do
    subject(:user) { build(:user) }
 
    it { is_expected.to validate_presence_of(:email) }
    it { is_expected.to validate_presence_of(:name) }
    it { is_expected.to validate_uniqueness_of(:email).case_insensitive }
  end
 
  describe "#premium?" do
    context "プレミアム会員の場合" do
      subject(:user) { build(:user, subscription_tier: "premium") }
 
      it "trueを返す" do
        expect(user.premium?).to be true
      end
    end
 
    context "無料会員の場合" do
      subject(:user) { build(:user, subscription_tier: "free") }
 
      it "falseを返す" do
        expect(user.premium?).to be false
      end
    end
  end
 
  describe "#display_name" do
    context "nameが設定されている場合" do
      it "nameを返す" do
        user = build(:user, name: "田中太郎", email: "taro@example.com")
        expect(user.display_name).to eq("田中太郎")
      end
    end
 
    context "nameが空の場合" do
      it "emailのユーザー名部分を返す" do
        user = build(:user, name: nil, email: "taro@example.com")
        expect(user.display_name).to eq("taro")
      end
    end
  end
end
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:name) { |n| "ユーザー#{n}" }
    sequence(:email) { |n| "user#{n}@example.com" }
    password { "password123" }
    subscription_tier { "free" }
 
    trait :premium do
      subscription_tier { "premium" }
    end
 
    trait :admin do
      role { "admin" }
    end
  end
end

INFO

FactoryBotのベストプラクティス

Factoryはシンプルに保ちましょう。デフォルトは最小限のデータのみ。特定の状態はtraitで表現。テストごとに必要な属性だけ上書きする。

サービスオブジェクトのテスト: 依存性注入の恩恵

「SOLIDで依存性逆転を学んだのが、ここで活きる。」

# app/services/order_creation_service.rb
class OrderCreationService
  def initialize(payment_gateway:, inventory_service:, notification_service:)
    @payment_gateway = payment_gateway
    @inventory_service = inventory_service
    @notification_service = notification_service
  end
 
  def call(user:, items:, payment_token:)
    order = nil
    ActiveRecord::Base.transaction do
      @inventory_service.reserve!(items)
      order = Order.create!(user: user, items: items, status: "confirmed")
      @payment_gateway.charge(order.total_amount, payment_token)
    end
    @notification_service.notify_order_confirmed(order)
    order
  rescue InventoryService::InsufficientStockError => e
    raise OrderCreationError, "在庫不足: #{e.message}"
  end
end
 
# spec/services/order_creation_service_spec.rb
RSpec.describe OrderCreationService do
  let(:payment_gateway) { instance_double("StripeGateway") }
  let(:inventory_service) { instance_double("InventoryService") }
  let(:notification_service) { instance_double("NotificationService") }
 
  subject(:service) do
    described_class.new(
      payment_gateway: payment_gateway,
      inventory_service: inventory_service,
      notification_service: notification_service
    )
  end
 
  let(:user) { create(:user) }
  let(:items) { [create(:order_item)] }
  let(:payment_token) { "tok_test_123" }
 
  before do
    allow(inventory_service).to receive(:reserve!)
    allow(payment_gateway).to receive(:charge)
    allow(notification_service).to receive(:notify_order_confirmed)
  end
 
  describe "#call" do
    context "正常系" do
      it "注文が作成される" do
        expect {
          service.call(user: user, items: items, payment_token: payment_token)
        }.to change(Order, :count).by(1)
      end
 
      it "支払いが処理される" do
        order = service.call(user: user, items: items, payment_token: payment_token)
        expect(payment_gateway).to have_received(:charge).with(order.total_amount, payment_token)
      end
 
      it "確認通知が送られる" do
        service.call(user: user, items: items, payment_token: payment_token)
        expect(notification_service).to have_received(:notify_order_confirmed)
      end
    end
 
    context "在庫不足の場合" do
      before do
        allow(inventory_service).to receive(:reserve!)
          .and_raise(InventoryService::InsufficientStockError, "商品A")
      end
 
      it "OrderCreationErrorを発生させる" do
        expect {
          service.call(user: user, items: items, payment_token: payment_token)
        }.to raise_error(OrderCreationError, /在庫不足/)
      end
 
      it "注文が作成されない" do
        expect {
          service.call(user: user, items: items, payment_token: payment_token)
        }.to raise_error(OrderCreationError)
          .and not_change(Order, :count)
      end
    end
  end
end

統合テスト: コンポーネントが正しく連携するか

# spec/requests/api/orders_spec.rb
RSpec.describe "POST /api/orders", type: :request do
  let(:user) { create(:user) }
  let(:product) { create(:product, stock: 10, price: 1_000) }
  let(:headers) { { "Authorization" => "Bearer #{user.api_token}" } }
 
  context "正常な注文" do
    let(:params) do
      {
        order: {
          items: [{ product_id: product.id, quantity: 2 }],
          payment_token: "tok_test"
        }
      }
    end
 
    before do
      # Stripeは実際に呼ばない
      stub_request(:post, "https://api.stripe.com/v1/charges")
        .to_return(status: 200, body: { id: "ch_test" }.to_json)
    end
 
    it "201を返す" do
      post "/api/orders", params: params, headers: headers
      expect(response).to have_http_status(:created)
    end
 
    it "注文が作成される" do
      expect {
        post "/api/orders", params: params, headers: headers
      }.to change(Order, :count).by(1)
    end
 
    it "在庫が減る" do
      expect {
        post "/api/orders", params: params, headers: headers
      }.to change { product.reload.stock }.by(-2)
    end
  end
 
  context "在庫切れ" do
    let(:product) { create(:product, stock: 0) }
    let(:params) do
      { order: { items: [{ product_id: product.id, quantity: 1 }] } }
    end
 
    it "422を返す" do
      post "/api/orders", params: params, headers: headers
      expect(response).to have_http_status(:unprocessable_entity)
    end
  end
end

TDD: テスト駆動開発のサイクル

「テストを先に書くという開発スタイルがTDD。」

Loading diagram...
# TDDの例: DiscountCalculatorを作る
 
# Step 1: Red — まず失敗するテストを書く
RSpec.describe DiscountCalculator do
  describe "#calculate" do
    it "プレミアム会員は10%OFF" do
      user = build(:user, :premium)
      calc = DiscountCalculator.new(user)
      expect(calc.calculate(10_000)).to eq(9_000)
    end
  end
end
# => NameError: uninitialized constant DiscountCalculator
 
# Step 2: Green — テストが通る最小実装
class DiscountCalculator
  def initialize(user)
    @user = user
  end
 
  def calculate(price)
    return price * 0.9 if @user.premium?
    price
  end
end
# => テスト通過
 
# Step 3: Refactor — コードを改善(テストは通ったまま)
class DiscountCalculator
  DISCOUNT_RATES = {
    "premium" => 0.10,
    "vip"     => 0.20,
    "free"    => 0.00
  }.freeze
 
  def initialize(user)
    @user = user
  end
 
  def calculate(price)
    rate = DISCOUNT_RATES.fetch(@user.subscription_tier, 0.00)
    (price * (1 - rate)).ceil
  end
end

WARNING

モックの使いすぎに注意

モックを多用するとテストが実装の詳細に依存し、リファクタリングのたびにテストが壊れます。「振る舞い(入力→出力)」をテストし、実装の詳細はできる限りテストしないようにしましょう。

テストカバレッジと品質

# Gemfile
gem "simplecov", require: false, group: :test
 
# spec/spec_helper.rb
require "simplecov"
SimpleCov.start "rails" do
  add_filter "/spec/"
  minimum_coverage 80  # 80%未満なら失敗
end
# .github/workflows/test.yml
name: Test
on: [pull_request]
jobs:
  rspec:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - name: Setup DB
        run: bundle exec rails db:create db:schema:load
        env:
          DATABASE_URL: postgres://postgres:password@localhost/app_test
      - name: Run RSpec
        run: bundle exec rspec --format progress
      - name: Upload coverage
        uses: codecov/codecov-action@v4

Stage 8 のまとめ

「テストは保険じゃない。設計を良くするためのツールだ。」

ヒロシはOrderCreationServiceのテストを書きながら、依存性注入の意味が初めて体感できた気がした。

「テストが書きやすかった。前なら全部モックだらけになってた。」

「SOLIDを学んだから書けた。設計とテストは表裏一体なんだ。」

テスト種類目的ツール
単体テストロジックの正確さRSpec + FactoryBot
統合テストコンポーネント連携RSpec request spec
E2Eテストユーザー体験Capybara + Selenium
性能テストスループット確認k6, JMeter

「次はDevOps と CI/CD。良いコードを書いて、良いテストを書いたら、次は安全に本番に届けるための自動化を学ぼう。」