Dobre praktyki w testach jednostkowych

dobre praktyki w testach

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

Nasza seria o testach trwa w najlepsze. Mamy za sobą całkiem sporo materiału, ale jeszcze dużo przed nami. Niemniej jednak, wiedza zawarta w poprzednich wpisach pozwoli napisać Ci wiele testów jednostkowych. Nie wspomniałem jeszcze o kilku ważnych zasadach, które pozwolą Ci pisać DOBRE testy, zgodne z ogólnie przyjętymi zasadami, a co za tym idzie łatwe w interpretacji, modyfikacji i uruchomieniu. Jeżeli szukasz informacji o dobrych praktykach w testowaniu, to czytaj dalej – ten artykuł jest dla Ciebie.

Na początku zaznaczę, że w poprzednich wpisach też znalazło się wiele porad, które mogą powtórzyć się w dzisiejszym. Nie jest to jednak wynikiem demencji starczej, a raczej próbą uporządkowania wiedzy i umieszczenia jej w jednym miejscu.

Cechy dobrego testu

Zacznijmy zatem od podstawowych kwestii. Jak myślisz? Czym powinien cechować się dobry test? Ludzie, których doświadczenie i wiedza są przytłaczająco większe od moich wyróżniają pięć takich cech:

szybki (ang. fast)

Myślę, że ta nie wymaga specjalnego komentarza. Przeznaczeniem testów jest częste uruchamianie, nie powinno więc być tak, żeby przebieg jakiegokolwiek z nich trwał zbyt długo. Ile to zbyt długo? – zapytasz. Myślę, że najlepszą odpowiedzią będzie: tak szybko, jak to możliwe. Niedopuszczalne jest, żeby test wykonywał się powyżej minuty, a idealnie będzie jak uruchomienie wszystkich testów, znajdujących się w projekcie zajmie około minuty lub mniej.

odizolowany (ang. isolated)

Ta cecha odnosi się zarówno do innych testów, jak i zależności w klasie testowanej. W skrócie – wynik testu, nie może być uzależniony od innych testów, natomiast sprawdzana powinna być tylko jedna klasa i jedna metoda. Wszystkie zależności używane wewnątrz niej izolujemy tworząc odpowiednie double. Więcej o tym w artykule na temat mockowania.

powtarzalny (ang. repeatable)

To też prosta w swoim założeniu cecha. Test ZAWSZE powinien dać ten sam rezultat. Uruchomiony samodzielnie lub razem w pozostałymi testami i na każdym komputerze – powinien wykonać się w identyczny sposób.

samosprawdzający (ang. self-checking)

Każdy test powinien sam weryfikować z jakim skutkiem się zakończył. Nie powinien wymagać człowieka, do jego obsługi. Realizowane jest to przez asercje, które weryfikują rezultaty.

timely

Nie doszukałem się sensownego odpowiednika do tego angielskiego słowa, które oznacza, że pisanie jednego testu nie powinno zajmować zbyt dużo czasu, w porównaniu z czasem potrzebnym do napisania sprawdzanej funkcjonalności. Dodatkowo testy powinno pisać się równolegle do rozwijanej funkcjonalności.

Okej mamy omówione teoretyczne wymagania co do testów, które moglibyśmy określić jako dobre. Jak zatem zrealizować je i przelać na kod? Już odpowiadam. Pierwszą i niepodważalną zasadą jest stosowanie podziału testu na sekcje arrange, act i assert. Pierwsza odpowiada za inicjalizację testowanego środowiska i kontekstu konkretnego testu, druga jest wywołaniem testowanej metody, a ostatnia odpowiada za weryfikacje otrzymanych rezultatów. Więcej o zasadzie AAA, przeczytasz w tym artykule. Oczywiście, jest to reguła, której zastosowanie nie sprawi automatycznie, że Twój test będzie książkowym przykładem kawałka dobrego kodu. Jednak jest to dobra baza do tego, aby takim się stał. Co więcej możesz zrobić? Kilka moich podpowiedzi.

Unikaj niepotrzebnych inicjalizacji

Z poprzednich postów, wiesz, że część inicjalizacji możesz przenieść do metod oznaczonych odpowiednim atrybutem (np. SetUp w przypadku NUnita, co więcej niektóre frameworki nie oferują takiej możliwości). Warto jednak nie rozpędzać się z nadmiernym przepakowaniem tej metody. Wszystkie zachowania specyficzne dla testu powinno się definiować wewnątrz metody testującej. To jest najbardziej odpowiednie miejsce do przygotowania danych dla testu. Innym dobrym miejscem są klasy double, przygotowane dla konkretnych przypadków. Redukują one ilość kodu w samym teście, poprawiają jego czytelność i ułatwiają modyfikację.

Unikaj magicznych wartości

Częstym błędem podczas testowania jest przypisanie „magicznych wartości” do zmiennych wartościowych lub stringów. Zdarza się, że są one zrozumiałe dla programisty, a nawet całego zespołu. Niemniej jednak w większości przypadków powodują wiele niezrozumień i dodatkowych pytań, szczególnie, gdy do testu wraca się po pewnym czasie od napisania, wystarczająco długim, żeby zapomnieć, co konkretna wartość może oznaczać. Całość można zastąpić zmienną precyzyjnie opisującą wartość jaką przypisujemy do właściwości klasy, którą inicjalizujemy. Przykład:

Kod z „magiczną wartością”:

model.Distance = 359;

Kod napisany poprawnie:

var distanceBetweenCompanyOffices = 359;
model.Distance = distanceBetweenCompanyOffices;

Oczywiście we właściwym kodzie, taka wartość powinna być pobierana z konfiguracji lub w inny sposób pozwalający na łatwą modyfikację bez przebudowywania projektu i ponownego deployu na serwer. Na potrzeby tego przykładu uprościłem to maksymalnie, żeby pokazać co mam na myśli.

Stosuj fluent builder pattern

Wzorzec ten pozwala na tworzenie całych obiektów za pomocą dedykowanej klasy i jej metod odpowiedzialnych za inicjalizację jednego elementu. Każda z metod zwraca instancję klasy, do której należy, przez co można odwoływać się do nich, oddzielając je kropkami. Zastosowanie odpowiedniego nazewnictwa pozwala na przygotowanie czytelnego i łatwego w analizie kodu, który pozwala na dowolne modyfikowanie właściwości konkretnego obiektu w schludny i elegancki sposób. Jeśli sam opis brzmi nieco mgliście, przykład rozjaśni wszystkie możliwości. Klasa, dla której zastosujemy wzorzec to stosowana wcześniej CreditCard:

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

Klasa buildera:

    public class CreditCardBuilder
    {
        private CreditCard _creditCard;

        public CreditCardBuilder()
        {
            _creditCard = new CreditCard();
        }

        public CreditCardBuilder WithNumber(string number)
        {
            _creditCard.CardNumber = number;
            return this;
        }

        public CreditCardBuilder WithOwner(string owner)
        {
            _creditCard.Owner = owner;
            return this;
        }

        public CreditCard Build()
        {
            var creditCard = _creditCard;
            _creditCard = new CreditCard();

            return creditCard;
        }
    }

Wykorzystanie:

            var testNumber = "10 0000 0000 0000 0000 0000 0000";
            var testOwner = "Jan Kowalski";
            var builder = new CreditCardBuilder();
            var creditCard = builder.WithNumber(testNumber)
                .WithOwner(testOwner)
                .Build();

Jak widać, builder posiada prywatne pole, którego typem jest stworzona wcześniej klasa. W konstruktorze następuje jej inicjalizacja. Każda metoda wewnątrz buildera zwraca swoją instancję za pomocą słowa kluczowego this. Jak napisałem wcześniej pozwala to na odwoływanie się do konkretnych metod, separując je kropkami. Każda z nich odpowiada za przypisanie wartości jednej, konkretnej właściwości klasy. Ostatnia metoda buildera – Build, jest specyficzna. Zwraca ona instancję klasy, która jest budowana. Aby po zwróceniu zbudowanego obiektu builder nie przechowywał niepotrzebnych danych, należy ponownie zainicjalizować prywatne pole klasy. Gdybyśmy tego nie zrobili, a chcieli użyć tej samej instancji buildera ponownie, przechowywałby już przygotowany obiekt, którego dane mogłyby się różnić od tych, które my chcielibyśmy wykorzystać. W celu uniknięcia takiej sytuacji dane z prywatnego pola zapisujemy w nowej zmiennej, samo pole inicjalizujemy ponownie, tak jak w konstruktorze, a wartość prywatnej zmiennej zwracamy jako rezultat z metody Build.

W przypadku bardziej złożonych obiektów, warto dodać tutaj także weryfikacje tego, czy obiekt został poprawnie utworzony. Natomiast gdy tworzone obiekty zawierają właściwości będące kolejnymi obiektami, warto zastosować dla nich dodatkowy builder. Dodatkowy przykład wykorzystania tego wzorca znajdziesz w repozytorium, rozwijanego w ramach tej serii, a znajdującego się na moim Bitbuckecie. Dodałem też bardziej złożony przykład buildera, żeby pokazać pełnię jego możliwości. Jego kod także znajdziesz w tym repo, a użycie w funkcji Main, w klasie Program projektu BushidoProgramisty.UnitTestingExample.

Podobnym rozwiązaniem może być użycie innego wzorca – fluent interface. Zbliżone nazwy sugerują podobieństwo i tak rzeczywiście jest. Główną różnicą jest to, że builder do tworzenia obiektów wykorzystuje osobne klasy, natomiast fluent interface zbudowany jest za pomocą extension method. Ten temat omówię szerzej w innym artykule, natomiast warto pamiętać o takiej możliwości już teraz. Przykład także umieściłem w projekcie.

Unikaj logiki w testach

Staraj się usuwać z kodu testów instrukcje warunkowe, instrukcje switch, czy pętle. Takich elementów NIE POWINNO być w tym miejscu! Wprowadzają one dodatkowe ryzyko wystąpienia błędu w samym teście, co zarazem powoduje błędną weryfikację właściwej implementacji.

Praktyki z którymi się nie zgadzam

Powyżej wymieniłem sporo praktyk, których stosowanie ułatwi testowanie i sprawi, że kod zyska na jakości i czytelności. Zdobywając wiedzę o unit testach spotkałem się z kilkoma zasadami, które nie do końca mnie przekonują – możliwe, że mam za małe doświadczenie lub czegoś poprawnie nie zrozumiałem, może być też jednak tak, że w moim rozumowaniu znajduje się ziarnko sensu i logicznego postępowania :). Niezależnie od tego, rezultatem tego wszystkiego jest to, że celowo nie umieszczałem na liście zasad, z którymi się nie zgadzam. Uważam jednak, że warto przedstawić je tutaj wraz z komentarzem. O słuszności mojego toku myślenia i stosowaniu tych praktyk zdecyduj sam, Drogi czytelniku.

Unikanie wielu asercji w jednym teście

Ta praktyka zakłada, że każdy test jednostkowy powinien mieć jak najmniejszą liczbę asercji wewnątrz, idealną sytuacją jest, gdy jest tylko jedna. Tłumaczy się to faktem, że test jednostkowy powinien sprawdzać jeden przypadek/scenariusz oraz charakteryzować się dobrą czytelnością tak napisanej metody. Co jednak w sytuacji, gdy testowana metoda jest komponentem, który zawiera w sobie skomplikowaną logikę odwołującą się do wielu innych elementów systemu? Wtedy nawet jeden przypadek wywołania jej może wymagać wielu asercji do PEŁNEGO zweryfikowania poprawności działania. A chyba o to właśnie nam chodzi, tak? Zależy nam na rzetelnym przetestowaniu konkretnej funkcjonalności.

Pragmatyk w takim przypadku uzna, że klasa jest źle zaprojektowana, a jej poprawa zagwarantowałaby też poprawne napisanie testu. Problem jest taki, że takie myślenia łatwo udowodnić stosując przykład kalkulatora (jak w tym wpisie), czy innej prostej klasy. Rzeczywistość jest bardziej skomplikowana. Często projektowanie klasy wymaga pójścia na kompromis pomiędzy podążaniem za praktykami, które bez dwóch zdań są dobre, a wymaganiami systemów, które są niedoskonałe, a czasami całkowicie do kitu, bo napisane przez ludzi, a nie nieomylne roboty. Abstrahując od filozoficznych dyskusji podsumuję, że według mnie stosowanie wielokrotnych asercji jest okej, w przypadku testowania jednego scenariusza. Tak, może to spowodować, że częściej będziemy szukać powodu, dla którego nasz test nie przechodzi. Tak, będziemy dłużej analizować test, żeby całkowicie zrozumieć zadanie jakie spełnia. W zamian za to będziemy mieć szczelnie sprawdzony komponent, którego modyfikacja od razu zostanie wyłapana przez testy, a o to chyba chodzi prawda? 🙂

Unikanie inicjalizacji kodu w metodach SetUp

Zasada ta głosi, że kod powinniśmy inicjalizować w części assert każdego testu. Zaletą takiego podejścia jest pełna izolacja każdego testu. Ja natomiast uważam za dopuszczalne umieszczanie wspólnej logiki w takich metodach z kilku powodów. Pierwszy to jedna z sztandarowych zasad dobrego programowania – DRY. Co jest akronimem od Don’t repeat yourself. Zaleca ona zamianę wielokrotnie napisanego TAKIEGO SAMEGO kodu, do miejsc, gdzie może zostać zapisany jeden raz i użyty dowolną ilość razy. Wykorzystanie metody oznaczonej atrybutem SetUp (w przypadku frameworka NUnit) jest przykładem zastosowania tej zasady. Dzięki temu zamiast inicjalizować jakiś obiekt w każdym teście osobno, tworzymy go raz w powyższej funkcji.

Drugim powodem jest to, że z dokumentacji wynika, że metoda oznaczona atrybutem SetUp wykonuje się przed każdym testem, co znaczy, że kod i tak uruchamiany jest niezależnie od każdego z nich. Oczywiście nie powinno się tutaj przesadzać i definiować elementów używanych w nie wszystkich testach. Taka metoda powinna zawierać TYLKO elementy wspólne dla wszystkich testów w obrębie klasy.

Podsumowanie

Pisanie testów to umiejętność jak każda. Początkowo może sprawiać nieco trudności, iść opornie i wywoływać wątpliwości. Jednak regularne zdobywanie wiedzy i rozwijanie swoich umiejętności w tym zakresie sprawi, że ich tworzenie stanie się dla Ciebie naturalne i oczywiste. Nie ważne jak bardzo bym chciał, nie jestem w stanie odrobić za Ciebie tej części – musisz zrobić to samodzielnie 🙂 Pomogą Ci w tym dobre praktyki i zasady jakich powinieneś się trzymać, a które opisane zostały w tym artykule. Razem przeszliśmy przez podstawowe zagadnienia i te bardziej złożone. Omówiliśmy też reguły, które mnie nie przekonały, jak jest z Tobą? Daj znać w komentarzu, może dzięki Tobie zmienię zdanie 🙂 Po raz kolejny zachęcam Cię do własnego poszukiwania wiedzy i samodzielnego kodowania testów. Jeśli uznałeś ten artykuł za pomocny to śmiało, podziel się nim z innymi w mediach społecznościowych. Możesz też dać znać o całej serii dotyczącej testowania, dostępnej pod tym linkiem. Przyciski znajdziesz poniżej.

Please follow and like us:

Dodaj komentarz

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