Variable d'instance/accesseur simulé pour une instance de classe à l'aide de RSpec
Dans notre application Rails, nous avons une API tierce (utilisant Thrift) que nous encapsulons avec des classes qui peuvent utiliser plusieurs méthodes pour récupérer des données à partir de la même instance, puis ajouter ces données dans une variable/accesseur d'instance.
Par exemple, nous avons une BookManagerclasse comme celle-ci :
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
Donc, ici, nous pouvons obtenir une liste de livres, ou un seul livre par book_idou author_id, puis l'API renverra les données et notre instance de classe aura ces livres.
La principale raison pour laquelle cette classe est construite comme ceci est que l'API est conçue avec un point de terminaison pour chaque entité de données et que nous devons utiliser plusieurs méthodes pour obtenir l'ensemble des données, par exemple si nous voulions récupérer les auteurs pour les livres nous utiliserions une méthode comme:
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 est utilisée dans notre application comme ceci :
book_manger = BookManager.new(current_user.token, params[:scope])
.find_book_by_id(params[:id])
@book = book_manger.books.first
Ou si on voulait aussi les auteurs, on enchaînerait les méthodes :
book_manger = BookManager.new(current_user.token, params[:scope])
.find_book_by_id(params[:id])
.with_authors
@book = book_manger.books.first
Et puis nous pouvons accéder aux données comme:
@book.book_name
@book.author.author_name
Espérons que tout cela ait du sens jusqu'à maintenant...
Ainsi, lorsque nous écrivons des tests RSpec pour notre application, nous voulons nous moquer de cela BookManagerafin qu'il n'appelle pas l'API réelle.
Par exemple, ici, j'ai créé des doubles des livres et dit à RSpec de renvoyer les livres (avec le livre à l'intérieur) lorsque la find_book_by_idméthode est appelée.
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)
Cependant, ce que j'ai trouvé, c'est que l' booksaccesseur renvoie toujours sa valeur par défaut de [], donc il ne définit pas réellement l' @booksintérieur de l'instance de classe en utilisant ma maquette.
Au lieu de cela, j'ai dû me moquer de l'API elle-même :
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)
Ce qui me permet ensuite d'utiliser le BookManager... qui pourrait être considéré comme une meilleure pratique car c'est l'API qui doit être moquée ... mais certaines de nos autres classes ont beaucoup de méthodes d'API imbriquées et j'espérais garder la moquerie plus simple et ne faites que simuler les classes utilisées dans le code plutôt que les méthodes imbriquées ci-dessous... Je suis également curieux de savoir comment je pourrais le faire !
Je suppose que la moquerie de BookManagerne fonctionne pas comme prévu car je me suis moqué de la méthode (dans ce cas, 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)` ne renvoie pas réellement les livres ...
Il semble que ce que je dois faire est de renvoyer l'instance de cette classe plutôt que simplement le booksmais je ne sais pas exactement comment je ferais cela en utilisant des simulations RSpec.
Réponses
Vous avez raison de dire pourquoi le stub que vous avez essayé ne fonctionne pas. Puisque vous vous moquez de la méthode qui définit la variable d'instance, chaque fois que vous accédez à la variable d'instance via le attr_accessorvous allez obtenir la valeur initialisée plutôt que la valeur de retour simulée de find_books_by_id.
Vous avez également raison dans votre instinct de ne pas vous moquer de l'API. Si votre objectif est de tester le code qui utilise le BookManager, vous devez vous moquer/stub de l' BookManagerinterface et non de ses objets subordonnés. En fait, vos tests ne doivent rien savoir de la structure interne du BookManager, y compris s'il conserve ou non l'état. Ce serait une violation de la loi de Déméter.
Mais, vos tests connaissent l'interface publique du BookManager, y compris le booksattr_accessor. La solution à votre problème consiste à stub cela et à simuler toutes les autres méthodes avec un objet null.
Comme ça:
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
Désormais, les appels à find_book_by_idet with_authorsexécuteront et renverront l'objet nul (self, essentiellement) qui fonctionne parfaitement avec votre chaînage de méthodes. Et, vous pouvez remplacer uniquement les méthodes qui vous intéressent, comme books.
De plus, vous obtiendrez des points bonus pour ne pas utiliser allow_any_instance_ofce qui devrait être réservé pour tester le code hérité le plus épineux.
Documents :https://relishapp.com/rspec/rspec-mocks/docs/basics/null-object-doubles