Limpiando mis specs con custom matchers

Leandro Segovia
Código Banana: El blog de Platanus
6 min readDec 14, 2016

RSpec o más específicamente rspec-expectations viene con un conjunto de built-in matchers que utilizamos a lo largo y ancho de nuestros tests, junto con los métodos expect(..).to y expect(..).not_to para definir expectations. Por ejemplo:

it { expect(a).to equal(b) } # pasa if a.equal?(b)

La prueba anterior, utiliza el built-in matcher equal para expresar “la esperanza” (expectation) de que la variable a sea igual b de una forma concisa y fácil de leer.

Como dije anteriormente, a lo largo de nuestras aplicaciones utilizamos estos built-in matchers para definir expectations pero, no es cierto que nuestros specs son tan simples como el que mostré en el ejemplo. Normalmente en una aplicación cualquiera las pruebas son más complejas, y esta complejidad, está íntimamente ligada al dominio de nuestra aplicación. A la lógica del negocio. Con el fin de simplificar “esas lógicas”, es que utilizamos, de igual forma que con los build-in matchers, custom matchers.

Como su nombre indica, un custom matcher es un matcher hecho a medida. Para crear los propios, RSpec viene con un DSL que nos hará más simple el trabajo. A continuación, les mostraré un ejemplo simple para enseñar su uso. Tengan en cuenta que la idea aquí no es explorar todo lo que se puede hacer con el DSL sino mostrar las ventajas de utilizar custom matchers.

Ejemplo:

Supongamos que tenemos un API con un UsersController, que incluye los típicos create, update y show.

class Api::V1::UsersController < Api::V1::BaseController
def create
respond_with User.create!(permitted_params), status: :created
end
def update
user.update_attributes!(permitted_params)
respond_with user
end
def show
respond_with user
end
private def permitted_params
params.require(:user).permit(:email, :name, :primary_phone, :secondary_phone, :rut, :password, :password_confirmation)
end
def user
@user ||= User.find(params[:id])
end
end

Los specs de este controller podrían verse así:

RSpec.describe Api::V1::UsersController, type: :controller do
let(:user_params) do
{
email: "lean@platan.us",
name: "Lean Longone",
primary_phone: "555444333",
secondary_phone: "666777888",
rut: "30972198"
}
end
describe "#update" do
let(:user) { create(:user) }
before do
params = {
format: :json,
id: user.id,
user: user_params
}
put(:update, params)
end
it "updates user's data" do
@user = JSON.parse(response.body)["user"]
user.reload
expect(@user["id"]).to eq(user.id)
expect(@user["email"]).to eq(user.email)
expect(@user["primary_phone"]).to eq(user.primary_phone)
expect(@user["secondary_phone"]).to eq(user.secondary_phone)
expect(@user["rut"]).to eq(user.rut)
end
end
describe "#show" do
let(:user) { create(:user) }
before do
params = {
format: :json,
id: user.id
}
get(:show, params)
end
it "returns user's data" do
@user = JSON.parse(response.body)["user"]
user.reload
expect(@user["id"]).to eq(user.id)
expect(@user["email"]).to eq(user.email)
expect(@user["primary_phone"]).to eq(user.primary_phone)
expect(@user["secondary_phone"]).to eq(user.secondary_phone)
expect(@user["rut"]).to eq(user.rut)
end
end
describe "#create" do
before do
user_params[:password] = user_params[:password_confirmation] = 12345678
params = {
format: :json,
user: user_params
}
post(:create, params)
end
it "creates new user with valid data" do
@user = JSON.parse(response.body)["user"]
user = User.last
expect(@user["id"]).to eq(user.id)
expect(@user["email"]).to eq(user.email)
expect(@user["primary_phone"]).to eq(user.primary_phone)
expect(@user["secondary_phone"]).to eq(user.secondary_phone)
expect(@user["rut"]).to eq(user.rut)
end
end
end

Como se puede observar, los tres specs revisan que luego de un update, create o show, la información del usuario sea válida. Si observan con detenimiento, verán en los 3 ejemplos que:

  • Con pequeñas variaciones, el código es el mismo.
  • Se debe ejecutar JSON.parse para acceder a la respuesta de cada endpoint.
  • Los specs no son concisos por lo que se dificulta su entendimiento a simple vista.

Utilizando custom matchers, podemos convertir los tests anteriores a algo como esto:

RSpec.describe Api::V1::UsersController, type: :controller do
let(:user_params) do
{
email: "lean@platan.us",
name: "Lean Longone",
primary_phone: "555444333",
secondary_phone: "666777888",
rut: "30972198"
}
end
describe "#update" do
let(:user) { create(:user) }
before do
params = {
format: :json,
id: user.id,
user: user_params
}
put(:update, params)
end
it { expect(user).to match_user_response(response) }
end
describe "#show" do
let(:user) { create(:user) }
before do
params = {
format: :json,
id: user.id
}
get(:show, params)
end
it { expect(user).to match_user_response(response) }
end
describe "#create" do
before do
user_params[:password] = user_params[:password_confirmation] = 12345678
params = {
format: :json,
user: user_params
}
post(:create, params)
end
it { expect(User.last).to match_user_response(response) }
end
end

Las ventajas?

  • Removimos código repetido.
  • Aportamos claridad haciendo el código más conciso.
  • Removimos las descripciones en los “it”s, ya que el matcher se auto explica.
  • Creamos un matcher que puede ser utilizado en cualquier otro spec de la aplicación.

Ahora bien, cómo construimos el matcher?

Se debe crear un archivo dentro del directorio support, o cualquier otro referenciado en spec/rails_helper.rb, con el siguiente contenido:

RSpec::Matchers.define :match_user_response do |response|
match do |user|
user_response = JSON.parse(response.body)["user"]
user.reload
[
"id",
"email",
"primary_phone",
"secondary_phone",
"rut"
].each do |attribute|
return false if user_response[attribute] != user.send(attribute)
end
true
end
end

Pienso que el código es bastante autoexplicativo pero podemos añadir que:

  • Se lo identifica a través de un nombre. En este caso: match_user_response
  • El bloque principal, recibe el valor esperado (response). En nuestro caso la respuesta de un endpoint que devuelve un JSON con la información de un usuario.
  • Dentro del bloque principal se ejecuta un método match, que a su vez recibe un bloque con el valor actual (user). Para nuestro ejemplo, una instancia del modelo User.
  • Dentro del bloque de match, se debe ejecutar código que evalúe una condición. En esta oportunidad, devolveremos true si los valores de la respuesta coinciden con los de la instancia de usuario, caso contrario, devolveremos false.

Esto es lo mínimo que debemos desarrollar para tener un matcher funcionando. Pero si lo que queremos es hacer un buen trabajo y entregar buen feedback a nuestros compañeros desarrolladores, podemos agregar, dentro del bloque de definición los siguientes métodos:

  • description: que nos permite definir un mensaje más humano si el test pasa sin errores.
  • failure_message: que se usa para definir mensajes de error que se mostrarán cuando utilicemos el matcher con expect(..).to
  • failure_message_when_negated: que es igual que failure_message pero que funciona con expect(..).not_to. No lo mostraré aquí ya que es un poco redundante.

Agregando los métodos, el matcher queda de la siguiente forma:

RSpec::Matchers.define :match_user_response do |response|
match do |user|
return false unless user
user_response = JSON.parse(response.body)["user"] rescue nil
return false unless user_response
user.reload
[
"id",
"email",
"primary_phone",
"secondary_phone",
"rut"
].each do |attribute|
if user_response[attribute] != user.send(attribute)
@diff_attr = attribute
return false
end
end
true
end
description do
"match response with user ##{user.id}"
end
failure_message do |user|
msg = "expected user attributes equal to response attributes."
return "#{msg} But given user is blank" if user.blank?
return "#{msg} But given response is not a valid response" if response.blank?
return "#{msg} But user's #{@diff_attr} is not equal in response" if @diff_attr
msg
end
end

Como se puede ver en el código anterior,

El ejecutar los specs, nos dará feedback como el siguiente:

  • Success!
  • Error cuando el user es inválido
  • Error cuando response es inválida
  • Error cuando los atributos de la instancia no coinciden con la respuesta.

De lo anterior podemos sacar una ventaja más!

  • Nuestros matchers nos dan la posibilidad de agregar información específica de errores que nos ayudarán a debuggear.

Esto ha sido todo! Espero haya podido transmitirles el concepto de custom matchers y sus ventajas. Hasta la próxima!

--

--