【テスト戦略】スピードと品質を両立させプロダクトを安定成長させる

テスト

はじめに

「テストはどれくらい書けばいいんだろう?」

開発者なら誰もが一度は抱いたことのある疑問ではないでしょうか。カバレッジ100%を目指すべきなのか、それとも重要な部分だけで十分なのか。チーム内でテスト方針が統一されていないと、品質にばらつきが生じたり、過剰なテストコードがメンテナンスの負債になったりします。

本記事では、実際の開発現場で直面したテストの課題を起点に、テストピラミッドを活用した戦略的なテスト設計責務分離によるテスタビリティの向上について解説します。

テスト戦略がない場合に発生すること

1. テスト戦略の不在による品質のばらつき

テスト戦略が定義されていない場合、下記のような問題が発生する可能性が高いです。

  • テストの粒度が統一されていない:ある人は細かくテストを書き、ある人は大雑把に書く
  • 品質にばらつきがある:網羅性、可読性、保守性の観点でテストコードの品質が安定しない
  • 基準が不明確:どこまでテストすべきかの判断基準がない

2. Fat Controllerによるテスタビリティの低下

もう一つアーキテクチャ分割を適切に実施しないとコントローラーにビジネスロジックが集中している状態が発生します。

<em># Fat Controllerの例</em>
class HealthCheckupsController < ApplicationController
  def create
    <em># ビジネスロジックがコントローラーに集中</em>
    @checkup = HealthCheckup.new(checkup_params)
    
    <em># 複雑な評価ロジック</em>
    if @checkup.bmi > 25 && @checkup.blood_pressure_high > 140
      @checkup.risk_level = calculate_risk_level(@checkup)
      @checkup.requires_consultation = true
    end
    
    <em># さらに複雑な処理が続く...</em>
    if @checkup.save
      send_notification(@checkup)
      redirect_to @checkup
    else
      render :new
    end
  end
end

この状態では、単体テストが書きにくく、結果として多くのロジックを統合テストで確認する傾向が強くなります。統合テストは実行時間が長く、開発サイクル全体を非効率にする原因となります。

解決策:テストピラミッドによる戦略的アプローチ

テストピラミッドとは

テストピラミッドは、アジャイル開発の文脈でMike Cohn氏によって提唱された手法です(Succeeding with Agile: Software Development Using Scrum, 2009)。この概念を活用することで、効率と品質のバランスが取れたテスト構成を実現できます。

       /\
      /  \     System Test (10%)
     /____\    
    /      \   Integration Test (20%)
   /________\  
  /          \ Unit Test (70%)
 /____________\

各層の役割と書き方

単体テスト(Unit Test):70%程度

目的:ビジネスロジックを高速かつ安定的に検証する

特徴

  • テスト時間が短く、コード量も少ないためメンテナンス工数が少ない
  • 異常系を多めに書く(実運用ではエラーケースの方が問題を引き起こすことが多い)
  • 統合テストでは書きづらい異常系をカバーする
<em># 単体テストの例</em>
RSpec.describe HealthCheckupRiskCalculator do
  describe '#calculate_risk_level' do
    context 'BMIが25を超え、血圧が140を超える場合' do
      it 'リスクレベル「高」を返すこと' do
        calculator = described_class.new(bmi: 26, blood_pressure_high: 145)
        expect(calculator.calculate_risk_level).to eq('high')
      end
    end
    
    context 'BMIが正常値の場合' do
      it 'リスクレベル「低」を返すこと' do
        calculator = described_class.new(bmi: 22, blood_pressure_high: 120)
        expect(calculator.calculate_risk_level).to eq('low')
      end
    end
    
    <em># 異常系のテストケース</em>
    context '不正な値が渡された場合' do
      it 'ArgumentErrorを発生させること' do
        expect {
          described_class.new(bmi: -1, blood_pressure_high: 140)
        }.to raise_error(ArgumentError)
      end
    end
  end
end

統合テスト(Integration Test):20%程度

目的:単体テストでカバーできない範囲の信頼性を確保する

書くべき内容

  • 1件の正常系テストで一連の挙動を確認
  • 最終的なレスポンスの形式を検証
  • 単体テストで書けなかった異常系を追加

注意点

  • テスト時間、コード量(メンテナンス工数)が増大する傾向にあるため単体テストに比べて少なめに書く
<em># 統合テストの例</em>
RSpec.describe 'HealthCheckups API', type: :request do
  describe 'POST /health_checkups' do
    context '正常系' do
      it '健康診断データを作成し、適切なレスポンスを返すこと' do
        post '/health_checkups', params: valid_params
        
        expect(response).to have_http_status(:created)
        expect(response.content_type).to include('application/json')
        expect(json_response['risk_level']).to eq('high')
      end
    end
  end
end

システムテスト(System Test):10%程度

目的:重要なユーザーフローのみをエンドツーエンドで検証

注意点

  • 実行時間が最も長くメンテナンスコストも高いため、最小限に抑える
  • 本当にクリティカルなユーザー体験のみをテストする
<em># システムテストの例</em>
RSpec.describe '健康診断登録フロー', type: :system do
  it 'ユーザーが健康診断データを登録できる' do
    visit new_health_checkup_path
    
    fill_in '身長', with: '170'
    fill_in '体重', with: '75'
    fill_in '血圧(高)', with: '145'
    
    click_button '登録'
    
    expect(page).to have_content('健康診断データを登録しました')
    expect(page).to have_content('リスクレベル: 高')
  end
end

重要なのは比率ではなく原則の理解

ただし、70:20:10という比率を教条的に守ることが目的ではありません。その背後にある核心的な原則を理解することが重要です。

核心的な原則

  1. 速くて安定したテストを多く書く
  2. 遅くて不安定なテストは最小限に
  3. 各層のテストが異なる価値を提供する
  4. フィードバックループを短く保つ

どれくらいテストを書けば良いのか

カバレッジ100%は目指すべきか?

結論から言うと、カバレッジ100%を目指す必要はありません

理由は2つあります。

1. カバレッジは品質を保証しない

カバレッジは「コードの何%が実行されたか」を測定する指標であり、「コードが正しく動作するか」を保証するものではありません。

「The Case Against 100% Code Coverage」でも指摘されているように、100%のカバレッジを達成しても、全てのパターンについてテストをしているとは言えないのです。

<em># カバレッジは100%でも、バグを見逃す例</em>
def calculate_discount(price, customer_type)
  if customer_type == 'premium'
    price * 0.9  <em># 10%割引</em>
  else
    price
  end
end

<em># このテストはカバレッジ100%だが...</em>
it 'プレミアム顧客に割引を適用する' do
  expect(calculate_discount(1000, 'premium')).to eq(900)
end

<em># 境界値や異常系のテストが抜けている</em>
<em># - priceが0や負の値の場合は?</em>
<em># - customer_typeがnilやその他の値の場合は?</em>

2. テストコード自体もメンテナンス対象の「負債」

テストコードもプロダクションコードと同様に保守が必要です。コードが多ければ多いほど、それをメンテナンスするのにも工数がかかります。

具体的な問題

  • 仕様変更があった際には、プロダクションコードだけでなく、関連するテストコードも修正する必要がある
  • テストコードが過剰に書かれていると、ちょっとした仕様変更でも大量のテストコードを修正しなければならず、開発速度が低下する

では、十分なテストとは何か?

『手を動かしてわかるクリーンアーキテクチャ』という書籍では、以下のように述べられています。

「自分が自信を持ってリリースできる状態であれば、テストコードは十分に書かれているとみなしてよい」

この考え方は非常にシンプルで実践的です。開発者自身が「このコードは本番環境にデプロイしても問題ない」と確信できるレベルのテストが書かれていれば、それで十分なのです。

コードベースの重要な部分を見極める

『単体テストの考え方/使い方』では、より具体的な指針が示されています。

重要な部分を見極める観点

  1. システムの中核となる部分:ビジネスロジックやドメインロジック
  2. 複雑なロジックが含まれる部分:条件分岐が多い、計算ロジックが複雑
  3. 変更頻度が高い部分:ビジネス要件の変更が頻繁にある箇所
  4. バグの影響が大きい部分:金額計算、個人情報処理など

ビジネスロジックのテストが最も費用対効果が高い

ビジネスロジック(システムの中核となる独自のルールや処理)はシステムの価値を生み出す中核であり、この部分にバグがあるとビジネスに直接的な影響を与える可能性が高いため、最も優先度の高いテスト対象となります。

ビジネスロジックのテストが費用対効果が高い理由

  1. ビジネスへの影響が大きい:健康診断の評価判定、年度計算、資格判定などのロジックにバグがあると、ビジネスに直接影響する
  2. 変更頻度が高い:法改正やビジネス要件の変更により、ビジネスルールは頻繁に変更されるため、テストによる回帰防止の効果が高い
  3. 複雑で理解しにくい:ドメイン知識が必要な複雑なロジックは、テストがないと変更時に壊してしまう危険性が高い

責務分離によるテスタビリティの向上

ビジネスロジックを効率的にテストするには、疎結合に保つことが重要です。

Fat Controllerをリファクタリングする

先ほどのFat Controllerの例を、責務分離を行ってリファクタリングしてみましょう。

Before(Fat Controller)

class HealthCheckupsController < ApplicationController
  def create
    @checkup = HealthCheckup.new(checkup_params)
    
    <em># ビジネスロジックがコントローラーに集中</em>
    if @checkup.bmi > 25 && @checkup.blood_pressure_high > 140
      @checkup.risk_level = calculate_risk_level(@checkup)
      @checkup.requires_consultation = true
    end
    
    if @checkup.save
      send_notification(@checkup)
      redirect_to @checkup
    else
      render :new
    end
  end
  
  private
  
  def calculate_risk_level(checkup)
    <em># 複雑な計算ロジック...</em>
  end
end

この状態では、コントローラーのテストで複雑なロジックを全て検証する必要があり、テストが遅く、メンテナンスしにくくなります。

After(責務分離)

<em># app/services/health_checkup_risk_calculator.rb</em>
class HealthCheckupRiskCalculator
  def initialize(bmi:, blood_pressure_high:)
    @bmi = bmi
    @blood_pressure_high = blood_pressure_high
  end
  
  def calculate_risk_level
    return 'high' if high_risk?
    return 'medium' if medium_risk?
    'low'
  end
  
  def requires_consultation?
    high_risk?
  end
  
  private
  
  def high_risk?
    @bmi > 25 && @blood_pressure_high > 140
  end
  
  def medium_risk?
    @bmi > 23 || @blood_pressure_high > 130
  end
end

<em># app/controllers/health_checkups_controller.rb</em>
class HealthCheckupsController < ApplicationController
  def create
    @checkup = HealthCheckup.new(checkup_params)
    
    <em># ビジネスロジックは専用のクラスに委譲</em>
    calculator = HealthCheckupRiskCalculator.new(
      bmi: @checkup.bmi,
      blood_pressure_high: @checkup.blood_pressure_high
    )
    
    @checkup.risk_level = calculator.calculate_risk_level
    @checkup.requires_consultation = calculator.requires_consultation?
    
    if @checkup.save
      HealthCheckupNotificationService.new(@checkup).send
      redirect_to @checkup
    else
      render :new
    end
  end
end

責務分離のメリット

  1. 単体テストが書きやすい:ビジネスロジックを独立してテストできる
  2. テストが高速:データベースアクセスやHTTPリクエストが不要
  3. テストが安定:外部依存がないため、テストが失敗しにくい
  4. 可読性の向上:各クラスの責務が明確になる
  5. 再利用性の向上:他の場所でも同じロジックを使える
<em># 高速で安定した単体テストが書ける</em>
RSpec.describe HealthCheckupRiskCalculator do
  describe '#calculate_risk_level' do
    it 'BMIと血圧が高い場合、highを返す' do
      calculator = described_class.new(bmi: 26, blood_pressure_high: 145)
      expect(calculator.calculate_risk_level).to eq('high')
    end
    
    it 'BMIがやや高い場合、mediumを返す' do
      calculator = described_class.new(bmi: 24, blood_pressure_high: 120)
      expect(calculator.calculate_risk_level).to eq('medium')
    end
    
    it '正常値の場合、lowを返す' do
      calculator = described_class.new(bmi: 22, blood_pressure_high: 120)
      expect(calculator.calculate_risk_level).to eq('low')
    end
  end
  
  describe '#requires_consultation?' do
    it '高リスクの場合、trueを返す' do
      calculator = described_class.new(bmi: 26, blood_pressure_high: 145)
      expect(calculator.requires_consultation?).to be true
    end
  end
end

まとめ

テスト戦略の確立は、一朝一夕にできるものではありません。しかし、以下のポイントを押さえることで、持続可能な品質保証の仕組みを構築できます。

重要なポイント

  1. テストピラミッドを活用する:単体テストを多く、統合テストは適度に、システムテストは最小限に
  2. カバレッジではなく価値を重視する:重要な部分を見極め、自信を持ってリリースできるレベルを目指す
  3. ビジネスロジックを疎結合に保つ:責務分離により、テストしやすい設計を実現する
  4. 段階的に改善する:完璧を目指すのではなく、継続的な改善を重視する

チーム全体で統一されたテスト戦略を持ち、効率的で保守しやすいテストコードを書けるようになることで、開発速度と品質の両立が可能になります。

参考文献

  • 『単体テストの考え方/使い方』
  • 『手を動かしてわかるクリーンアーキテクチャ ヘキサゴナルアーキテクチャによるクリーンなアプリケーション開発』
  • Succeeding with Agile: Software Development Using Scrum (Mike Cohn, 2009)
  • The Case Against 100% Code Coverage

コメント

タイトルとURLをコピーしました