💭

【RSpec入門】instance_doubleで安全なテストを!doubleとの違いを実例で理解しよう

に公開

なぜinstance_doubleが必要なの?

前回、RSpecのdouble(モック)について学びましたが、実はdoubleには重大な欠陥があります。

「えっ、モック便利じゃん!」と思ったあなた、ちょっと待ってください。
実際の開発現場で起きる、こんなヒヤッとする体験談をお聞きください...

実際にあった怖い話 😱

登場人物(再び)

  • EmailService:メール送信を担当
  • UserRegister:ユーザー登録とメール送信の連携

Phase 1: 最初の実装

# email_service.rb
class EmailService
  def send_welcome_mail(email)
    puts "Welcome mail sent to #{email}"
    # 実際のメール送信処理
  end
end
# user_register.rb
class UserRegister
  def initialize(email_service)
    @email_service = email_service
  end
  
  def register(email)
    puts 'ユーザー登録中...'
    @email_service.send_welcome_mail(email)
    puts 'ユーザー登録完了!'
  end
end

Phase 2: テストを書く(doubleを使用)

# spec/user_register_spec.rb
RSpec.describe UserRegister do
  describe '#register' do
    it 'ウェルカムメールが送信される' do
      email_service = double(EmailService)
      expect(email_service).to receive(:send_welcome_mail).with('test@example.com')
      
      user_register = UserRegister.new(email_service)
      user_register.register('test@example.com')
    end
  end
end

テスト成功! みんなハッピー!

Phase 3: リファクタリング(ここで事件発生)

ある日、先輩エンジニアがこう言いました:
send_welcome_mailよりdeliver_welcome_mailの方が英語として自然だね」

# email_service.rb(メソッド名を変更)
class EmailService
  def deliver_welcome_mail(email)  # 名前を変更!
    puts "Welcome mail sent to #{email}"
  end
end

でも、テストのことを忘れて本体のコードだけ変更...

Phase 4: 悲劇の始まり

$ rspec spec/user_register_spec.rb

テスト成功!

「あれ?テスト通ってるから大丈夫でしょ」

Phase 5: 本番環境でエラー発生! 💥

user_register = UserRegister.new(EmailService.new)
user_register.register('real-user@example.com')

# NoMethodError: undefined method `send_welcome_mail' for #<EmailService>

本番環境で大事故発生! 😱

なぜこんなことが起きたの?

doubleの「嘘つき」な性格

通常のdoubleは、どんなメソッド名でも受け入れてしまいます

# こんな存在しないメソッドでもOKしちゃう
email_service = double(EmailService)
expect(email_service).to receive(:totally_fake_method)  # 存在しないメソッド
expect(email_service).to receive(:another_fake_method)  # これも存在しない
expect(email_service).to receive(:completely_made_up)   # これも!

# テストは成功してしまう... 😰

つまり、doubleは:

  • 😈 「実際のクラスにあるメソッドかどうかなんて知らないよ〜」
  • 😈 「テストで指定されたメソッドなら何でも受け入れるよ〜」
  • 😈 「実装が変わっても気づかないよ〜」

instance_doubleが救世主として登場! 🦸‍♀️

instance_doubleは、実際のクラスの仕様をチェックする優秀な監視役です。

同じ例をinstance_doubleで書き直してみよう

RSpec.describe UserRegister do
  describe '#register' do
    it 'ウェルカムメールが送信される' do
      # doubleの代わりにinstance_doubleを使用
      email_service = instance_double(EmailService)
      expect(email_service).to receive(:send_welcome_mail).with('test@example.com')
      
      user_register = UserRegister.new(email_service)
      user_register.register('test@example.com')
    end
  end
end

EmailServiceのメソッド名を変更した後...

# email_service.rb
class EmailService
  def deliver_welcome_mail(email)  # 名前変更
    puts "Welcome mail sent to #{email}"
  end
end

テスト実行すると...

$ rspec spec/user_register_spec.rb

Failure/Error: expect(email_service).to receive(:send_welcome_mail).with('test@example.com')
  the EmailService class does not implement the instance method: send_welcome_mail

Finished in 0.01234 seconds (files took 0.12345 seconds to load)
1 example, 1 failure

🎉 テストが失敗!これで事故を防げた!

doubleとinstance_doubleの違いを表で比較

項目 double instance_double
メソッドの存在チェック ❌ しない ✅ する
引数の数チェック ❌ しない ✅ する
実装変更の検知 ❌ できない ✅ できる
テストの信頼性 ⚠️ 低い ✅ 高い
書きやすさ ✅ 簡単 ✅ 簡単(同じ)
実行速度 ⚡ 高速 ⚡ 高速(ほぼ同じ)

具体例で違いを見てみよう

1. 存在しないメソッドの場合

# EmailServiceクラス(実際の定義)
class EmailService
  def deliver_welcome_mail(email)
    puts "Welcome mail sent to #{email}"
  end
end
# doubleの場合
email_service = double(EmailService)
expect(email_service).to receive(:non_existent_method)  # 存在しないメソッド
# ✅ テスト成功(問題!)

# instance_doubleの場合
email_service = instance_double(EmailService)
expect(email_service).to receive(:non_existent_method)  # 存在しないメソッド
# ❌ エラー: the EmailService class does not implement the instance method: non_existent_method

2. 引数の数が違う場合

# EmailServiceクラス(引数は1個)
class EmailService
  def deliver_welcome_mail(email)  # 引数1個
    puts "Welcome mail sent to #{email}"
  end
end
# doubleの場合
email_service = double(EmailService)
expect(email_service).to receive(:deliver_welcome_mail).with('email', 'extra_arg')  # 引数2個
# ✅ テスト成功(問題!)

# instance_doubleの場合
email_service = instance_double(EmailService)
expect(email_service).to receive(:deliver_welcome_mail).with('email', 'extra_arg')  # 引数2個
# ❌ エラー: Wrong number of arguments

より複雑な例:SNS投稿サービス

実際のプロジェクトっぽい例を見てみましょう。

サービスクラス

# social_media_service.rb
class SocialMediaService
  def post_tweet(message, hashtags = [])
    puts "Posting tweet: #{message} #{hashtags.join(' ')}"
  end
  
  def upload_image(image_path, caption)
    puts "Uploading image: #{image_path} with caption: #{caption}"
  end
end

利用側のクラス

# content_publisher.rb
class ContentPublisher
  def initialize(social_media_service)
    @social_media_service = social_media_service
  end
  
  def publish_blog_post(title, content, image_path)
    # ツイート投稿
    @social_media_service.post_tweet("新しい記事を投稿しました: #{title}", ['#ブログ', '#新着'])
    
    # 画像アップロード
    @social_media_service.upload_image(image_path, title)
  end
end

テストの比較

doubleを使った場合(危険)

RSpec.describe ContentPublisher do
  describe '#publish_blog_post' do
    it 'SNSに投稿される' do
      social_service = double(SocialMediaService)
      
      # 間違ったメソッド名でもテスト成功してしまう
      expect(social_service).to receive(:tweet_post)  # 正しくは post_tweet
        .with("新しい記事を投稿しました: テスト記事", ['#ブログ', '#新着'])
      
      expect(social_service).to receive(:image_upload)  # 正しくは upload_image
        .with('/path/to/image.jpg', 'テスト記事')
      
      publisher = ContentPublisher.new(social_service)
      publisher.publish_blog_post('テスト記事', '内容', '/path/to/image.jpg')
      
      # テスト成功(でも実際は動かない!)
    end
  end
end

instance_doubleを使った場合(安全)

RSpec.describe ContentPublisher do
  describe '#publish_blog_post' do
    it 'SNSに投稿される' do
      social_service = instance_double(SocialMediaService)
      
      # 正しいメソッド名じゃないとエラーになる
      expect(social_service).to receive(:post_tweet)  # ✅ 正しいメソッド名
        .with("新しい記事を投稿しました: テスト記事", ['#ブログ', '#新着'])
      
      expect(social_service).to receive(:upload_image)  # ✅ 正しいメソッド名
        .with('/path/to/image.jpg', 'テスト記事')
      
      publisher = ContentPublisher.new(social_service)
      publisher.publish_blog_post('テスト記事', '内容', '/path/to/image.jpg')
      
      # テスト成功(実際にも動く!)
    end
  end
end

instance_doubleの実践的な使い方

1. 戻り値も設定したい場合

it 'アップロード成功時のレスポンスを返す' do
  social_service = instance_double(SocialMediaService)
  
  # メソッドの呼び出しを検証 + 戻り値も設定
  expect(social_service).to receive(:upload_image)
    .with('/path/to/image.jpg', 'テスト記事')
    .and_return({ success: true, url: 'https://example.com/uploaded.jpg' })
  
  publisher = ContentPublisher.new(social_service)
  result = publisher.upload_with_response('/path/to/image.jpg', 'テスト記事')
  
  expect(result[:success]).to be true
end

2. 複数回の呼び出し

it '複数の画像をアップロードする' do
  social_service = instance_double(SocialMediaService)
  
  expect(social_service).to receive(:upload_image).twice
  
  publisher = ContentPublisher.new(social_service)
  publisher.upload_multiple_images(['/image1.jpg', '/image2.jpg'])
end

3. 引数の柔軟なマッチング

it 'どんな画像パスでもアップロードできる' do
  social_service = instance_double(SocialMediaService)
  
  # any_args を使って引数を柔軟にマッチ
  expect(social_service).to receive(:upload_image).with(any_args)
  
  publisher = ContentPublisher.new(social_service)
  publisher.upload_image('/any/path/image.jpg', 'any caption')
end

よくある質問と回答

Q: instance_doubleって書くのが面倒じゃない?

A: 確かに文字数は多いですが、バグを防ぐ価値は計り知れません
本番環境でのエラーを考えれば、この程度の手間は安いものです!

Q: 全部instance_doubleにすべき?

A: 外部サービス(API、DB、メールなど)は絶対instance_doubleがおすすめ!
内部の簡単なヘルパーメソッドなどは、doubleでも良い場合があります。

Q: 既存のプロジェクトではどうすればいい?

A: 新しいテストはinstance_doubleで書き、既存のものは問題が起きたときにinstance_doubleに移行するのが現実的です。

メリット・デメリット

👍 instance_doubleのメリット

  • 🛡️ 安全性: 実装とテストの不整合を早期発見
  • 🔍 信頼性: テストが通れば実際に動く可能性が高い
  • 📚 ドキュメント: テストを見れば正しいメソッド名・引数が分かる
  • 🚀 リファクタリング: 安心してメソッド名を変更できる

👎 instance_doubleのデメリット

  • 📝 文字数: doubleより長い
  • ⚠️ 依存: 実際のクラスが定義されている必要がある
  • 🧠 学習コスト: 概念を理解する必要がある

まとめ:いつ何を使うべき?

🟢 instance_doubleを使うべき場面

  • 外部API連携(重要!)
  • メール送信サービス
  • 決済処理
  • データベース操作
  • ファイル操作
  • 基本的に副作用のある処理全般

🔵 doubleでも良い場面

  • 単純な計算処理のモック
  • 文字列操作のヘルパー
  • 一時的なプロトタイプ作成
  • 内部の単純な処理

📋 実践的な判断基準

「このメソッドが実際に存在しなかったら本番環境でエラーになる?」
→ YES なら instance_double
→ NO なら double でもOK

最後に:実際のコードレビューでよく見るコメント

# ❌ レビューでNGをもらうコード
email_service = double(EmailService)
expect(email_service).to receive(:send_mail)  # メソッド名が曖昧

# ✅ レビューでOKをもらうコード
email_service = instance_double(EmailService)
expect(email_service).to receive(:deliver_welcome_mail).with('user@example.com')

レビュアーの心の声
「instance_doubleを使ってくれてありがとう!これなら安心してマージできるよ 😊」

テストは「未来の自分」や「チームメンバー」への贈り物です。
instance_doubleを使って、より安全で信頼できるテストを書いていきましょう!

Happy Testing! 🎉


次回予告: instance_doubleの応用編として、class_doubleobject_doubleについても解説予定です。お楽しみに!

Discussion