Mock out variabile/accessorio di istanza per un'istanza di classe utilizzando RSpec

Aug 20 2020

Nella nostra applicazione Rails abbiamo un'API di terze parti (utilizzando Thrift) che incorporiamo con classi che possono utilizzare più metodi per recuperare i dati dalla stessa istanza e quindi aggiungere tali dati in una variabile/accessorio di istanza.

Ad esempio abbiamo una BookManagerclasse così:

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

Quindi qui possiamo ottenere un elenco di libri o un singolo libro di book_ido author_ide quindi l'API restituirà i dati e la nostra istanza di classe avrà questi libri.

Il motivo principale per cui questa classe è costruita in questo modo è perché l'API è progettata con un endpoint per ogni entità di dati e dobbiamo utilizzare più metodi per ottenere l'intero set di dati, quindi ad esempio se volessimo recuperare gli autori per i libri useremmo un metodo come:

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

  self
end

La classe viene utilizzata nella nostra applicazione in questo modo:

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

Oppure, se volessimo anche gli autori, concatenammo i metodi:

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

E poi possiamo accedere ai dati come:

@book.book_name
@book.author.author_name

Spero che tutto questo abbia senso fino ad ora...


Quindi, quando scriviamo test RSpec per la nostra app, vogliamo simularlo in BookManagermodo che non chiami l'API effettiva.

Ad esempio qui ho creato i doppi dei libri e ho detto a RSpec di restituire i libri (con il libro all'interno) quando find_book_by_idviene chiamato il metodo.

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)

Tuttavia quello che ho scoperto è che l' booksaccessor restituisce sempre il suo valore predefinito di [], quindi in realtà non imposta l' @booksistanza all'interno della classe usando il mio mock.

Invece, ho dovuto prendere in giro l'API stessa:

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)

Il che quindi mi consente di utilizzare BookManager... che potrebbe essere considerato una pratica migliore in quanto è l'API che necessita di derisione ... ma alcune delle nostre altre classi hanno molti metodi API nidificati e speravo di mantenere la derisione più semplice e prendi in giro solo le classi utilizzate nel codice piuttosto che i metodi nidificati di seguito ... Sono anche curioso di sapere come potrei farlo!

Presumo che la presa in giro di BookManagernon funzioni come previsto perché ho preso in giro il metodo (in questo caso 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)` in realtà non restituisce i libri ...

Sembra che quello che devo fare sia restituire l'istanza di quella classe piuttosto che solo la books, ma non sono sicuro di come lo farei usando i mock RSpec.

Risposte

1 aridlehoover Aug 21 2020 at 13:57

Hai ragione sul motivo per cui lo stub che hai provato non funziona. Poiché stai deridendo il metodo che imposta la variabile di istanza, ogni volta che accedi alla variabile di istanza tramite attr_accessorotterrai il valore inizializzato anziché il valore di ritorno deriso di find_books_by_id.

Hai anche ragione nel tuo istinto a non deridere l'API. Se il tuo obiettivo è testare il codice che utilizza BookManager, allora dovresti deridere/stubare l' BookManagerinterfaccia e non i suoi oggetti subordinati. In effetti, i tuoi test non dovrebbero sapere nulla della struttura interna di BookManager, incluso se mantiene o meno lo stato. Sarebbe una violazione della Legge di Demetra.

Ma i tuoi test conoscono l'interfaccia pubblica di BookManager, incluso booksattr_accessor. La soluzione al tuo problema è bloccarlo e prendere in giro tutti gli altri metodi con un oggetto nullo.

Come questo:

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

Ora, le chiamate a find_book_by_ide with_authorseseguiranno e restituiranno l'oggetto nullo (self, essenzialmente) che funziona perfettamente con il concatenamento del metodo. E puoi bloccare solo i metodi che ti interessano, come books.

Inoltre, otterrai punti bonus per il mancato utilizzo allow_any_instance_ofche dovrebbero essere riservati per testare il codice legacy più spinoso.

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