🔖

RSpecで`change`を使うべき場面とは?`eq`より優れている理由を解説

に公開

RSpecでchangeを使うべき場面とは?eqより優れている理由を徹底解説

RSpecでテストを書いていると、「この場面ではeqchangeどちらを使うべき?」と悩むことがありませんか?この記事では、changeメソッドが威力を発揮する場面と、なぜeqbeよりも適しているケースがあるのかを具体例とともに解説します。

TL;DR(要約)

  • change: 実行前後での値の変化を検証(副作用のテスト)
  • eq/be: 現在の値や状態を検証(結果のテスト)
  • changeが適している場面: データベースへの保存、カウンターの増減、配列への要素追加など

changeメソッドとは?

changeメソッドは、何かの処理を実行する前後で値が変化したかどうかをテストするマッチャーです。

# 基本的な書き方
expect { 何かの処理 }.to change { 監視したい値 }

なぜeqだけでは不十分なのか?

eqだけを使った場合の問題点

RSpec.describe "ユーザー作成のテスト" do
  it "ユーザーが作成される" do
    # これだけでは不十分!
    user = User.create(name: "太郎")
    expect(user.name).to eq("太郎")
    
    # 問題:本当にデータベースに保存されたかわからない
    # 問題:他のユーザーが削除されていないかわからない
    # 問題:ユーザー数が正しく増加したかわからない
  end
end

changeを使った場合の利点

RSpec.describe "ユーザー作成のテスト" do
  it "ユーザーが作成される" do
    # 副作用もしっかりテスト
    expect {
      User.create(name: "太郎")
    }.to change { User.count }.by(1)
    
    # 作成されたユーザーの内容も確認
    expect(User.last.name).to eq("太郎")
  end
end

changeが威力を発揮する具体例

1. データベースのレコード数の変化

RSpec.describe UsersController do
  describe "POST #create" do
    let(:user_params) { { name: "太郎", email: "taro@example.com" } }

    it "ユーザーが1人増える" do
      expect {
        post :create, params: { user: user_params }
      }.to change { User.count }.by(1)
    end

    it "管理者ユーザーが1人増える" do
      admin_params = user_params.merge(role: "admin")
      
      expect {
        post :create, params: { user: admin_params }
      }.to change { User.where(role: "admin").count }.by(1)
    end
  end

  describe "DELETE #destroy" do
    let!(:user) { User.create(name: "太郎") }

    it "ユーザーが1人減る" do
      expect {
        delete :destroy, params: { id: user.id }
      }.to change { User.count }.by(-1)
    end
  end
end

2. 関連するモデルの同時変更

RSpec.describe "記事の投稿" do
  let(:user) { User.create(name: "太郎") }

  it "記事とタグが正しく作成される" do
    expect {
      user.articles.create(
        title: "Rails入門",
        content: "Railsについて...",
        tag_list: "Rails,Ruby"
      )
    }.to change { Article.count }.by(1)
     .and change { Tag.count }.by(2)
     .and change { user.articles.count }.by(1)
  end
end

3. 状態の変化を伴う処理

RSpec.describe Order do
  let(:order) { Order.create(status: "pending") }

  describe "#ship!" do
    it "ステータスが変更され、発送日が設定される" do
      expect {
        order.ship!
      }.to change { order.status }.from("pending").to("shipped")
       .and change { order.shipped_at }.from(nil)
    end
  end

  describe "#complete!" do
    let(:order) { Order.create(status: "shipped") }

    it "注文が完了し、完了数がカウントアップされる" do
      expect {
        order.complete!
      }.to change { order.status }.to("completed")
       .and change { Order.completed.count }.by(1)
    end
  end
end

4. キューやバックグラウンドジョブ

RSpec.describe "メール送信" do
  it "メール送信ジョブがキューに追加される" do
    expect {
      User.create(email: "test@example.com")
    }.to change { ActionMailer::Base.deliveries.size }.by(1)
  end

  it "複数のジョブが適切にキューイングされる" do
    expect {
      10.times { |i| User.create(email: "user#{i}@example.com") }
    }.to change { WelcomeEmailJob.jobs.size }.by(10)
  end
end

5. ファイルシステムの操作

RSpec.describe FileUploader do
  let(:uploader) { FileUploader.new }

  it "ファイルがアップロードされる" do
    file = fixture_file_upload("test.jpg", "image/jpeg")
    
    expect {
      uploader.upload(file)
    }.to change { Dir.glob("uploads/*").count }.by(1)
  end

  it "古いファイルが削除される" do
    # 事前に古いファイルを作成
    old_file = uploader.upload(fixture_file_upload("old.jpg"))
    
    expect {
      uploader.cleanup_old_files
    }.to change { File.exist?(old_file.path) }.from(true).to(false)
  end
end

changeの様々な書き方

基本パターン

# 値が変化することを確認
expect { do_something }.to change { counter }

# 特定の値だけ変化することを確認
expect { do_something }.to change { counter }.by(1)

# from/toで具体的な変化を指定
expect { do_something }.to change { status }.from("pending").to("completed")

# 変化しないことを確認
expect { do_something }.not_to change { other_counter }

複数の変化を同時にテスト

expect {
  user.articles.create(title: "新記事", published: true)
}.to change { user.articles.count }.by(1)
 .and change { user.published_articles.count }.by(1)
 .and change { user.updated_at }

ブロック内での詳細な確認

expect {
  order = Order.create(items: [item1, item2])
}.to change { Order.count }.by(1)

# 作成されたオブジェクトの詳細も確認
created_order = Order.last
expect(created_order.items.count).to eq(2)
expect(created_order.total_amount).to eq(item1.price + item2.price)

changeを使うべき場面 vs 使わない場面

changeを使うべき場面

  • データベースへの保存・削除・更新
  • カウンターや数値の増減
  • 配列やハッシュへの要素追加・削除
  • 状態の変化を伴う処理
  • 外部サービスへの API コール数
  • ログファイルへの書き込み

changeが不適切な場面

# 単純な計算結果のテスト
expect(Calculator.add(2, 3)).to eq(5)  # ✅ これでOK

# 以下は不自然
expect { result = Calculator.add(2, 3) }.to change { result }  # ❌ 意味がない

実際の判断例

RSpec.describe ShoppingCart do
  describe "#add_item" do
    let(:cart) { ShoppingCart.new }
    let(:item) { Item.new(name: "商品A", price: 1000) }

    # ❌ eqだけでは不十分
    it "商品が追加される(不十分なテスト)" do
      cart.add_item(item)
      expect(cart.items.last).to eq(item)  # 追加されたかわからない
    end

    # ✅ changeで副作用もテスト
    it "商品が追加される(完全なテスト)" do
      expect {
        cart.add_item(item)
      }.to change { cart.items.count }.by(1)
       .and change { cart.total_price }.by(1000)
      
      expect(cart.items.last).to eq(item)  # 内容も確認
    end
  end
end

パフォーマンスの考慮

changeメソッドは処理を2回実行するため、重い処理の場合は注意が必要です。

# 重い処理の場合は工夫が必要
RSpec.describe "大量データ処理" do
  it "バッチ処理が正しく動作する" do
    # before_countを事前に取得
    before_count = ProcessedRecord.count
    
    BatchProcessor.run
    
    # 変化量を直接計算
    expect(ProcessedRecord.count - before_count).to eq(1000)
  end
end

まとめ

changeメソッドは、副作用を伴う処理のテストで真価を発揮します。

比較項目 eq/be change
用途 現在の状態・値の確認 実行前後の変化の確認
適用場面 計算結果、オブジェクトの属性 DB操作、状態変更、副作用
テストの観点 「結果が正しいか?」 「期待した変化が起きたか?」

良いテストは、何をテストしたいかを明確にすることから始まります。値そのものを確認したいならeq、変化を確認したいならchangeを選びましょう。

適切なマッチャーを選ぶことで、テストの意図が明確になり、バグを見つけやすい堅牢なテストスイートが作れるようになります!

Discussion