Pester (3) – maskowanie poleceń
Po bałaganie związanym z przygotowaniem do konferencji, samą konferencją i wszystkim tym, co przyszło po jej zakończeniu – pora wreszcie wrócić do cyklu o module Pester. W pierwszych dwóch częściach przyjrzeliśmy się zastosowaniom tego modułu, sposobom jego instalacji oraz podstawowej składni, z której korzystać będziemy przy tworzeniu testów. Dziś przyszła pora na temat, który jest szalenie istotny w przypadku rozwiązań takich jakich jak skrypty/ moduły w PowerShellu: maskowaniu poleceń. Chodzi to o to, by wilk był syty i owca cała: polecenie PowerShella mogą wprowadzać nieodwracalne zmiany w systemie, nie chcemy więc prawdziwych poleceń uruchamiać w trakcie testowania kodu. Z drugiej strony: jeśli chcemy prześledzić w pełni zachowanie naszej funkcji to musi zadbać o to, by maskować na tyle subtelnie, by kod napisany przez nas „się nie zorientował”. Przyjrzymy się też temu, jak tworzyć raporty o pokryciu naszego kodu testami.
Funkcja, którą staramy się przetestować, z założenia systemu zmieniać nie będzie. Pobiera ona jedynie informacje z zewnętrznego API. Czy warto więc maskować cokolwiek w tym wypadku? Dużo zależy od tego gdzie i jak zamierzamy przeprowadzać testy. Testy automatyczne, mające na celu potwierdzenie, że nasz kod zachowuje się dokładnie tak, jak oczekujemy, najlepiej przeprowadzić z wykorzystaniem zamaskowanej wersji poleceń. Dlaczego? Brak połączenia z Internetem, czy też zmiany w samym API nie wpłyną na wynik testów. Naturalnie, warto też potwierdzić, że szczególnie zmiany po stronie API nie spowodują, że nasza funkcja/ skrypt niespodziewanie przestaną działać. Ale ten rodzaj testów lepiej przeprowadzać na etapie późniejszym, na przykład przed opublikowaniem kodu.
Ja to mocknąć?
Do maskowania rzeczywistych poleceń służy polecenie Mock. Polecenie to posiada kilka parametrów, ale jedyny wymagany parametr to CommandName. Jeśli do niego się ograniczymy to „uciszymy” rzeczywiste polecenie w każdym momencie naszych testów, niezależnie od tego, z jakimi parametrami polecenie to zostało uruchomione. Tego rodzaju maskowanie przyda nam się w dwu przypadkach: chcemy absolutnie upewnić się, że polecenia trwale zmieniające stan systemu nie zostaną uruchomione (na przykład Remove-Item), lub interesować nas będzie jedynie to, czy maskowane polecenie zostało w ogóle uruchomione w trakcie działania naszego skryptu/ funkcji.
Describe 'Testujemy maskowanie poleceń' { | |
Mock -CommandName New-Item | |
It 'Nie stworzy pliku c:\jakaśNazwa' { | |
New-Item -Path c:\jakaśNazwa | |
} | |
} | |
Get-Item C:\jaka* |
Zgodnie z oczekiwaniami – plik nie powstanie. Można więc śmiało powiedzieć, że polecenie przestało stanowić potencjalne źródło problemów. Jeśli gdziekolwiek w testowanym kodzie z niego korzystamy, to w ramach naszych testów polecenie to nie zwróci żadnego rezultatu i nie wprowadzi żadnych zmian w systemie.
Na ogół jednak maskując polecenie naśladować chcemy rzeczywiste jego zachowanie. Get-ADUser powinno zwrócić obiekt użytkownika w AD, Get-ChildItem pliki/foldery a w naszym wypadku – Invoke-WebRequest powinno zwrócić zawartość pobraną ze zdalnego serwera. W takim wypadku skorzystać musimy z parametru MockWith, któremu przekażemy blok skryptu, który ma być uruchamiany zamiast maskowanego przez nas polecenia. Będzie to wręcz konieczne wszędzie tam, gdzie wynik jednego polecenia przekierowywać będziemy do innego polecenia przy pomocy potoku. Przykład takiego maskowania dla interesującego nas polecenia, Invoke-WebRequest:
$maskowaneObiekty = @( | |
@{ | |
title = 'Tytuł zamkniętego PRa' | |
body = 'Opis zamkniętego PRa' | |
user = @{ | |
login = 'użyszkodnik' | |
} | |
state = 'closed' | |
created_at = (Get-Date -Date 2017-Jan-01).ToUniversalTime().ToString('o') -replace '\.\d+' | |
closed_at = (Get-Date -Date 2017-Jan-31).ToUniversalTime().ToString('o') -replace '\.\d+' | |
}, | |
@{ | |
title = 'Tytuł otwartego PRa' | |
body = 'Opis otwartego PRa' | |
user = @{ | |
login = 'innyUżyszkodnik' | |
} | |
state = 'open' | |
created_at = (Get-Date -Date 2017-Jan-01).ToUniversalTime().ToString('o') -replace '\.\d+' | |
closed_at = '' | |
} | |
) | |
$maskujPrzyPomocy = @{ | |
Content = $maskowaneObiekty | ConvertTo-Json | |
} | |
Describe 'Zwracanie obiektów z różnymi właściwościami' { | |
Mock -CommandName Invoke-WebRequest -MockWith { | |
$maskujPrzyPomocy | |
} | |
# Testy... | |
} |
Warto tu wspomnieć, że wszelkie parametry dostępne w ramach polecenia są również do naszej dyspozycji w ramach tak tworzonego bloku skryptu. W przypadku poleceń pobierających dane najistotniejsze jest jak najwierniejsze odwzorowanie zachowania interesującego nas polecenia. W przypadku poleceń zmieniających system na ogół będziemy emulować te zmiany. Doskonale przydaje się do tego pseudo-dysk testdrive. Może się jednak zdarzyć, że zmieniać zachowanie polecenia będziemy chcieć jedynie wtedy, gdy testowane przez nas polecenie przekaże do niego odpowiednie parametry. W takiej sytuacji posłużyć możemy się parametrem ParameterFilter, któremu przekażemy blok skryptu. Wynik jego działania przekształcony do wartości logicznej zdecyduje, czy nasze maskowanie zostanie wykorzystane, czy nie:
Describe 'Nadpisujemy Write-Host' { | |
Mock -CommandName Write-Host -MockWith { | |
Write-Warning -Message $Object | |
} -ParameterFilter { | |
$Object -match '^Nadpisz' | |
} | |
Write-Host -Object 'Nie nadpisuj...' | |
Write-Host -Object 'Nadpisz mnie!' | |
} |
Skrypt zwróci ostrzeżenie tylko wtedy, gdy wiadomość zaczynać się będzie słowem ‚Nadpisz’ – w przeciwnym wypadku uruchomione zostanie ‚prawdziwe’ polecenie Write-Host:
W ten sposób możemy mieć ogólną maskę i maski szczegółowe, których zachowanie różnić się będzie od domyślnego sposobu maskowania danego polecenia. Oczywiście, jeśli różnice mają być niewielkie to równie dobrze możemy skorzystać z wartości parametru w ramach bloku skryptu MockWith i prostej konstrukcji takiej jak ‚if’ bądź ‚switch’.
Maskowanie nieobecnych
Problemem, na którym potkniemy się prędzej czy później jest kwestia maskowania poleceń, których nie ma na systemie, na którym przeprowadzamy testy. I o ile w przypadku maszyny, na której tworzymy polecenie na ten problem raczej się nie natkniemy (w końcu pisząc polecenie będziemy też chcieli potwierdzić, że uzyskujemy na wyjściu pożądany efekt), o tyle w przypadku różnego rodzaju serwerów oferujących automatyczne testy problemu tego raczej nie unikniemy. Problem ten manifestuje się na ogół błędem w skrypcie testującym kod:
Problem ten wynika bezpośrednio z tego, jak faktycznie działa maskowanie. De facto tworzymy w ten sposób funkcję zastępczą. Aby jednak funkcję taką stworzyć musimy najpierw pobrać z bieżącego systemu informację o samym poleceniu. Bez tej informacji nie możemy ustalić, jakie polecenie oferuje parametry, jakiego typu muszą być wartości do parametrów przypisane, czy parametry wspierają potok i inne.
Rozwiązania tego problemu są dwa: w przypadku stosunkowo prostych modułów i poleceń możemy po prostu wymagany moduł doinstalować. Od momentu, gdy maskowane polecenie pojawi się na naszym systemie, wszelkie próby ukrycia prawdziwego polecenia się powiodą.
Alternatywa to utworzenie modułu „zastępczego” – zawierającego sygnatury poleceń (czyli funkcje wyłącznie z blokiem parametrów). Aby mieć pewność, że w ramach testów odpowiednio zamaskujemy tak utworzone polecenia warto jeszcze dorzucić jakiś wyjątek w ramach polecenia tego generowany: zmusi nas to do odpowiednio „higienicznych” testów, w których wszystko to, czego nie zamierzamy „na prawdę” uruchomić, będzie odpowiednie zamaskowane. Taką funkcję uzyskać możemy na kilka sposobów. Najlepiej wzorując się na rzeczywistym module. Dla przykładu, metoda wykorzystująca funkcjonalność dostępną w ramach PowerShella:
$polecenie = Get-Command Get-GitHubPullRequest | |
@" | |
function Get-GitHubPullRequest { | |
param ( | |
$( | |
[Management.Automation.ProxyCommand]:: | |
GetParamBlock( | |
$polecenie | |
) | |
) | |
) | |
throw "Błąd w poleceniu Get-GitHubPullRequest - zamaskuj mnie!" | |
} | |
"@ |
Istotne jest, by tworząc takie funkcje zwrócić uwagę na typy przypisane parametrom: jeśli typ jest oferowany jedynie w ramach samego modułu, warto „podmienić go” na typ ogólniejszy (zwykle sprawę rozwiązuje wszystko-w-sobie-mający typ [Object].
Pokrycie kodu
Testowanie kodu powinno być pełne. Nie wystarczy, że przetestujemy ścieżkę „pełny sukces”. Sprawdzić powinniśmy też, czy w przypadku błędów lub braku wyniku nasza funkcja zachowa się dokładnie tak, jak tego od niej oczekujemy. Aby sprawdzić na ile nasz kod pokryty jest przez testy skorzystać musimy z dodatkowego parametru, CodeCoverage. Przekażemy do niego albo samą ścieżkę do pliku (w formie ciągu znaków) albo tablicę skrótów ze ścieżką i opcjonalnie albo nazwą funkcji, albo linią rozpoczęcia/ zakończenia testu. Możemy też skorzystać w tym wypadku z wyrażeń wieloznacznych. Dla przykładu: przywrócę starszą wersję testu (bez pełnego pokrycia) a następnie wersję pełną:
W pierwszym wypadku – pokrycie było skromne. Dodanie kilku testów obejmujących „poprawne” ścieżki kodu sprawiło, że całość testowanego kodu znajduje pokrycie w testach. Nie gwarantuje to oczywiście pełnej skuteczności testów, ale przynajmniej potwierdza, że wszystkie fragmenty kodu napisanego w trakcie testów były uruchamiane. I tym optymistycznym akcentem kończymy trzecią część. W kolejnej przyjrzymy się temu, jak wyglądać mogą nasze testy, gdy skorzystamy z domyślnie dostępnych weryfikatorów.