Was ist ein DSL und warum brauchen Sie es?
\n\nStellen Sie sich vor, Sie schreiben keinen Code, sondern eine Anleitung in verständlicher Sprache, die sich selbst in ein funktionierendes Programm verwandelt. Genau so funktionieren domänenspezifische Sprachen (DSL – Domain Specific Language). Im Gegensatz zu universellen Sprachen (Java, Python, C#) wird ein DSL zur Lösung einer bestimmten Aufgabe entwickelt – sei es die Serverkonfiguration, die Beschreibung von Geschäftsregeln oder die Erstellung komplexer SQL-Abfragen.
\n\nKotlin ist der ideale Kandidat für die Erstellung von DSLs. Dank Lambdas mit Empfänger, Erweiterungen und type-safe Buildern können Sie Code schreiben, der wie native Syntax aussieht. In diesem Artikel behandeln wir nicht nur die Theorie, sondern erstellen einen echten DSL für ein Test-Framework – und Sie werden sehen, wie einfach und spannend das ist.
\n\nWarum ist Kotlin der König der DSLs?
\n\nKotlin hat die Liebe der Entwickler nicht nur wegen seiner Kompatibilität mit Java gewonnen. Seine syntaktischen Möglichkeiten ermöglichen die Erstellung interner DSLs – Sprachen, die innerhalb der Host-Sprache leben und deren Syntax verwenden. Hier sind die drei Säulen, auf denen jedes Kotlin-DSL basiert:
\n\n- \n
- Lambdas mit Empfänger – ermöglichen den Zugriff auf Methoden eines Objekts ohne explizite Angabe des Namens. Dies ist die Grundlage des Builder-Patterns. \n
- Erweiterungen – fügen jeder Klasse neue Funktionen hinzu, auch wenn Sie sie nicht ändern können. \n
- Infix-Funktionen – ermöglichen das Schreiben von Code im Stil natürlicher Sprache:
should be equalTo 5. \n
Diese Möglichkeiten verwandeln das Schreiben von DSLs in einen Lego-Baukasten: Sie nehmen vorgefertigte Blöcke – Lambdas, Funktionen, Eigenschaften – und bauen daraus eine verständliche und sichere Sprache.
\n\nErstellen eines einfachen DSLs für Tests: Schritt für Schritt
\n\nLassen Sie uns einen DSL zum Schreiben von Tests erstellen. Das Ziel ist, dass Tests wie eine lesbare Beschreibung aussehen und nicht wie eine Reihe von assert-Aufrufen:
\n\ntest("Der Rechner sollte Zahlen addieren") {\n val result = Calculator.add(2, 3)\n result should beEqualTo 5\n result shouldNot beNull\n}\n\nGeben Sie zu, das ist fast Englisch. Lassen Sie uns nun analysieren, wie das funktioniert.
\n\nSchritt 1. Erstellen des Testkontexts
\n\nWir benötigen eine Funktion test, die einen Namen und einen Codeblock akzeptiert. Der Block wird in einem Kontext ausgeführt, in dem should und shouldNot verfügbar sind:
class TestContext {\n fun Any.should(matcher: Matcher) {\n if (!matcher.matches(this)) {\n throw AssertionError("Erwartet ${matcher.expected}, aber $this erhalten")\n }\n }\n\n fun Any.shouldNot(matcher: Matcher) {\n if (matcher.matches(this)) {\n throw AssertionError("Erwartet nicht ${matcher.expected}, aber $this erhalten")\n }\n }\n}\n\nfun test(name: String, block: TestContext.() -> Unit) {\n println("Führe Test aus: $name")\n try {\n TestContext().block()\n println("✓ Bestanden")\n } catch (e: AssertionError) {\n println("✗ Fehlgeschlagen: ${e.message}")\n }\n}\n\nDer entscheidende Punkt: TestContext.() -> Unit ist ein Lambda mit Empfänger. Innerhalb des Blocks verweist this auf das Objekt TestContext, sodass die Methoden should und shouldNot implizit verfügbar werden.
Schritt 2. Implementieren der Matcher
\n\nJetzt erstellen wir einfache Matcher. beEqualTo und beNull sind Funktionen, die Matcher-Objekte zurückgeben:
interface Matcher {\n val expected: String\n fun matches(value: Any?): Boolean\n}\n\nfun beEqualTo(expected: Any) = object : Matcher {\n override val expected = "gleich $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