Mock out variabile/accessorio di istanza per un'istanza di classe utilizzando RSpec
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 BookManager
classe 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_id
o author_id
e 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 BookManager
modo 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_id
viene 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' books
accessor restituisce sempre il suo valore predefinito di []
, quindi in realtà non imposta l' @books
istanza 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 BookManager
non 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
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_accessor
otterrai 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' BookManager
interfaccia 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 books
attr_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_id
e with_authors
eseguiranno 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_of
che dovrebbero essere riservati per testare il codice legacy più spinoso.
Documenti:https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles