Materiały służące do zadań z artykułu dostępne na repo: https://github.com/RossmannTech/tests-as-documentation

Wstęp

Do częstych problemów programisty możemy zaliczyć: jak program działa? jak go uruchomić? czego spodziewać się po kodzie? dlaczego programista tworzący rozwiązanie wybrał jedno rozwiązanie nad innym? Jeżeli założymy, że program jest rozwiązaniem pewnej potrzeby lub problemu, możemy z łatwością dojść do wniosku, że możliwych odpowiedzi na te pytania jest tyle, ile możliwych programów wynikowych - dziesiątki. Skąd zatem wiadomo, czy program prawidłowo rozwiązuje problem? Powinna to rozstrzygać dokumentacja.

Tylko co w sytuacji, gdy dokumentacja jest przestarzała, nie przedstawia intencji programisty, albo jest napisana wysoko poziomowo dla osoby używającej programu, a nie dla przyszłych programistów? Jak poradzić sobie z ciągłym rozwijaniem się programu jednocześnie utrzymując wiarygodną dokumentację dla naszych następców?

Z ratunkiem przychodzą nam testy! Możemy pokusić się o użycie naszych testów i doprowadzenie ich do stanu, w którym będą w stanie opisać: działanie programu, intencje programisty, logikę biznesową oraz zamierzony cel programisty. Dodatkowymi plusami będzie minimalizacja regresji podczas rozwoju naszego programu i możliwość sprawdzenia, czy program spełnia kryteria dokumentacji w procesie Continuous Integration.

W tym artykule chciałbym przedstawić kroki, które można przedsięwziąć aby doprowadzić testy do postaci, w której dałoby się je traktować jak dokumentację.

Zaczynamy

Na początek zapoznajmy się z klasą, którą testujemy. Nasz scenariusz to klasa RoomFinder, która aktualnie umożliwia nam szukanie pokojów, które możemy pózniej wynająć. Spójrzmy na kod tej klasy:

public List<Room> FindAvailableRooms(int roomSize, DateTime availableFrom, DateTime availableTo)
{
    if (roomSize < 1)
    {
        throw new InvalidOperationException("Room size smaller than 1 is not possible");
    }

    if (availableFrom > availableTo)
    {
        throw new InvalidOperationException("Available From date cannot be after available To date.");
    }

    if (availableFrom == availableTo)
    {
        throw new InvalidOperationException("Room booking is available for at least one night");
    }

    if (availableFrom < DateTime.Today || availableTo < DateTime.Today)
    {
        throw new InvalidOperationException("Cannot book rooms in the past");
    }

    return _roomRepository.SearchForRooms(roomSize, availableFrom, availableTo);
}

Mamy tutaj trochę logiki walidacyjnej i finalnie zwracamy rezultat z naszego repo. Po krótkiej analizie możemy podjąć decyzję, że ta klasa służy nam za strażnika od wysyłania niepotrzebnych zapytań do bazy zanim z niej skorzystamy. Można by ją zrefaktoryzować, ale jest to temat, który mógłby spokojnie zapełnić osobny artykuł.

Skoro wiemy już z czym pracujemy, skupmy naszą uwagę na to jak wygląda test do tej bazy:

[Fact]
public void Test()
{
    Assert.Equal(2, new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(2, DateTime.Now, DateTime.Now););
    Assert.Throws<InvalidOperationException>(() => new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(-1, DateTime.Now, DateTime.Now));
    Assert.True(rooms.All(room => room.Price > 0));
    Assert.Throws<InvalidOperationException>(() => new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(2, DateTime.Now.AddDays(-1), DateTime.Now));
    Assert.Throws<InvalidOperationException>(() => new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(2, DateTime.Now.AddDays(-1), DateTime.Now.AddDays(-1)));
}

Na pierwszy rzut oka widać elementy, które prawdopodobnie każdy by poprawił, ale dla dobra artykułu załóżmy, że test był pisany pod presją czasu celem pokrycia jak największej liczby przypadków w jak najkrótszym czasie.

Zanim wykonamy pierwszy krok zadajmy sobie pytania:

  • co robi ten test?
  • co sprawdza?
  • jakie gwarancje zapewnia?

Jeśli udało Ci się już znaleźć na nie odpowiedzi, zobaczmy w jaki sposób można by przetransformować ten test do formatu dokumentacji deweloperskiej.

Poprawne nazewnictwo testów

Nazwa naszego testu wyświetla się od razu w test runnerze. Jest to pierwszy kawałek informacji jaki zwracamy programiście, któremu chcemy powiedzieć, że coś poszło nie tak. Najpierw pojawia się nazwa metody testu, a dopiero potem w szczegółach widzimy który assert nie przeszedł. Mając to na uwadze możemy nazwać nasz test po prostu "TestingFinderFunctionality", dzięki czemu puszczając testy następnym razem będziemy widzieli przy której konkretnie funkcjonalności występują problemy.

Pamiętajmy, że zależnie od tego jak ustawimy nasz test runner, będziemy widzieć różne rozgałęzienia naszych testów, jednak to nazwa metody i klasy powinny być dla nas najważniejsze, ponieważ będą one zawsze na pierwszym froncie podczas puszczania testów w Visual Studio/Rider'ze.

Obraz pokazujący efekt przed i po zmianie nazw

Powyżej mamy przedstawiony efekt tych dwóch małych zmian. Klasa nazywa się teraz RoomFinderTests a test który przeprowadza kontrolę funkcjonalności nazywa się TestingFinderFunctionality.

Teraz kiedy nasze testy nie przechodzą wiemy gdzie szukać problemu, a to samo w sobie jest już potężną wspomogą podczas debug'owania. Kod od testu wygląda teraz następująco:

[Fact]
public void TestingFinderFunctionality()
{
    Assert.Equal(2, new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(2, DateTime.Now, DateTime.Now););
    Assert.Throws<InvalidOperationException>(() => new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(-1, DateTime.Now, DateTime.Now));
    Assert.True(rooms.All(room => room.Price > 0));
    Assert.Throws<InvalidOperationException>(() => new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(2, DateTime.Now.AddDays(-1), DateTime.Now));
    Assert.Throws<InvalidOperationException>(() => new RoomFinder(new SqlRoomRepository()).SearchAvailableRooms(2, DateTime.Now.AddDays(-1), DateTime.Now.AddDays(-1)));
}

Separacja Testowanych Funkcjonalności

Skoro wiemy już, że nazwy naszych metod są pierwszymi nazwami, które pokażą się użytkownikowi, możemy się pokusić o testowanie mniejszych ilości funkcjonalności tak, aby lepiej oddać intencje działania programu. Patrząc na kod z pierwszego kroku można zauważyć, że istnieje jeden test, który testuje "Funkcjonalność". Sama nazwa testu nie powie nam jakiej funkcjonalności oczekujemy. Tutaj w jednym teście zostały zawarte informacje o tym ilu dostępnych pokoi oczekujemy, jakiej walidacji się spodziewamy i o tym, że cena wszystkich pokoi zwróconych do użytkownika musi być wyższa od zera.

Można by się pokusić o pytanie "Po co dzielić to wszystko na różne metody?". Odpowiedź na to pytanie tkwi w wygodzie tego podejścia. W poprzednim scenariuszu nasze testy nie przeszły. Żeby zobaczyć czemu wystarczy spojrzeć na błąd który zwrócił nam test runner:

Obraz pokazujacy efekt przed i po zmianie nazw

Czy moglibyśmy się domyślić, że chodzi o nie zgadzającą się ilośc pokoi w teście funkcyjnym? Bez znajomości testu musimy otworzyć program i sprawdzić stack trace aby w ogóle zacząć wiedzieć który Assert się nie udał. Strata czasu na analizę Asserta jest zdecydowanie większa niż przygotowanie oddzielnej metody. Jest to pierwszy argument z wielu, dzięki którym zasada "One Assert per test" jest powszechnie uważana za stosowną do testów jednostkowych. Pamiętajmy tylko, że nie ma świętych zasad które zawsze się spełnią. Czasami więcej niż jeden Assert będzie konieczny aby zapewnić poprawność funkcjonalności.

*Ćwiczenie: Spróbuj podzielić testy na funkcjonalność tak aby można było określić w razie czego jaka funkcjonalność szwankuje.*

Gotowe? Spróbuj porównać swój wynik do rozwiązania, które jest poniżej. Możesz też poszukać odpowiedzi w repo w folderze z drugim krokiem.

Testy podzielony wg. funkcjonalności

Każda nasza funkcjonalnośc jest teraz testowana oddzielnie i jesteśmy o krok bliżej od upewnienia się że nasze testy można traktować jak dokumentację.

Zapożyczenie z BDD sposobu opisywania naszych testów

Zanim przejdziemy dalej do uczenia się technik które polepszą naszę testy, skupmy się jeszcze na moment nad nazwami naszych testów. Wszystko da się ustandaryzować a w szczególności sposób w jaki nazywamy nasze testy.

BDD czyli Behavior-driven design to sposób programowania, w którym używamy wysoko poziomowego języka do napisania scenariuszy, które mogą przygotowywać analitycy, testerzy lub ktokolwiek inny związany z projektem. Te scenariusze traktuje się jako testy i tak samo jak w TDD (Test-driven development) doprowadzenie ich do "zielonego stanu" i refaktoryzacja kończą proces programowania.

Scenariusze w BDD są pisane używając języka Given-When-Then który opisuje oczekiwane zachowanie systemu. Przykładowym scenariuszem wysoko poziomowym może być:

    Given user is on the search page
    When user clicks on the search button
    Then user gets the search results

Ten krótki scenariusz mówi nam jakie zachowanie powoduje wynik którego oczekujemy i dodatkowo mówi jaki ten wynik powinien być. To prowadzi do pełnego oddania intencji naszego programu. Jedynym problemem w takich scenariuszach to rozległość zdań. W testach jednostkowych długie nazwy naszych metod mogą utrudnić pracę.

Dobrym standardem jest prefixowanie testów używając When oraz Should. When służy do wytłumaczenia Warunku wstępnego a Should oczekiwany efekt końcowy. Taka standardyzacja podejścia do nazw ma dwa plusy:

  • Nie musimy rozmyślać nad tym jak nazywać nasze testy. Mamy od tego reguły.
  • Używając When możemy w łatwy sposób testować rozgałęzienia jeżeli program takie posiada.

Weżmy na przykład jeden z testów pokazanych wyżej. Jego nazwa: ThrowOnAvailableToInThePast. Możemy go przerobić na dwa sposoby. Możemy nazwać test: WhenAvailableToIsInPast_ShouldThrowError. Możemy też pokusić się o nazwę ShouldNotAllowAvailableToInThePast. Które podejście jest lepsze? Jak zawsze, zależy. Ja osobiście staram się unikać When kiedy tylko moge dlatego drugie podejscie jest dla mnie przyjaźniejsze. Dodatkowo, throw jest słowem programistycznym i implikuje jaki rodzaj zarządzania błędami został użyty w tym programie. To czy jest to pozytywne czy nie, zostawiam już Tobie.

*Ćwiczenie: Spróbuj przerobić nazwy swoich testów na format zaproponowany wyżej*

Poniżej przykład możliwych nazewnictw naszych testów:

Testy nazwane zapożyczając podejście BDD

Mockowanie zewnętrznych zależności

Na cel tego kroku chciałbym zwrócić uwagę na test ShouldReturnCorrectNumberOfRooms. Jego kod można zobaczyć poniżej:

[Fact]
public void ShouldReturnCorrectNumberOfRooms()
{
    var roomFinder = new RoomFinder(new SqlRoomRepository());
    var rooms = roomFinder.SearchAvailableRooms(2, DateTime.Now, DateTime.Now);

    Assert.Equal(2, rooms.Count);
}

Jest w nim mały problem. Nasz finder ma wstrzykiwaną prawdziwą zależność SqlRoomRepository, tak jak cała reszta naszych testów. Jakie mogą być tego skutki? Nasz test jednostkowy testuje więcej niż zakres swojego programu, a przede wszystkim opiera się o zaufanie do bazy, że zawsze znajdzie sposób by zwrócić nam dokładnie dwa pokoje (bo tyle pokoi oczekujemy w teście). Z tego powodu można powiedzieć, że nasz test jest kruchy. Mamy test, który nie ogranicza się do testowania jednostki naszego programu.

Dużo lepszym testem (a nawet dwoma) byłoby przetestowanie, czy program odwołuje się do repozytorium i zwraca z niego wyniki. Wtedy nie martwimy się o ilość zwróconych pokoi i nie obchodzi nas to co dzieje się w bazie. Aby móc wykonać takie operacje możemy wykorzystać Mocking, czyli wstrzykiwanie fałszywych zależności, które potrafią w programie udawać te prawdziwe. Dokładne wprowadzenie do Mock'owania jest załączone w linkach do dalszego czytania. Na początek przedstawię tylko biblioteczkę Moq, z której możemy skorzystać.

Po zainstalowaniu Moq możemy zacząć naszą pracę. Do stworzenia naszego Mock'a potrzebujemy najpierw MockRepository. Tworzymy je w następujący sposób:

var mockRepository = new MockRepository(MockBehavior.Loose);

Różnice między Loose a Strict można doczytać w dokumentacji Moq. Tworzenie mock'a do naszych testów to już tylko wywołanie Create na naszym stworzonym repozytorium mock'ów.

var roomRepositoryMock = mockRepository.Create<IRoomRepository>();

Zwróćmy uwagę że w Moq nasze instancje nie są od razu objektami które mockujemy. Te instancje są zwrocone w objekcie Mock<T>. Aby dostać się do instancji którą chcemy wstrzyknąć, musimy odwołać się do Object w naszym stworzonym mocku. Wstrzyknięcie naszego mock'a do room findera wygląda więc następująco:

var roomFinder = new RoomFinder(roomRepositoryMock.Object);

Mając taki Mock zdobyliśmy bardzo silne narzędzie do dalszego testowania. Możemy ustawić zwracane rezultaty Mockowanej zależności. Jeżeli chcemy aby zależność zawsze zwracała 2 pokoje wystarczy ustawić zwrot:

roomRepositoryMock
    .Setup(mock => mock.SearchForRooms(It.IsAny<int>(), It.IsAny<DateTime>(), It.IsAny<DateTime>()))
    .Returns(new List<Room>() {new Room(), new Room()});

Z takim ustawieniem nie musimy się już zamartwiać stanem bazy danych, który mógłby generować nam fałszywe alarmy. Pełen test wygląda wtedy następująco:

Test zawierający Mock

Teraz wiemy jak odseparować nasze testy jednostkowe od zewnętrznych zależności.

Arrange Act Assert

Skoro nasze testy jednostkowe potrafią opisać program, jego funkcjonalności i mamy pewność że nie wpływają na niego zewnętrzne zależności, powinniśmy ułatwić ich zrozumiałość programistom. Pierwszym krokiem żeby to osiągnąć będzie wzorzec Arrange-Act-Assert.

Polega on na podzieleniu zadań wykonywanych podczas testu:

  • Szykowania się do testu - Arrange.
  • Akcji którą testujemy - Act.
  • Sprawdzenia wyniku - Assert.

Szykowanie się powinno zawierać kroki, które pozwolą nam na wykonanie akcji oraz testu czy akcja odniosła żądany skutek. Akcja powinna być jedna i mówić jasno jaką metodę testujemy. Sprawdzenie wyniku zawsze powinno być na końcu.

*Ćwiczenie: Spróbuj zorganizować test ilości pokoi w stylu arrange act assert.

Rozwiązanie podziału Arrange-Act-Assert

Takie komentarze arrange act assert są nie potrzebne. Z czasem to podejście wchodzi w krew i ciężko inaczej pisać testy.

Kolejnym wcześniej nie wspomnianym plusem takiego podejścia jest możliwość czytania testów od dołu do góry. Wiemy że zawsze nasz Assert będzie na dole testu a zaraz nad nim będzie akcja wywołująca test. Jeżeli będzie nas interesować etap szykowań do testu, możemy na nie spojrzeć wyżej. Mimo wszystko Assert i Act będą już dobrze przesiane.

Poprawiamy nasze Asserty

Poprawiliśmy już nazwy, poprawiliśmy organizację testów, usunęliśmy zewnętrzne zależności oraz podzieliliśmy testy według funkcjonalności. Zostało już tylko popracować nad Assertami. Asserty w XUnit oraz NUnit nie są strasznie wymowne a czytanie ich wiadomości z błędów nie mówi nam wiele więcej niż po prostu nie zdany test.

Z ratunkiem przychodzą FluentAssertions. Dzięki nim możemy pisać bardziej opisowe Asserty które czytają się bardziej jak opis niż jak instrukcje programowe np. Nasz assert ilości pokoi zamieni się z:

Assert.Equal(2, result.Count);

na:

result.Should().HaveCount(2);

A nasze rzucenie wyjątku można zamienić z:

Assert.Throws<InvalidOperationException>(() => roomFinder.SearchAvailableRooms(-1, DateTime.Now, DateTime.Now));

na:

Action act = () => roomFinder.SearchAvailableRooms(-1, DateTime.Now, DateTime.Now);

act.Should().Throw<InvalidOperationException>();

Są dodatkowe funkcje które znacznie poprawiają pisanie użytecznych i czytelnych assert'ów. Żeby je zgłębić wystarczy zajrzeć do dokumentacji która jest załączona niżej. Reszta przeróbek jest wykonana na repo.

Podsumowanie – Doskonalenie swoich testów:

W tym artykule dowiedzieliśmy się jak pisać testy które mogą nam robić za dokumentację. Wiemy już jak jasno przekazać co się dzieje w teście oraz jak pozwolić na szybkie zrozumienie intencji. Mimo wszystko nie wyczerpaliśmy tego materiału.

Jest dużo tematów które można poruszyć mówiąc o tym jak poprawić nasze testy. Podążanie tymi zasadami do ostatniego kroku pozwoli nam na rozpoczęcie pracy nad programem z testami które kroczek po kroczku tłumaczą intencję programisty oraz działanie programu. Jeżeli nasze pokrycie jest obszerne, można się nawet pokusić o to że napisaliśmy dokumentację do naszego kodu. Jest wiele tematów które pozwolą nam na lepsze zrozumienie dlaczego niektóre testy więcej nam powiedzą od drugich.

Z doświadczeniem przyjdzie też wiedza na temat tego co jest najważniejsze podczas testowania. Razem z tą wiedzą przyjdą odpowiedzi na pytania nie zawarte w tym artykule a które warto przemyśleć:

  • Jak mało powinniśmy testować na raz?
  • Co powinien zawierać test a co można ukryć za prywatnymi metodami?
  • Gdzie pozwolić na test uwzględniający zachowanie paru klas a gdzie zastosować podejście testowania klasy po klasie?
  • Jak aplikować Don't repeat yourself w testach?
  • Kiedy nie warto jest testować?
  • Czy testować zewnętrzne biblioteki które dołączają do naszego projektu?
  • Jak zautomatyzować swoją pracę podczas pisania testów aby móc skupić się na oddaniu ich intencji?
  • Czy warto korzystać z programów auto generujących testy?

Na te pytania prędzej czy później każdy programista piszący testy będzie musiał sobie odpowiedzieć. Aby było wam trochę łatwiej, poniżej załączam parę linków do poszerzenia swojej wiedzy:

Więcej na temat testów: