¿Qué es un DSL y por qué lo necesita?
\n\nImagine que no escribe código, sino una instrucción en un lenguaje comprensible que se convierte por sí sola en un programa funcional. Así es como funcionan los lenguajes específicos del dominio (DSL — Domain Specific Language). A diferencia de los lenguajes universales (Java, Python, C#), un DSL se crea para resolver una tarea concreta — ya sea la configuración de un servidor, la descripción de reglas de negocio o la construcción de consultas SQL complejas.
\n\nKotlin es el candidato ideal para crear DSL. Gracias a las lambdas con receptor, las extensiones y los constructores type-safe, puede escribir código que parece sintaxis nativa. En este artículo no solo veremos teoría, sino que crearemos un DSL real para un framework de pruebas — y verá lo sencillo y fascinante que es.
\n\n¿Por qué Kotlin es el rey de los DSL?
\n\nKotlin se ha ganado el cariño de los desarrolladores no solo por su compatibilidad con Java. Sus capacidades sintácticas permiten crear DSL internos — lenguajes que viven dentro del lenguaje anfitrión y utilizan su sintaxis. Estos son los tres pilares sobre los que se sustenta cualquier DSL en Kotlin:
\n\n- \n
- Lambdas con receptor — permiten acceder a los métodos de un objeto sin indicar explícitamente su nombre. Esta es la base del patrón builder. \n
- Extensiones — añaden nuevas funciones a cualquier clase, incluso si no puede modificarla. \n
- Funciones infijas — permiten escribir código en un estilo similar al lenguaje natural:
should be equalTo 5. \n
Estas capacidades convierten la creación de DSL en un juego de construcción Lego: toma bloques ya preparados — lambdas, funciones, propiedades — y ensambla con ellos un lenguaje claro y seguro.
\n\nCreando un DSL simple para pruebas: paso a paso
\n\nEscribamos un DSL para realizar pruebas. El objetivo es que las pruebas se vean como una descripción legible, no como un conjunto de llamadas a assert:
\n\ntest("La calculadora debe sumar números") {\n val result = Calculator.add(2, 3)\n result should beEqualTo 5\n result shouldNot beNull\n}\n\nReconozca que esto es casi inglés. Ahora analicemos cómo funciona.
\n\nPaso 1. Crear el contexto de la prueba
\n\nNecesitamos una función test que reciba un nombre y un bloque de código. El bloque se ejecuta en un contexto donde están disponibles should y shouldNot:
class TestContext {\n fun Any.should(matcher: Matcher) {\n if (!matcher.matches(this)) {\n throw AssertionError("Se esperaba ${matcher.expected}, pero se obtuvo $this")\n }\n }\n\n fun Any.shouldNot(matcher: Matcher) {\n if (matcher.matches(this)) {\n throw AssertionError("No se esperaba ${matcher.expected}, pero se obtuvo $this")\n }\n }\n}\n\nfun test(name: String, block: TestContext.() -> Unit) {\n println("Ejecutando prueba: $name")\n try {\n TestContext().block()\n println("✓ Aprobada")\n } catch (e: AssertionError) {\n println("✗ Fallida: ${e.message}")\n }\n}\n\nEl punto clave: TestContext.() -> Unit es una lambda con receptor. Dentro del bloque, this hace referencia al objeto TestContext, por lo que los métodos should y shouldNot están disponibles implícitamente.
Paso 2. Implementar los matchers
\n\nAhora crearemos matchers simples. beEqualTo y beNull son funciones que devuelven objetos Matcher:
interface Matcher {\n val expected: String\n fun matches(value: Any?): Boolean\n}\n\nfun beEqualTo(expected: Any) = object : Matcher {\n override val expected = "igual a $expected"\n override fun matches(value: Any?) = value == expected\n}\n\nval beNull = object : Matcher {\n override val expected = "null"\n override fun matches(value: Any?) = value == null\n}\n\n