This is a conference talk (in german).
Kotlin eignet sich hervorragend zur Erstellung domain-spezifischer Sprachen (DSLs). Mit Kotlin DSL können Entwickler*innen die Testlogik klar und präzise ausdrücken, was zu besser lesbarem, leichter wartbarem und insgesamt effizienterem Code führt. So kann der der Testcode sogar mit wenig oder gar keiner Erfahrung in Kotlin gelesen und bearbeitet werden, sodass er für das gesamte Entwicklungsteam zugänglicher und die Zusammenarbeit zwischen Entwicklerinnen und Testern gefördert wird. In diesem Vortrag gibt Lars Michaelis eine grundlegende Einführung in Kotlin DSL und zeigt deren praktischen Nutzen. Er demonstriert am Beispiel einer Testanwendung mit Spring Boot, wie es mit Kotlin DSL gelingt, Tests auf intuitive und deklarative Weise zu schreiben, und wie diese Tests mit Spring Boot zusammenarbeiten können.
In diesem Vortrag lernst Du:
- was Kotlin DSL ausmacht und welche Bedeutung es für die Entwicklung von domain-spezifischen Sprachen hat
- die praktische Anwendung von Kotlin DSL anhand einer Testanwendung mit Spring Boot
- wie Kotlin DSL es ermöglicht, Tests intuitiv und deklarativ zu gestalten
Definition: Eine Domain-Specific Language ist eine Sprache, die speziell für eine bestimmte Anwendungsdomäne entwickelt wurde.
Beispiele: HTML, SQL, Gradle, ...
- Erhöhte Produktivität und Lesbarkeit.
- Fokussierung auf das Problem statt auf Implementierungsdetails.
- Vereinfachung der Kommunikation zwischen Entwicklern und Domänenexperten.
- Erklären CompanyRepository.kt
- Erklären CompanyController.kt inkl. CompanyControllerTest.kt
-> CompanyRepository#exits() mit infix
--> Aufruf im CompanyController.kt anpassen
Erlaubt es, Klassen nachträglich zu erweitern und macht den Code lesbarer.
-> fluent Api im Controller
fun create(@RequestBody dto: CreateCompanyDto) : ReadCompanyDto = dto
.mapToEntity()
.storyInDatabase()
.mapToDto()
fun readTweet(@PathVariable id: Long): ResponseEntity<ReadCompanyDto> {
if (companyRepository exists id) {
return companyRepository.get(id).wrapInResponse()
}
return ResponseEntity.notFound().build()
}
private fun CompanyEntity.storeInDatabase() = companyRepository.save(this)
Keine Angabe des Type: Macht die DSLs kompakter und intuitiver.
var name : String = "this is a string"
var name = "this is a string"
// kein expliziter Rückgabewert angegeben
fun create(@RequestBody dto: CreateCompanyDto) = dto
.mapToEntity()
.storyInDatabase()
.mapToDto()
-> Test des Controllers
--> DTO Factory mit Lambda with receiver
Was ich gerne hätte
content = body {
name = "Panzerknacker AG"
employee {
name = "Karlchen Knack"
email = "karlchen@knack.de"
}
employee {
name = "Kuno Knack"
email = "kuno@knack.de"
}
}
Erzeugen einer TestDataFactory.kt
- Builder erzeugen
// einfacher Builder
class CreateEmployeeDtoBuilder {
var name: String = "Donald Duck"
var email: String = "donald@duck.de"
fun build() = CreateEmployeeDto(name = name, email = email)
}
class CreateCompanyDtoBuilder(private val employees: MutableList<CreateEmployeeDto> = mutableListOf()) {
var name: String = "Entenhausen AG"
fun buildJson() = jacksonObjectMapper().writeValueAsString(CreateCompanyDto(name = name, employees = employees))
}
- Lambda with Receiver erstellen, um Builder nutzen zu können
// init: CreateCompanyDtoBuilder.() -> Unit bedeutet, dass das Lambda, das als Argument übergeben wird, den Typ CreateCompanyDtoBuilder als Receiver hat.
// Innerhalb des Lambdas können alle Eigenschaften und Methoden von Tag direkt aufgerufen werden.
fun body(init: CreateCompanyDtoBuilder.() -> Unit): String {
val builder = CreateCompanyDtoBuilder()
builder.init()
return builder.buildJson()
}
// lesbarer
fun body(block: CreateCompanyDtoBuilder.() -> Unit) = CreateCompanyDtoBuilder().apply(block).buildJson()
- Lambda with Receiver
class CreateCompanyDtoBuilder(private val employees: MutableList<CreateEmployeeDto> = mutableListOf()) {
var name: String = "Entenhausen AG"
fun employee(init: CreateEmployeeDtoBuilder.() -> Unit) {
employees.add(CreateEmployeeDtoBuilder().apply(block).build())
}
fun buildJson() = jacksonObjectMapper().writeValueAsString(CreateCompanyDto(name = name, employees = employees))
}
Jetzt haben wir alles, was wir zum schreiben von DSLs benötigen
Was ich gerne hätte
apiTest(mockMvc) {
post("/api/company/") {
prepare {
// mocking stuff
}
body {
// body dsl stuff
}
verify {
// status, content, ...
}
}
}
WebTestFactory.kt
@MyDsl
class PostBuilder(private val contextPath: String) {
var preparation: (Any.() -> Unit)? = null
var body: String = ""
var validation: (MockMvcResultMatchersDsl.() -> Unit)? = null
fun prepare(block: Any.() -> Unit) {
this.preparation = block
}
fun body(block: CreateCompanyDtoBuilder.() -> Unit) {
this.body = CreateCompanyDtoBuilder().apply(block).buildJson()
}
fun verify(dsl: MockMvcResultMatchersDsl.() -> Unit) {
this.validation = dsl
}
}
fun post(contextPath: String, block: PostBuilder.() -> Unit) {
PostBuilder(contextPath).apply(block)
}
CompanyControllerTest.kt
@Test
fun `create a new company`() {
post("/api/company/") {
prepare {
every { companyRepositoryMock.save(any()) } returnsArgument 0
}
body {
name = "Panzerknacker AG"
employee {
name = "Karlchen Knack"
email = "karlchen@knack.de"
}
employee {
name = "Kuno Knack"
email = "kuno@knack.de"
}
}
verify {
status { isOk() }
content { contentType(MediaType.APPLICATION_JSON) }
content { jsonPath("$.id") { exists() } }
content { jsonPath("$.name") { value("Panzerknacker AG") } }
content { jsonPath("$.employees[0].id") { exists() } }
content { jsonPath("$.employees[0].name") { value("Karlchen Knack") } }
content { jsonPath("$.employees[0].email") { value("karlchen@knack.de") } }
content { jsonPath("$.employees[1].id") { exists() } }
content { jsonPath("$.employees[1].name") { value("Kuno Knack") } }
content { jsonPath("$.employees[1].email") { value("kuno@knack.de") } }
}
}
}
Es fehlt der Rahmen
class ApiTestBuilder(private val mockMvc: MockMvc) {
private var postBuilder: PostBuilder? = null
fun post(contextPath: String, block: PostBuilder.() -> Unit) {
this.postBuilder = PostBuilder(contextPath).apply(block)
}
fun execute() {
postBuilder?.let {
it.preparation?.invoke {}
val post = mockMvc.post(it.contextPath) {
contentType = MediaType.APPLICATION_JSON
content = it.body
}
if (it.validation != null) {
post.andExpect(it.validation!!)
}
}
}
}
fun apiTest(mockMvc: MockMvc, block: ApiTestBuilder.() -> Unit) = ApiTestBuilder(mockMvc).apply(block).execute()
Anpassung Test + Ausführen
@Test
fun `create a new company`() {
apiTest(mockMvc) {
post("/api/company/") {
prepare {
every { companyRepositoryMock.save(any()) } returnsArgument 0
}
body {
name = "Panzerknacker AG"
employee {
name = "Karlchen Knack"
email = "karlchen@knack.de"
}
employee {
name = "Kuno Knack"
email = "kuno@knack.de"
}
}
verify {
status { isOk() }
content { contentType(MediaType.APPLICATION_JSON) }
content { jsonPath("$.id") { exists() } }
content { jsonPath("$.name") { value("Panzerknacker AG") } }
content { jsonPath("$.employees[0].id") { exists() } }
content { jsonPath("$.employees[0].name") { value("Karlchen Knack") } }
content { jsonPath("$.employees[0].email") { value("karlchen@knack.de") } }
content { jsonPath("$.employees[1].id") { exists() } }
content { jsonPath("$.employees[1].name") { value("Kuno Knack") } }
content { jsonPath("$.employees[1].email") { value("kuno@knack.de") } }
}
}
}
}
- Autocompletion bietet mir alle Felder an
- Employee kann in Employee verschachtelt werden
@DslMarker
annotation class MyDsl
@MyDsl
class CreateEmployeeDtoBuilder
@MyDsl
class CreateCompanyDtoBuilder
Dann werden die Felder immer noch angeboten, aber der Compiler verhindert eine Nutzung