Simular variable de instancia/accesorio para una instancia de clase usando RSpec

Aug 20 2020

En nuestra aplicación Rails, tenemos una API de terceros (que usa Thrift) que envolvemos con clases que pueden usar múltiples métodos para recuperar datos de la misma instancia y luego agregar esos datos a una variable/accesorio de instancia.

Por ejemplo, tenemos una BookManagerclase así:

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

Así que aquí podemos obtener una lista de libros, o un solo libro por book_ido author_idy luego la API devolverá los datos y nuestra instancia de clase tendrá estos libros.

La razón principal por la que esta clase está construida de esta manera es porque la API está diseñada con un punto final para cada entidad de datos y tenemos que usar varios métodos para obtener el conjunto de datos completo, por ejemplo, si quisiéramos recuperar los autores de los libros. usaríamos un método como:

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 clase se usa en nuestra aplicación así:

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

O si también quisiéramos a los autores, encadenaríamos los métodos:

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

Y luego podemos acceder a los datos como:

@book.book_name
@book.author.author_name

Esperemos que todo esto tenga sentido hasta ahora...


Entonces, cuando estamos escribiendo pruebas RSpec para nuestra aplicación, queremos simular esto BookManagerpara que no llame a la API real.

Por ejemplo, aquí he creado dobles de los libros y le he dicho a RSpec que devuelva los libros (con el libro dentro) cuando find_book_by_idse llama al método.

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)

Sin embargo, lo que encontré es que el booksaccesor siempre devuelve su valor predeterminado de [], por lo que en realidad no está configurando el @booksinterior de la instancia de clase usando mi simulacro.

En cambio, tuve que burlarme de la propia API:

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)

Lo que luego me permite usar el BookManager... que podría argumentarse como una mejor práctica, ya que es la API la que necesita burlarse... pero algunas de nuestras otras clases tienen muchos métodos de API anidados y esperaba mantener la burla más simple y solo simule las clases utilizadas en el código en lugar de los métodos anidados a continuación ... ¡También tengo curiosidad por saber cómo podría hacerlo!

Supongo que burlarse de BookManagerno funciona como se esperaba porque me burlé del método (en este 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)` en realidad no devuelve los libros...

Parece que lo que debo hacer es devolver la instancia de esa clase en lugar de solo bookspero no estoy seguro de cómo lo haría usando simulacros de RSpec.

Respuestas

1 aridlehoover Aug 21 2020 at 13:57

Tiene razón acerca de por qué el código auxiliar que probó no funciona. Dado que se está burlando del método que establece la variable de instancia, cada vez que acceda a la variable de instancia a través attr_accessorde obtendrá el valor inicializado en lugar del valor de retorno simulado de find_books_by_id.

También tiene razón en su instinto de no burlarse de la API. Si su objetivo es probar el código que usa el BookManager, entonces debe simular/stubar la BookManagerinterfaz, no sus objetos subordinados. De hecho, sus pruebas no deberían saber nada sobre la estructura interna del BookManager, incluso si mantiene o no el estado. Eso sería una violación de la Ley de Deméter.

Pero sus pruebas conocen la interfaz pública de BookManager, incluido el booksattr_accessor. La solución a su problema es bloquear eso y burlarse de todos los demás métodos con un objeto nulo.

Como esto:

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

Ahora, llama a find_book_by_idy with_authorsejecutará y devolverá el objeto nulo (yo, esencialmente) que funciona perfectamente con el encadenamiento de su método. Y puede añadir solo los métodos que le interesen, como books.

Además, obtendrá puntos de bonificación por no usar allow_any_instance_of, que deberían reservarse para probar el código heredado más espinoso.

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