RSpecで役に立ちそうないくつかのヒント(翻訳)
Rubyにおける二大テスティングフレームワークといえば、RSpecとMiniTestです。RSpecは(訳注: 英語的に)非常に表現力の豊かなテスティングフレームワークであり、テストを読みやすくする素晴らしい機能やヘルパーが多数用意されています。ここでは、RSpecのテストを書きやすく、読みやすく、メンテしやすくするのに役立つ、あまり知られていないテクニックをご紹介いたします。
以下は、Books
とAuthors
があるシステムのコードです。読みやすいテストコードを書いてみましょう。
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
変数を使わないと、たとえば「Author
にname
があること」というアサーションは次のようになってしまうでしょう。
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つある)ことをテストしたいが、著書のtitle
やgenre
は何でも構わない場合、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
引数マッチャは柔軟にできているので、欲しいハッシュのキーバリューペアを指定することも、キーだけを指定することもできます(複数指定可)。上のテストでは、Book
のtitle
とgenre
のことだけ考えればよいのです。
🔗 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
エラーがスローされるので、デバッグ時間を大きく削減できます。
概要
原著者の許諾を得て翻訳・公開いたします。