Mengolok-olok variabel / pengakses instance untuk instance kelas menggunakan RSpec

Aug 20 2020

Dalam aplikasi Rails kami memiliki API pihak ketiga (menggunakan Hemat) yang kami bungkus dengan kelas yang dapat menggunakan beberapa metode untuk mengambil data dari contoh yang sama dan kemudian menambahkan data itu ke dalam variabel / pengakses contoh.

Misalnya kami memiliki BookManagerkelas seperti ini:

class BookManager
  attr_accessor :token, :books, :scope, :total_count

  def initialize(token, scope, attrs={})
    @token = token
    @scope = scope
    @books = []
    @total_count = 0
  end

  # find all books
  def find_books
    @books = API.find_books(@token, @scope)
    @total_count = @books.count
  
    self
  end

  # find a single book by book_id
  def find_book_by_id(book_id)
    @books = API.find_book_by_id(@token, @scope, book_id)

    self
  end

  # find a single book by author_id
  def find_book_by_author_id(author_id)
    @books = API.find_book_by_author_id(@token, @scope, author_id)

    self
  end
end

Jadi di sini kita bisa mendapatkan daftar buku, atau satu buku dengan book_idatau author_iddan kemudian API akan mengembalikan data dan instance kelas kita akan memiliki buku-buku ini.

Alasan utama kelas ini dibangun seperti ini adalah karena API dirancang dengan titik akhir untuk setiap entitas data dan kita harus menggunakan beberapa metode untuk mendapatkan seluruh kumpulan data, jadi misalnya jika kita ingin mengambil penulis untuk bukunya kami akan menggunakan metode seperti:

def with_authors(&block)
  books.each do |book|
    book.author = API.find_author_by_id(@token, @scope, book.author_id, &block)
  end

  self
end

Kelas digunakan dalam aplikasi kita seperti ini:

book_manger = BookManager.new(current_user.token, params[:scope])
                         .find_book_by_id(params[:id])
@book = book_manger.books.first

Atau jika kami menginginkan penulisnya juga, kami akan merangkai metode:

book_manger = BookManager.new(current_user.token, params[:scope])
                         .find_book_by_id(params[:id])
                         .with_authors
@book = book_manger.books.first

Dan kemudian kita dapat mengakses data seperti:

@book.book_name
@book.author.author_name

Semoga ini semua masuk akal sampai sekarang ...


Jadi saat kami menulis pengujian RSpec untuk aplikasi kami, kami ingin meniru ini BookManagersehingga tidak memanggil API yang sebenarnya.

Misalnya di sini saya telah membuat buku ganda dan memberi tahu RSpec untuk mengembalikan buku (dengan buku di dalamnya) ketika find_book_by_idmetode ini dipanggil.

book = double('book', book_id: 1, book_name: 'Book Name')
books = double('books', books: [book])
allow_any_instance_of(BookManager).to receive(:find_book_by_id).and_return(books)

Namun apa yang saya temukan adalah bahwa bookspengakses selalu mengembalikan nilai defaultnya [], jadi itu tidak benar-benar mengatur @booksinstance di dalam kelas menggunakan tiruan saya.

Sebaliknya, saya harus mengejek API itu sendiri:

book = double('book', book_id: 1, book_name: 'Book Name')
books = double('books', books: [book])
allow(API).to receive(:find_book_by_id).and_return(books)

Yang kemudian memungkinkan saya untuk menggunakan BookManager... yang bisa dikatakan sebagai praktik yang lebih baik karena API-lah yang perlu diejek ... tetapi beberapa kelas kami yang lain memiliki banyak metode API bersarang dan saya berharap untuk menjaga agar ejekan lebih sederhana dan hanya mengejek kelas yang digunakan dalam kode daripada metode bersarang di bawah ini ... Saya juga ingin tahu bagaimana saya bisa melakukannya!

Saya berasumsi bahwa mengejek yang BookManagertidak berfungsi seperti yang diharapkan karena saya telah mengejek metode (dalam hal ini find_book_by_id) which is what actual sets @books and therefore the accessor/instance variable is always empty... so in this particular case, using.and_return (books) `tidak benar-benar mengembalikan buku ...

Sepertinya yang perlu saya lakukan adalah mengembalikan instance kelas itu daripada hanya bookstetapi saya tidak yakin bagaimana saya akan melakukannya menggunakan tiruan RSpec.

Jawaban

1 aridlehoover Aug 21 2020 at 13:57

Anda benar tentang mengapa rintisan yang Anda coba tidak berhasil. Karena Anda mengejek metode yang menyetel variabel contoh, setiap kali Anda mengakses variabel contoh melalui, attr_accessorAnda akan mendapatkan nilai yang diinisialisasi daripada nilai kembalian palsu find_books_by_id.

Naluri Anda juga benar untuk tidak mengejek API. Jika tujuan Anda adalah untuk menguji kode yang menggunakan BookManager, maka Anda harus membuat tiruan / stub BookManagerantarmuka bukan objek bawahannya. Faktanya, pengujian Anda seharusnya tidak mengetahui apa pun tentang struktur internal BookManager, termasuk apakah ia mempertahankan status atau tidak. Itu akan menjadi pelanggaran Hukum Demeter.

Namun, pengujian Anda mengetahui tentang antarmuka publik dari BookManager, termasuk booksattr_accessor. Solusi untuk masalah Anda adalah dengan menghentikan itu, dan mengejek semua metode lain dengan objek null.

Seperti ini:

let(:book_manager) { double(BookManager).as_null_object }
let(:book) { double('book', book_id: 1, book_name: 'Book Name') }
let(:books) { [book] }

before do
  allow(BookManager).to receive(:new).and_return(book_manager)
  allow(book_manager).to receive(:books).and_return(books)
end

Sekarang, panggilan ke find_book_by_iddan with_authorsakan mengeksekusi dan mengembalikan objek null (self, pada dasarnya) yang berfungsi sempurna dengan rangkaian metode Anda. Dan, Anda dapat menghentikan hanya metode yang Anda pedulikan, seperti books.

Plus, Anda akan mendapatkan poin bonus karena tidak digunakan allow_any_instance_ofyang seharusnya disediakan untuk menguji kode warisan yang paling sulit.

Dokumen: https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles