Diseñar un DSL en Ruby: el ejercicio de RSpec
Notas de un ejercicio de entrevista técnica que no supe resolver en vivo, reconstruido con el razonamiento que me faltaba. Todo el código de aquí está corrido y verificado en Ruby 3.4.7 con rspec-expectations 3.13.5.
Dónde estaba parado
Medición previa al ejercicio, según la metodología de la araña.
El hueco que explica por qué no salió en vivo está en el eje Feynman: podía escribir el
DSL sin poder explicar qué hace instance_eval con self.
El ejercicio
Hacer que esto funcione, desde cero:
expect("string").to(contain("s")) # => true
expect([1, 2, 3]).to have_size(10) # => falseSuena simple. Me bloqueé, y no por sintaxis. Me bloqueé porque no sabía qué iba en una clase, qué iba afuera, y a quién pertenecía .to. Empecé a escribir clases sin saber cuáles, y desde ahí no hay piso: nada te dice qué es correcto.
El error de fondo fue el punto de partida. Empecé por las clases. Hay que empezar por el otro lado.
La regla: el call site es la especificación
El código que quieres que funcione te dice el diseño. No es intuición ni talento: es una lectura. Cada pieza de expect("string").to(contain("s")) obliga algo.
expect("string") se llama sin receptor
No escribes algo.expect(...). Entonces expect es un método sobre self — alcanzable por cualquier ruta: definido en el contexto actual, heredado, o mezclado por un módulo.
Ojo con el matiz que yo mismo enuncié mal la primera vez: “sin receptor explícito” no significa “método global de top-level”. Significa “método sobre self”. En el RSpec real, expect es un método de instancia del módulo RSpec::Matchers, que rspec-core incluye en cada example group. Ahí está el salto de diseño: sin receptor no implica top-level, implica mixin.
Lo único que el call site sí garantiza: al no haber punto ni .(), no puede ser una variable local que contenga un callable. Es una llamada a método.
.to se llama sobre el resultado de expect(...)
Entonces expect tiene que devolver algo cuya cadena de lookup responda a to.
Aquí es donde yo sobrevendí la derivación. Dije “to pertenece a ese objeto, es lo único que puede recibirlo, está forzado”. Falso. El call site solo obliga a que algo en la cadena responda a to. Dónde lo defines es decisión de diseño, y la gema real lo demuestra: to vive en el módulo RSpec::Expectations::ExpectationTarget::InstanceMethods — precisamente para poder incluirlo en Minitest::Expectation. La cadena real:
[ValueExpectationTarget, ExpectationTarget, ExpectationTarget::InstanceMethods, Object]
Y la sintaxis legacy should ponía el equivalente directamente en BasicObject, sin wrapper alguno. O sea que expect podía devolver el valor crudo.
El valor entra en expect(...) y se usa en .to(...)
Son dos llamadas separadas, así que algo tiene que recordar el valor entre ambas. Estado → objeto.
Ésa es la única razón por la que hay una clase aquí. No “porque los DSL usan clases”. Porque hay que recordar algo. Si no necesitaras recordar nada, no necesitarías el objeto.
contain("s") va como argumento y se consume después
Ésta es la pieza que no vi, y es el corazón del ejercicio.
Cuando Ruby ejecuta contain("s"), tiene el "s" — pero el "string" no está ahí. Vive en otro objeto que contain no puede ver. La restricción es temporal, no de tipos: en ese momento falta la mitad de la información.
Así que contain no puede devolver el resultado de la comparación. Tiene que devolver una computación diferida: una caja que haga closure sobre lo esperado y sepa terminar el trabajo cuando reciba el resto.
(Yo lo enuncié como “no puede devolver un booleano, tiene que devolver un valor”. Impreciso: un booleano es un valor. La restricción es el diferimiento.)
to recibe esa caja y tiene que usarla
Necesitan un contrato compartido. En el ejercicio: que la caja responda a call(actual).
Entender call, que es lo que de verdad no entendía
call no es magia
Es un nombre de método cualquiera. La prueba: renómbralo.
class Expectation
def to(matcher) ; matcher.verificar(@actual) ; end # ya no se llama call
end
class ContainMatcher
def verificar(actual) ; actual.include?(@expected) ; end
end
# => true funciona idénticocall es solo una convención. Podría ser verificar, evaluar, ejecutame.
El problema real: los datos llegan en momentos distintos
Puse trazas en cada paso y corrí expect("string").to(contain("s")):
[paso 1] expect('string') → guardo el valor: "string"
[paso 2] contain('s') corre AHORA. Tengo esperado="s"
pero NO tengo el valor actual. No puedo comparar todavía.
[paso 3] to() recibe el matcher. AHORA tengo las dos piezas.
[paso 4] el cuerpo del lambda corre DESPUÉS, cuando to lo invoca.
ahora sí tengo AMBOS: esperado="s" actual="string"
[paso 5] to() devuelve: true
Ésta es toda la lección. expected llega en el paso 2; actual en el paso 4. call es el botón de la caja, y el paso 4 es cuando alguien lo aprieta.
Y fíjate quién lo aprieta: to. Porque to es el único punto del programa donde las dos piezas están juntas. El matcher no se invoca solo; lo dispara quien tiene el contexto completo.
El lambda, mientras espera, mantiene vivo el "s". Eso es un closure: la caja se lleva consigo las variables del lugar donde nació.
El premio de llamarle call
->(x) { x * 2 }.respond_to?(:call) # => trueProc ya define #call. Todos los lambdas traen ese botón de fábrica. Así que si eliges call como contrato, los lambdas entran gratis:
expect("string").to(ConCall.new("s")) # => true objeto con #call
expect("string").to(->(a) { a.include?("s") }) # => true lambdaEl mismo to, sin cambiar una línea. Eso es duck typing: no importa qué es, importa que responda a call. Si le hubiera puesto verificar, tendría que escribir clases siempre.
Las formas de invocarlo
f = ->(x) { x.upcase }
f.call("hola") # => "HOLA" explícita, la más clara
f.("hola") # => "HOLA" azúcar. NO es un método: Ruby lo traduce a .call
f["hola"] # => "HOLA" estilo indexado
f === "hola" # => "HOLA" por esto un lambda funciona en case/whenMétodos reales de Proc que invocan el cuerpo: [:yield, :===, :[], :call]. El .() no aparece porque es sintaxis, no método.
Donde ya usaba esto sin saberlo: Rack
Todo middleware de Rack es un objeto que responde a call(env):
class MiMiddleware
def initialize(app) ; @app = app ; end
def call(env) # el contrato de Rack
status, headers, body = @app.call(env) # llamo al siguiente de la pila
[status, headers, body]
end
end
app_final = ->(env) { [200, {}, ["ok"]] } # una app que es un LAMBDA
MiMiddleware.new(app_final).call(path: "/login")Corrido: el middleware es una clase, la app final es un lambda, y se encadenan sin problema porque ambos responden a call. Rack eligió ese nombre por la misma razón que el ejercicio: así una app puede ser una clase, un lambda o un Sinatra completo.
Otros lugares del mismo patrón: un bloque (arr.each { } — each invoca tu bloque), Sidekiq (defines perform, Sidekiq lo invoca después), un job encolado (guardas el trabajo, otro proceso lo dispara). En todos: alguien guarda trabajo, y otro lo ejecuta más tarde.
Las 4 implementaciones
El núcleo no cambia en ninguna:
class Expectation
def initialize(actual) ; @actual = actual ; end
def to(matcher) ; matcher.call(@actual) ; end
end
def expect(actual) = Expectation.new(actual)1 · Clases
class ContainMatcher
def initialize(expected) ; @expected = expected ; end
def call(actual) ; actual.include?(@expected) ; end
end
def contain(expected) = ContainMatcher.new(expected)2 · Lambda
def contain(expected)
->(actual) { actual.include?(expected) } # closure captura `expected`
endExpectation no cambia ni una línea. Ahí está la prueba de que el contrato era duck-typed y no dependía de las clases.
3 · Curry
CONTAIN = ->(expected, actual) { actual.include?(expected) }.curry
expect("string").to(CONTAIN["s"])Dar 1 de 2 argumentos devuelve un lambda esperando el que falta.
4 · define_method + extend
module Matchers
SPECS = { contain: ->(e, a) { a.include?(e) }, have_size: ->(e, a) { a.size == e } }
SPECS.each { |name, fn| define_method(name) { |e| ->(a) { fn.call(e, a) } } }
end
extend MatchersLa escalera método → lambda
Ésta es la conversión que me pidieron y no pude hacer:
def have_size_m(expected)
->(actual) { actual.size == expected }
end
method(:have_size_m) # => Method, arity 1
method(:have_size_m).to_proc # => Proc, lambda? true
->(e) { ->(a) { a.size == e } } # => Proc, arity 1
# los tres: .call(3).call([1,2,3]) · .(3).([1,2,3]) · [3][[1,2,3]]Lo que el call site NO determina
La derivación da la forma, no el contrato completo. No determina:
-
La negación (
not_to, aliasto_not). -
La forma con bloque (
expect { }.to raise_error), que en la gema hace dispatch víaExpectationTarget.for(value, block)→BlockExpectationTargetvsValueExpectationTarget, y lanzaArgumentErrorsi pasas ambos o ninguno. -
Qué devuelve
to(la gema devuelve el match result truthy). - El protocolo de mensajes de fallo.
-
La composabilidad (
Composable#===delega amatches?, lo que permite matchers encase/wheny anidados).
Encuadre honesto: el call site deriva la forma; el contrato sale de los requisitos, no de la sintaxis.
El contrato real de RSpec NO es #call
Verificado contra rspec-expectations 3.13.5:
eq('string').respond_to?(:call) # => false
eq('string').respond_to?(:matches?) # => true
expect("string").to ->(a) { a.include?("s") }
# => NoMethodError: undefined method 'matches?' for an instance of ProcEl .call del ejercicio era una simplificación didáctica. El protocolo real obligatorio son solo dos métodos: matches?(actual) y failure_message. Verificado: una clase con solo esos dos pasa RSpec::Matchers.is_a_matcher? y funciona sin warnings.
Opcionales: does_not_match?, failure_message_when_negated (obligatorio de facto solo si soportas not_to — sin él, una negación que falla revienta con NoMethodError crudo), description (necesaria para el one-liner is_expected y para matchers compuestos), supports_block_expectations?, diffable?.
El análogo real de una lambda-matcher es satisfy { |x| ... } — el bloque va dentro del matcher, no como el matcher. Y satisfy{}.respond_to?(:call) es false.
Para matchers custom, la API oficial:
RSpec::Matchers.define :be_in_zone do |zone|
match { |player| player.in_zone?(zone) }
endAlias oficial RSpec::Matchers.matcher. Devuelve un DSL::Matcher que ya trae description autogenerada del nombre y mensajes de fallo por default.
Las trampas verificadas
curry — dos fallan en silencio
->(a, b) { }.curry.arity # => -1 BORRA la introspección (era 2)
->(a, b = 9) { [a,b] }.curry["s"] # => ["s", 9] NO devuelve parcial: invoca de una
# curry cuenta SOLO parámetros requeridos
->(*a) { a }.curry[1][2] # => nil FALLA EN SILENCIO
->(*a) { a }.curry(2)[1][2] # => [1, 2] con aridad explícita síUn lambda currificado sigue aplicando la aridez (ArgumentError con 3 args); un proc currificado los ignora en silencio.
lambda vs proc — la diferencia que muerde en un DSL
No son solo aridez y return. La tercera es auto-splat:
proc { |a, b| [a, b] }.call([1, 2]) # => [1, 2] destructura el array
->(a, b) { [a, b] }.call([1, 2]) # => ArgumentErrorImporta si el matcher va a recibir arrays o hashes. Y: return en lambda retorna solo del lambda; en proc retorna del método envolvente; un proc que escapa de su definidor lanza LocalJumpError. Además lambda(&mi_proc) ya no existe en Ruby 3.x — no hay forma limpia de promover un proc a lambda.
define_method
- Es público en
Moduledesde Ruby 2.5 (no hace faltasend). -
Sí existe a top level, pero define un método público de instancia en
Object(contamina), a diferencia de undeftop-level que crea uno privado. - Tiene aridez estricta tipo lambda aunque le pases un block:
define_method(:m) { |a, b| }con un solo arg lanzaArgumentError. No hay auto-splat. -
La razón real de usarlo: el cuerpo es un closure sobre las locales del scope circundante, algo que
defno puede hacer. Si no necesitas capturar nada,defes más simple.
El otro tema del mismo ejercicio: include, prepend, extend
class A; end
module Mod1; end
module Mod2; end
class B < A
include Mod1
prepend Mod2
end
B.ancestors # => [Mod2, B, Mod1, A, Object, Kernel, BasicObject]
# ^^^^ prepend ANTES ^^^^ include DESPUÉSRuby busca de izquierda a derecha y usa el primero. Si la clase y ambos módulos definen el mismo método, gana el prependido, y el incluido nunca corre porque la clase está antes que él.
Para qué sirve prepend: envolver un método existente
module Auditoria
def save
puts "[audit] antes"
resultado = super # llama al save ORIGINAL de la clase
puts "[audit] después"
resultado
end
end
class Registro
prepend Auditoria
def save ; :ok ; end
endCuidado con enunciar la regla demasiado amplia. “include no puede envolver con super” es refutable en un segundo — sí puede, cuando el método viene de la superclase:
module Wrap; def greet; "wrap(" + super + ")"; end; end
class Base; def greet; "base"; end; end
class Child < Base; include Wrap; end # => "wrap(base)" ✅
class Child2 < Base; include Wrap; def greet; "propio"; end; end # => "propio" ❌Enunciado correcto: include no puede interceptar un método que la clase define en su propio cuerpo; prepend se inserta antes de la clase y por eso su super cae en el método propio.
Dos trampas del lookup
-
Las singleton classes no están en
ancestors. Paraobj.foola búsqueda arranca enobj.singleton_class, no enobj.class. -
include/prependnunca aportan métodos de clase. Verificado:B.singleton_class.ancestorsno contieneMod1niMod2. Ése es el detalle que suelen buscar al preguntar include vs extend.
Y extend(M) es include M en la singleton class del receptor — misma resolución, pero distinto hook: extend dispara M.extended(obj); el otro dispara M.included(obj.singleton_class).
Sobre extend Matchers a top level: funciona, pero solo alcanza call sites donde self es main — dentro de una clase falla con NameError. La gema real usa include, porque el DSL se usa dentro de los example groups.
Dos teóricas que tenía desactualizadas
El GC sí colecta símbolos
“El garbage collector no libera símbolos de memoria” es la respuesta pre-Ruby 2.2 (2014). Verificado en 3.4.7: con GC.disable creé 200,000 símbolos dinámicos, Symbol.all_symbols creció exactamente 200,000, y tras GC.start la fuga neta fue cero.
-
Static symbols (literales
:fooen el código, method tables, ivars, constantes) → inmortales, los pinnea el bytecode compilado. -
Dynamic symbols (
"x".to_symsobre un string calculado,sendcon nombre interpolado) → sí se colectan desde 2.2. Ése era el vector de DoS por symbol exhaustion, y está cerrado.
Respuesta de una oración: todos los símbolos son frozen e idénticos entre sí, y desde Ruby 2.2 el GC sí colecta los creados dinámicamente; los literales son inmortales porque los retiene el bytecode.
String literals
“Dos literales iguales son objetos distintos” solo es cierto sin el magic comment. Con # frozen_string_literal: true se deduplican y están frozen — verificado: a.equal?(b) # => true. Y casi todo gem moderno y app Rails lo lleva.
Y: no existe “Ruby 3.5” — el sucesor de 3.4 es Ruby 4.0, liberado el 25-dic-2025, y no congeló los literales por defecto. Los chilled strings de 3.4 siguen siendo un warning de deprecation apagado por default (solo aparece con -w).
El error de proceso, que importa más que el de código
Me quedé callado mientras me bloqueaba. En una entrevista de coding en vivo el razonamiento en voz alta ES el deliverable, no el código terminado.
El protocolo, 90 segundos:
- Escribe el call site objetivo como comentario, arriba.
-
Narra la derivación: “
expectno tiene receptor, entonces es un método sobre self..toviene del resultado, entoncesexpectdevuelve algo que responde ato. El valor tiene que sobrevivir entre las dos llamadas, entonces ese algo guarda estado…” - Escribe los stubs vacíos primero — solo las firmas.
- Recién entonces llena los cuerpos.
Aunque te equivoques en el cuerpo, con los pasos 1–3 el entrevistador ya vio lo que buscaba. Un bloqueo silencioso no le da nada.
Lo que me llevo
El hueco no era Ruby. Era elegir el seam: decidir qué es un objeto, qué es un método, y quién le habla a quién, sin que nadie te lo haya dado ya resuelto. En un monolito heredado las abstracciones ya existen y trabajas dentro de ellas; en una hoja en blanco de 45 minutos, no.
Y la herramienta contra eso no es memorizar patrones: es leer el call site como especificación. Escribir primero cómo se usa, y derivar hacia atrás qué obliga cada pieza.