¿Qué son los Concerns y cómo probarlos?
por: Christina Gaitán
Los Concerns son una herramienta que nos permite incluir módulos en las clases para crear mixins, ésta herramienta es ofrecida por la librería de ActiveSupport. Una de las ventajas de utilizar Concerns es que nos da la habilidad de modelar comportamiento compartido entre varias clases, mientras mantenemos el principio de DRY, Don’t Repeat Yourself.
Para demostrar esto, voy a utilizar un ejemplo sencillo, supongamos que estamos desarrollando una aplicación para la venta de Libros, en nuestra aplicación los usuarios tienen la habilidad de dejar comentarios sobre los libros pero también sobre los autores de éstos, utilizando solamente modelos para implementar esta funcionalidad, podríamos crear un código parecido al siguiente:
app/models/libro.rb
class Libro < ApplicationRecord
belongs_to :autor
has_many :comentarios, as: :comentable
def ultimo_comentario
comentarios.last
end
def num_comentarios
comentarios.count
end
end
app/models/autor.rb
class Autor < ApplicationRecord
has_many :libros
has_many :comentarios, as: :comentable
def ultimo_comentario
comentarios.last
end
def num_comentarios
comentarios.count
end
end
app/models/comentario.rb
class Comentario < ApplicationRecord
belongs_to :comentable, polymorphic: true, optional: true
end
En este ejemplo tenemos lógica repetida tanto en el modelo de Libro como en el modelo de Autor, aquí es cuando podemos utilizar los Concerns para extraer esa lógica.
Lo primero que tenemos que hacer es generar un módulo que extienda a ActiveSupport, dentro del directorio app/models/concerns , nuestro módulo en este caso se llamará Comentable
app/models/concerns/comentable.rb
module Comentable
extend ActiveSupport::Concern
end
Ahora, dentro de este nuevo módulo podemos incluir el código que tenemos repetido en el modelo de Libro y en el modelo de Autor.
app/models/concerns/comentable.rb
module Comentable
extend ActiveSupport::Concern
def ultimo_comentario
comentarios.last
end
def num_comentarios
comentarios.count
end
end
Una vez hecho esto, debemos incluir en nuestros modelos el módulo Comentable y eliminar los métodos que acabamos de copiar para que nuestros modelos queden de la siguiente manera:
app/models/libro.rb
class Libro < ApplicationRecord
include Comentable
belongs_to :autor
has_many :comentarios, as: :comentable
end
app/models/autor.rb
class Autor < ApplicationRecord
include Comentable
has_many :libros
has_many :comentarios, as: :comentable
end
Ahora, si ponemos atención, podemos notar que todavía tenemos código repetido en los modelos, en ambos tenemos la relación
has_many :comentarios, as: :comentable
Afortunadamente, esta relación, también la podemos extraer hacia nuestro Concern utilizando el bloque included .
El bloque included es ejecutado dentro del contexto de la clase que incluya éste módulo, por lo que es muy útil para incluir las relaciones de ActiveRecord.
app/models/concerns/comentable.rb
module Comentable
extend ActiveSupport::Concern
included do
has_many :comentarios, as: :comentable
end
def ultimo_comentario
comentarios.last
end
def num_comentarios
comentarios.count
end
end
Finalmente, el código en nuestros modelos quedaría de la siguiente manera:
app/models/libro.rb
class Libro < ApplicationRecord
include Comentable
belongs_to :autor
end
app/models/autor.rb
class Autor < ApplicationRecord
include Comentable
has_many :libros
end
Pero, ¿qué pasaría si tuviéramos algún método de clase en nuestros modelos?, por ejemplo, un método para obtener el Libro o el Autor con más comentarios:
app/models/libro.rb
class Libro < ApplicationRecord
include Comentable
belongs_to :autor
def self.mas_comentado
# regresa el Libro más comentado
end
end
app/models/autor.rb
class Autor < ApplicationRecord
include Comentable
has_many :libros
def self.mas_comentado
# regresa el Autor más comentado
end
end
Esto también lo podríamos extraer en nuestro Concern utilizando el bloque class_methods . El código que se encuentre dentro de este bloque será agregado a la clase misma y nuestro Concern quedaría así:
app/models/concerns/comentable.rb
module Comentable
extend ActiveSupport::Concern
included do
has_many :comentarios, as: :comentable
end
class_methods do
def mas_comentado
# regresa el Libro/Autor mas comentado
end
end
def ultimo_comentario
comentarios.last
end
def num_comentarios
comentarios.count
end
end
En el ejemplo que desarrollamos, los modelos siguen manteniendo la misma funcionalidad, pero gracias al Concern que implementamos el mantenimiento de la aplicación ahora será más fácil porque ya no tenemos código duplicado en los modelos.
¿Cómo probar los Concerns?
Ahora bien, hemos hecho un gran avance evitando la duplicidad de código en nuestros modelos, pero ¿qué podemos hacer con las pruebas?
Suponiendo estamos usando Rspec, FactoryBot y Shoulda Matcher para probar nuestros modelos, el código de nuestras pruebas puede ser parecido al siguiente:
spec/models/autor_spec.rb
require 'rails_helper'
RSpec.describe Autor, type: :model do
it { expect(subject).to have_many(:libros) }
it { expect(subject).to have_many(:comentarios) }
describe '#ultimo_comentario' do
context 'cuando el libro no tiene comentarios' do
it 'regresa nil' do
libro = FactoryBot.create :libro
expect(libro.ultimo_comentario).to eq nil
end
end
context 'cuando el libro tiene comentarios' do
it 'regresa el ultimo comentario' do
libro = FactoryBot.create :libro
comentarios = FactoryBot.create_list :comentario, 3, comentable: libro
expect(libro.ultimo_comentario).to eq comentarios.last
end
end
end
describe '#num_comentarios' do
context 'cuando el libro no tiene comentarios' do
it 'regresa cero' do
libro = FactoryBot.create :libro
expect(libro.num_comentarios).to eq 0
end
end
context 'cuando el libro tiene comentarios' do
it 'regresa el ultimo comentario' do
libro = FactoryBot.create :libro
comentarios = FactoryBot.create_list :comentario, 3, comentable: libro
expect(libro.num_comentarios).to eq 3
end
end
end
end
spec/models/libro_spec.rb
require 'rails_helper'
RSpec.describe Libro, type: :model do
it { expect(subject).to belong_to(:autor) }
it { expect(subject).to have_many(:comentarios) }
describe '#ultimo_comentario' do
context 'cuando el libro no tiene comentarios' do
it 'regresa nil' do
libro = FactoryBot.create :libro
expect(libro.ultimo_comentario).to eq nil
end
end
context 'cuando el libro tiene comentarios' do
it 'regresa el ultimo comentario' do
libro = FactoryBot.create :libro
comentarios = FactoryBot.create_list :comentario, 3, comentable: libro
expect(libro.ultimo_comentario).to eq comentarios.last
end
end
end
describe '#num_comentarios' do
context 'cuando el libro no tiene comentarios' do
it 'regresa cero' do
libro = FactoryBot.create :libro
expect(libro.num_comentarios).to eq 0
end
end
context 'cuando el libro tiene comentarios' do
it 'regresa el ultimo comentario' do
libro = FactoryBot.create :libro
comentarios = FactoryBot.create_list :comentario, 3, comentable: libro
expect(libro.num_comentarios).to eq 3
end
end
end
end
Aunque técnicamente estas pruebas siguen siendo útiles, porque nuestros modelos mantienen la misma funcionalidad, también podemos evitar la duplicidad de código en ellas.
Para esto utilizaremos los “Shared example group” de Rspec, las pruebas que estén presentes en este grupo serán ejecutadas en el contexto de las pruebas que los incluyan, lo cual es muy útil cuando queremos probar Concerns.
Para incluir los “Shared Example” en nuestras pruebas de modelo debemos de usar el método it_behaves_like(). Y cuando necesitemos hacer referencia a la clase que estamos probando dentro de los “Shared Example” podemos utilizar el método described_class.
Utilizando los “Shared Examples” nuestras pruebas quedarían así:
spec/models/autor_spec.rb
require 'rails_helper'
RSpec.describe Autor, type: :model do
it_behaves_like "comentable"
it { expect(subject).to have_many(:libros) }
end
spec/models/libro_spec.rb
require 'rails_helper'
RSpec.describe Libro, type: :model do
it_behaves_like "comentable"
it { expect(subject).to belong_to(:autor) }
end
spec/concerns/comentable_spec.rb
require 'spec_helper'shared_examples_for "comentable" do
let(:factory_model) { described_class.to_s.underscore.to_sym }
it { expect(subject).to have_many(:comentarios) }
describe '#ultimo_comentario' do
context "cuando el #{described_class} no tiene comentarios" do
it 'regresa nil' do
test_subject = FactoryBot.create factory_model
expect(test_subject.ultimo_comentario).to eq nil
end
end
context "cuando el #{described_class} tiene comentarios" do
it 'regresa el ultimo comentario' do
test_subject = FactoryBot.create factory_model
comentarios = FactoryBot.create_list :comentario, 3, comentable: test_subject
expect(test_subject.ultimo_comentario).to eq comentarios.last
end
end
end
describe '#num_comentarios' do
context "cuando el #{described_class} no tiene comentarios" do
it 'regresa cero' do
test_subject = FactoryBot.create factory_model
expect(test_subject.num_comentarios).to eq 0
end
end
context "cuando el #{described_class} tiene comentarios" do
it 'regresa el ultimo comentario' do
test_subject = FactoryBot.create factory_model
comentarios = FactoryBot.create_list :comentario, 3, comentable: test_subject
expect(test_subject.num_comentarios).to eq 3
end
end
end
end
La principal modificación que sufrieron nuestras pruebas es que ahora en lugar de hacer referencia a un Libro o un Autor hacen referencia a una clase que puede variar dependiendo de en donde sean incluidos los “Shared example”.
Para lograr esa flexibilidad estamos utilizando la variable factory_model, con la cual podemos crear un objeto del modelo que está siendo probado.
test_subject = FactoryBot.create factory_model
Y para facilitar la lectura, también modificamos los contextos para incluir la clase descrita.
context "cuando el #{described_class} tiene comentarios" do
Así es como conseguimos evitar la duplicidad de código en nuestras pruebas y facilitamos el mantenimiento de las mismas.