Testy jednostkowe w Androidzie cz.5 – MockK

Wstęp

O technice tworzenia i stosowania mocków oraz ich rodzajach pisałem we wpisie „Testy jednostkowe w Androidzie cz. 4 – dummy, stub, mock, spy, fake”. W tym artykule przybliżę bibliotekę MockK, która odciąża programistę od pisania własnych mocków oraz umożliwia stworzenie mocków klas finalnych, konstruktorów czy metod statycznych. Dzięki temu jest możliwe przetestowanie praktycznie całego kodu, nawet tego zależnego od zewnętrznych bibliotek.

MockK jest świetną biblioteką, jednakże niesie za sobą także duże niebezpieczeństwo. Nieprawidłowe użycie tej biblioteki może drastycznie obniżyć jakość kodu w porównaniu do tworzenia kodu opartego o klasyczne tworzenie mocków. Bardzo duże możliwości są jednak zbawienne przy pracy z długiem technologicznym, z kodem, który ma strukturę utrudniającą albo uniemożliwiającą napisanie poprawnego testu.

Dostępne biblioteki do mockowania

Oprócz MockK istnieje kilka innych popularnych bibliotek do mockowania:

  • MockK
  • Mockito
  • EasyMock
  • JMockit

MockK jako jedyny jest w pełni napisany w Kotlinie, co zapewnia wsparcie wszystkich dobrodziejstw językowych, w tym wsparcie dla Coroutines.

Co prawda biblioteka Mockito posiada dodatkową bibliotekę (https://github.com/nhaarman/mockito-kotlin), która dostarcza wiele usprawnień dla Kotlina, jednak trzeba mieć to na uwadze, że biblioteki tego rodzaju w pewnym momencie mogą zostać zaniedbane przez twórców, co spowoduje konieczność migracji do innego rozwiązania.

Ponieważ MockK ma większe możliwości od Mockito oraz jego składnia jest bardziej przyjazna dla kodu napisanego w kotlinie to w tym artykule i w innych przykładach będę korzystał z MockK.

Konfiguracja MockK

MockK nie wymaga żadnej konfiguracji poza dodaniem zależności:

Relase Version
testImplementation "io.mockk:mockk:{version}"

Możliwa jest także dodatkowa konfiguracja biblioteki za pomocą pliku settings.properties, więcej informacji znajdziesz tutaj.

Podstawowe możliwości MockK

MockK jest naprawdę potężną biblioteką, mimo że pierwsza jej wersja pojawiła się pod koniec 2017 roku, W poniższych przykładach opartych na prostych testach postaram się przedstawić, w jaki sposób tworzyć mocki i co można mockować. W następnym rozdziale przedstawię kilka realnych problemów, w których taka biblioteka jest zbawieniem. Jeśli chcesz zobaczyć wszystkie możliwości biblioteki, to znajdziesz te informacje na oficjalnej stronie MockK https://mockk.io/.

mock

Mockowanie zwracanych wartości klasy

    @Test
    fun `test mock returned values`() {
        val realData = mockk<RealData> {
            every { id } returns "mockData"
            every { name } returns "mockName"
            every { intNum } returns 600
            every { doubleNum } returns 600.0
        }

        //mock ustawia wartości ustawione za pomocą metody every
        assertThat(realData.id, equalTo("mockData"))
        assertThat(realData.name, equalTo("mockName"))
        assertThat(realData.intNum, equalTo(600))
        assertThat(realData.doubleNum, equalTo(600.0))
    }

Metoda mockk<RealData>() tworzy mock klasy RealData. Stworzony w ten sposób mock nie zwraca żadnej wartości, dlatego należy skonfigurować mocka za pomocą dostarczonego DSL’a. Do zamockowania (przesłonięcia metody) należy wykorzystać funkcję every{} podając w bloku wywołanie metody lub właściwości, a następnie za blokiem za pomocą metody infix fun returns podać wartość, jaka ma zostać zwrócona przez zmockowaną metodę lub właściwość. Jeśli za bardzo pokręciłem to tu jest forma graficzna:

Mock z wartościami domyślnymi

Jeśli nie zamockujesz wszystkich metod lub właściwości za pomocą every{} to podczas odwołania np. do właściwości zostanie rzucony wyjątek MockKException

    @Test(expected = MockKException::class)
    fun `test mockk`() {
        val realData = mockk<RealData>()
        realData.id
    }

Nie jest na szczęście koniecznie mockowanie wszystkich metod, można użyć parametru relaxed=true, aby biblioteka zamockowała wszystkie metody wartościami domyślnymi:

    @Test
    fun `test relaxed mockk`() {
        val realData = mockk<RealData>(relaxed = true)

        //mock ustawia puste ciągi znaków jako domyślne
        assertThat(realData.id, equalTo(""))
        assertThat(realData.name, equalTo(""))
        //mock ustawia dla liczb wartości równe 0
        assertThat(realData.intNum, equalTo(0))
        assertThat(realData.doubleNum, equalTo(0.0))
    }

Istnieje również parametr włączający automatyczne mockowanie tylko i wyłącznie metod, które nic nie zwracają (czyli zwracające Unit).

    @Test(expected = MockKException::class)
    fun `test relaxed unit fun mockk get value`() {
        val realData = mockk<RealData>(relaxUnitFun = true)
        realData.id
    }

    @Test
    fun `test relaxed unit fun mockk`() {
        val realData = mockk<RealData>(relaxUnitFun = true)
        realData.reverseName()
    }

Mockowanie zachowania metody

Oprócz mockowania zwracanych wartości można także zamockować zachowanie funkcji. Użycie metody infix fun answers pozwala przekazać w formie lambdy ciało funkcji, aby MockK podczas wywołania metody wywołał przekazaną lambdę:

    @Test
    fun `test mock answers`() {
        val realData = mockk<RealData>(relaxed = true) {
            every { reverseName() } answers { println("Text reversed!") }
        }

        realData.reverseName()
    }

Ten mechanizm można wykorzystać np. do mockowania asynchronicznych funkcji:

    interface RealDataReposiotry {
        fun getAsync(callback: (data: RealData) -> Unit)
    }

    @Test
    fun `test async func`() {
        val givenRealData = RealData()
        val repository = mockk<RealDataReposiotry> {
            every { getAsync(any()) } answers {
                val callback = it.invocation.args[0] as (data: RealData) -> Unit
                callback.invoke(givenRealData)
            }
        }

        repository.getAsync {
            assertThat(it, equalTo(givenRealData))
        }
    }

Mockowanie z dopasowaniem argumentów

Dla różnych parametrów odpowiedź mocka może być różna. Dla jednego parametru może zwracać wartość poprawną a dla innego wartość null bądź rzucać wyjątek za pomocą metody infix fun throws:

    @Test
    fun `test mock with argument matching`() {
        val expectedRealData = RealData("1")
        val anyExpectedRealData = RealData("any")
        val manager = mockk<Manager> {
            every { findRealData(any()) } returns anyExpectedRealData
            every { findRealData("1") } returns expectedRealData
            every { findRealData("2") } returns null
            every { findRealData("exception") } throws IllegalAccessError()
            every { findRealData("exception") } throws IllegalAccessError()
            every { findRealData(match { it.contains("my") }) } returns expectedRealData
        }

        assertThat(manager.findRealData("_____"), equalTo(anyExpectedRealData))
        assertThat(manager.findRealData("1"), equalTo(expectedRealData))
        assertThat(manager.findRealData("2"), nullValue())
        assertFail(IllegalAccessError::class) {
            manager.findRealData("exception")
        }
        assertThat(manager.findRealData("myId"), equalTo(expectedRealData))

    }

Metoda any() powoduje, że dany mock zostanie zastosowany w przypadku gdy parametr nie pasuje do pozostałych bardziej szczegółowych parametrów. Należy jednak pamiętać aby mock z wartością any() ustawiać zawsze na samym początku. Możliwe jest także mockowanie metod z wieloma parametrami i przeplatanie wartości oraz marcherów np. any().

    @Test
    fun `test mock with partial argument matching`() {
        val anyIdExpectedRealData = RealData("1", "name1")
        val anyNameExpectedRealData = RealData("1", "anyName")
        val anyExpectedRealData = RealData("any", "anyName")

        val manager = mockk<Manager> {
            every { findRealData(any(), any()) } returns anyExpectedRealData
            every { findRealData("1", any()) } returns anyNameExpectedRealData
            every { findRealData(any(), "name1") } returns anyIdExpectedRealData
            every { findRealData(any(), isNull()) } throws IllegalAccessError()
        }

        assertThat(manager.findRealData("_____", "____"), equalTo(anyExpectedRealData))
        assertThat(manager.findRealData("1", "_____"), equalTo(anyNameExpectedRealData))
        assertThat(manager.findRealData("____", "name1"), equalTo(anyIdExpectedRealData))
        assertFail(IllegalAccessError::class) {
            manager.findRealData("exception", null)
        }
    }

Mockowanie statycznych i globalnych funkcji

Statyczne metody i globalne funkcje są przekleństwem w kodzie z dużym długiem technologicznym, w którym nie ma testów. Skutecznie utrudniają one napisane testów jednostkowych. MockK umożliwia zamockowanie takich metod. Niestety nie da się zamockować wszystkich metod, na przykład metody System.currentTimeMillis(), która potrafi skutecznie zepsuć testy. Istnieje jednak proste rozwiązanie, stworzenie globalnej funkcji, która zwraca czas:

fun getTime() = System.currentTimeMillis()

Następnie należy podmienić w kodzie wszystkie wystąpienia System.currentTimeMillis() i zamockowanie ją w teście podając nazwę klasy. Należy pamiętać, że nazwa klasy globalnych metod ma nazwę pliku wraz z rozszerzeniem bez kropki UtilsGlobalFunKt:

    @Test
    fun `test mocking global method`() {
        mockkStatic("pl.androidcoder.mockk.reference.UtilsGlobalFunKt")
        every { getTime() } returns 1

        assertThat(getTime(), equalTo(1L))
    }

W przypadku kodu w Javie, można stworzyć klasę ze statyczną metodą zwracającą czas i podmienić wywołania System.currentTimeMillis() na Time.getCurrentTime().

class Time{
    fun getCurrentTime() = System.currentTimeMillis()
}

Następnie tak jak w przypadku globalnych metod, należy w teście użyć funkcji mockkStatic(Time::class), jednak w typ przypadku podać klasę zamiast nazwy pakietu.

    @Test
    fun `test mocking java static method`() {
        mockkStatic(Time::class)
        every { Time.getTime() } returns 1

        assertThat(Time.getTime(), equalTo(1L))
    }

Mockowanie obiektów i companion object

W Kotlinie nie ma czegoś takiego jak funkcja statyczna klasy, jest za to Companion Object. Jest on obiektem „dopiętym” do klasy. Aby zamockować go w podobny sposób do metod statycznych należy użyć metody mockkObject zamiast mockkStatic:

    @Test
    fun `test mocking companion object`() {
        mockkObject(UtilsKotlin.Companion)
        every { UtilsKotlin.getTime() } returns 1

        assertThat(UtilsKotlin.getTime(), equalTo(1L))
    }

W kotlinie mogą wystąpić także obiekty, które nie mają konkretnej klasy, je także można zamockować za pomocą metody mockkObject

    @Test
    fun `test mocking object`() {
        val someObject = object{
            fun getTime() = System.currentTimeMillis()
        }

        mockkObject(someObject)
        every { someObject.getTime() } returns 1L

        assertThat(someObject.getTime(), equalTo(1L))
    }

Mockowanie konstruktora

Jedna z moich ulubionych cech tej biblioteki, która umożliwia napisanie testów do klas, które nie używają wstrzykiwania zależności i zależności są tworzone wewnątrz klasy. Zamockowanie konstruktora pozwala na podmianę obiektu, który zwraca konstruktor na mocka:

    @Test
    fun `test mocking constructor`(){
        mockkConstructor(Time::class)
        every { anyConstructed<Time>().getCurrentTime() } returns 1

        val time = Time()
        assertThat(time.getCurrentTime(), equalTo(1L))
    }

Tutaj przykład w jaki sposób można wykorzystać tę funkcjonalność w praktyce: „Klasy nie korzystające ze wstrzykiwania zależności”

Mockowanie z matcherem

Zwracana wartość przez mock może być uwarunkowana nie tylko przez statyczne określenie parametrów, ale także bez bardziej generyczne, wykorzystując matchery takie jak: any, less, more etc.

    @Test
    fun `test mock with matcher`() {
        val storage = mockk<TextStorage> {
            every { getText(any()) } returns "any other"
            every { getText(isNull()) } returns "nullText"
            every { getText(less(5)) } returns "smaller than 5"
            every { getText(more(1000)) } returns "grater than 1000"
            every { getText(5) } returns "exactly 5"
            every { getText(range(10, 50)) } returns "between 10 and 50 inclusive"
            every {
                getText(range(60, 100, false, false))
            } returns "between 60 and 100 exclusive"

            every {
                getText(and(range(200, 300), match { it % 2 == 0 }))
            } returns "between 200 and 300 and it is even number"

            every { getText(or(110, 120)) } returns "it is 110 or 120"
        }

        assertThat(storage.getText(null), equalTo("nullText"))
        assertThat(storage.getText(4), equalTo("smaller than 5"))
        assertThat(storage.getText(1001), equalTo("grater than 1000"))
        assertThat(storage.getText(5), equalTo("exactly 5"))

        assertThat(storage.getText(10), equalTo("between 10 and 50 inclusive"))
        assertThat(storage.getText(20), equalTo("between 10 and 50 inclusive"))
        assertThat(storage.getText(50), equalTo("between 10 and 50 inclusive"))

        assertThat(storage.getText(60), not(equalTo("between 60 and 100 exclusive")))
        assertThat(storage.getText(80), equalTo("between 60 and 100 exclusive"))
        assertThat(storage.getText(100), not(equalTo("between 60 and 100 exclusive")))

        assertThat(storage.getText(202), equalTo("between 200 and 300 and it is even number"))
        assertThat(storage.getText(256), equalTo("between 200 and 300 and it is even number"))
        assertThat(storage.getText(207), not(equalTo("between 200 and 300 and it is even number")))

        assertThat(storage.getText(110), equalTo("it is 110 or 120"))
        assertThat(storage.getText(120), equalTo("it is 110 or 120"))
    }

spy

Spy jest metodą, która opakowuje istniejący obiekt w mocka. Tak stworzony mock zachowuje się jak zwyczajny obiekt, do momentu gdy, któraś z metod nie zostanie zamockowana za pomocą every. Metoda spy może być bardzo pomocna gdy mamy stworzone ręcznie mocki ale implementacja funkcjonalności verify pochłonęłaby za dużo czasu.

    @Test
    fun `test spy`() {
        val realData = RealData()
        val spyRealData = spyk(realData)

        spyRealData.name = "unknown"
        verify { spyRealData.name = "unknown" }
    }
    @Test
    fun `test mock on spy`() {
        val realData = RealData()
        val spyRealData = spyk(realData)
        every { spyRealData.name } returns "mocked"

        spyRealData.name = "unknown"
        verify { spyRealData.name = "unknown" }
        assertThat(spyRealData.name, equalTo("mocked") )
    }

verify

Metoda verify pozwala na zweryfikowanie interakcji z mockiem lub braku interakcji. Umożliwia także sprawdzenie ilości interakcji a także kolejności wywołania poszczególnych funkcji.

Weryfikowanie interakcji z mockiem (atLeast, atMost, exactly times)

Domyślnie metoda verify nie weryfikuje liczby interakcji (zezwala na Int.MAX_VALUE interakcji). Moim zdaniem jedna interakcja dla wywołania metody powinna być domyślna, ponieważ w większości przypadków w teście metoda jest wywołana tylko raz, a w nielicznych oczekujemy, że zostanie wywołana kilkukrotnie. Na szczęście istnieją 3 parametry określające ile razy ma się wywołać metoda atLeast, atMost oraz exactly:

    @Test
    fun `test verify`() {
        val textStorage = mockk<TextStorage> {
            every { getText(any()) } returns "mock text"
        }

        textStorage.getText(1)
        textStorage.getText(1)
        textStorage.getText(1)

        verify(atLeast = 2) { textStorage.getText(1) }
        verify(atMost = 4) { textStorage.getText(1) }
        verify(exactly = 3) { textStorage.getText(1) }
        verify { textStorage.getText(1) } //atMost = Int.MAX_VALUE,

        assertFail { verify(atLeast = 5) { textStorage.getText(1) } }
        assertFail { verify(exactly = 2) { textStorage.getText(1) } }
        assertFail { verify(atMost = 2) { textStorage.getText(1) } }
    }

Istnieje także konstrukcja, która sprawdza czy na mocku nie zostały wykonane żadne interakcje, jeśli w testach zaszła jakaś interakcja z takim mockiem to test zostanie zakończony błędem:

    @Test
    fun `test mock never call`() {
        val manager = mockk<Manager>()

        verify { manager wasNot called }
    }

    @Test(expected = java.lang.AssertionError::class)
    fun `test mock never call failed`() {
        val manager = mockk<Manager> {
            every { getText(any()) } returns "mock tekst"

        }

        manager.getText(1)

        verify { manager wasNot called }
    }

Weryfikowanie kolejności interakcji z mockiem

Weryfikowanie kolejności interakcji z mockiem jest czasem konieczne, gdy testujemy proces w którym kolejność wywołania metod jest istotna np. płatność. MockK dostarcza kilku konfiguracji pozwalających na weryfikację wywołań i ich kolejność.

Kolejność bez znaczenia

Domyślnie ustawiony sposób sprawdzenia jest taki, że kolejność nie ma znaczenia oraz na mocku może zostać wykonanych więcej interakcji niż zadeklarowano w bloku verify:

    @Test
    fun `test verify Ordering_UNORDERED`() {
        val manager = mockk<Manager>{
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verify {//ordering=Ordering.UNORDERED
            manager.findRealData("456")
            manager.findRealData("123")
        }
    }

Ścisła kontrola interakcji bez kolejności

ordering = Ordering.ALL lub verifyAll sprawdza czy wszystkie interakcje z bloku zostały wykonane, sprawdza również czy nie zostały wykonane inne. Jeśli były jakieś interakcje inne niż te w bloku verifyAll test zostanie zakończony błędem.

    @Test
    fun `test verify Ordering_ALL`() {
        val manager = mockk<Manager>{
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verify(ordering = Ordering.ALL) {
            manager.findRealData("456")
            manager.getText(1)
            manager.findRealData("123")
        }

        assertFail {
            verify(ordering = Ordering.ALL) {
                manager.findRealData("456")
                manager.findRealData("123")
            }
        }
    }

    @Test
    fun `test verify all`() {
        val manager = mockk<Manager>{
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verifyAll {
            manager.findRealData("456")
            manager.findRealData("123")
            manager.getText(1)
        }
    }

Kolejność wywołania

ordering = Ordering.ORDERED lub verifyOrder sprawdza czy kolejność interakcji była prawidłowa. Na mocku może zostać także wykonanych więcej interakcji niż znajduje się w bloku verifyOrder.

    @Test
    fun `test verify Ordering_ORDERED`() {
        val manager = mockk<Manager> {
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verify(ordering = Ordering.ORDERED) {
            manager.findRealData("123")
            manager.findRealData("456")
        }
    }

    @Test
    fun `test verifyOrder`() {
        val manager = mockk<Manager> {
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verifyOrder {
            //ordering=Ordering.ORDERED
            manager.findRealData("123")
            manager.findRealData("456")
        }
    }


    @Test
    fun `test verifyOrder inverse`() {
        val manager = mockk<Manager> {
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.findRealData("456")
        manager.getText(1)

        verifyOrder(inverse = true) {
            //ordering=Ordering.ORDERED
            manager.findRealData("456")
            manager.findRealData("123")
        }
    }

Ścisła kontrola interakcji z kolejnością

Najbardziej restrykcyjną kontrolą jest ordering = Ordering.SEQUENCE lub verifySequence. W tym przypadku kolejność jak i ilość interakcji z danym mockiem ma znaczenie.

    @Test
    fun `test verify Ordering_SEQUENCE`() {
        val manager = mockk<Manager> {
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verify(ordering = Ordering.SEQUENCE) {
            manager.findRealData("123")
            manager.getText(1)
            manager.findRealData("456")
        }

        assertFail {
            verify(ordering = Ordering.SEQUENCE) {
                manager.findRealData("123")
                manager.getText(1)
            }
        }
    }



    @Test
    fun `test verifySequence`() {
        val manager = mockk<Manager> {
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }

        manager.findRealData("123")
        manager.getText(1)
        manager.findRealData("456")

        verifySequence {
            manager.findRealData("123")
            manager.getText(1)
            manager.findRealData("456")
        }

        assertFail {
            verifySequence {
                manager.findRealData("123")
                manager.getText(1)
            }
        }
    }

Przechwytywanie argumentów

Może zajść potrzeba przeprowadzenia testu, w którym nie interesuje nas interakcja z mockiem lecz wartość parametru przekazana do tego mocka. W typ przypadku należy skorzystać z metody capture oraz obiektu CapturingSlot<T> stworzonego za pomocą metody slot:

    @Test
    fun `test capture to slot`() {
        val slot = slot<Int>()

        val manager = mockk<Manager> {
            every { getText(capture(slot)) } returns "mock tekst"
        }

        manager.getText(100)
        manager.getText(10)
        manager.getText(1)

        assertThat(slot.captured, equalTo(1))
    }

W przypadku konieczności przechwycenia większej niż jednej wartości należy użyć MutableList<T> oraz metody capture:

    @Test
    fun `test capture to list`() {
        val captured = mutableListOf<Int>()

        val manager = mockk<Manager> {
            every { getText(capture(captured)) } returns "mock tekst"
        }

        manager.getText(100)
        manager.getText(10)
        manager.getText(1)

        assertThat(captured, contains(100, 10, 1))
    }

Użycie matcherów

Tak jak w przypadku biblioteki Hamcrest , MockK także dostarcza możliwość weryfikacji parametrów za pomocą matcherów (wszystkie dostępne matchery w bibliotece znajdziesz Tutaj). Aby to zrobić, zamiast wartości w weryfikowanej metodzie należy przekazać odpowiedni matcher:

    @Test
    fun `test verify matcher`() {
        val manager = mockk<Manager>(relaxed = true) {
            every { getText(any()) } returns "mock tekst"
            every { findRealData(any()) } returns RealData()
            every { findRealData(any(), any()) } returns RealData()
        }


        val data = RealData()
        manager.getText(100)
        manager.getText(10)
        manager.getText(1)
        manager.addObject(data)

        verify { manager.getText(less(1000)) }
        verify { manager.getText(match { it == 10 }) }
        verify { manager.getText(more(0)) }
        verify { manager.getText(more(0)) }
        verify { manager.getText(or(1, 2)) }
        verify { manager.getText(range(1, 100)) }
        verify { manager.addObject(ofType(RealData::class)) }

        verify { manager.addObject(refEq(data)) }
        assertFail { verify { manager.addObject(refEq(RealData())) } }
    }

Co mockować?

Jest trochę kuszące wykorzystywać MockK wszędzie, ale może to się odbić czkawką. Odradzam stosowanie jej jeśli klasa mockowana jest skomplikowana i można bez większych przeszkód stworzyć taki mock manualnie na podstawie abstrakcji. Jeśli konfiguracje mocków zajmują kilkukrotnie więcej miejsca niż sam test, to czytelność testu drastycznie spada. Własnoręcznie dobrze nazwane mocki lub stuby zwiększają czytelność testów w porównaniu do generycznych metod, jakie dostarcza MockK.

Są jednak sytuacje, w których MockK świetnie się sprawdza np. zamockowanie tylko jednej funkcji w interfejsie, który ma ich kilkadziesiąt. Jest on nieoceniony przy pisaniu testów dla starszego kodu, który nie był dobrze przemyślany, ma dużo zewnętrznych zależności i nie korzysta ze wstrzykiwania zależności. Poniżej przedstawiam kilka przypadków, w których MockK ułatwi Ci życie:

Funkcje z zewnętrznych bibliotek

Chyba największą zmorą testowania jest uzależnienie się od bibliotek zewnętrznych lub SDK. W przypadku Androida na szczęście jest rozwiązanie: Robolectric, jednakże nie zawsze jest konieczne zaprzęganie tak wielkiego frameworka, jeśli chce się zmockować tylko jeden obiekt. Jeśli nie testujesz swojego kodu, to zapewnie masz dużo odwołań do obiektów typu Context lub Activity.

class ActivityConfigurator(
    private val activity: Activity
) {
    fun configure() {
        activity.title = "Hello world"
        activity.actionBar?.setDisplayShowHomeEnabled(true)
        activity.actionBar?.setHomeButtonEnabled(true)
    }
}

Powyższy kod wydaje się nie do przetestowania bez testów instrumentalnych lub Robolectric. Jednakże można napisać do niego test oparty na mockach:

class ActivityConfiguratorTest {
    private val activity = mockk<Activity>(relaxed = true)

    @Test
    fun testActivityConfiguration() {
        //GIVEN
        val configurator = ActivityConfigurator(activity)
        //WHEN
        configurator.configure()
        //THEN
        verify { activity.title = "Hello world" }
        verify { activity.actionBar?.setDisplayShowHomeEnabled(true) }
        verify { activity.actionBar?.setHomeButtonEnabled(true) }
    }
}

Metoda mockk tworzy mock klasy Activity, parametr relaxed=true pozwala na dostarczenie mocka bez konkretnej implementacji tzn. przy wywołaniu activity.title = "Hello world" MockK nie zwróci wyjątku.

Mocki stworzone przez MockK rejestrują każdą interakcję z obiektem, dzięki czemu można zweryfikować jakie operacje zostały wykonane na Activity:

        verify { activity.title = "Hello world" }
        verify { activity.actionBar?.setDisplayShowHomeEnabled(true) }
        verify { activity.actionBar?.setHomeButtonEnabled(true) }

MockK ma dużą przewagę nad innymi tego typu bibliotekami, ponieważ wykonuje głębokie mockowanie tzn. wszystkie obiekty zwracane przez mock także są mockiem. W poniższym przykładzie obiekt zwracany przez activity.actionBar także jest mockiem.

        verify { activity.actionBar?.setHomeButtonEnabled(true) }

Klasy nie korzystające ze wstrzykiwania zależności

W aplikacjach stworzonych przez mniej doświadczonych programistów można znaleźć dużo klas, w których konstruowane są obiekty. Nie zawsze jest to błąd, czasami mogą być to klasy wewnętrzne, których w danej chwili nie ma sensu wstrzykiwać, jednakże w obydwu przypadkach można natrafić na problem.

Mając poniższy kod i napisane testy do klasy CodeMapper, jak napisać test do CodeViewModel bez powtarzania testów z CodeMapper i bez tworzenia interfejsu dla CodeMapper?

class CodeMapper(val context: Context) {
    fun map(code: Int): String {
        return when (code) {
            1 -> context.getString(R.string.item_not_found)
            2 -> context.getString(R.string.access_denied)
            3 -> context.getString(R.string.too_many_request)
            4 -> context.getString(R.string.account_deleted)
            else -> context.getString(R.string.unknown_error)
        }
    }

}

class CodeViewModel(context: Context) {

    private val errorMapper = CodeMapper(context)

    var errorText = ""

    fun setCode(code: Int) {
        errorText = errorMapper.map(code)
    }
}

Teoretycznie można stworzyć mock dla Context a potem zamockować funkcje context.getString. Test taki byłby zależny od implementacji, ponieważ zmiana funkcji context.getString na context.resources.getString nie zmieniłoby zachowania aplikacji ale testy zakończyłyby się błędem.

Najlepszym rozwiązaniem byłoby zmienić klasę CodeViewModel na taką, która przyjmuje CodeMapper jako argument i dostarczyć mock CodeMapper.

class CodeViewModel(errorMapper: CodeMapper) {

    var errorText = ""

    fun setCode(code: Int) {
        errorText = errorMapper.map(code)
    }
}

jednakże nie zawsze jest to możliwe np. gdy z kodu korzysta kilka projektów i nie możemy w tej chwili zrobić tak dużej zmiany a chcemy jednak napisać ten test.

MockK dostarcza funkcję mockkConstructor, która pozwala stworzyć mock konstruktora klasy. Dzięki czemu można dostarczyć do CodeViewModel mock klasy CodeMapper bez jej edytowania:

class CodeViewModelTest {
    @Before
    fun setUp() {
        mockkConstructor(CodeMapper::class)
        every { anyConstructed<CodeMapper>().map(1) } returns "one"
        every { anyConstructed<CodeMapper>().map(2) } returns "two"
    }

    @Test
    fun name() {
        val codeViewModel = CodeViewModel(mockk())
        codeViewModel.setCode(1)
        assertThat(codeViewModel.errorText, equalTo("one"))
        codeViewModel.setCode(2)
        assertThat(codeViewModel.errorText, equalTo("two"))
    }
}

Jeśli masz możliwość zrefaktorowania klasy to zrób to zamiast używać metody mockkConstructor.

Złe użycie Singletona

Dla niektórych Singleton jest antywzorcem, a niektórzy go nadużywają. Prawda zazwyczaj jest po środku. Jakie jest zatem prawidłowe użycie singletona? Singleton prawidłowo powinien być używany, tylko do dostarczenia instancji danego obiektu, zapewniając przy tym, że nie ma duplikatu w systemie. Klasa nie powinna mieć informacji o tym, że obiekt dostarczony jest przez Singleton. Dlaczego? Bo w trakcie życia systemu może się to zmienić.

Weźmy na przykład najprostszą implementację Singletona w Javie:

public class SomeSingleton {

    private static SomeSingleton singleton;

    public static SomeSingleton getSingleton() {
        if (singleton == null)
            singleton = new SomeSingleton();
        return singleton;
    }

    private String data = null;

    private SomeSingleton() {
    }

    public String getData() {
        return data;
    }

    public void setData(String data) {
        this.data = data.trim().toLowerCase();
    }
}

oraz jego nieprawidłowe użycie:

class OnDataHandler {
    fun onNewData(data: String) {
        SomeSingleton.getSingleton().data = data
    }
}

Tutaj jest prawidłowe, jakbyś był zainteresowany:

class OnDataHandler(private val someSingleton : SomeSingleton) {
    fun onNewData(data: String) {
        someSingleton.data = data
    }
}

Nieprawidłowe użycie skutecznie uniemożliwia przetestowania klasy bez użycia specjalnych narzędzi jakie dostarcza MockK – funkcji, mockStatic:

class OnDataHandlerTest {
    @Test
    fun `handler should save data in singleton`() {
        mockkStatic(SomeSingleton::class) {
            val mockedSingleton = mockk<SomeSingleton>(relaxed = true)
            every { SomeSingleton.getSingleton() } returns mockedSingleton

            OnDataHandler().onNewData("Some data")

            verify { mockedSingleton.data = "Some data" }
        }
    }
}

Jak widzisz, test ten nie znajduje się w grupie dobrze czytelnych testów. Trzeba się zagłębić w działanie biblioteki, która pozwala na takie hacki. Co ciekawe użycie konstrukcji every { SomeSingleton.getSingleton() } returns mockk(relaxed = true) powoduje załamanie się testu, zatem biblioteki do mockowania też mają błędy i swoje problemy.

Wszelkiego tego typu konstrukcje mogą się zawalić przy nowych wersjach języka bądź biblioteki, ponieważ bazują one w dużej mierze na refleksji. Nie polegaj na nich jako rozwiązaniu pierwszego wyboru ale jako rozwiązanie ostatniego wyboru.

Brak abstrakcji do stworzenia własnego mocka

Tworzenie specjalnie abstrakcji, aby móc napisać własny mock do jednej metody może spowodować wzrost ilości klas i skomplikowanie projektu. W wielu przypadkach wykorzystanie biblioteki takiej jak MockK jest wystarczające.

class UserViewModelUpdater(
    private val userProvider: UserProvider,
    private val viewModel: UserViewModel
) {

    fun updateUser() {
        val user = userProvider.getUser()
        viewModel.setUser(user)
    }

}

Jeśli UserProvider oraz UserViewModel są klasami konkretnymi, to aby przetestować powyższą klasę należy dostarczyć jej mock dla UserProvider oraz spy dla UserViewModel.

class UserViewModelUpdaterTest {

    private val viewModel = spyk(UserViewModel())
    private val userProvider = mockk<UserProvider>()

    private val updater = UserViewModelUpdater(userProvider, viewModel)

    @Test
    fun name() {
        //GIVEN
        val user = User("John", "Doe")
        every { userProvider.getUser() } returns user
        //WHEN
        updater.updateUser()
        //THEN
        verify { viewModel.setUser(user) }
    }
}

Do tak prostych klas nie opłacałoby się tworzyć abstrakcji po to tylko, aby przetestować jedną małą klasę. Oczywiście, gdy zajdzie potrzeba być może będzie trzeba stworzyć abstrakcję, mimo to tak napisany test będzie nadal aktualny.

Źródła

Jeśli uważasz treść za wartościową to podziel się nią z innymi. Dziękuję.

Mateusz Chrustny

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *