Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RSpecで役に立ちそうないくつかのヒント(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。


  • 2017/07/24: 初版公開
  • 2023/05/11: 更新

RSpecで役に立ちそうないくつかのヒント(翻訳)

Rubyにおける二大テスティングフレームワークといえば、RSpecとMiniTestです。RSpecは(訳注: 英語的に)非常に表現力の豊かなテスティングフレームワークであり、テストを読みやすくする素晴らしい機能やヘルパーが多数用意されています。ここでは、RSpecのテストを書きやすく、読みやすく、メンテしやすくするのに役立つ、あまり知られていないテクニックをご紹介いたします。

以下は、BooksAuthorsがあるシステムのコードです。読みやすいテストコードを書いてみましょう。

class Book
  attr_reader :title, :genre

  def initialize(title, genre)
    @title = title
    @genre = genre
  end
end
class Author
  attr_reader :books

  def initialize(name, books)
    @name = name
    @books = Array(books)
  end

  def has_written_a_book?
    !books.empty?
  end
end

🔗 subject変数とlet変数

subject変数やlet変数の宣言は、specの繰り返しを避けてDRYに書くよい方法のひとつです。

subject変数やlet変数を使わないと、たとえば「Authornameがあること」というアサーションは次のようになってしまうでしょう。

describe Author do
  before do
    @book_genre = '歴史創作もの'
    @book_title = '二都物語'
    @book = Book.new(@book_genre, @book_title)
    @author_name = 'チャールズ・ディケンズ'
    @author = Author.new(@author_name, [@book])
  end

  describe '#name' do
    it 'nameが1つある' do
      expect(@author.name).to eq(@author_name)
    end
  end
end

これは正しいのですが、本の冊数やペンネームといったテスト項目を追加するうちにAuthorのテストが繰り返しだらけになってしまいます。

以下のようにsubject変数やlet変数を使えば、コードがDRYになり、再利用しやすくなります。

describe Author do
  let(:book_genre) { '歴史創作もの' }               #👈
  let(:book_title) { '二都物語' }                  #👈
  let(:book) { Book.new(book_genre, book_title) } #👈
  let(:book_array) { [book] }                     #👈
  let(:author_name) { 'チャールズ・ディケンズ' }       #👈
  subject { Author.new(author_name, book_array) } #👈

  describe '#name'do
    it 'nameが1つある' do
      expect(subject.name).to eq(author_name)
    end
  end

  describe '#books' do
    context 'with books' do
      it '著書が1つ以上ある' do
        expect(subject.books).to eq(book_array)
      end
    end

    context '著書がない場合' do
      context 'books変数がnil' do
        let(:book_array) { nil }                  #👈

        it 'booksは空の配列になる' do
          expect(subject.books).to eq([])
        end
      end

      context 'books変数が空の配列の場合' do
        let(:book_array) { [] }                   #👈

        it 'booksは空の配列になる' do
          expect(subject.books).to eq([])
        end
      end
    end
  end
end

上のコードではlet変数を使っているので、beforeブロックを何度も使ってインスタンス変数のセットやリセットを繰り返す必要がなくなります。具体的には、itブロックが実行されるたびに、そこから最も近くにあるcontextの内側のlet変数を使ってsubjectが初期化されます。

重要なlet変数を定義することで、let(:book_array) { nil }のようにlet宣言そのものを変更する形で、subject.booksが配列であるかどうかをテストするコンテキストを簡単に作成できるようになりました。これにより、nil[]入力を含むシナリオを簡単にテストできます。

🔗 条件が厳しくないexpectationを使う

細かい部分が重要ではないRSpecテストでは、一般的なexpectationやプレースホルダを利用できます。プレースホルダを活用することでテストの複雑さを軽減でき、本当に重要な部分に集中できます。

🔗 1. anything

名前から想像がつくとおり、anythingマッチャは「メソッドで引数が1個必要だが、引数は何であってもよい」テストを書くのに使えます。

Authorのテストで、著書が1冊ある(=Bookが1つある)ことをテストしたいが、著書のtitlegenreは何でも構わない場合、anythingの出番です。

describe Author do
  describe '#has_written_a_book?' do
    context '本を複数渡した場合' do
      subject { Author.new(name, books) }
      let(:books) { [Book.new(anything, anything)] } #👈
      it 'trueになる' do
        expect(subject.has_written_a_book?).to eq(true)
      end
    end
  end
end

🔗 2. hash_including

Hashを返すことが期待されるメソッドのテストでは、Hashの特定の要素だけが他の要素よりも重要な場合があります。hash_includingマッチャは、ハッシュ全体を指定せずに、特定のキーバリューペアのアサーションを書くのに使えます。キーバリューペアはいくつでも指定できます。

Bookクラスにメソッドが1つあり、そのメソッドが(追加情報をフェッチするために)新しいHTTPクライアントを1つインスタンス化する次のコードがあるとします。

class Book
  # ...
  def fetch_information
    HTTPClient.new({ title: title, genre: genre, time: Time.now })
              .get('/information')
  end
end

このメソッドのテストコードは、「クライアントはいくつかの重要な項目を使って初期化される」というアサーションになるはずです。hash_includingマッチャはこんなときに便利です。

describe Book do
  describe '#fetch_information' do
    let(:book_genre) { '歴史創作もの' }
    let(:book_title) { '二都物語' }
    subject { Book.new(title, genre) }

    it 'クライアントが正常にインスタンス化される' do
      expect(HTTPClient).to receive(:new)
                        .with(hash_including(title: book_title, #👈
                                             genre: book_genre))
      subject.fetch_information
    end
  end
end

hash_including引数マッチャは柔軟にできているので、欲しいハッシュのキーバリューペアを指定することも、キーだけを指定することもできます(複数指定可)。上のテストでは、Booktitlegenreのことだけ考えればよいのです。

🔗 3. match_array

RubyのArray同士の比較は、両者の要素が完全に同一、かつ順序も一致する場合にのみ等しくなります。しかしテストによってはそこまで厳密な一致条件は不要な場合もあります。そんなときには、RSpecのmatch_arrayマッチャでテストをいい感じに書けます。

Authorクラスでデータベースからbooksのリストを取り出した場合、デフォルトのスコープやレコードの更新などの要因によってbooksの順序が一貫しないことがあります。

以下のようなfetch_booksメソッドで考えてみましょう。

class Author
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def fetch_books
    BookDB.find_by(author_name: name)
  end
end

match_arrayを使えば、順序を気にすることなく「正しい本のリストが返される」というアサーションを書けます。

describe Author do
  describe '#fetch_books' do
    let(:name) { 'ジェーン・オースティン' }
    let!(:books) do
      Array.new(2) do
        BookDB.create_book(author_name: name)
      end
    end

    subject { Author.new(name: name) }

    it '本のリストを正しく取り出せる' do
      expect(subject.fetch_books).to match_array(books) #👈
    end
  end
end

🔗 double(身代わり)を検証する

RSpecのモック(mock)は、動作することが期待されるコードが実際に動作することを確認するシンプルな方法の1つです。

Bookからブックレビューを取得するときに、サードパーティのAPIが使われるとします。こうした機能の単体テストでは、APIを実際に呼ぶべきではありません。代わりに、リクエストが正しく行われたというアサーションを書くべきです。

class Book
  # ..
  def reviews
    Review::API.new(SUPER_SECRET_API_KEY)
               .get("reviews/?title=#{ title }&genre=#{ genre }")
  end
end

上のコードをテストする場合、スタブメソッドを書いて適当なテストデータを返すようにする方法が考えられます。

describe Book do
  let(:book_genre) { '創作歴史もの' }
  let(:book_title) { '二都物語' }
  subject { Book.new(book_title, book_genre)  }

  describe '#reviews' do
    let(:fake_reviews) do
      [
        { critic: 'Pat M.', stars: 5, comments: '凄い!' },
        { critic: 'Sanjay R.', stars: 5, comments: '面白い!' },
        { critic: 'Rupa T.', stars: 4, comments: '見事なり!' }
      ]
    end
    let(:test_api_client) { Review::API.new(TEST_API_KEY) }

    before do
      allow(Review::API).to receive(:new).and_return(test_api_client)
      allow(test_api_client).to receive(:get).and_return(fake_reviews)
    end

    it 'APIからデータを取り出せる' do
      expect(subject.reviews).to eq(fake_reviews)
    end
  end
end

上のテストコードは、Review::APIのブックレビューの取得方法が変更されない限り有用です。しかし何らかの理由でこのメソッドに変更が生じた場合、テストコードはパスし、production環境ではアプリが失敗してしまいます。

このような場合は、代わりにinstance_doubleを使うことで、テスト中はテストデータを返すようにしながら、「特定のメソッドが呼び出されること」というアサーションを書けます。

describe Book do
  let(:book_genre) { '創作歴史もの' }
  let(:book_title) { '二都物語' }
  subject { Book.new(book_title, book_genre)  }

  describe '#reviews' do
    let(:fake_reviews) do
      [
        { critic: 'Pat M.', stars: 5, comments: '凄い!' },
        { critic: 'Sanjay R.', stars: 5, comments: '面白い!' },
        { critic: 'Rupa T.', stars: 4, comments: '見事なり!' }
      ]
    end
    let(:test_api_client) do
      instance_double(Review::API, get: fake_reviews)                 #👈
    end

    before do
      allow(Review::API).to receive(:new).and_return(test_api_client) #👈
    end

    it 'APIからデータを取り出せる' do
      expect(subject.reviews).to eq(fake_reviews)
    end
  end
end

instance_double(Review::API, get: fake_reviews)の行では、クライアントのクラスがdouble(身代わり)を検証するためにインスタンス化され、getメソッドをスタブ化してfake_reviewsを返しています。

最後に重要な部分はallow(Review::API).to receive(:new).and_return(test_api_client)です。ここでは、Review::APIクラスでnewが呼ばれたときに新しいインスタンスではなくdoubleを使うようReview::APIクラスに指示しています。

こうしておけば、ブックレビュー取得用のReview::APIのインスタンスメソッドが変更されたときに(期待どおり)テストが失敗してNoMethodErrorエラーがスローされるので、デバッグ時間を大きく削減できます。

関連記事

Ruby: テストを不安定にする5つの残念な書き方(翻訳)

[Rails] RSpecをやる前に知っておきたかったこと

FactoryGirlでtraitを使うとintegration test書くのが捗るという話

rspecで事前・事後の環境設定を切り替える

[Rails] RSpecのモックとスタブの使い方


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。