Sentencja jest zwięzła i gdyby się nad nią dłużej zastanowić, niesie za sobą olbrzymią ilość treści. Cała prelekcja jest bardzo dobra, a niżej linkuję do fragmentu który szczególnie mi się spodobał.
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.
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.
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ś.
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.
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.
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:
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.
To był wcześniejszy tytuł tego tekstu, który nie za bardzo się kliknął, ale zostawiłem go, ponieważ jeśli już ktoś dotarł tutaj, to ten tytuł w maksymalnie skondensowanej formie wyjaśnia na czym polega pomysł rozwiązania i równie zwięźle podsumowuje wyżej linkowany artykuł o projektowaniu REST API.
Domena
Jako przykład operacji biznesowej posłuży mi domena nauki wraz z jej artykułami naukowymi, które podlegają procesowi recenzji przed opublikowaniem. W tej domenie miałem okazję pracować przez ostatnie dwa lata.
Praca naukowa, która pomyślnie przeszła proces recenzji jest publikowana w czasopiśmie naukowym. Manuskryptem będziemy nazywać pracę naukową, która jest w trakcie recenzji. W celu przeprowadzenia recenzji, recenzenci są zapraszani dla konkretnego manuskryptu. Oni decydują o jego akceptacji lub odrzuceniu. Manuskrypt zaakceptowany jest następnie wysyłany do czasopisma naukowego, aby mógł zostać opublikowany jako artykuł.
Implementacja
Ogólny wzorzec operacji biznesowych na poziomie API przedstawia się następująco: na poziomie zasobów (resources) dodaje się nowy zasób o nazwie „actions„. Następnie, w obrębie „actions„, dodawana jest konkretna operacja biznesowa, czyli czasownik (action) w formie rozkazującej.
POST /{resource}/{id}/actions/{action}
Przykładem synchronicznej operacji biznesowej może być zaproszenie recenzenta oraz akceptacja lub odrzucenie manuskryptu. Każda z tych operacji biznesowych może być realizowana przez inny proces, który zależy od konfiguracji specyficznej dla poszczególnych magazynów naukowych.
POST /manuscripts/256/actions/invite
POST /manuscripts/256/actions/accept
POST /manuscripts/256/actions/reject
Sync
W odpowiedzi zwracany jest status 204 (No Content).
204 No Content
Async
W odpowiedzi zwracany jest status 202 (Accepted), a w nagłówku „Location” podawany jest link, za pomocą którego można sprawdzić status realizowanego procesu.
Powodem modelowania asynchronicznej operacji biznesowej może być proces, w którym wymagane są więcej niż jeden krok (na przykład dodatkowa zgoda redaktora magazynu naukowego) lub gdy operacja jest czasochłonna i nie oczekuje się, że klient będzie czekać na jej zakończenie.
Przykładowo, publikację artykułu naukowego w magazynie naukowym można zamodelować w ten sposób.
Następnie klient, korzystając z otrzymanego linku, wykonuje zapytanie.
GET /journals/16384/actions/submit/4096
W przypadku, gdy akcja nadal jest w trakcie wykonywania, wówczas zwracany jest status 102 (Processing) wraz z nagłówkiem „Retry-After„, który informuje, kiedy klient powinien wykonać ponowne sprawdzenie statusu.
102 Processing Retry-After: 30
W przypadku, gdy akcja została wykonana w całości, wówczas zwracany jest status 204 (No Content).
204 No content
Sync lub Async
W szczególnych przypadkach możemy dać klientowi możliwość wyboru, czy dana operacja ma być wykonywana synchronicznie, czy asynchronicznie.
Operacja zostanie wykonana synchronicznie, jeśli klient podczas wywoływania API ustawi nagłówek „Expect” z wartością statusu 204 (No Content).
POST /journals/16384/actions/submit Expected: 204-no-content
Operacja zostanie wykonana asynchronicznie, jeśli klient podczas wywoływania API ustawi nagłówek ‚Expect’ z wartością statusu 202 (Accepted).
POST /journals/16384/actions/submit Expected: 202-accepted
W przypadku, gdy wybrany przez klienta oczekiwany sposób wykonania nie jest dostępny, wówczas akcja zwraca status 417 (Expectation Failed).
417 Expectation Failed
Acknowledgement
W ostatnim roku pracowałem nad projektem, w którym modelowaliśmy HTTP API w stylu Remote Procedure Call (RPC). Architektem i pomysłodawcą tego podejścia był Konrad Kwiatkowski, który kompletnie zmienił moje postrzeganie świata, przekonując mnie, że nie musimy dążyć do osiągnięcia czwartej, a nawet półtorej wersji dojrzałości modelu Richardsona dla REST API. Za tę perspektywę jestem mu niezmiernie wdzięczny.
Wyluzujcie z tym kodem, mam coś, co zrobi za Was całą brudną robotę. CleanupCode Command-Line Tool w połączeniu z GitHub Actions – to jest to. Wystarczy kilka kliknięć i… voilà, kod sam się czyści. W README repo ReSharper CLI CleanupCode znajdziecie wszystko, czego potrzeba, żeby to ustawić.
Rzućcie okiem na ten link: ReSharper CLI CleanupCode – tak, to moje dzieło, które rok temu wylądowało na GitHub Marketplace. To właśnie tam zaczarowałem GitHub Actions, żeby sprzątały kod za Was.
Jeśli ciekawi Was, jak naprawdę działa ta GitHub Action, sprawdźcie repo Blef, mój open source projekt robiony po godzinach.To właśnie tam też, wśród skomplikowanych workflows GitHub, testowałem to rozwiązanie przez ostatni rok. Jest to open source więc mam możliwość podzielić się z Wami tym kawałkiem pracy. Zapraszam do repo Blef – i może nawet do dołączenia do gry?
Więc dajcie sobie szansę na nowe lepsze życie. W końcu kodowanie ma być przyjemnością, a nie sprzątaniem. Niech kod czyści się sam a wy miejcie czas na to, co naprawdę lubicie. Odpalcie sobie tego CleanupCode’a i GitHub Actions, a potem już tylko relaks i kodowanie, jak lubicie!
Jakby co, piszcie, chętnie podzielę się większymi szczegółami!
Update 03-01-2024: Zamieszczam link do rozmowy na Twitterze, która stanowi znakomite uzupełnienie informacji o narzędziu oraz zawiera cenne spostrzeżenia dotyczące samego konceptu. Dla osób, które nie posiadają konta na Twitterze, każda z odpowiedzi została opublikowana osobno.
Zgadzam się z ideą, tj. twardo zdefiniowane reguły dla kodu i opakowanie tego w automatyzację. Nie zgadzam się z automagicznymi commitami dodawanymi w CI. IMHO pipeline powinien weryfikować wysyłany kod tym samym źródłem prawdy, ale co najwyżej zrobić fail. Bez własnych inwencji.
Tak i z czasem zrezygnowałem z tych auto tworzonych commitów w PR. Odpalam skrypt lokalny na kompie który tworzy mi lokalnie commita, który później wypycham na requesta. GitHub Action ustawione mam tak że powiadamia mnie o tym że jest potrzebny commit z formatem kodu
Powiem więcej, gdy tworzony jest taki automatyczny commit to gdy o tym sobie zapomnisz to jest ryzyko że będziesz miał sam ze sobą konflikty na twoim branchu gdy wypchniesz coś na PR i gdy tam na PR zostanie stworzony commit a ty lokalnie dodasz równolegle inny commit.
Commita automatycznie tworzę na lokalnym kompie odpalając ten sam skrypt który jest w GitHub Action z odpowiednimi flagami. GitHub Action ma dwa tryby możesz go odpalać tak że robi ci autocommita i możesz go odpalać tak że ci go nie robi ale informuję Cię o failu.
Najbardziej "strict" byłoby zafiksowanie w kodzie wersji toola do sprawdzania kodu, albo jeszcze dalej: dołączenie binarki tego toola do repo. W ten sposób sukces lokalnie zawsze oznacza sukces w CI, nic się nie rozjedzie.
Update 04-01-2024: Jeden z problemów, który rozwiązuje ta GitHub Action.
Ostatnie komentarze w moich PR ograniczają się w 90% jeśli nie więcej do istoty w której linijce ma być { lub } Czy ma być 1x 2x 3x entery pomiędzy linijkami odstępów w okolicach = I takich tam krytycznych uwag
Update 04-01-2024: Narzędzie/podejście/metoda ta przybliża do osiągnięcia tego co Arkency opisali na swoim blog poście Disadvantages of Pull Requests. Nomen omen, bardzo polecam przeczytać.
Spotykamy się w bardzo luźnej atmosferze, gdzie w trakcie spotkania możemy poczęstować się darmową pizzą oraz browarem. Rozmowy mają charakter nieformalny. Zdobywamy nową wiedzę, nie zapominając o miłej atmosferze.
Po zakończeniu drugiej prelekcji często zostajemy na dłużej, aby nawiązać nowe znajomości oraz wymienić spostrzeżenia, często również są ścisłe, specjalistyczne, techniczne i często trudne z dziedziny programowania.
Prelegenci
Nagrody
100 spotkanie KGD .NET
10th Making Software & 100th KGD .NET – Kraków 2016
Przekazywanie zmiennej typu wartościowego, takiej jak int czy czy struct do metody przez referencję oraz zwracanie wartości z metody przez referencję to coś, co rzadko wykorzystuje się na co dzień, dlatego od czasu do czasu warto odświeżyć sobie tę wiedzę.
W załączonym kodzie możesz prześledzić różnice między przekazywaniem argumentów przez wartość oraz przez referencję. Przedstawiam przykłady przekazywania typów wartościowych oraz referencyjnych jako argumenty do metod i modyfikowania ich wartości wewnątrz metod.