🔊 ASP .NET Core + Verify + Wiremock + Testcontainer + Test Builder

Wpis ten jest uzupełnieniem mojej prelekcji, którą wygłosiłem na KGD .NET. Zawarłem tutaj wszystko to, czego nie zdążyłem powiedzieć podczas wystąpienia. Osobom, które nie mogły być obecne na żywo, gorąco sugeruję obejrzeć nagranie z wystąpienia dopiero po zapoznaniu się z tym artykułem. Taka jest właściwa kolejność. Dla osób, które pojawiły się na meetupie, wpis dostarcza brakującej części wprowadzającej, która może być kluczowa dla pełnego zrozumienia oraz motywacji, która stała za prezentowanym rozwiązaniem.

149 spotkanie Krakowskiej Grupy .NET

17 kwietnia 2024 roku o godzinie 18:00 w klubie HEVRE wygłosiłem moją pierwszą publiczną prelekcję na temat „ASP .NET Core + Verify + Wiremock + Testcontainers + Test Builder„.

Verify to wygodne tworzenie asercji w oparciu o snapshot testing. Wiremock skutecznie emuluje interfejsy 3rd-party API. Testcontainers ułatwia zarządzanie bazami danych. Test Builder to wypracowane rozwiązanie, które łączy te narzędzia w jedną spójną całość, maksymalizując efektywność i czytelność procesu testowania. Omówię znaczenie pisania testów na wysokim poziomie abstrakcji, co umożliwia szybkie wprowadzanie zmian w kodzie. Dostarczę kompleksowe rozwiązanie, które nie betonuje aplikacji za pomocą mocków na niższych warstwach. Unikniemy spowolnienia spowodowanego koniecznością dostosowywania starych mocków do nowego kodu. Zwrócę uwagę na to, aby aplikacja uruchamiana na potrzeby testów była uruchamiana identycznie jak na produkcji, bez modyfikacji kontenera IoC za pomocą mocków.

Nagranie na YouTube

W pierwszej kolejności koniecznie przeczytaj dalszą część artykułu, a następnie wróć tutaj po jej zakończeniu, aby obejrzeć to nagranie. Zalecam oglądanie nagrania na komputerze, na nieco większym monitorze.

GitHub

Tutaj znajdziesz kod Test Buildera, który jest częścią prezentacji. To kompleksowe rozwiązanie integruje ASP .NET Core, Verify, Wiremock, Testcontainers oraz Test Builder, tworząc spójną całość. Projekt testów nie jest specyficzną implementacją konkretnego przykładu aplikacji, można go skopiować w całości i wdrożyć do własnego projektu. Projekt ten jest implementacją opisanych poniżej dobrych praktyk testowania.

Testy mogą być zdradliwe

Chcemy być Agile. Niezaprzeczalnie Scrum to jedno z narzędzi, które pomaga być Agile, natomiast zamiast stosować Scrum dla samego jego stosowania o wiele ważniejsze jest przygotowanie bazy kodu w taki sposób, aby tworzenie oprogramowania było szybkie i łatwe. Będąc bardziej precyzyjnym: stosujmy Scrum jednak nie zapominajmy, że stosujemy go po to aby wytworzyć nasz kod w taki sposób, aby pozwalał nam być Agile.

Jednym z narzędzi w kodzie, które pomaga osiągnąć zwinność są testy. Testy różnych rodzajów, testy jednostkowe, testy integracyjne, testy e2e, itd.

Jednak pisanie testów jest niebezpieczne i może być zdradliwe. Pisanie testów zamiast nas przyspieszyć to może nas spowolnić. Jeśli napiszemy je źle, zamiast być Agile, możemy stać się jak ‚muchy w smole’.

Testy napisane w niepoprawny sposób mogą powodować zabetonowanie kodu aplikacji. Nie jest to, jednak, jedyne niebezpieczeństwo. Skoncentrujmy się na tym zagrożeniu bardziej szczegółowo. Innymi słowy, jest to spowolnienie wynikające z utrudnienia wykonywania szybkiej refaktoryzacji. Przykładowo, możemy znaleźć się w sytuacji, w której dodanie nowej funkcjonalności wymaga prostego i szybkiego refaktoryzacji. Załóżmy, że refaktoryzacja może nam zająć dwie godziny, jednak jeśli nasze testy są ściśle powiązane z detalami implementacyjnymi, konieczne będą również zmiany w testach istniejących. Takie prace nad testami mogą potrwać dodatkowo jeden dzień.

W rezultacie zamiast być zwinnym i dostarczyć rozwiązanie w ciągu dwóch godzin, nasze testy ostatecznie opóźniają dostarczenie nowej funkcjonalności o jeden dzień. Nie o to nam chodziło.

Rozmowa ucznia z mistrzem

Uczeń

Dlaczego dodatkowy dzień?

Mistrz

Mając testy bazujące na mockach interfejsów obiektów w warstwie aplikacji oraz warstwie infrastruktury, posiadamy testy bardzo ściśle powiązane z aktualną implementacją, co w efekcie powoduje, że jakakolwiek najmniejsza zmiana implementacji wymaga bardzo często refaktoringu wielu testów.

Sprawdzanie, czy dana metoda została wywołana w ramach innej metody, nie sprawdza nam tego, czy funkcjonalność działa poprawnie, a jedynie sprawdza, czy dana metoda została wywołana w ramach innej metody. Takie testy, bazujące na mockach, nie sprawdzają nam, czy funkcjonalność działa zgodnie z wymaganiami. Testy takie sprawdzają, czy autor testów jest również autorem implementacji.

Porozmawiajmy teraz o testach, które mockują interfejsy do zależności systemów trzecich. Przykładowo załóżmy, że mamy integrację z 3rd-party HTTP API lub posiadamy integrację z własną DB. Częstą praktyką w takich przypadkach jest mockowanie na poziomie implementacji interfejsu wartości zwracanej z metody. Tego typu testy również wymagają od nas znajomości szczegółów implementacyjnych na najniższych warstwach infrastrukturalnych. Jesteśmy zmuszeni napisać mock, ściśle powiązany z schematem danych zwracanych z wybranej metody, której implementacja, z punktu widzenia dostarczanej funkcjonalności, jest tylko szczegółem implementacyjnym.

Jeśli do tych testów dorzucimy częsty antywzorzec, którym nie jest dostateczne skupianie się na architekturze kodu w testach, gdzie często programowanie jest zgodne z wzorcem copyiego-pasta. Kopiowanie, wklejanie, lekka modyfikacja kolejnego przypadku testowego i do przodu. Brak implementacji, na przykład fabryki dla obiektów zwracanych z mockowanych metod, to częsta praktyka. Mocki są pisane bardzo niestarannie.

W efekcie kończymy z testami, które betonują nam obecną implementację, na zawsze utrwalając aktualny stan, niezależnie od tego, czy ta implementacja jest dobra, czy zła. One po prostu utrwalają implementację i mówią że taka a nie inna jest implementacja i koniec.

Drobny refaktoring wymaga od nas edycji wszystkich tych kilkudziesięciu lub więcej testów oraz wprowadzenia analogicznych zmian w wielu miejscach, gdzie nie zadbano o odpowiednią architekturę testów.

Idźmy dalej, i załóżmy, że architektura testów jest na dobrym poziomie i drobny refaktoring zapala nam kilkadziesiąt testów na czerwono. Jesteśmy w tak komfortowej sytuacji, że tylko jedno miejsce w testach wystarczy poprawić, aby wszystkie testy zapaliły się na zielono. Jednak nie o to tutaj chodzi. Tutaj mamy jeszcze inny problem. My nie chcemy, aby nasze testy za każdym razem, gdy wprowadzamy refaktoring w szczegółach implementacyjnych, wymagały poprawy. Jeśli nie zmieniamy funkcjonalności, to zmiana implementacji nie powinna nas zmuszać do zmiany testów. Te testy są dla nas pomocne, gdy nie musimy ich modyfikować wraz z refaktoringiem kodu. Jeśli dokonamy refaktoringu kodu i nie wprowadzimy błędów, to oczekujemy od testów, że bez ich zmian wszystkie zapalą się na zielono, co będzie dla nas potwierdzeniem, że nie wprowadziliśmy regresji podczas refaktoringu.

Konieczność zmiany testów podczas zmiany implementacji nie jest bezpieczna. Równie dobrze mogę popełnić błąd w implementacji i nie mając o tym świadomości, mogę tak zmodyfikować testy, żeby przeszły pozytywnie. Takie testy nie chronią mnie przed regresją.

Kontynuujmy dalej, od testów oczekuję tego, że nawet gdy usunę w całości obecną implementację i dostarczę nową, to nadal nie jestem zmuszony do edycji testów, ponieważ potwierdzają one poprawność nowej implementacji.

Przykładowo, jeśli po czasie, na następny dzień lub za miesiąc dochodzimy do wniosku, że daną implementację należy zmienić, zmianę chcemy wykonać, aby łatwiejsze było utrzymywanie kodu. Widzimy duże zyski, widzimy błędy popełnione w przeszłości i po prostu chcemy je teraz naprawić. Nie dostarczamy nowej funkcjonalności. Wszystko do tej pory, tak jak działało, ma działać jak działa. Nie negocjujemy czasu na tę refaktoryzację u zamawiającego. To jest refaktoryzacja, która ma nam pomóc w dostarczaniu kolejnych funkcjonalności.

I teraz, zmierzając do brzegu: mając testy ściśle powiązane z implementacją, możemy skończyć w miejscu, w którym 150 testów zapali nam się na czerwono. Nie zmieniliśmy działania obecnej funkcjonalności, nie wprowadziliśmy błędów, nie dodaliśmy nowych funkcjonalności, a mimo tego 150 testów jest czerwonych.

Powiem więcej, tego typu testy mają złą cechę, że one utrwalają potencjalne złe rozwiązania. Jeśli ktoś dostarczy nową funkcjonalność, ale napisaną w nie do końca rozwojowy sposób, popełni w tych implementacjach błędy i potem przyklepie tą implementację betonującymi testami, to złe rozwiązanie, mimo że z czasem zostanie dostrzeżone, to i tak zostanie w złej formie już na zawsze. Będzie ciągnąć się za nami na przyszłość przez cały czas wytwarzania danej aplikacji.

Takie testy, zamiast zachęcać nas do refaktoringu, wręcz przeciwnie odstraszają nas od tego, a przecież to właśnie dzięki testom powinniśmy czuć się bezpieczni i zachęcani do ciągłego udoskonalania aplikacji.

Uczeń

Jak osiągnęliście to, aby testy pomagały a nie przeszkadzały?

Mistrz

Należy pisać testy oparte o publiczny kontrakt/API aplikacji. W przypadku aplikacji ASP .NET Core Web API będą to testy bazujące na interfejsie HTTP, który ta aplikacja udostępnia.

Unikajmy pisania testów na niższym poziomie. Dzięki takiemu podejściu, nie tylko możemy przeprowadzić łatwy refaktoring, ale również mamy możliwość usunięcia w całości istniejącego kodu i napisania nowej implementacji od podstaw przy równoczesnym braku konieczności poprawiania istniejących testów.

OK, ale teraz ktoś mógłby mieć zarzut, że brak użycia mocków oznacza, iż nasze testy komunikują się z infrastrukturą, środowiskami deweloperskimi, testowymi, integracyjnymi, pre-produkcyjnymi co może wpłynąć na czas ich wykonywania i stabilność. Nie chcemy mieć powolnych testów oraz nie chcemy aby nasze testy zapalały się czerwono od losowych, niezależnych od nas przyczyn.

Uczeń

Jak więc zrobić takie testy stabilnymi?

Mistrz

Zamiast łączyć się z zewnętrznymi systemami, stworzyliśmy mocki tych systemów w postaci wirtualnych serwerów HTTP hostowanych w pamięci RAM.

Dokładniej mówiąc, użyliśmy biblioteki Wiremock, która pozwala na zdefiniowanie endpointów HTTP dla każdego przypadku testowego z osobna. W mocku definiujemy ścieżkę, metodę oraz body wywołania HTTP oraz odpowiedź jaką oczekujemy. Dzięki temu, w naszych testach, mamy prawdziwą komunikację z serwerami HTTP. Wszystko odbywa się na localhost w pamięci RAM. W Wiremock mamy do dyspozycji bardzo proste i wygodne fluent API, dzięki któremu możemy zasymulować dowolny serwer HTTP w obrębie danego przypadku testowego.

Uczeń

No dobrze, a co z bazami danych? Czy potrzebujecie ich na środowiskach testowych, skoro nie korzystacie z mocków?

Mistrz

W tym przypadku zastosowaliśmy podejście, w którym podczas uruchamiania testów tworzymy nowy serwer bazodanowy w kontenerze Docker. Do tego celu użyliśmy narzędzia o nazwie Testcontainers. Konfiguracja i używanie tego narzędzia są bardzo proste.

Testcontainers wykonuje całą dodatkową pracę związaną z czyszczeniem baz danych i usuwaniem serwera po zakończeniu testów. Działa to bardzo dobrze. Nie trzeba się samemu martwić o poprawne usunięcie bazy danych z poprzedniego uruchomienia testów. Kod związany z tą czynnością zawsze był trudny, a czasami pojawiały się problemy, gdy coś poszło nie tak, pozostawiając nieposprzątane bazy danych. Może to nastąpić, gdy testy zostaną przerwane w trakcie działania lub gdy testy napisane nie do końca są poprawne, ale chcemy je uruchomić w celach debugowania. Zawsze było to źródłem różnych problemów i konieczności ręcznego sprzątania po testach. Dzięki Testcontainers nie ma już tego problemu. Narzędzie to zajmuje się wszystkim automatycznie.

Uczeń

Więc dla każdego testu tworzycie nowy serwer bazy danych w Dokerze? Wydaje się to być bardzo powolne rozwiązanie.

Mistrz

Odpowiedź brzmi: nie. Nie robimy tego w ten sposób. To byłoby faktycznie bardzo wolne. Takie podejście ma jeszcze inną wadę: na produkcji aplikacja nie jest restartowana dla każdego nowego wywołania scenariusza, który wykonuje użytkownik. Testy również, więc nie powinny być tak pisane, aby dla każdego osobnego testu uruchamiana była od nowa aplikacja wraz z nową bazą danych. Jednak nie o tym chciałem tutaj mówić. Porozmawiamy o tym jeszcze w dalszej części. Wracając do tematu, uruchamiamy jednorazowo całą aplikację wraz z jedną bazą danych dla wszystkich testów.

Uczeń

W związku z tym, czy nie będziecie mieć niestabilnych testów? Czy nie będzie tak, że jeden test zmieni stan aplikacji w taki sposób, że spowoduje, iż kolejne testy zakończą się niepowodzeniem? Czy kolejność testów nie będzie miała znaczenia? Czy wasze testy uruchamiane są zawsze w tej samej kolejności, czy też uruchamiane są wszystkie testy równolegle? Jeśli uruchamiasz testy synchronicznie, to jest to powolne rozwiązanie. A jeśli równolegle, to czy wasze testy nie są niedeterministyczne?

Mistrz

Nie, nasze testy piszemy tak, aby symulować realne użytkowanie naszego systemu na produkcji. Przypadki testowe są przygotowane w taki sposób, żeby odzwierciedlać rzeczywistą interakcję użytkownika z naszym systemem. Stosując takie podejście uruchamiamy wszystkie testy równolegle, a jeden test nie wpływa na pozostałe testy.

Przyjrzyjmy się chwilowo aplikacji, która wspiera proces recenzji prac naukowych przed ich opublikowaniem w czasopismach. Załóżmy, że mamy do czynienia z manuskryptem napisanym przez pracownika naukowego oraz recenzenta, który w trakcie recenzji dodaje komentarze do tego manuskryptu. Dodatkowo, korzystając z publicznego interfejsu API HTTP, nie mamy kontroli nad generowanymi identyfikatorami (ID).

Biorąc pod uwagę użytkowanie aplikacji, recenzent nie szuka swojego komentarza za pomocą ID, lecz raczej szuka go na podstawie treści, którą wcześniej napisał. W naszych testach możemy postępować analogicznie, zapewniając, że każdy komentarz w każdym teście posiada unikalną treść. Dzięki temu, możemy śledzić komentarze w danym scenariuszu testowym bez konieczności poznania ich identyfikatorów.

Warto zaznaczyć, że brak ID wynika z faktu, że poprzedni endpoint dodający komentarz nie zwracał ID, lecz jedynie potwierdzenie powodzenia dodania komentarza. Istnieje jednak inny endpoint, który zwraca wszystkie komentarze do manuskryptu wraz z ich ID. Jednak na poziomie tego endpointa, zwracającego wszystkie komentarze do manuskryptu, nie jesteśmy w stanie jednoznacznie określić, który ID odpowiada nowo dodanemu komentarzowi. Taki model API jest dość często spotykany. Wynika on z interakcji, jakie wykonuje klient naszej aplikacji, którym jest jakaś aplikacja Web SPA. Niemniej jednak, użytkownik wie, który komentarz jest jego, ponieważ zna jego treść. W przypadku naszych testów postępujemy analogicznie do tego, jak nasze API jest wykorzystywane przez klienta webowego oraz do tego, w jaki sposób nasz użytkownik używa tej aplikacji na froncie, tj. wyszukujemy komentarz na podstawie jego treści i budujemy kolejny request wykorzystując znalezione ID.

Dzięki temu, nasze testy zachowują się zgodnie z zachowaniami użytkownika aplikacji, zapewniając jednocześnie izolację danych między testami. Mając unikatowe komentarze w każdym teście, mamy pewność, że żaden test nie wpłynął na dane innego testu. Wszystkie testy uruchamiamy równolegle. Co dodatkowo symuluje nam asynchroniczne używanie naszej aplikacji przez wielu użytkowników równocześnie, będące codziennością dla naszej aplikacji uruchomionej na produkcji.

Dodatkową korzyścią wynikającą z takiego podejścia jest to, że testujemy aplikację w sposób zbliżony do warunków produkcyjnych. Na produkcji nie restartujemy aplikacji po każdej interakcji z systemem. Ponadto, na produkcji HTTP API naszej aplikacji jest wywoływane równolegle przez wielu użytkowników, a nasze testy wchodzą również w interakcję z systemami trzecimi na takich samych zasadach, jakie panują na produkcji.

Uczeń

Jak wygląda przykładowy przypadek testowy w kodzie?

Mistrz

Przykładowy test z prezentacji KGD .NET z projektu NTeoTestBuildeR obejmuje scenariusz, w którym tworzę nowe zadanie todo, przeglądam je, oznaczam jako wykonane, a na koniec jeszcze raz przeglądam to zakończone zadanie.

[Fact]
public async Task DoneTodo()
{
    // assert
    var testCase = "1D1E4128-31F3-4108-8CF6-C2E7F2E495BC";
    var title = $"Land on the moon {testCase}";
    var tag = "astronomy";

    // act
    var actual = await new TodosTestBuilder()
        .CreateTodo(description: "Set up a to-do", title, tags: [tag])
        .GetTodo(description: "Retrieve already created to-do item", title)
        .DoneTodo(description: "Mark the to-do as done", title)
        .GetTodo(description: "Retrieve the to-do that has been done", title)
        .Build();

    // assert
    await Verify(actual);
}

Inny przykład testu z tej samej prezentacji KGD .NET z projektu NTeoTestBuildeR obejmuje scenariusz, w którym tworzę dwa nowe zadania todo, zmieniam tagi tym zadaniom, oznaczam jedno z zadań jako wykonane, a następnie przeglądam swoje zadania.

[Fact]
public async Task ChangeTagsAndMarkTodoAsDone()
{
    // arrange
    var testCase = "A052551C-4577-4D84-9FFC-AA7227F11C54";
    var theoryTitle = $"Define theory of everything {testCase}";
    var flightTitle = $"Flight to Alpha Centauri {testCase}";
    var astronomy = "astronomy";
    var physics = "physics";
    var theory = "theoretical";
    var practice = "practical";

    // act
    var actual = await new TodosTestBuilder()
        .CreateTodo(description: "Set up first theoretical to-do", theoryTitle, tags: [astronomy])
        .CreateTodo(description: "Set up second practical to-do", flightTitle, tags: [astronomy])
        .ChangeTags(description: "Change tags of the theory", theoryTitle, newTags: [physics, theory])
        .ChangeTags(description: "Change tags of the practice", flightTitle, newTags: [astronomy, practice])
        .DoneTodo(description: "Mark the theoretical to-do as done", theoryTitle)
        .GetTodo(description: "Retrieve the theoretical to-do that has been done", theoryTitle)
        .GetTodo(description: "Retrieve the practical that has not been done yet", flightTitle)
        .Build();

    // assert
    await Verify(target: actual);
}

Naszym celem jest również zapewnienie czytelności kodu testów. Chcemy, aby kod testów był łatwy do zrozumienia i jednoznacznie pokazywał cel testu. Kod testów powinien być zrozumiały również dla osób o mniejszej wiedzy technicznej, na przykład dla zamawiających oprogramowanie. Natomiast dla deweloperów, którzy chcą zagłębić się w szczegóły implementacyjne, przygotowana jest niższa warstwa testów. Taki podział gwarantuje nam przejrzystość i zrozumienie przypadku testowego na wyższym poziomie, a na niższej warstwie oferuje wszystkie szczegóły implementacyjne.

Przykład innego testu z repozytorium Confab, który napisałem podczas przerabiania kursu DevMentors o tytule ‚Modularny Monolit‚. Kurs ten nie dotyczy testów, jednak jako dodatkowe ćwiczenie napisałem w nim testy. Podczas tych warsztatów sprawdzałem opisywane tutaj podejście do pisania testów w różnych projektach. Jeśli jesteś zainteresowany większą ilością szczegółów na temat tego repozytorium, odsyłam do pliku README tego projektu.

[Fact]
internal async Task Given_Track_When_Create_Slot_Then_NotContent204()
{
    // arrange
    var target = await TestBuilder
        .WithAuthentication()
        .WithHost()
        .WithConference()
        .WithTrack()
        .Build();

    // act
    var actual = await target.CreateRegularSlot();

    // assert
    actual.ShouldBeNoContent204();
}

Tutaj przykład testu, który realizuje cały przebieg rozgrywki między dwoma graczami w grę karcianą Blef. Widzimy w nim, kolejno, jakie ruchy gracze wykonywali od samego początku aż do zwycięstwa jednego z nich. Gra jest również dostępna w repozytorium GitHub. Znajdziesz tam testy napisane zgodnie z opisywanym podejściem. Po więcej szczegółów odsyłam do pliku README.

public class TwoPlayersPlayTheGame
{
    [Fact]
    public async Task Scenario()
    {
        var results = await new TestBuilder()
            .NewGame()
            .JoinPlayer(WhichPlayer.Knuth)
            .JoinPlayer(WhichPlayer.Planck)
            .NewDeal()
            .GetGameFlow()
            .GetDealFlow(new(1))
            .GetCards(WhichPlayer.Knuth, deal: new(1), description: "Knuth has one card")
            .GetCards(WhichPlayer.Planck, deal: new(1), description: "Planck has one card")
            .BidHighCard(WhichPlayer.Knuth, FaceCard.Nine, description: "Knuth starts the deal")
            .Check(WhichPlayer.Planck, description: "Planck checks, Knuth get lost the deal)")
            .GetGameFlow()
            .GetDealFlow(new(1))
            .GetCards(WhichPlayer.Knuth, deal: new(2), description: "Knuth has two cards")
            .GetCards(WhichPlayer.Planck, deal: new(2), description: "Planck has one card")
            .BidHighCard(WhichPlayer.Planck, FaceCard.Ace, description: "Planck starts the deal")
            .Check(WhichPlayer.Knuth, description: "Knuth checks and get lost the deal")
            .GetGameFlow()
            .GetDealFlow(new(2))
            .GetCards(WhichPlayer.Knuth, deal: new(3), description: "Knuth has three cards")
            .GetCards(WhichPlayer.Planck, deal: new(3), description: "Planck has one card")
            .BidPair(WhichPlayer.Knuth, FaceCard.King, description: "Bad move Knuth!")
            .BidPair(WhichPlayer.Planck, FaceCard.Ace, description: "Planck starts the deal")
            .Check(WhichPlayer.Knuth, description: "Knuth checks and get lost the deal")
            .GetGameFlow()
            .GetDealFlow(new(3))
            .GetCards(WhichPlayer.Knuth, deal: new(4), description: "Knuth has four cards")
            .GetCards(WhichPlayer.Planck, deal: new(4), description: "Planck has one card")
            .BidTwoPairs(WhichPlayer.Planck, FaceCard.Nine, FaceCard.Ten, description: "Planck starts the deal")
            .BidFourOfAKind(WhichPlayer.Knuth, FaceCard.Nine)
            .Check(WhichPlayer.Planck, description: "Planck checks and Knuth get lost the deal")
            .GetGameFlow()
            .GetDealFlow(new(4))
            .GetCards(WhichPlayer.Knuth, deal: new(5), description: "Knuth has five cards")
            .GetCards(WhichPlayer.Planck, deal: new(5), description: "Planck has one card")
            .BidHighCard(WhichPlayer.Knuth, FaceCard.Queen, description: "Bad move Knuth!")
            .BidHighCard(WhichPlayer.Planck, FaceCard.King, description: "Planck starts the deal")
            .BidPair(WhichPlayer.Knuth, FaceCard.Queen)
            .BidPair(WhichPlayer.Planck, FaceCard.King)
            .BidFullHouse(WhichPlayer.Knuth, FaceCard.Ace, FaceCard.King)
            .Check(WhichPlayer.Planck, description: "Planck checks and Knuth get lost the GAME!")
            .GetGameFlow("Planck wins the game!")
            .GetDealFlow(new(5))
            .Build();

        await Verify(results);
    }
}

Uczeń

Powiedz coś więcej o statystykach waszych testów.

Mistrz

Dzięki zastosowaniu opisanego podejścia przyspieszyliśmy wykonywanie testów z poziomu około 7 minut i 30 sekund do około 28 sekund. Muszę tutaj dodać, że większość czasu z tych 28 sekund to czas potrzebny na uruchomienie aplikacji i instancjowanie bazy danych dla pierwszego testu. Oznacza to, że po wielu latach wytwarzania aplikacji, gdy wielokrotnie zwiększy się liczba testów, czas ich wykonania praktycznie się nie zmieni i zostanie nadal na poziomie około pół minuty.

Posiadamy obecnie około 300 testów, które dają nam pokrycie na poziomie około 90%. Tak duży procent pokrycia był bardzo łatwy do osiągnięcia, ponieważ testy nasze sprawdzają wszystkie warstwy aplikacji, od API, przez warstwę aplikacji, infrastrukturę i domenę, przechodząc przez cały kod aplikacji tak, jak dzieje się to na produkcji.

Mistrz

Zatem, młody przyjacielu, który idziesz przede mną… czy pójdziemy razem?

Uczeń

Tak, chodźmy razem. I dziękuję za czas, który mi poświęciłeś.


Czytelność testów

Polecam obejrzeć prezentację Jakuba Nabrdalika pod tytułem „Improving your Test Driven Development in 45 minutes” a w szczególności fragment który rozpoczyna się w 21. minucie i 28. sekundzie i kończy w 29. minucie i 40. sekundzie.

Test powinien zawierać minimalną ilość informacji, wystarczającą do zrozumienia, czego dotyczy w kontekście wymagań stawianych przed aplikacją. Wszystkie szczegóły implementacyjne powinny być ukryte, aby nie rozpraszać uwagi czytającego.

Za dużo informacji to jest dokładnie to samo, co za mało informacji.
Jakub Nabrdalik

Testy odpalają aplikację w taki sam sposób jak aplikacja odpalana jest na produkcji

Dwie pozycje, które koniecznie musisz obejrzeć, bardzo zwięźle, jasno i technicznie wyjaśniają aspekt związany z testowaniem. Zawierają wskazówki, aby nasze testy nie symulowały nierealnych sytuacji, które nigdy nie zdarzają się na produkcji. Jest to fundamentalnie istotne, abyśmy ufali swoim testom. Możemy im ufać jedynie wtedy, gdy uruchamiają aplikację w identyczny sposób, jak ma to miejsce w środowisku produkcyjnym.

CEZARY PIĄTEK – Stress-free automatic deployments with Component Tests

CEZARY PIĄTEK - Stress-free automatic deployments with Component Tests

KRZYSZTOF PORĘBSKI – Kubernetes onboarding from developer’s perspective

Konkluzja

W kontekście dążenia do zwinności w procesie wytwarzania oprogramowania, koncentracja na Scrumie jako narzędziu nie wystarczy. Ważniejsze jest przygotowanie kodu tak, aby był elastyczny i umożliwiał szybkie reagowanie na zmiany. Testy odgrywają kluczową rolę w osiągnięciu tej zwinności, jednak ich nieprawidłowe pisanie może skutkować utrwaleniem błędów i opóźnieniami w procesie. Konieczne jest unikanie testów zbyt mocno związanych z implementacją, co może prowadzić do potrzeby wielokrotnego refaktoringu testów przy każdej zmianie. Zamiast tego, testy powinny być oparte na publicznym interfejsie aplikacji, co ułatwia refaktoring i utrzymanie testów w przypadku zmian w szczegółach implementacyjnych. Dodatkowo, zamiast używać mocków na niższym poziomie, lepiej jest symulować zewnętrzne zależności za pomocą wirtualnych serwerów HTTP lub kontenerów Docker, co zapewnia stabilność i skalowalność testów. Ostatecznie, testy powinny odzwierciedlać rzeczywiste interakcje użytkownika z aplikacją, zapewniając równocześnie izolację między testami i możliwość równoległego ich wykonywania.

Verify

Verify to wygodne tworzenie asercji w oparciu o snapshot testing. Jeśli chcesz wygodnie i szybko pisać asercje w testach, jeśli nie chcesz mieć całego tego piekła związanego z ciągiem asercji. Gdy masz złożony obiekt z wieloma właściwościami, mający kolekcje z dziesiątkami elementów, jeśli chcesz pisać testy szybko i wygodnie, polecam zainteresować się narzędziem Verify.

Wiremock

Wiremock to narzędzie służące do tworzenia i zarządzania mockami interfejsów HTTP, umożliwiające symulowanie zachowania rzeczywistych serwerów API. Jest łatwe w konfiguracji i obsłudze, a jednocześnie potężne. Dzięki niemu nie musisz już pisać mocków rejestrowanych w kontenerze IoC na poziomie aplikacji.

Testcontainers

Testcontainers to narzędzie, które ułatwia testowanie aplikacji poprzez automatyczne uruchamianie i zarządzanie kontenerami Docker w czasie wykonywania testów. Dzięki Testcontainers możesz łatwo symulować środowiska zależne, takie jak bazy danych czy kolejki komunikatów, co umożliwia szybkie i skuteczne testowanie aplikacji w izolowanych warunkach.

Test Builder

Autorskie rozwiązanie, które umożliwia budowę czytelnych i łatwych w utrzymaniu testów, zgodnych z praktykami opisanymi w tym wpisie na blogu, sprawia, że proces testowania staje się wygodny.

Koncept ewoluował przez długie lata, zapoczątkowany przez Krzysztofa Porębskiego, który jako pierwszy przedstawił ideę kolekcjonowania kroków scenariusza testowego w kolekcji funkcji, które później są wywoływane jedna po drugiej w metodzie ‚Build’ i za pomocą pętli ‚foreach’. Podążając za rozwiązaniem tamtych czasów, pomysł ten powrócił po wielu latach.

Confab

Pierwszą implementację stworzyłem w projekcie Confab, który powstał w ramach kursu o Modularnym Monolicie od DevMentors. Ten kurs nie skupiał się na testach. Choć kurs nie był skoncentrowany na testach, Confab był pierwszym projektem, na którym postanowiłem wypróbować pisanie testów w sposób bardziej czytelny, łatwy w utrzymaniu i mniej skomplikowany. Swoją drogą, zakup tego kursu to chyba najlepsza inwestycja kilku stówek w moim życiu.

Blef

Następnie zastosowałem to samo podejście w projekcie Blef, który pisałem po godzinach dla zabawy. W tym projekcie nadal doskonaliłem pomysł na pisanie testów. Blef to gra karciana, która nie została stworzona z myślą o pisaniu testów. Jednakże, testy okazały się być nieocenionym dodatkiem, który znacząco ułatwił pracę nad projektem.

Frontiers

Ostatecznie z powodzeniem wykorzystałem to rozwiązanie w projekcie Artemis podczas pracy w Frontiers. Projekt ten trwał przez okres jednego roku. Pracowały nad nim równolegle dwa zespoły, składające się z około 2-3 backend developerów, 2-3 frontend developerów, liderów, architektów, testerów i właścicieli produktów. Wykorzystywaliśmy ASP .NET Core Web API. Projekt opierał się na Modularnym Monolicie, gdzie każdy moduł miał własną architekturę. Najczęstsze architektury modułów obejmowały: API + Services + 3rd-party-API + DB, CRUD (API + DB), oraz Clean Architecture (w tym 3rd-party-API). Projekt posiadał mnóstwo integracji z zewnętrznymi API obejmując zarówno HTTP API, jak i GraphQL. Persystencję mieliśmy w PostgreSQL oraz Redis.


Acknowledgement

Chciałbym podziękować wszystkim osobom za pomoc oraz za pomysły, które od nich otrzymałem zarówno w bezpośrednim kontakcie, jak i za pośrednictwem prezentacji, które wygłaszali, oraz podczas codziennej współpracy: Cezary Piątek (Wiremock), Krzysztof Porębski (Test Builder), Marcin Celej (Verify), Jakub Nabrdalik (Czytelność Testów), Robert Łysoń (Testcontainer), DevMentors (Modularny Monolit).

Verify

Po raz pierwszy o bibliotece Verify usłyszałem na 141. spotkaniu Krakowskiej Grupy .NET podczas wystąpienia Marcina Celeja z prelekcją ‚My Recent Project Speed Run’, którą można obejrzeć na YouTube.

Wiremock

W serii wpisów Cezarego Piątka znajdziesz wiele przydatnych informacji. To dzięki niemu poznałem Wiremock i przy jego wsparciu opracowałem obecne rozwiązanie.

Zachęcam do zapoznania się z tymi wpisami na jego blogu:

Testcontainers

Narzędzie to poznałem dzięki Robertowi Łysoń, pracując wspólnie nad projektem Artemis w Frontiers. Wcześniej zarządzanie bazą danych wykonywałem sam, na piechotę, robiąc to ręcznie, co było zawsze uciążliwe i posiadało niedoskonałości. Testcontainers bardzo ułatwił nam życie.

Test Builder

Ten pomysł rozwijał się przez lata. Jednakże, początki i koncepcję po raz pierwszy zarysował dawno temu Krzysztof Porębski. Pracowaliśmy wówczas nad rozproszonym systemem opartym na asynchronicznych zdarzeniach. Inspirując się ówczesnymi rozwiązaniami, pomysł odżył po latach. Dawne wspomnienia skłoniły mnie do odtworzenia tamtych pomysłów.


Teraz, to jest właściwy moment,
abyś przełączył się na nagranie prezentacji dostępne na YouTube.