Testy jednostkowe w Androidzie cz. 4 – dummy, stub, mock, spy, fake

Wstęp

Klasy posiadające zależności od innych klas nie są tak łatwe w testowaniu, jak funkcje statyczne operujące na łańcuchach znaków. Aby umożliwić napisanie testów jednostkowych do tych klas, trzeba posłużyć się mechanizmem mockowania

Słowo „mock” jest angielskim czasownikiem, który oznacza „przedrzeźniać”. Klasy tego typu udają zachowanie klas produkcyjnych w konkretnych sytuacjach (np. wystąpienie specyficznego błędu). Dzięki zastosowaniu mocków możesz zasymulować w testach każdy możliwy scenariusz.

Mocki są bardzo potężnym narzędziem, bez którego pisanie testów jednostkowych dla klas ze skomplikowanymi zależnościami nie byłoby możliwe.

Dlaczego stosować mockowanie?

Załóżmy, że jest pewna aplikacja, która wyświetla na ekranie dane użytkownika. Chciałbym napisać test do klasy UserDetailsViewModel, która zależy od UserRepository, które zależy od UserService a UserService wykonuje zapytania HTTP do backendu.

Aby stworzyć test dla UserDetailsViewModel, musiałbym stworzyć instancję UserDetailsRepository i UserService oraz dostarczyć testowy serwer, który pokryłby wszystkie możliwe scenariusze. Brzmi to zbyt skomplikowanie.

W rozwiązaniu tego problemu pomoże najważniejsza właściwość programowania obiektowego – ABSTRAKCJA. Dzięki abstrakcji mogę stworzyć implementację UserRepository, która będzie symulowała konkretne przypadki zachodzące w realnym systemie (np. błędy pobierania danych). Takie implementacje są nazywane mockami.

Co daje mockowanie?

  • Uniezależnienie testów od implementacji zależności
  • Możliwość przetestowania wszystkich przypadków testowych niskim kosztem
  • Szybkość działania

Typy Mocków

Dummy

Klasy typu Dummy są zwykłą zaślepką, która kompletnie nic nie robi. Używa się ich wtedy, gdy musisz dodać do klasy zupełnie nieistotną zależność w kontekście testu.

Weźmy na przykład klasę Heater, która w konstruktorze ma wymagany HeaterListener:

class Heater(
        private var currentTemperature: Int,
        private val listener: HeaterListener) {

    fun increaseTemperature(temperature: Int) {
        currentTemperature += temperature
        listener.onTemperatureChanged(temperature)
    }

    fun getTemerature(): Int {
        return currentTemperature
    }

    interface HeaterListener {
        fun onTemperatureChanged(temperature: Int)
    }
}

class DummyHeaterListener : Heater.HeaterListener{
    override fun onTemperatureChanged(temperature: Int) {
        //no-op dummy listener
    }
}

HeaterListener nie jest potrzebny do przetestowania metody getTemperature, dlatego w teście tej metody można go zignorować, wstrzykując implementację DummyHeaterListener:

class DummyHeaterListener : Heater.HeaterListener{
    override fun onTemperatureChanged(temperature: Int) {
        //no-op dummy listener
    }
}
class HeaterTest{
    @Test
    fun testIncreaseTemperature() {
        //ASSERT
        val initialTemperature = 20
        val temperatureStep = 4
        val expectedTemperature = initialTemperature + temperatureStep
        val heater = Heater(initialTemperature, DummyHeaterListener())
        //ACT
        heater.increaseTemperature(temperatureStep)
        //ASSERT
        assertThat(heater.getTemerature(), equalTo(expectedTemperature))
    }
}

Jeśli w jakimś teście trzeba zastosować taką zaślepkę, to należy się zastanowić nad poprawnością testowanej klasy. W wielu przypadkach jest to objaw zbyt małej spójności klasy (Lack Of Cohesion) co oznacza, że można podzielić klasę na mniejsze. W przypadku klasy Heater można zmienić parametr listener na opcjonalny lub stworzyć oddzielną metodę setHeaterListener.

Stub

Stub też nie posiada żadnej logiki, ale jest nieco wyżej w hierarchii niż Dummy, ponieważ to co dostarcza Stub ma znaczenie dla testu. Używa się go wtedy, gdy w teście trzeba zasymulować konkretny przypadek.

Na przykład, klasa ProductRepository pobiera dane z ProductService i mapuje je do klasy Product. W przypadku nieposiadania mocków trzeba byłoby dostarczyć implementację ProductService, która łączyłaby się z serwerem, który musiałby generować dane wyjściowe – za skomplikowane (wtedy nawet nie byłyby to testy jednostkowe, lecz integracyjne)

class ProductRepository(private val service: ProductService) {
    fun getProduct(productId: Int): Product? {
        return mapResponseToProduct(service.getProduct(productId))
    }

    fun mapResponseToProduct(productResponse: ProductResponse): Product? {
        val price = Money(
                BigDecimal(productResponse.price),
                productResponse.currency)
        return Product(productResponse.id, productResponse.name, price)
    }
}

Dlatego pisząc testy do powyższej implementacji można stworzyć Stub, aby zasymulować wystąpienie błędu braku produktu:

class ProductNotFoundProductServiceStub : ProductService{
    override fun getProduct(productId: Int): ProductResponse {
        throw ProductNotFoundException()
    }
}
class ProductRepositoryTest{
    @Test
    fun should_return_null_when_no_product(){
        //ARRANGE
        val service = ProductNotFoundProductServiceStub()
        val repository = ProductRepository(service)
        //ACT
        val product = repository.getProduct(1)
        //THEN
        assertThat(product, nullValue())
    }
}

Co zrobić, jeśli trzeba przetestować więcej niż jeden błąd? Napisać kilkanaście Stubów? Można, ale jest bardziej kompaktowe rozwiązanie – mock.

Mock

Mock umożliwia skompresowanie wielu stubów w jedną konfigurowalną klasę:

class ProductServiceMock : ProductService {

    private var action: (() -> ProductResponse) = { throw NotImplementedError() }

    override fun getProduct(productId: Int): ProductResponse {
        return action.invoke()
    }

    fun getProductThrowsError(exception: Exception) {
        action = { throw exception }
    }

    fun getProductReturns(product: ProductResponse) {
        action = { product }
    }
}

Jeśli będziesz chciał, aby metoda getProduct zwróciła błąd musisz poinformować o tym Mocka za pomocą metody getProductThrowsError. Jeśli chcesz, aby getProduct zwrócił obiekt ProductResponse to użyj metody getProductReturns.

Mocki mogą być proste tak jak ten powyższy, ale mogą być o wiele bardziej skomplikowane na przykład wtedy, gdy chciałbyś uzależniać wynik działania funkcji od argumentów:

class ProductServiceAdvancedMock : ProductService {

    private val actions: MutableMap<Int, (() -> ProductResponse)> = mutableMapOf()

    fun getProductThrowsError(productId: Int, exception: Exception) {
        actions[productId] = { throw exception }
    }

    fun getProductReturns(productId: Int, product: ProductResponse) {
        actions[productId] = { product }
    }

    override fun getProduct(productId: Int): ProductResponse {
        return actions[productId]?.invoke() ?: throw NotImplementedError()
    }
}

To jakiego Mocka potrzebujesz, zależy od testowanej klasy. Jeśli nie wiesz, ile istnieje przypadków testowych, zacznij od stworzenia Stubów, ponieważ są one proste i bardzo czytelne.

Spy

Mocki są przydatne, gdy chcesz zasymulować dane wchodzące do testowanej klasy. W przypadku gdy chcesz zweryfikować dane wychodzące z testowanej klasy to musisz zastosować klasy typu Spy.

Spy jak sama nazwa mówi, szpieguje zachowanie. Na przykład klasa ResetPasswordClickHandler przechwytuje kliknięcie resetowania hasła z UI, po czym wywołuje ResetPasswordCommand, który odwołuje się już do odpowiednich serwisów resetujących hasło.

class ResetPasswordClickHandler(private val resetPasswordCommand: ResetPasswordCommand) {
    fun onResetPasswordClick(email: String?) {
        if (!email.isNullOrBlank()) {
            resetPasswordCommand.resetPassword(email!!)
        }
    }
}

interface ResetPasswordCommand {
    fun resetPassword(email: String)
}

ResetPasswordClickHandler ma za zadanie nie przepuścić pustych wartości email. Aby zweryfikować czy ResetPasswordClickHandler działa poprawnie trzeba zastosować klasę typu Spy, która zweryfikuje czy została wywołana metoda resetPassword, czy nie została wywołana.

class ResetPasswordCommandSpy : ResetPasswordCommand {

    private val resetPasswords = mutableListOf<String>()

    override fun resetPassword(email: String) {
        resetPasswords += email
    }

     fun verifyResetPassword(email: String) {
        when {
            resetPasswords.size == 0 -> 
                fail("resetPassword method should invoked with `$email` argument but never invoked")
            resetPasswords.size > 0 -> 
                fail("resetPassword method should invoked with `$email` argument but invoked ${resetPasswords.size} times")
            resetPasswords[0] != email -> 
                fail("resetPassword method should invoked with `$email` argument but invoked with `${resetPasswords[0]}`")
        }
    }

    fun verifyNeverResetPasswod(){
        if(resetPasswords.size != 0)
            fail("reset password should not invoked but was ${resetPasswords.size} times")
    }
}

Dzięki klasie ResetPasswordCommandSpy, która weryfikuje wywołanie metody resetPassword można sprawdzić, czy ResetPasswordClickHandler działa poprawnie.

class ResetPasswordClickHandlerTest {

    private val resetPasswordCommandSpy = ResetPasswordCommandSpy()
    private val resetPasswordClickHandler = ResetPasswordClickHandler(resetPasswordCommandSpy)

    @Test
    fun should_not_reset_password_when_email_is_empty() {
        //ACT
        resetPasswordClickHandler.onResetPasswordClick("")
        //ASSERT
        resetPasswordCommandSpy.verifyNeverResetPasswod()
    }

    @Test
    fun should_not_reset_password_when_email_is_blank() {
        //ACT
        resetPasswordClickHandler.onResetPasswordClick("    ")
        //ASSERT
        resetPasswordCommandSpy.verifyNeverResetPasswod()
    }

    @Test
    fun should_not_reset_password_when_email_is_null() {
        //ACT
        resetPasswordClickHandler.onResetPasswordClick(null)
        //ASSERT
        resetPasswordCommandSpy.verifyNeverResetPasswod()
    }

    @Test
    fun should_reset_password_when_email_is_not_null_or_blank() {
        //ARRANGE
        val givenEmail = "a"
        //ACT
        resetPasswordClickHandler.onResetPasswordClick(givenEmail)
        //ASSERT
        resetPasswordCommandSpy.verifyResetPassword(givenEmail)
    }
}

Fake

Klasy Fake symulują działanie produkcyjnych implementacji bądź komponentów. Tego typu klasy są pomocne przy bardziej złożonych lub skomplikowanych klasach i komponentach. Na przykład klasa SafeStorage, która daje dostęp do zapisywania i odczytu danych tylko po podaniu prawidłowego hasła może być problematyczna do zmockowania:

interface SafeStorage {
    fun authorize(password: String): Storage?
}

interface Storage {
    fun read(key: String): String?
    fun save(key: String, value: String)
}

class ImportantDataSaver(private val safeStorage: SafeStorage) {

    companion object {
        private const val IMPORTANT_KEY = "important_data"
    }

    fun save(password: String, importantData: String) {
        safeStorage.authorize(password)?.apply {
            save(IMPORTANT_KEY, importantData)
        }
    }
}

Dlatego można tutaj wykorzystać klasy typu Fake, które posiadają uproszczoną logikę pozwalającą zasymulować działanie takiego mechanizmu. Należy jednak pamiętać, że klasy tego typu należy także przetestować. Dlatego powinno się minimalizować użycie takich klas, ponieważ napisanie ich, przetestowanie i utrzymanie jest droższe niż w przypadku prostego mockowania.

class FakeSafeStorage(private val storagePassword: String) : SafeStorage {
    override fun authorize(password: String): Storage? {
        return if (storagePassword == password)
            FakeStorage()
        else
            null
    }
}

class FakeStorage : Storage {
    private var map = mutableMapOf<String, String>()

    override fun save(key: String, value: String) {
        map[key] = value
    }

    override fun read(key: String): String? {
        return map[key]
    }
}

Wykorzystanie FakeSafeStorage oraz FakeStorage pozwala na stworzenie kompaktowych i czytelnych testów:

class ImportantDataSaverTest {

    private val correctPassword = "correctPassword"
    private val storage = FakeStorage()
    private val safeStorage = FakeSafeStorage(correctPassword, storage)

    private val importantDataSaver = ImportantDataSaver(safeStorage)

    @Test
    fun `should save data when password correct`() {
        //ARRANGE
        val data = "some important data"
        //ACT
        importantDataSaver.save(correctPassword, data)
        //ASSERT
        assertThat(storage.read("important_data"), equalTo(data))
    }

    @Test
    fun `should not save data when password incorrect`() {
        //ARRANGE
        val data = "some important data"
        //ACT
        importantDataSaver.save("incorrect password", data)
        //ASSERT
        assertThat(storage.read("important_data"), nullValue())
    }
}

Podsumowanie

Bez powyższych technik nie istniałoby testowanie jednostkowe. Dzięki tym klasom możesz zasymulować wszystkie przypadki, jakie mogą pojawić się w systemie i sprawdzić, czy klasa, którą napisałeś działa poprawnie.

Decyzja, którą technikę powinieneś zastosować, zależy od sytuacji. Jeśli nie jesteś pewny jak złożony jest problem to zacznij od stworzenia Stubów. Gdy uznasz, że jest ich już za dużo, stwórz inteligentny Mock. Gdy mock będzie zbyt skomplikowany a testy nieczytelne, pomyśl nad zrefaktorowaniem klasy aby testy były mniej skomplikowane, ponieważ skomplikowane testy = skomplikowana implementacja. Gdy mimo uproszczenia mockowanie jest skomplikowane można dopiero zastanowić się nad klasą typu Fake.

Ź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 *