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
- Clean Code: Advanced TDD
- Praca z zastanym kodem. Najlepsze techniki – Michael Feathers