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
endINFO
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
endTDD: テスト駆動開発のサイクル
「テストを先に書くという開発スタイルが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
endWARNING
モックの使いすぎに注意
モックを多用するとテストが実装の詳細に依存し、リファクタリングのたびにテストが壊れます。「振る舞い(入力→出力)」をテストし、実装の詳細はできる限りテストしないようにしましょう。
テストカバレッジと品質
# 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@v4Stage 8 のまとめ
「テストは保険じゃない。設計を良くするためのツールだ。」
ヒロシはOrderCreationServiceのテストを書きながら、依存性注入の意味が初めて体感できた気がした。
「テストが書きやすかった。前なら全部モックだらけになってた。」
「SOLIDを学んだから書けた。設計とテストは表裏一体なんだ。」
| テスト種類 | 目的 | ツール |
|---|---|---|
| 単体テスト | ロジックの正確さ | RSpec + FactoryBot |
| 統合テスト | コンポーネント連携 | RSpec request spec |
| E2Eテスト | ユーザー体験 | Capybara + Selenium |
| 性能テスト | スループット確認 | k6, JMeter |
「次はDevOps と CI/CD。良いコードを書いて、良いテストを書いたら、次は安全に本番に届けるための自動化を学ぼう。」