Dla programistów i nie tylko dla nich. Na przykładzie języka PHP.
Po kilku słowach wprowadzenia, nadszedł czas na sedno sprawy.
Pierwszym elementem, z którym stykamy się podczas pisania testów to struktura katalogów, w jakiej będziemy je umieszczać.
Naszym zdaniem najprostsza i zarazem najłatwiejsza do zrozumienia dla programistów struktura to taka, która jest lustrzanym odbiciem struktury aplikacji.
Poniżej przykład struktury powstających testów na przykładzie standardowo pisanej aplikacji we frameworku Laravel.
Jak widać, stworzone testy jednostkowe niemal odpowiadają lustrzanemu układowi katalogów, tak jak testowane klasy i ich umiejscowienie w aplikacji.
W strukturze katalogów warto też rozważyć podział na testy integracyjne i jednostkowe.
I tu ważna kwestia – z reguły testy jednostkowe są znacznie szybsze od testów integracyjnych. Podczas pisania i rozwijania kodu o wiele łatwiej jest nam wstępnie weryfikować kod najpierw za pomocą testów jednostkowych, a dopiero w dalszej kolejności za pomocą testów integracyjnych.
Innym zastosowaniem podziału na katalogi jest sytuacja, w której chcemy puścić wszystkie testy w systemie. Wówczas najlepiej rozpocząć od testów jednostkowych, a dopiero jeśli te przejdą – puścić testy integracyjne, które zajmują więcej czasu.
Zatem podstawa struktury katalogów powinna wyglądać następująco:
Dopiero wewnątrz tych katalogów robimy odbicie lustrzane katalogów aplikacji (oczywiście tylko dla tych katalogów, których klasy testujemy).
Istotnym elementem jest poprawne nazwanie metody testowej.
Najpierw przypadek prostszy, kiedy piszemy kilka testów do całej klasy posiadającej niewiele metod i niewiele kombinacji użycia.
W takim przypadku nazwa metody testowej powinna zaczynać się tak samo jak nazwa metody, którą ona testuje. Jest to jakby prefix nazwy metody, dalsza jej cześć może odpowiadać np. oczekiwanemu rezultatowi. Prefix od dalszej części powinien być oddzielony za pomocą _.
Format powinien być następujący:
testedMethodName_StateUnderTest_ExpectedBehavior
Poniżej przykład klasy testowanej oraz nazw metod z kodu ją testującego.
class OrdersController { public function index(){…}; public function show($id){…}; public function store(OrderParams $params){…}; public function destroy($id){…}; } class OrdersControllerTest() extends TestCase { ... /** * @test */ public function destory_validId_success(){…}; /** * @test */ public function destory_validIdButStateIsBlocked_error(){…}; /** * @test */ public function destory_invalidId_error(){…}; }
Kolejny przypadek to sytuacja, w której konkretna metoda w klasie może posiadać wiele możliwych kombinacji do przetestowania (UWAGA – jest to sygnał, że być może ta metoda nie spełnia wymogu Single Responsibility i może wymagałaby refaktoryzacji). Zdarza się to często w przypadku pisania testu jednostkowego, wówczas powinny zostać rozmieszczone pliki testu według następującej zasady – powinien zostać stworzony katalog o nazwie takiej samej, jak nazwa testowanej klasy, a następnie plik test zaczynający się od nazwy metody, która jest testowana.
Czyli zamiast postaci takiej:
Nasze drzewo katalogów prezentować się będzie w następującej postaci:
Taka struktura pozwala także łatwo odnaleźć w IDE wszystkie powiązane klasy testowe z poziomu kodu źródłowego poprzez wyszukanie frazy nazwaKlasyTest przykładowo search: OrdersControllerTest.
Ważnym aspektem podczas pisania testów jest takie opisanie testu, aby było jasne, jaki kod dany test testuje oraz jaki scenariusz użycia. W tym kontekście bardzo pomocna jest technika stosowana przez UniTree, po części spójna z Gherkin. Polega ona na opisaniu testu za pomocą doc-bloków @Feature-@Scenario-@Case.
Najprościej wytłumaczyć to za pomocą uwierzytelniania (authentication) do systemu.
Podczas przeglądania testów bardzo ważne jest, aby test zdradzał, co w ogóle testuje oraz gdzie i czego możemy się spodziewać. Zatem jego treść powinna być jak czytelny kawałek prozy, opisujący w maksymalnie zrozumiały sposób co test robi, ale niekoniecznie opisujący detale techniczne.
Po otwarciu pliku testowego chcielibyśmy przecież skupić całą uwagę na jego logice, a nie na technicznych szczegółach.
Zacznijmy od podziału kodu na oczekiwane w testach bloki.
Niemal każdy test składa się z trzech bloków:
Wprowadzenie komentarzy opisujących ten podział bardzo upraszcza szybkie zrozumienie, co i gdzie się dzieje w teście.
public function synchronize_forNewSynchronization_databaseRowCreated(): void { //GIVEN $synchronizer = $this->newSynchronizer(); //WHEN $result = $synchronizer->synchronize(); //THEN $this->assertDatabaseHas( 'meals', [ 'external_source' => 'WORDPRESS', 'title' => 'How To Make Chicken Fajitas (Easy Fajita Recipe)', ] ); }
When we have a more complicated part of setting up the environment, it starts to be even more valuable:
public function resolve_floatValue_valuesAreEqual() { // GIVEN $nutritional_model = Mockery::mock(NutritionModel::class); $nutritional_model->shouldReceive(‘getNutritions’)->once(); $nutritional_value = \Mockery::mock(ConstNutrition::class); $nutritional_value->shouldReceive('getValue')->once()->andReturn(§0); // WHEN $summarized_nutrition = $this->makeNutritionBag($nutrition_model); $actual_calories = $summarized_nutrition->calculate(); // THEN $this->assertSame($nutritional_value, $actual_calories); }
Jak widać powyżej, klarownym stało się co i gdzie jest w teście, pomimo że sam test jest nadal dość zawiły. Należałoby uprościć taki test do postaci bardziej „opisowej”.
Pomocnym w osiągnieciu tego założenia jest także ekstrakcja kodu do osobnych metod, a jeszcze lepiej do osobnego pliku. W przypadku PHP przydatnym jest tutaj zastosowanie Traits.
Poniżej przykład jak można powyższy kod uczynić bardziej czytelnym poprzez ekstrakcję szczegółów do osobnych metod z odpowiednim ich nazwaniem, tak aby określały jasno jaka jest ich intencja.
class MealTest { public function resolve_floatValue_valuesAreEqual() { // GIVEN $nutrition_model = $this->mockNutritionModel(); $nutritional_value = $this->mockNutritionValue(); // WHEN $summarized_nutrition = $this->makeNutritionBag($$nutrition_model); $actual_calories = $summarized_nutrition->calculate(); // THEN $this->assertSame($nutritional_value, $actual_calories); } private function mockNutritionModel() { $nutritional_model = Mockery::mock(NutritionModel::class); $nutritional_model->shouldReceive(‘getNutritions’)->once(); return $nutritional_model; } private function mockNutritionValue() { $nutritional_value = Mockery::mock(ConstNutrition::class); $nutritional_value->shouldReceive('getValue')->once()->andReturn(null); return $nutritional_value; } }
Następny listing prezentuje wygląd klasy po wyniesieniu tych metod do osobnego traita. Jak widać główna klasa testowa nie rozprasza uwagi programisty i pozwala zrozumieć co się w danym teście dzieje.
class MealTest { public function resolve_floatValue_valuesAreEqual() { // GIVEN $nutrition_model = $this->mockNutritionModel(); $nutritional_value = $this->mockNutritionValue(); // WHEN $summarized_nutrition = $this->makeNutritionBag($$nutrition_model); $actual_calories = $summarized_nutrition->calculate(); // THEN $this->assertSame($nutritional_value, $actual_calories); } }
Przykładowe rozbudowane drzewko z traitami:
Bardzo ważnym elementem pisania kodu testów jest założenie, że testy powinny być od siebie niezależne. Co to oznacza? Oznacza, że w przypadku kodu testów jednostkowych nie stosujemy zbytnio zasady DRY pomiędzy różnymi klasami testowymi.
Przykładowo, we wcześniejszym rozdziale opisaliśmy, że aby wyodrębnić kod do traita każda klasa testowa powinna mieć swój trait, który nie może być używany przez inne klasy testowe. Nawet jeśli inna klasa testowa miałaby z początku identyczny trait, powinna ona uzyskać swój niezależny trait. Z czasem dojdzie prawdopodobnie do sytuacji, w której klasa testowa będzie miała wprowadzone zmiany, a przecież nie chcemy sami sobie generować konfliktów pomiędzy testami różnych klas.
W skrócie – test powinien sprawdzać jedno konkretne użycie. Nawet jeśli wymagałoby to napisania wielu podobnych testów. Ograniczenie powielania kodu w obrębie klasy testowej możemy osiągnąć za pomocą Traits dla danej klasy.
Jednoznaczność oznacza tutaj, że podczas sprawdzania oczekiwanego rezultatu test posiada tylko jedna asercję.
Czasem zdarzają się przypadki, w których poszczególny kod chcielibyśmy przetestować przy różnych warunkach brzegowych. W przypadku pisania testów dla poszczególnych funkcjonalności powinniśmy tutaj wykorzystać data providers, które ograniczają konieczność powtarzania kodu testu wielokrotnie dla różnych parametrów. Piszemy tylko jedna metodę testową i parametryzujemy ją, natomiast wartości parametrów wstrzykujemy za pomocą dataprovidera.
Poniżej przykładowy kod bez data providera i z data providerem.
/** * @feature Registration * @scenario Send Freelancer Application FormRegister * @case Require coming Freelancer send Application with CSR * @dataProvider provideCsr * @test */ public function it_required_freelancer_send_application_form_with_csr($state, $csr): void { //...... } public function provideCsr(): array { return [ [ 'state' => 'FL', 'csr' => 'fl-csr', ], [ 'state' => 'AZ', 'csr' => null, ], [ 'state' => 'AZ', 'csr' => 'az-csr', ], ]; }
WAŻNE – także w tym przypadku powinniśmy zadbać o niezależność testów i nie współdzielić data providerow pomiędzy wieloma testami. Mogą być tu jednak pewne wyjątki, na przykład, kiedy wiemy, że konkretny zestaw danych powinien być zawsze spójny w obrębie większej liczby elementów systemu – np. konkretne konta użytkownika o konkretnych rolach, które trzeba utworzyć dla testów i się nimi zalogować, a nie mają one większego wpływu na sposób testowania i logikę testów (nie da się inaczej przygotować wymaganych symulowanych użytkowników).
Andrzej Fenzel, Pomysłodawca UniTree.app