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

概要

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

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の定義がlet(:book_array) { nil }に変わるだけで、subject.booksが配列かどうかを入力がnil[]でテストするコンテキストになります。

loose 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マッチャは、ハッシュ全体を指定しなくても特定のkey/valueペアがあるアサーションを書くのに使えます。key/valueペアはいくつでも指定できます。

Bookクラスにメソッドが1つあり、そのメソッドが(追加情報をfetchするために)新しい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は柔軟にできているので、欲しいハッシュのkey/valueペアを指定することも、keyだけを指定することもできます(複数指定もあり)。上のテストでは、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(身代わり)でverifyする

RSpecのモックは、動作することが期待されるコードが実際に動作することを確認するシンプルな方法の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: 'やる夫', stars: 5, comments: '凄い!' },
        { critic: 'やらない夫', stars: 5, comments: '面白い!' },
        { critic: 'やった夫', 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: 'やる夫', stars: 5, comments: '凄い!' },
        { critic: 'やらない夫', stars: 5, comments: '面白い!' },
        { critic: 'やった夫', 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(身代わり)のverify」としてインスタンス化され、getメソッドをスタブ化してfake_reviewsを返しています。

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

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

関連記事(RSpec)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ