MockK

Lesezeit: 7 Minuten

Vor vier Jahren habe ich mich auf meinem Blog mit dem Java-Framework Mockito auseinandergesetzt. Mittlerweile arbeite ich bevorzugt mit Kotlin. Diese JVM-basierte Sprache hat aus den Entscheidungen von Java viele Schlüsse gezogen und macht einiges fundamental anders. Mockito existiert zwar auch für Kotlin, hat aber einige Einschränkungen. Für möglichst idiomatischen Mock-Code von Kotlin-Objekten und -Funktionen setze ich daher MockK ein.

In diesem Artikel gebe ich Einblicke in dieses Framework und zeige, wie schnell und effizient man mit MockK starten kann.

Der erste Mock

Die Funktion mockk erzeugt eine Mock-Instanz einer Klasse. Hier offenbart sich der erste Unterschied zu Mockito, denn man muss kein Class-Objekt der zu mockenden Klasse übergeben. Stattdessen ist mockk eine generische Funktion und der Typ mit dem Keyword reified versehen, so dass die Typinformation zur Laufzeit erhalten bleibt und MockK damit die richtige Instanz zurückgeben kann.

val foo = mockk<File>()
val bar = mockk<String>(relaxed = true)

Der vorangegangene Code erzeugt zwei Mock-Objekte foo und bar, von denen das zweite mit dem Parameter relaxed erzeugt wird und damit Default-Werte für alle seine Funktionen und Properties hinterlegt hat. Ist dies nicht der Fall, wie beim ersten Mock foo, muss jede aufgerufene Funktion explizit gemockt werden, da sonst eine Exception fliegt.

Analog zu Mockito existiert auch eine Annotation @MockK, über die ein Mock erzeugt werden kann, mir gefällt die Instanzierung über die Funktion aber mehr, da dieser Schritt im Debugger besser nachvollziehbar ist.

Die Bibliothek springmockk integriert MockK mit dem Spring-Framework und stellt dafür unter anderem die Annotation @MockkBean zur Verfügung.

Die Funktion spyk kann eine vorhandene Instanz entgegennehmen oder genau wie mockk auf Basis der Typinformation ein Spy-Objekt erzeugen.

Funktionen mocken

Während in Mockito bzw. BDDMockito die Methoden when und given existieren, wird in MockK die Funktion every verwendet. Diese nimmt einen Block entgegen und ermöglicht über die Funktionen returns, answers und throws das Verhalten beim Aufruf zu definieren.

class Foo {
    fun greet(name: String) = "Hello, $name"
}

fun test() {
    val mockReturns = mockk<Foo>()
    every { mockReturns.greet(any())
    } returns "Hello Mocked"

    val mockAnswers = mockk<Foo>()
    every { mockAnswers.greet(any())
    } answers { "Hello ${firstArg<String>()}" }

    val mockThrows = mockk<Foo>()
    every { mockThrows.greet(any())
    } throws IllegalArgumentException("mocked")
}

Der Code demonstriert die verschiedenen Möglichkeiten von Mocks. Die Funktion returns erzeugt einen fixen Rückgabewert bei jedem Aufruf der Funktion. Mittels answers und der Scope Function firstArg wird beim zweiten Mock die Antwort dynamisch aus dem Argument beim Aufruf erzeugt. Die Funktion throws sorgt dafür, dass jeder Aufruf zu einer Exception führt.

Hierarchische Mocks

Mocks können hierarchisch aufgebaut werden. Dies bietet sich an, wenn man die inneren Mocks nur einmal benötigt und sich daher weitere Zuweisungen sparen möchte, wie das folgende Beispiel demonstriert:

class Foo { fun foo() = Bar() }
class Bar { fun bar() = Baz() }
class Baz { fun baz() = "baz" }

fun test() {
    val mock = mockk<Foo>()
    every { mock.foo() } returns mockk {
        every { bar() } returns mockk {
            every { baz() } returns "bazMocked"
        }
    }
}

Interaktionen

Mit verify und wasNot kann sichergestellt werden, dass eine bestimmte Funktion aufgerufen oder mit einem Mock interagiert wurde.

class Foo {
    fun bar() = "bar"
}

fun examples() {
    val foo = mockk<Foo>()
    verify { foo wasNot Called }
    verify { foo.bar() }
    verify(exactly = 0) { foo.bar() }
    verify(exactly = 1) { foo.bar() }
    verify(atLeast =  2) { foo.bar() }
    verify(atMost = 3) { foo.bar() }
}

Der Code verifiziert in folgender Reihenfolge, dass:

  • keine Interaktion mit dem Mock stattgefunden hat
  • bar aufgerufen wurde
  • bar nicht aufgerufen wurde
  • bar genau einmal aufgerufen wurde
  • bar mindestens zweimal aufgerufen wurde
  • bar maximal dreimal aufgerufen wurde

MockK unterstützt aus Mockito bekannte Argument Matcher wie any, eq und isNull. Mit der Funktion withArg kann ein benutzerdefinierter Matcher als Block übergeben werden:

class Foo {
    fun bar(baz: Map<Int, String>) =
        baz.getOrDefault(0, "baz")
}

fun examples() {
    val foo = mockk<Foo>()
    verify {
        foo.bar(
            withArg { map -> assert(map[0] == "foo") }
        )
    }
}

Argumente erfassen

Slots in MockK sind das Pendant zu Argument Captors in Mockito. Der Umgang mit ihnen unterscheidet sich je nachdem, ob man genau einen Wert oder eine variable Anzahl erfassen möchte.

Um genau einen Wert je Argument zu erfassen, bietet sich die Funktion slot an, die der Semantik von mockk und spyk folgt:

class Foo(val v: String)
fun bar(foo: Foo) = "bar"

fun test() {
    val fooSlot = slot<Foo>()
    verify { bar(capture(fooSlot)) }
    bar(Foo("foo"))
    assert(fooSlot.isCaptured)
    assert(fooSlot.captured.v == "foo")
}

Die Alternative dazu, die eine beliebige Anzahl an Aufrufen erfassen kann, ist eine MutableList:

class Foo(val v: String)
fun bar(foo: Foo) = "bar"

fun test() {
    val fooSlot = mutableListOf<Foo>()
    verify { bar(capture(fooSlot)) }
    bar(Foo("foo1"))
    bar(Foo("foo2"))
    assert(fooSlot.size == 2)
    assert(fooSlot[0].v == "foo1")
    assert(fooSlot[1].v == "foo2")
}

Extension Functions, Objects und Top Level Functions

Extension Functions können mit MockK wie normale Funktionen gemockt werden:

fun String.foo() = "foo"

fun test() {
    val stringMock = mockk<String>()
    every { stringMock.foo() } returns "bar"
}

Singleton und Companion Objects können erst nach Aufruf von mockkObject gemockt werden. Im Anschluss sollte unmockkObject aufgerufen werden, da es sonst zu Fehlern kommen kann. Das folgende Beispiel verwendet die Variante von mockkObject, die einen Block entgegennimmt und nach Abarbeitung desselbigen automatisch unmockkObject aufruft:

object Foo {
    fun foo() = "foo"
}

class Bar {
    companion object {
        fun bar() = "bar"
    }
}

fun test() {
    mockkObject(Foo) {
        every { Foo.foo() } returns "fooMocked"
    }
    mockkObject(Bar) {
        every { Bar.bar() } returns "barMocked"
    }
}

Das Mocken von Top Level Functions folgt dem gleichen Schema. Die Funktionen heißen mockkStatic und unmockkStatic. Top Level Functions werden über zwei aufeinanderfolgende Doppelpunkte referenziert:

fun baz() = "baz"

fun main() {
    mockkStatic(::baz) {
        every { baz() } returns "bazMocked"
    }
}

Fazit

MockK erlaubt es, in Kotlin unter Zuhilfenahme von Blöcken und Reifizierung idiomatische Mocks zu schreiben und ihr Verhalten dynamisch zu gestalten. Dieser Artikel hat einen ersten Einblick gegeben, was mit MockK möglich ist. Das Framework bietet darüber hinaus aber auch dedizierte Unterstützung für Koroutinen und viele weitere spannende Features.