Mockowanie i test doubles

mockowanie

Ten wpis jest częścią serii o testach. Całość znajdziesz pod tym adresem.

W poprzednich wpisach omówiliśmy kilka kwestii związanych z testowaniem aplikacji. Część z nich poruszała teoretyczne zagadnienia. Udało się także zająć praktyką i napisać kilka podstawowych testów. Dzisiaj zajmiemy się kolejną ważną kwestią – mocki.

Wprowadzenie

Przykład kalkulatora z poprzedniego artykułu jest dobry pod względem dydaktycznym. Łatwo pokazać na nim proste mechanizmy i podstawowe zasady, które powinniśmy stosować podczas testowania aplikacji. Niestety tutaj kończą się jego zalety. W świecie rzeczywistych systemów informatycznych nie ma zbyt wielu sytuacji, w których trzeba będzie napisać testy dla tak prostej klasy nie posiadającej żadnych zależności. Walidacja danych, logowanie informacji, serializacja, to tylko przykłady zadań, które realizowane są w wielu miejscach w systemie. Żeby nie powielać kodu za nie odpowiedzialnego w każdej klasie, wydziela się je do osobnych plików. Tworzy się osobne komponenty, odpowiedzialne za poszczególne zadania. Natomiast klasy potrzebujące konkretnej funkcjonalności posiadają obiekty klas komponentów, których wymaga algorytm. Spójrz na przykład:


    public class PaymentHandler : IPaymentHandler
    {
        private readonly IPaymentValidator _paymentValidator;
        private readonly IPaymentService _paymentService;

        public PaymentHandler(IPaymentValidator paymentValidator, IPaymentService paymentService)
        {
            _paymentValidator = paymentValidator;
            _paymentService = paymentService;
        }

        public PaymentResult HandlePayment(CreditCard creditCard)
        {
            _paymentValidator.Validate(creditCard);

            var result = _paymentService.ProcessPayment(creditCard);

            return result;
        }
    }

Dzięki wykorzystaniu IPaymentValidatora, sprawdzanie stanu aplikacji jest do niego oddelegowane, a klasa PaymentHandler ma tylko jedną odpowiedzialność – przeprowadzenie całego procesu płatności. Koncept prosty i genialny zarazem. Do czasu gdy nie trzeba napisać testów jednostkowych…

Zakładają one, że powinno się testować tylko jedną klasę i metodę. Gdy nie zainicjujesz zależności tej klasy (w naszym przypadku jest to jedynie IpaymentValidator) program podczas próby wykonania testu wyrzuci wyjątek. Konkretnie NullReferenceException. Natomiast, gdy stworzysz instancję serwisów – nie będzie to już test jednostkowy, bo swoim zasięgiem obejmie więcej niż jedną metodę. Oba te rozwiązania są do kitu, czy jest jakieś inne? Tak się składa, że jest i są nim właśnie mocki – główny temat dzisiejszego artykułu.

Czym są mocki?

Zastanówmy się, czym właściwie są mocki? Pozwalają na symulowanie zachowania komponentów testowanej klasy. Inaczej mówiąc „podszywają się” pod klasy, które podczas normalnego przebiegu programu przekazane byłyby przez konstruktor oraz umożliwiają manipulowanie wartościami jakie zwracane są w miejscach użycia. Dzięki nim testowana klasa jest ODIZOLOWANA od jej zależności. Pozwala to na przetestowanie tylko tego kodu, który powinniśmy.

Stosowanie mocków wymaga odpowiedniego projektowania klas – w sposób pozwalający na inicjalizację fałszywych zależności. Realizuje się to przez używanie interfejsów, zamiast ich konkretnych implementacji (oczywiście to nie jedyna zaleta stosowania takiego podejścia). Frameworki do mockowania zwykle potrzebują właśnie ich do inicjalizacji zależności i nie potrafią tworzyć instancji konkretnych klas, a nawet gdyby mogły, byłoby to bezcelowe.

Podział

Pisząc o mockach użyłem dużego uogólnienia. Miałem na myśli grupę obiektów, które służą do imitowania klas, a ich poprawną, chociaż w mojej opinii, rzadziej spotykaną nazwą jest test doubles. Jest to termin zbiorczy na różne rodzaje takich obiektów, wyróżnia się tutaj:

Dummy

Atrapa, której jedynym zadaniem jest przekazanie do konstruktora w celu utworzenia instancji testowanej klasy. Poza inicjalizacją nie robi nic. Nie zawiera w sobie żadnej logiki.

Fake

Ten rodzaj double z kolei zawiera logikę, jednak działającą inaczej niż właściwa implementacja. Fake jest rodzajem obejścia, skrócenia rzeczywistego kodu. Przykładem może być zastąpienie operacji na bazie zapisem i odczytem z kolekcji trzymanej w pamięci.

Stub

Służy do weryfikowania stanu obiektów po przebiegu testowanej operacji. Realizuje to przez zwracanie zawsze tych samych, predefiniowanych wartości. System otrzymując te same dane powinien zachowywać się w przewidywalny sposób. Przykład: klasa odpowiedzialna jest za przygotowanie przycisku kontrolki w aplikacji. Wiemy, że gdy użytkownik ma uprawnienia administratora, to ta powinna być aktywna – flaga IsEnabled powinna zwracać wartość true. Sprawdzany użytkownik pobierany jest w testowanej metodzie. Do takiego testu można użyć stuba, który zawsze będzie zwracał użytkownika będącego administratorem. Dzięki temu można oczekiwać, że rezultatem przebiegu funkcji zawsze będzie aktywny przycisk.

Mock

Za jego pomocą można weryfikować zachowania testowanego systemu. Wyobraź sobie, że klasa, którą chcesz sprawdzić pobiera produkty z bazy danych i dla każdego z nich generuje osobny plik xml. W takim algorytmie możemy wyróżnić dwa zachowania:

  • pobieranie produktów – 1 raz
  • generowanie pliku – n razy, gdzie n to liczba produktów

Mocki sprawdzają właśnie takie zachowania. Ten dla klasy pobierającej dane zostanie wywołany 1 raz. Przygotowując testy możemy założyć, że zwróci on 5 produktów. Na tej podstawie wiemy, że mock do generowania plików powinien być wywołany 5 razy.

Czy nazewnictwo jest istotne?

Od lat w świecie testów jednostkowych toczą się dyskusje na temat tego, czy nazywając klasy i obiekty reprezentujące konkretne doubles, powinno się rozróżniać ich specyficzne nazwy (Dummy, Fake, etc.). Ilu programistów tyle opinii, ja natomiast staram się szukać balansu. Uważam, że wypada wiedzieć o różnych możliwościach tworzenia obiektów imitujących właściwe klasy. Warto też rozumieć różnice między nimi. Nie ma jednak wątpliwości, że większość takich obiektów tworzonych jest za pomocą frameworków służących do tego celu, np. Moq, czy FakeItEasy. Co więcej niejednokrotnie zdarza się też, że jeden obiekt double spełnia wymagania dla kilku rodzajów jednocześnie. Np. mocka i stuba(testuje się zachowanie oraz sprawdza stan po zwróceniu konkretnych danych). Może się też zdarzyć, że taki obiekt, pomimo tego, że daje możliwość sprawdzenia  zachowań będzie wykorzystany jako dummy (spełni swoją rolę tylko podczas inicjalizacji testowanej klasy). Niemniej jednak zdarzają się sytuacje, w których oprócz korzystania z zewnętrznych frameworków tworzy się własne rozwiązania symulujące pewne elementy systemu.

Co wobec tego zrobić z nazewnictwem?

Moim skromnym zdaniem, powinno się stosować poprawne nazewnictwo dla własnoręcznie stworzonych doubles, natomiast dla obiektów wykorzystujących rozwiązania frameworka warto stosować narzucone nazewnictwo. Przykład: Moq udostępnia generyczną klasę Mock<>;, która pozwala na stworzenie imitacji konkretnego interfejsu. W przypadku IPaymentValidatora instancję tworzoną w taki sposób: new Mock(); nazwałbym _paymentValidatorMock niezależnie, czy z kodu wynika, że spełnia założenia bycia Fake, Dummy, Stubem czy Mockiem. Robię tak z dwóch powodów:

  1. framework narzuca taką nazwę i nazywane obiektu nazwą double jakie reprezentuje, w przypadku gdy w nazwie typu posiada słowo Mock, wydaje mi się niekonsekwentne i nieeleganckie.
    Mock<IPaymentValidator> _paymentValidatorDummy;
    

    nie wygląda zbyt sensownie, prawda?

  2. Istnieje ryzyko, że w trakcie rozwoju testów, coś co spełniało wymagania Fake, po kolejnych modyfikacjach, stanie się nagle stubem. Myślę, że w wielu przypadkach taka zmiana nie zostanie zauważona i obiekt nadal byłby nazywany dummy, co wprowadziłoby kolejne niespójności i utrudnienia w analizie.

Żaden z tych problemów nie występuje w doubles, które tworzymy własnoręcznie. Po stronie programisty leży decyzja jak nazwać konkretną klasę. Co więcej implementuje ona konkretne funkcjonalności, które właśnie przez nazwę powinny być reprezentowane. Dodatkowo zmiany tutaj nie są tak proste do wprowadzenia, jak w przypadku obiektów klas z frameworka, dostarczającego wszystkie funkcje. Istnieje więc dużo mniejsze ryzyko pominięcia zmian w nazewnictwie klasy. Oczywiście nadal trzeba pamiętać o zmianie nazwy wszystkich instancji, ale tym również można się zająć podczas zmiany nazwy klasy.

Przykłady

Po raz kolejny wyszło całkiem sporo teorii. Przyszedł jednak czas, żeby zabrać się za praktykę. Przygotowałem kilka testów, które dołączyłem do repozytorium na Bitbuckecie z poprzedniego artykułu.

Testowany system

Do przykładów stworzyłem implementację płatności, do której odwoływałem się wcześniej. Składa się ona z trzech interfejsów:

1. IPaymentValidator



public interface IPaymentValidator
{
    void Validate(CreditCard creditCard);
}

2. IPaymentService


public interface IPaymentService
{
    PaymentResult ProcessPayment(CreditCard creditCard);
}

3. IPaymentHandler


public interface IPaymentHandler
{
    PaymentResult HandlePayment(CreditCard creditCard);
}

oraz ich podstawowych implementacji. W przypadku klas PaymentValidator i PaymentService zostawiłem domyślny kod, który podczas przebiegu wyrzuci NotImplementedException, ponieważ klasy nie będą nigdzie używane, a do testów użyjemy całkowicie innych obiektów. Będziemy weryfikować poprawność implementacji klasy PaymentHandler, którą widziałeś na początku artykułu.

Na potrzeby całej funkcjonalności stworzyłem też dwie klasy przekazujące dane:
1. CreditCard – zawierające dane karty kredytowej, potrzebnej do wykonania płatności

    public class CreditCard
    {
        public string CardNumber { get; set; }
        public string Owner { get; set; }
    }

2. PaymentResult – przedstawiająca rezultat przeprowadzenia płatności, w naszym wypadku posiada tylko flagę informującą o powodzeniu/niepowodzeniu

    public class PaymentResult
    {
        public bool Success { get; set; }
    }

Część właściwa – testy

Przejdźmy więc do testów. Zacznijmy od implementacji wykorzystującej framework do tworzenia Mocków – Moq. Stosując jego możliwości zadeklarujmy zmienne obiektów, imitujących zależności klasy PaymentHandler:

        private IPaymentHandler _paymentHandler;
        private Mock<IPaymentService> _paymentServiceMock;
        private Mock<IPaymentValidator> _paymentValidatorMock;

Inicjalizacja

W metodzie oznaczonej atrybutem SetUp, podobnie jak poprzednim razem inicjalizujemy je:

        [SetUp]
        public void SetUp()
        {
            _paymentServiceMock = new Mock<IPaymentService>();
            _paymentValidatorMock = new Mock<IPaymentValidator>();

            _paymentHandler = new PaymentHandler(_paymentValidatorMock.Object, _paymentServiceMock.Object);
        }

Na początku tworzymy instancję mocków (linie 3 i 4). Ostatnim krokiem implementacji metody SetUp jest stworzenie instancji klasy PaymentHandler. Tutaj do konstruktora przekazywane są parametry o typach: IPaymentValidator i IPaymentService. Możemy je uzyskać wykorzystując stworzone wcześniej Mocki, a konkretnie ich właściwość Object.

Przejdźmy do samych testów:

        [Test]
        public void HandlePayment_WhenCorrectCreditCardProvided_ThenPaymentIsSucceeded()
        {
            //arrange
            var creditCard = new CreditCard
            {
                CardNumber = "10 0000 0000 0000 0000 0000 0000",
                Owner = "Jan Kowalski"
            };

            _paymentServiceMock
                .Setup(x => x.ProcessPayment(creditCard))
                .Returns(new PaymentResult { Success = true });

            //act
            var result = _paymentHandler.HandlePayment(creditCard);

            //assert
            Assert.IsNotNull(result);
            Assert.True(result.Success);
            _paymentValidatorMock.Verify(x => x.Validate(creditCard), Times.Once);
            _paymentServiceMock.Verify(x => x.ProcessPayment(creditCard), Times.Once);
        }

Arrange

W sekcji arrange tworzę model, który przekazywany będzie do testowanej metody. Dodatkowo we właściwej implementacji obiekt klasy implementującej interfejs IPaymentService wywołuje metodę ProcessPayment, która zwraca obiekt PaymentResult – reprezentujący wynik przeprowadzonej płatności. W przypadku niezdefiniowania żadnego zachowania dla tej metody Mock zawsze zwracałby null, co umożliwiłoby przetestowanie tylko jednego przypadku. Nas interesuje więcej możliwości, dlatego wykorzystując kod z linii 11-13 opisujemy zachowanie Mocka.

Metoda Setup wskazuje na funkcję IPaymentService, której zachowanie chcemy opisać, jako parametr wejściowy podajemy. Metoda Returns definiuje jak ma zachowywać się funkcja ProcessPayment – zwrócić odpowiedni model klasy.

Całość będzie wykonana dopiero podczas wywołania funkcji ProcessPayment w trakcie przebiegu testów.

Act

Część act wygląda bardzo podobnie. Rezultat metody zapisywany jest w zmiennej. Jeśli spojrzysz jednak na implementację klasy PaymentHandler, to zauważysz, że obiekt klasy PaymentResult, który jest zwracany, to także obiekt, który zwraca metoda ProcessPayment z interfejsu IPaymentService, dla którego używamy mocka w tym teście. Jego zachowanie opisaliśmy w części arrange.

Uporządkujmy to, dla lepszego zrozumienia:
interfejs IPaymentHandler implementuje klasa PaymentHandler

  • metoda HandlePayment zwraca obiekt klasy PaymentResult
  • metoda HandlePayment tak naprawdę przekazuje rezultat metody wywołanej w środku niej – ProcessPayment z interfejsu IPaymentService
  • w oryginalnej implementacji ten interfejs implementowany jest przez klasę PaymentService
  • w testach tego nie chcemy, dlatego zamiast tego tworzymy mocka
  • zachowanie mocka, w przypadku wywołania metody ProcessPayment definiujemy w metodzie HandlePayment_WhenCorrectCreditCardProvided_ThenPaymentIsSucceeded

Uffff, może to być skomplikowane na początku, jednak po chwili zastanowienia zrozumiesz, że ma to sens 🙂

Assert

Pozostała już tylko część assert naszego testu. Pomijając asercje omówione we wcześniejszym artykule, pojawiła się jedna nowa rzecz. Mianowicie, po raz kolejny korzystam z możliwości biblioteki Moq, za pomocą której sprawdzam ilość wywołań konkretnych metod wewnątrz testowanej metody. W przypadku HandlePayment, wiemy, że w przypadku naszego testu, gdy wszystko poszło sprawnie, kod powinien uruchomić metodę Validate z interfejsu IPaymentValidator, a następnie ProcessPayment z IPaymentService. Aby to sprawdzić używam metody Verify, która, w tym przypadku, przyjmuje 2 parametry- metodę, której wywołania mają być sprawdzane oraz ilość wywołań. W przypadku gdyby ilość faktycznych wywołań się nie zgadzała, kod wyrzuci wyjątek, co spowoduje, że test nie przejdzie.

Kolejne przykłady

Podobne testy można napisać dla przypadków, w których podaje się niepoprawne dane karty kredytowej. Podobnie jak ostatnio, żeby nie przedłużać takie testy znajdziecie w tym samym repozytorium na Bitbuckecie.

Innym przykładem wykorzystania Mocka jest symulowanie wyrzucania wyjątków. Validator, który napisałem nie ma żadnej implementacji. Załóżmy jednak, że w przypadku, gdyby wykrył jakiś błąd związany z kartą kredytową, wyrzuciłby wyjątek. Taką sytuację zasymulowałem w kolejnym teście:

        [Test]
        public void HandlePayment_WhenCreditCardDoesNotPassValidation_ThenExceptionIsThrown()
        {
            //arrange
            var creditCard = new CreditCard
            {
                CardNumber = "10 0000 0000 0000 0000 0000 0000",
                Owner = null
            };

            _paymentValidatorMock
                .Setup(x => x.Validate(It.IsAny<CreditCard>()))
                .Throws<Exception>();

            //act
            TestDelegate action = () => _paymentHandler.HandlePayment(creditCard);

            //assert
            Assert.Throws<Exception>(action);
            _paymentValidatorMock.Verify(x => x.Validate(creditCard), Times.Once);
            _paymentServiceMock.Verify(x => x.ProcessPayment(creditCard), Times.Never);
        }

Różnicę widać w przypadku części arrange, gdzie definiuję dodatkowe zachowanie dla mocka interfejsu IPaymentValidator, a także w części assert. Jak widzisz, metoda ProcessPayment z powodu wyjątku wyrzuconego przez IPaymentValidatora, nie będzie wywołana. Jest to odzwierciedlone w ostatniej linii testu.

Testowanie z użyciem własnych doubles

W poprzednich testach wykorzystywaliśmy doubles stworzone przez klasy biblioteki Moq. Oczywiście każdy z nich możesz stworzyć własnoręcznie. Nie będzie z pewnością tak złożony, niemniej jednak do wykonania prostych testów powinien wystarczyć. Spójrz na moje przykłady. IPaymentValidator nie zmienia nic w kodzie, wywoływana jest tylko metoda i to wszystko. Dlatego w jego przypadku wystarczy dummy:

    public class PaymentValidatorDummy : IPaymentValidator
    {
        public void Validate(CreditCard creditCard)
        {            
        }
    }

W przypadku IPaymentService sytuacja jest bardziej złożona, bo kod wykorzystuje wartość zwróconą z metody ProcessPayment – badany więc będzie stan obiektu, co znaczy, że do tego przykładu potrzebne będzie stworzenie stuba:

    public class PaymentServiceSuccessfluPaymentStub : IPaymentService
    {
        public PaymentResult ProcessPayment(CreditCard creditCard)
        {
            return new PaymentResult { Success = true };
        }
    }

Test z wykorzystaniem własnych doubles

Cała klasa testów wygląda następująco:

    [TestFixture]
    public class PaymentHandlerOwnDoublesTests
    {
        private IPaymentHandler _paymentHandler;
        private IPaymentService _paymentServiceStub;
        private IPaymentValidator _paymentValidatorDummy;

        [SetUp]
        public void SetUp()
        {
            _paymentValidatorDummy = new PaymentValidatorDummy();
            _paymentServiceStub = new PaymentServiceSuccessfluPaymentStub();

            _paymentHandler = new PaymentHandler(_paymentValidatorDummy, _paymentServiceStub);
        }

        [Test]
        public void HandlePayment_WhenProvidedCorrectData_ThenPaymentIsSucceeded()
        {
            //arrange
            var creditCard = new CreditCard
            {
                CardNumber = "10 0000 0000 0000 0000 0000 0000",
                Owner = "Jan Kowalski"
            };

            //act
            var result = _paymentHandler.HandlePayment(creditCard);

            //assert
            Assert.IsNotNull(result);
            Assert.True(result.Success);
        }
    }

Jest kilka spraw, na które warto zwrócić uwagę. Po pierwsze nie używam tutaj biblioteki Moq. Moje doubles implementują te same interfejsy, co właściwe implementacje, używane w normalnym kodzie, zachowują się jednak inaczej – wykorzystują uproszczone mechanizmy. Dzięki temu możemy w łatwy sposób stworzyć instancję double i wykorzystać je w tworzeniu instancji klasy testowanej. Kod samego testu nie zmienił się prawie wcale. Jedyną różnicą jest to, że nie mogłem wykorzystać metod Verify – należą do biblioteki Moq, której w naszym przypadku nie używamy.

Podsumowanie

Omówiliśmy dzisiaj duuuuży kawałek wiedzy dotyczącej testów jednostkowych. Co więcej, informacje te są czymś, co pomoże Ci pisać bardziej skomplikowane testy jednostkowe. Z pewnością wykorzystasz to w codziennej pracy, gdzie złożone klasy nie są tak proste to przetestowania jak te w poprzednim wpisie. W tych zadaniach pomogą Ci mocki. W tym artykule opisałem, czym one są oraz jak się dzielą. Przejrzeliśmy też kilka przykładów. Przypominam, że wszystkie przedstawione dzisiaj, a nawet kilka więcej znajdziesz w moim repozytorium z wszystkimi testami na bitbuckecie. Gorąco zachęcam Cię też do własnych eksperymentów z unit testami. Spróbuj napisać je dla projektów, których jesteś autorem, da Ci to o wiele większe zrozumienie tematu niż przeczytanie tego, czy jakiegokolwiek innego artykułu. Jeśli natrafisz na jakiś problem, śmiało pisz w komentarzu, z pewnością coś da się z tym zrobić 🙂 Jeśli uznałeś, że artykuł ma sens i wyniosłeś z niego jakąś lekcję, proszę daj o nim znać w mediach społecznościowych. Przyciski do tego znajdziesz jak zwykle na dole 🙂

Please follow and like us:

Dodaj komentarz

This site uses Akismet to reduce spam. Learn how your comment data is processed.