【品質向上】単体テストを書く時に意識すべき9つのポイント

テスト

単体テストは、ソフトウェア開発において品質を保証するための重要な要素です。しかし、テストコードの質が低いと、保守性が悪化し、かえって開発の足かせになることもあります。本記事では、実務で単体テストを書く際に特に気をつけている9つのポイントをまとめました。

1. テストの独立性を保つ

なぜ重要か

テスト間に依存関係があると、実行順序によって結果が変わったり、一つのテストの失敗が他のテストに影響を与えたりします。これはデバッグを困難にし、テストの信頼性を損ないます。

具体的な実践方法

# ❌ 悪い例:テスト間で状態を共有
RSpec.describe User do
  before(:all) do
    @user = User.create!(name: 'Alice', email: 'alice@example.com')
  end

  it 'ユーザーを作成する' do
    expect(@user).to be_persisted
  end

  it 'ユーザーを取得する' do
    user = User.find(@user.id) # 前のテストに依存
    expect(user.name).to eq('Alice')
  end
end

# ✅ 良い例:各テストが独立している
RSpec.describe User do
  it 'ユーザーを作成する' do
    user = User.create!(name: 'Alice', email: 'alice@example.com')
    expect(user).to be_persisted
  end

  it 'ユーザーを取得する' do
    user = User.create!(name: 'Bob', email: 'bob@example.com')
    found_user = User.find(user.id)
    expect(found_user.name).to eq('Bob')
  end
end

各テストは自己完結し、セットアップからクリーンアップまでを自身で完結させるべきです。FactoryBotを使うとさらに簡潔に書けます。

2. テスト名は「何をテストしているか」を明確に

なぜ重要か

テストが失敗したとき、テスト名だけで何が問題なのかを素早く把握できることは、開発効率に直結します。

具体的な実践方法

# ❌ 悪い例:曖昧なテスト名
RSpec.describe UserRegistration do
  it 'test1' do
    # ...
  end
  
  it '正常系' do
    # ...
  end
  
  it 'エラー' do
    # ...
  end
end

# ✅ 良い例:振る舞いと期待結果が明確
RSpec.describe UserRegistration do
  it '有効なメールアドレスでユーザー登録すると、新しいユーザーIDが返される' do
    # ...
  end
  
  it '既に登録済みのメールアドレスで登録すると、ConflictErrorが発生する' do
    # ...
  end
  
  it '無効な形式のメールアドレスで登録すると、ValidationErrorが発生する' do
    # ...
  end
end

私は「〜すると、〜になる」という形式でテスト名を書くことを心がけています。これにより、入力条件と期待結果が一目で分かります。

3. AAA(Arrange-Act-Assert)パターンを意識する

なぜ重要か

テストコードの構造を統一することで、可読性が向上し、他の開発者がテストを理解しやすくなります。

具体的な実践方法

RSpec.describe PriceCalculator do
  it '割引適用後の金額を正しく計算する' do
    # Arrange(準備):テストに必要なデータやオブジェクトを準備
    original_price = 10_000
    discount_rate = 0.2
    calculator = PriceCalculator.new

    # Act(実行):テスト対象の処理を実行
    discounted_price = calculator.apply_discount(original_price, discount_rate)

    # Assert(検証):期待結果と実際の結果を比較
    expect(discounted_price).to eq(8_000)
  end
end

3つのセクションをコメントで明示的に分けることで、テストの意図がより明確になります。

4. 1つのテストで1つの観点だけを検証する

なぜ重要か

複数の観点を1つのテストに詰め込むと、テストが失敗したときに原因の特定が困難になります。

具体的な実践方法

# ❌ 悪い例:複数の観点を1つのテストで検証
RSpec.describe UserRegistration do
  it 'ユーザー登録機能' do
    user = UserRegistration.register(name: 'Alice', email: 'alice@example.com')
    
    expect(user.id).to be_present
    expect(user.name).to eq('Alice')
    expect(user.created_at).to be_a(Time)
    expect(UserMailer).to have_received(:welcome_email).with('alice@example.com')
    expect(Rails.logger).to have_received(:info).with('USER_REGISTERED')
  end
end

# ✅ 良い例:観点ごとにテストを分割
RSpec.describe UserRegistration do
  it 'ユーザー登録すると、新しいユーザーオブジェクトが返される' do
    user = UserRegistration.register(name: 'Alice', email: 'alice@example.com')
    
    expect(user.id).to be_present
    expect(user.name).to eq('Alice')
  end

  it 'ユーザー登録すると、確認メールが送信される' do
    allow(UserMailer).to receive(:welcome_email)
    
    UserRegistration.register(name: 'Alice', email: 'alice@example.com')
    
    expect(UserMailer).to have_received(:welcome_email).with('alice@example.com')
  end

  it 'ユーザー登録すると、登録イベントがログに記録される' do
    allow(Rails.logger).to receive(:info)
    
    UserRegistration.register(name: 'Alice', email: 'alice@example.com')
    
    expect(Rails.logger).to have_received(:info).with('USER_REGISTERED')
  end
end

5. テストデータは最小限かつ意味のあるものにする

なぜ重要か

不必要に複雑なテストデータは、テストの本質を見えにくくし、保守コストを増加させます。

具体的な実践方法

# ❌ 悪い例:テストに不要なデータが多すぎる
RSpec.describe UserValidator do
  it 'メールアドレスのバリデーション' do
    user = User.new(
      id: 123,
      name: 'Alice Smith',
      email: 'invalid-email',
      age: 30,
      address: '123 Main St',
      phone_number: '090-1234-5678',
      registered_at: Time.current,
      newsletter_enabled: true
    )
    
    expect { user.validate! }.to raise_error(ActiveRecord::RecordInvalid)
  end
end

# ✅ 良い例:テストに必要なデータだけを使用
RSpec.describe EmailValidator do
  it '無効な形式のメールアドレスはバリデーションエラーになる' do
    invalid_email = 'invalid-email'
    
    validator = EmailValidator.new(invalid_email)
    
    expect(validator.valid?).to be false
  end
end

# さらに良い例:FactoryBotで必要な属性だけ上書き
RSpec.describe User do
  it '無効な形式のメールアドレスは保存できない' do
    user = build(:user, email: 'invalid-email')
    
    expect(user).not_to be_valid
    expect(user.errors[:email]).to be_present
  end
end

テストが何を検証しているのかを明確にするため、関連するデータだけを使用します。

6. エラーケースも必ずテストする

なぜ重要か

正常系だけでなく、異常系のテストも重要です。実際の運用では、エラーケースの方が問題を引き起こすことが多いからです。

具体的な実践方法

RSpec.describe Calculator do
  describe '#divide' do
    it '正の数同士の除算' do
      expect(Calculator.divide(10, 2)).to eq(5)
    end

    it '0で割ろうとするとエラーが発生する' do
      expect { Calculator.divide(10, 0) }.to raise_error(ZeroDivisionError, '0で割ることはできません')
    end

    it '数値以外を渡すとエラーが発生する' do
      expect { Calculator.divide('10', 2) }.to raise_error(TypeError)
    end
  end
end

正常系と異常系の両方をカバーすることで、より堅牢なコードになります。

7. テストの実行速度を意識する

なぜ重要か

遅いテストは開発フローを妨げ、テストの実行頻度を下げる原因になります。

具体的な実践方法

  • データベースやファイルシステムなどのI/Oは可能な限りモックする
  • 不必要な待機時間(sleep, timeout)を避ける
  • テストごとに重い初期化処理を繰り返さない(before, let!を活用)
# ❌ 悪い例:毎回データベースに接続
RSpec.describe User do
  it 'ユーザーを取得する' do
    # 実際にDBに保存して取得(遅い)
    user = User.create!(name: 'Alice', email: 'alice@example.com')
    found_user = User.find(user.id)
    expect(found_user.name).to eq('Alice')
  end
end

# ✅ 良い例:メモリ上のオブジェクトを使う
RSpec.describe User do
  it 'ユーザーを取得する' do
    # DBに保存せず、メモリ上でバリデーションだけテスト
    user = build(:user, name: 'Alice')
    expect(user.name).to eq('Alice')
  end
end

# 外部APIのモック例
RSpec.describe PaymentService do
  it '決済を処理する' do
    # 実際のAPIを呼ばず、モックを使用
    stub_request(:post, "https://api.payment.com/charge")
      .to_return(status: 200, body: { success: true }.to_json)
    
    result = PaymentService.charge(amount: 1000)
    expect(result[:success]).to be true
  end
end

8. テストコードも本番コードと同じ品質で書く

なぜ重要か

テストコードも保守の対象です。品質の低いテストコードは、長期的に技術的負債となります。

具体的な実践方法

  • 重複したコードはヘルパー関数やFactoryBotに抽出する
  • マジックナンバーは定数化する
  • 分かりやすい変数名を使う
# ❌ 悪い例:重複が多く可読性が低い
RSpec.describe Authorization do
  it 'test1' do
    user = User.new(id: 1, name: 'Alice', role: 'admin', status: 1)
    expect(Authorization.can_access?(user, '/admin')).to be true
  end

  it 'test2' do
    user = User.new(id: 2, name: 'Bob', role: 'user', status: 1)
    expect(Authorization.can_access?(user, '/admin')).to be false
  end
end

# ✅ 良い例:FactoryBotを使い、意図が明確
# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    name { 'Test User' }
    role { :user }
    status { :active }
    
    trait :admin do
      role { :admin }
    end
  end
end

# spec/models/authorization_spec.rb
RSpec.describe Authorization do
  describe '.can_access?' do
    it '管理者は管理画面にアクセスできる' do
      admin_user = create(:user, :admin)
      expect(Authorization.can_access?(admin_user, '/admin')).to be true
    end

    it '一般ユーザーは管理画面にアクセスできない' do
      normal_user = create(:user)
      expect(Authorization.can_access?(normal_user, '/admin')).to be false
    end
  end
end

9. カバレッジは目安、100%を目指さない

なぜ重要か

カバレッジ100%を目指すと、価値の低いテストを量産することになりがちです。重要なのは、重要なビジネスロジックとエッジケースをカバーすることです。

具体的な考え方

カバレッジは品質の指標の一つですが、絶対的なものではありません。以下のような観点でテストの優先順位を決めます。

高優先度

  • ビジネスロジックのコアとなる処理
  • 過去にバグが発生した箇所
  • 複雑な条件分岐を含む処理
  • 外部サービスとの連携部分

低優先度

  • 単純なgetter/setter
  • フレームワークが提供する機能
  • 自動生成されたコード

まとめ

単体テストは、以下のポイントを意識することで、保守性が高く価値のあるものになります。

  1. テストの独立性を保つ
  2. 分かりやすいテスト名をつける
  3. AAAパターンで構造化する
  4. 1テスト1観点の原則を守る
  5. テストデータは最小限にする
  6. エラーケースもカバーする
  7. 実行速度を意識する
  8. テストコードの品質を保つ
  9. カバレッジは目安として使う

テストは書くこと自体が目的ではなく、品質を保証し、安心してリファクタリングできる環境を作ることが目的です。これらのプラクティスを意識しながら、チームやプロジェクトに合ったテスト文化を育ていくと良いと思います。

コメント

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