Blog
Kotlin Multiplatforms drei Ebenen des Testens mit Kotest

Kotlin Multiplatform ermöglicht die gemeinsame Nutzung von Kotlin-Code auf verschiedenen Plattformen, einschließlich JVM, JavaScript, iOS, Android und nativ. Dies ist nützlich, wenn wir eine gemeinsame Geschäftslogik haben, die für alle Plattformen relevant ist. Wenn Funktionen in dieser gemeinsamen Codebasis eine plattformspezifische Implementierung erfordern, dann bietet Kotlin Multiplatform einen Mechanismus von expected und actual Deklarationen, um im Wesentlichen Platzhalter zu erstellen, die pro Plattform implementiert werden. Wir werden diesen Mechanismus später in diesem Artikel untersuchen. Ein Kotlin Multiplattform-Projekt enthält mehrere Module, die als Source Sets bezeichnet werden, um eine solche gemeinsame Nutzung von Code zu ermöglichen. Standardmäßig gibt es für jede Plattform zwei Quellcodesätze, einen für den Code ( jvmMain, jsMain) und eine für die Tests (jvmTest, jsTest) und zusätzlich zwei Quellensätze für gemeinsamen Code (commonMain und commonTest), die in allen Hauptkompilierungen eines Projekts verwendet werden. Diese Module sind in einer Hierarchie strukturiert, um jede Ebene der gemeinsamen Nutzung von Code zu ermöglichen.
[caption id="attachment_56320" align="aligncenter" width="571"]
Hierarchie der Quellensätze in Kotlin Multiplatform[/caption]
Ein Anwendungsfall ist die gemeinsame Nutzung von Abhängigkeiten. Unten sehen Sie ein Beispiel, bei dem commonTest Kotest-Abhängigkeiten definiert, die für alle Plattformen verwendet werden, und die JVM-spezifischen Abhängigkeiten dem jvmTest Quellensatz hinzugefügt werden.
sourceSets {
val commonTest by getting {
dependencies {
implementation(libs.kotest.assertions.core)
implementation(libs.kotest.framework.engine)
implementation(kotlin("test-common"))
implementation(kotlin("test-annotations-common"))
}
}
val jvmTest by getting {
dependencies {
implementation(libs.kotest.runner.junit5)
}
}
}
Jede Plattform hat auch ihren eigenen spezifischen Code, so dass z.B. der JVM-Teil Ihrer Anwendung einen Server implementieren kann, das Frontend in JavaScript-Code aufgebaut ist und die mobilen Apps auf der Grundlage der Quellensätze für Android und iOS erstellt werden.
Dieser Artikel zeigt, wie wir alle drei Codeschichten in einem Kotlin Multiplattform-Projekt testen können: allgemeinen Code, erwartete/eigentliche Deklaration und plattformspezifischen Code. Wir verwenden Kotest als Test-Framework, weil es über integrierte Multiplattform-Unterstützung verfügt. Das Framework delegiert das Testen an plattformspezifische Engines, wie z.B. JUnit und Mocha. Alle Codebeispiele finden Sie auf GitHub.
Gemeinsamer Code
Code, der von allen Plattformen gemeinsam genutzt wird, befindet sich standardmäßig im commonMain Quellenset. Es wäre nicht sinnvoll, dafür auf jeder Plattform Tests zu schreiben. Stattdessen können wir den Quellensatz commonTest verwenden, um die gemeinsame Testsuite zu speichern. Nehmen wir an, wir benötigen eine Funktion, die die Fakultät einer Zahl im Quellenset commonMain berechnet:
package nl.bjornvanderlaan.factorial
fun factorial(number: Int): Int
= (1..number).fold(1) { acc, i -> acc * i}
Wir können den Testcode in commonTest zur Verfügung stellen:
package nl.bjornvanderlaan.factorial
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.ints.shouldBeExactly
class FactorialTest : StringSpec({
"Factorial of 5 is 120" {
factorial(5) shouldBeExactly 120
}
})
Wie wir sehen können, funktioniert das Testen von allgemeinem Code genauso wie in 'normalen' Kotlin-Projekten. Das Tolle daran ist jedoch, dass diese Funktion nun auf allen möglichen Plattformen verwendet werden kann, was ein großer Vorteil sein kann, wenn wir die gesamte Geschäftslogik hier schreiben.
Erwartete / tatsächliche Erklärungen
Manchmal gibt es Funktionen, die wir in unserem allgemeinen Code verwenden möchten, die aber mit plattformspezifischem Code viel effizienter implementiert werden können. Zum Beispiel bietet die Multiplattform-Standardbibliothek von Kotlin keine Möglichkeit, einen String in base64 zu kodieren. Wir könnten unsere eigene Implementierung entwickeln oder nach einer Open-Source-Bibliothek suchen, aber einige unserer Zielplattformen bieten diese Funktionalität bereits von Haus aus. Kotlin bietet einen Mechanismus von expected Deklaration, und plattformspezifische Quellensätze liefern die actual Deklaration, die der erwarteten Deklaration und der tatsächlichen Implementierung entspricht. Neben Funktionen funktioniert dieser Mechanismus auch für andere Konstrukte wie Klassen, Enums und Annotationen. Nachfolgend sehen Sie ein Beispiel für diesen Mechanismus für die JVM. Der Code basiert auf einem Tutorial von JetBrains.
/**
* Source set: commonMain
*/
package nl.bjornvanderlaan.base64
expect fun encodeBase64(input: String): String
fun encodeGreeting(name: String): String {
return "Greetings $name, from Kotlin Multiplatform!"
}
/**
* Source set: jvmMain
*/
package nl.bjornvanderlaan.base64
import java.util.Base64
actual fun encodeBase64(input: String): String {
return Base64.getEncoder().encode(input.encodeToByteArray()).decodeToString()
}
Wie wir sehen können, kann die erwartete Funktion in commonMain von anderen Funktionen wie encodeGreeting verwendet werden. Durch diesen Mechanismus können wir gemeinsamen Code schreiben, wo es Sinn macht, und Platzhalter dort stehen lassen, wo wir erwarten, dass die Plattformen eine tatsächliche Implementierung liefern. Ähnlich wie bei dem gemeinsamen Code können wir unseren Test in commonTest schreiben:
/**
* Source set: commonTest
*/
class Base64Test: StringSpec({
"String is properly encoded to Base64" {
val input = "Kotlin is awesome"
val encodedInput = encodeBase64(input)
encodedInput shouldBe "S290bGluIGlzIGF3ZXNvbWU="
}
})
Wenn wir Gradle unser Projekt testen lassen, führt es diesen Test automatisch für alle plattformspezifischen Varianten der erwarteten Funktion aus. Auch wenn wir also mehrere tatsächliche Implementierungen haben, müssen wir unsere Testsuite nicht wiederholen.
Besondere Funktion
Schließlich gibt es Teile der Anwendung, die wir nur für bestimmte Plattformen erstellen möchten. Da die Quellensets hierarchisch organisiert sind, können wir wählen, in welche Testquelle wir unsere Tests stellen möchten. Wenn wir eine Webanwendung erstellen, möchten wir, dass der Servercode speziell für die JVM geschrieben wird, und der JavaScript-Quellsatz würde den Front-End-Code enthalten. Die Tests werden dann wahrscheinlich in die Quellcodesätze jvmTest und jsTest eingefügt. Wir können Kotest verwenden, um Tests zu schreiben, wie wir es bei Kotlin-Projekten für eine Plattform tun würden. Der Vorteil von Kotest ist, dass wir ein einheitliches Test-Framework haben und außerdem alle Hilfsfunktionen und benutzerdefinierten Assertions aus den übergeordneten Source Sets verwenden können. Nehmen wir an, wir möchten eine Funktion für die JVM-Plattform schreiben, die die Fakultät einer Zahl berechnet. Beachten Sie, dass der Wert bereits größer ist als das, was Integer verarbeiten kann, wenn die Zahl 4 ist. Glücklicherweise können wir, da diese Funktion spezifisch für die JVM ist, BigInteger für diesen Zweck verwenden.
/**
* Source set: jvmMain
*/
package nl.bjornvanderlaan.factorialoffactorial
import nl.bjornvanderlaan.factorial.factorial
import java.math.BigInteger
fun factorialOfFactorial(number: Int): BigInteger {
val bigNumber = factorial(number).toBigInteger()
var currentNumber = BigInteger.ONE
var accumulator = BigInteger.ONE
while(currentNumber <= bigNumber) {
accumulator *= currentNumber
currentNumber++
}
return accumulator
}
Die obige Funktion zeigt auch, wie das jvmMain Quellenset die faktorielle Funktion aus dem übergeordneten commonMain auf die gleiche Weise verwenden kann wie seine eigenen Funktionen. Die obige Funktion kann mit Kotest auf die gleiche Weise getestet werden, wie wir es in den anderen Beispielen gesehen haben:
/**
* Source set: jvmTest
*/
package nl.bjornvanderlaan.factorialoffactorial
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import java.math.BigInteger
class FactorialOfFactorialTest: StringSpec({
"Factorial of factorial of 4 is 620448401733239439360000" {
factorialOfFactorial(4) shouldBe BigInteger("620448401733239439360000")
}
})
Diese Funktion und ihre Tests sind im JVM-spezifischen Teil unserer Anwendung definiert und können daher von Klassen wie BigInteger profitieren.
Mittlere Stufen
Die obigen Beispiele gehen von nur zwei Hierarchieebenen aus: die gemeinsame übergeordnete Ebene und ihre plattformspezifischen Kinder. In der Praxis könnten wir mehr Ebenen einführen. Wenn wir z.B. auch eine mobile Anwendung für iOS und Android erstellen, könnten wir einen Zwischenquellsatz mobileMain haben, der vom Quellsatz commonMain abhängt und als übergeordneter Quellsatz für iosTest und androidMain fungiert. In diesem Fall kann der Quellsatz mobileTest Tests für Funktionen enthalten, die in mobileMain implementiert sind, oder erwartete Funktionen, die in untergeordneten Quellsätzen implementiert sind.
Fazit
Kotlin Multiplatform hilft uns, den Zeitaufwand für das Schreiben und die Pflege desselben Codes für verschiedene Plattformen zu reduzieren, und Kotest hilft uns, unsere Testbemühungen zu vereinheitlichen, indem es ein Multiplattform-Framework bereitstellt. Multiplattform-Projekte sind als eine Hierarchie von Quellensätzen strukturiert. Die Regeln für das Testen sind an jedem Knotenpunkt dieselben: Kotest führt unsere Tests entweder an der Implementierung im Hauptquellensatz selbst aus oder holt die tatsächlichen Implementierungen aus den untergeordneten Quellensätzen. So bietet uns Kotlin Multiplatform die Flexibilität, eine super DRY Codebasis und Testsuite zu erstellen, unabhängig von der Anzahl der Plattformen, die wir unterstützen möchten.
Verfasst von

Bjorn van der Laan
Software engineer at Xebia Software Development
Contact



