Testy jednostkowe, jak napisać testowalny kod i dlaczego ma to znaczenie

Opublikowany: 2022-03-11

Testowanie jednostkowe jest niezbędnym instrumentem w zestawie narzędzi każdego poważnego programisty. Czasami jednak napisanie dobrego testu jednostkowego dla konkretnego fragmentu kodu może być dość trudne. Mając trudności z testowaniem własnego lub cudzego kodu, programiści często myślą, że ich problemy są spowodowane brakiem podstawowej wiedzy o testowaniu lub tajnych technik testowania jednostkowego.

W tym samouczku o testowaniu jednostkowym zamierzam pokazać, że testy jednostkowe są dość łatwe; prawdziwe problemy, które komplikują testowanie jednostkowe i wprowadzają kosztowną złożoność, są wynikiem źle zaprojektowanego, nietestowalnego kodu. Omówimy, co utrudnia testowanie kodu, jakich antywzorców i złych praktyk powinniśmy unikać, aby poprawić testowalność oraz jakie inne korzyści możemy osiągnąć, pisząc testowalny kod. Zobaczymy, że pisanie testów jednostkowych i generowanie testowalnego kodu to nie tylko sprawienie, by testowanie było mniej kłopotliwe, ale żeby sam kod był bardziej niezawodny i łatwiejszy w utrzymaniu.

Samouczek testowania jednostkowego: ilustracja na okładce

Co to jest testowanie jednostkowe?

Zasadniczo test jednostkowy to metoda, która tworzy instancję niewielkiej części naszej aplikacji i weryfikuje jej zachowanie niezależnie od innych części . Typowy test jednostkowy składa się z 3 faz: Najpierw inicjuje mały fragment aplikacji, którą chce przetestować (znaną również jako testowany system lub SUT), a następnie stosuje pewien bodziec do testowanego systemu (zwykle poprzez wywołanie metoda na nim), a na końcu obserwuje wynikające z tego zachowanie. Jeśli zaobserwowane zachowanie jest zgodne z oczekiwaniami, test jednostkowy przechodzi pomyślnie, w przeciwnym razie kończy się niepowodzeniem, wskazując, że gdzieś w testowanym systemie występuje problem. Te trzy fazy testów jednostkowych są również znane jako Arrange, Act and Assert lub po prostu AAA.

Test jednostkowy może zweryfikować różne aspekty behawioralne testowanego systemu, ale najprawdopodobniej będzie należał do jednej z dwóch następujących kategorii: oparte na stanie lub oparte na interakcji . Weryfikacja, czy testowany system daje poprawne wyniki lub czy jego wynikowy stan jest poprawny, nazywa się testowaniem jednostkowym opartym na stanie , podczas gdy sprawdzanie, czy poprawnie wywołuje określone metody, nazywa się testowaniem jednostkowym opartym na interakcji .

Jako metaforę właściwego testowania jednostek oprogramowania wyobraź sobie szalonego naukowca, który chce zbudować nadprzyrodzoną chimerę z żabimi nogami, mackami ośmiornicy, ptasimi skrzydłami i psią głową. (Ta metafora jest bardzo zbliżona do tego, co programiści faktycznie robią w pracy). W jaki sposób naukowiec miałby upewnić się, że każda wybrana przez niego część (lub jednostka) rzeczywiście działa? Cóż, może wziąć, powiedzmy, jedną żabie udko, zastosować do niej bodziec elektryczny i sprawdzić, czy skurcze mięśni są prawidłowe. To, co robi, to zasadniczo te same kroki Umów-Działaj-Zatwierdź, co w teście jednostkowym; jedyną różnicą jest to, że w tym przypadku jednostka odnosi się do obiektu fizycznego, a nie do abstrakcyjnego obiektu, z którego budujemy nasze programy.

co to jest testowanie jednostkowe: ilustracja

We wszystkich przykładach w tym artykule będę używał C#, ale opisane koncepcje dotyczą wszystkich języków programowania obiektowego.

Przykład prostego testu jednostkowego może wyglądać tak:

 [TestMethod] public void IsPalindrome_ForPalindromeString_ReturnsTrue() { // In the Arrange phase, we create and set up a system under test. // A system under test could be a method, a single object, or a graph of connected objects. // It is OK to have an empty Arrange phase, for example if we are testing a static method - // in this case SUT already exists in a static form and we don't have to initialize anything explicitly. PalindromeDetector detector = new PalindromeDetector(); // The Act phase is where we poke the system under test, usually by invoking a method. // If this method returns something back to us, we want to collect the result to ensure it was correct. // Or, if method doesn't return anything, we want to check whether it produced the expected side effects. bool isPalindrome = detector.IsPalindrome("kayak"); // The Assert phase makes our unit test pass or fail. // Here we check that the method's behavior is consistent with expectations. Assert.IsTrue(isPalindrome); }

Test jednostkowy a test integracyjny

Kolejną ważną rzeczą do rozważenia jest różnica między testowaniem jednostkowym a testowaniem integracyjnym.

Celem testu jednostkowego w inżynierii oprogramowania jest weryfikacja zachowania stosunkowo niewielkiego fragmentu oprogramowania, niezależnie od innych części. Testy jednostkowe mają wąski zakres i pozwalają nam objąć wszystkie przypadki, zapewniając, że każda część działa poprawnie.

Z drugiej strony testy integracyjne pokazują, że różne części systemu współpracują ze sobą w rzeczywistym środowisku . Weryfikują złożone scenariusze (możemy myśleć o testach integracyjnych jako o użytkowniku wykonującym jakąś operację wysokiego poziomu w naszym systemie) i zwykle wymagają obecności zasobów zewnętrznych, takich jak bazy danych lub serwery WWW.

Wróćmy do naszej metafory szalonego naukowca i załóżmy, że udało mu się połączyć wszystkie części chimery. Chce wykonać test integracyjny powstałego stworzenia, upewniając się, że może, powiedzmy, chodzić po różnych rodzajach terenu. Przede wszystkim naukowiec musi naśladować środowisko, w którym istota może chodzić. Następnie rzuca stworzenie do tego środowiska i szturcha go kijem, obserwując, czy chodzi i porusza się zgodnie z przeznaczeniem. Po zakończeniu testu szalony naukowiec czyści cały brud, piasek i skały, które są teraz rozrzucone w jego uroczym laboratorium.

przykładowa ilustracja testu jednostkowego

Zwróć uwagę na istotną różnicę między testami jednostkowymi i integracyjnymi: test jednostkowy weryfikuje zachowanie niewielkiej części aplikacji, odizolowanej od środowiska i innych części i jest dość łatwy do zaimplementowania, podczas gdy test integracyjny obejmuje interakcje między różnymi komponentami w środowisko zbliżone do rzeczywistego i wymaga więcej wysiłku, w tym dodatkowych faz konfiguracji i demontażu.

Rozsądne połączenie testów jednostkowych i integracyjnych zapewnia, że ​​każda pojedyncza jednostka działa poprawnie, niezależnie od innych, oraz że wszystkie te jednostki grają dobrze po zintegrowaniu, dając nam wysoki poziom pewności, że cały system działa zgodnie z oczekiwaniami.

Musimy jednak pamiętać, aby zawsze określić, jaki rodzaj testu wdrażamy: test jednostkowy czy integracyjny. Różnica może czasami być myląca. Jeśli myślimy, że piszemy test jednostkowy, aby zweryfikować jakiś subtelny przypadek brzegowy w klasie logiki biznesowej i zdamy sobie sprawę, że wymaga on obecności zasobów zewnętrznych, takich jak usługi sieciowe lub bazy danych, coś jest nie tak — zasadniczo używamy młota do złamać orzech. A to oznacza zły projekt.

Co sprawia, że ​​test jednostkowy jest dobry?

Zanim przejdziemy do głównej części tego samouczka i napiszemy testy jednostkowe, szybko omówmy właściwości dobrego testu jednostkowego. Zasady testowania jednostkowego wymagają, aby dobry test był:

  • Łatwy do pisania. Deweloperzy zazwyczaj piszą wiele testów jednostkowych, aby objąć różne przypadki i aspekty zachowania aplikacji, więc kodowanie wszystkich tych procedur testowych powinno być łatwe bez ogromnego wysiłku.

  • Czytelny. Cel testu jednostkowego powinien być jasny. Dobry test jednostkowy opowiada historię o pewnym behawioralnym aspekcie naszej aplikacji, więc powinno być łatwe do zrozumienia, który scenariusz jest testowany i — jeśli test się nie powiedzie — łatwe do wykrycia, jak rozwiązać problem. Z dobrym testem jednostkowym możemy naprawić błąd bez faktycznego debugowania kodu!

  • Niezawodny. Testy jednostkowe powinny zakończyć się niepowodzeniem tylko wtedy, gdy w testowanym systemie występuje błąd. Wydaje się to dość oczywiste, ale programiści często napotykają problem, gdy ich testy kończą się niepowodzeniem, nawet jeśli nie zostały wprowadzone żadne błędy. Na przykład testy mogą zakończyć się niepowodzeniem podczas uruchamiania jednego po drugim, ale zakończyć się niepowodzeniem podczas uruchamiania całego zestawu testów lub przekazać naszą maszynę programistyczną i zakończyć się niepowodzeniem na serwerze ciągłej integracji. Sytuacje te wskazują na wadę projektową. Dobre testy jednostkowe powinny być powtarzalne i niezależne od czynników zewnętrznych, takich jak środowisko czy stan techniczny.

  • Szybko. Deweloperzy piszą testy jednostkowe, aby móc je wielokrotnie uruchamiać i sprawdzać, czy nie zostały wprowadzone żadne błędy. Jeśli testy jednostkowe są powolne, programiści są bardziej skłonni do pomijania ich uruchamiania na własnych komputerach. Jeden powolny test nie zrobi znaczącej różnicy; dodaj jeszcze tysiąc i na pewno utkniemy w oczekiwaniu na chwilę. Powolne testy jednostkowe mogą również wskazywać, że testowany system lub sam test współdziała z systemami zewnętrznymi, czyniąc go zależnym od środowiska.

  • Naprawdę jedność, a nie integracja. Jak już wspomnieliśmy, testy jednostkowe i integracyjne mają różne cele. Zarówno test jednostkowy, jak i testowany system nie powinny mieć dostępu do zasobów sieciowych, baz danych, systemu plików itp., aby wyeliminować wpływ czynników zewnętrznych.

To tyle — nie ma tajemnic pisania testów jednostkowych . Istnieje jednak kilka technik, które pozwalają nam pisać testowalny kod .

Kod testowalny i nietestowalny

Część kodu jest napisana w taki sposób, że napisanie dla niego dobrego testu jednostkowego jest trudne lub wręcz niemożliwe. Co więc utrudnia testowanie kodu? Przyjrzyjmy się kilku antywzorcom, zapachom kodu i złym praktykom, których powinniśmy unikać podczas pisania testowalnego kodu.

Zatruwanie bazy kodów czynnikami niedeterministycznymi

Zacznijmy od prostego przykładu. Wyobraź sobie, że piszemy program dla mikrokontrolera inteligentnego domu, a jednym z wymagań jest automatyczne włączanie światła na podwórku, jeśli wykryje tam jakiś ruch wieczorem lub w nocy. Zaczęliśmy od podstaw, implementując metodę, która zwraca ciąg reprezentujący przybliżoną porę dnia („Noc”, „Poranek”, „Popołudnie” lub „Wieczór”):

 public static string GetTimeOfDay() { DateTime time = DateTime.Now; if (time.Hour >= 0 && time.Hour < 6) { return "Night"; } if (time.Hour >= 6 && time.Hour < 12) { return "Morning"; } if (time.Hour >= 12 && time.Hour < 18) { return "Afternoon"; } return "Evening"; }

Zasadniczo ta metoda odczytuje bieżący czas systemowy i zwraca wynik na podstawie tej wartości. Więc co jest nie tak z tym kodem?

Jeśli pomyślimy o tym z perspektywy testów jednostkowych, zobaczymy, że nie da się napisać odpowiedniego testu jednostkowego opartego na stanie dla tej metody. DateTime.Now jest zasadniczo ukrytym wejściem, które prawdopodobnie zmieni się podczas wykonywania programu lub między uruchomieniami testów. W ten sposób kolejne wezwania do niego przyniosą różne rezultaty.

Takie niedeterministyczne zachowanie uniemożliwia testowanie wewnętrznej logiki metody GetTimeOfDay() bez faktycznej zmiany daty i godziny systemowej. Zobaczmy, jak taki test musiałby zostać zaimplementowany:

 [TestMethod] public void GetTimeOfDay_At6AM_ReturnsMorning() { try { // Setup: change system time to 6 AM ... // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(); // Assert Assert.AreEqual("Morning", timeOfDay); } finally { // Teardown: roll system time back ... } }

Takie testy naruszyłyby wiele zasad omówionych wcześniej. Zapis byłby kosztowny (ze względu na nietrywialną konfigurację i logikę wyłączania), zawodny (może się nie powieść, nawet jeśli w testowanym systemie nie ma błędów, na przykład z powodu problemów z uprawnieniami systemu) i nie gwarantuje Biegnij szybko. I wreszcie, ten test nie byłby w rzeczywistości testem jednostkowym — byłby czymś pomiędzy testem jednostkowym a testem integracyjnym, ponieważ udaje, że testuje prosty przypadek brzegowy, ale wymaga skonfigurowania środowiska w określony sposób. Wynik nie jest wart wysiłku, co?

Okazuje się, że wszystkie te problemy z testowalnością są spowodowane przez niskiej jakości API GetTimeOfDay() . W obecnej formie ta metoda ma kilka problemów:

  • Jest ściśle powiązany z konkretnym źródłem danych. Nie jest możliwe ponowne użycie tej metody do przetwarzania daty i godziny pobranej z innych źródeł lub przekazanej jako argument; metoda działa tylko z datą i godziną konkretnej maszyny, która wykonuje kod. Ścisłe sprzężenie jest głównym źródłem większości problemów z testowalnością.

  • Narusza to zasadę pojedynczej odpowiedzialności (SRP). Metoda ma wiele obowiązków; zużywa informacje, a także je przetwarza. Innym wskaźnikiem naruszenia SRP jest sytuacja, gdy pojedyncza klasa lub metoda ma więcej niż jeden powód do zmiany . Z tej perspektywy metoda GetTimeOfDay() może zostać zmieniona albo ze względu na wewnętrzne korekty logiki, albo dlatego, że należy zmienić źródło daty i godziny.

  • Kłamie na temat informacji wymaganych do wykonania swojej pracy. Deweloperzy muszą przeczytać każdy wiersz rzeczywistego kodu źródłowego, aby zrozumieć, jakie ukryte dane wejściowe są używane i skąd pochodzą. Sama sygnatura metody nie wystarczy do zrozumienia zachowania metody.

  • Trudno to przewidzieć i utrzymać. Zachowania metody, która zależy od mutowalnego stanu globalnego, nie można przewidzieć po prostu czytając kod źródłowy; konieczne jest uwzględnienie jego aktualnej wartości, wraz z całą sekwencją zdarzeń, które mogły ją wcześniej zmienić. W rzeczywistej aplikacji próba rozwikłania tych wszystkich rzeczy staje się prawdziwym bólem głowy.

Po zapoznaniu się z API, w końcu to naprawmy! Na szczęście jest to o wiele łatwiejsze niż omawianie wszystkich jego wad — wystarczy przełamać ściśle powiązane obawy.

Naprawianie interfejsu API: wprowadzenie argumentu metody

Najbardziej oczywistym i najłatwiejszym sposobem naprawienia API jest wprowadzenie argumentu metody:

 public static string GetTimeOfDay(DateTime dateTime) { if (dateTime.Hour >= 0 && dateTime.Hour < 6) { return "Night"; } if (dateTime.Hour >= 6 && dateTime.Hour < 12) { return "Morning"; } if (dateTime.Hour >= 12 && dateTime.Hour < 18) { return "Noon"; } return "Evening"; }

Teraz metoda wymaga, aby obiekt wywołujący dostarczył argument DateTime , zamiast potajemnie szukać tych informacji samodzielnie. Z perspektywy testów jednostkowych jest to świetne; metoda jest teraz deterministyczna (tzn. jej wartość zwracana w pełni zależy od danych wejściowych), więc testowanie oparte na stanie jest tak proste, jak przekazanie wartości DateTime i sprawdzenie wyniku:

 [TestMethod] public void GetTimeOfDay_For6AM_ReturnsMorning() { // Arrange phase is empty: testing static method, nothing to initialize // Act string timeOfDay = GetTimeOfDay(new DateTime(2015, 12, 31, 06, 00, 00)); // Assert Assert.AreEqual("Morning", timeOfDay); }

Zauważ, że ta prosta refaktoryzacja rozwiązała również wszystkie problemy z API omówione wcześniej (ścisłe sprzężenie, naruszenie SRP, niejasne i trudne do zrozumienia API) poprzez wprowadzenie jasnego połączenia między tym, jakie dane powinny być przetwarzane, a tym, jak to zrobić.

Doskonała — metoda jest testowalna, ale co z jej klientami ? Teraz obowiązkiem wywołującego jest podanie daty i godziny do metody GetTimeOfDay(DateTime dateTime) , co oznacza, że ​​mogą one stać się nietestowalne, jeśli nie poświęcimy wystarczającej uwagi. Zobaczmy, jak sobie z tym poradzić.

Naprawianie interfejsu API klienta: wstrzykiwanie zależności

Załóżmy, że kontynuujemy prace nad systemem inteligentnego domu i wdrażamy następującego klienta metody GetTimeOfDay(DateTime dateTime) — wspomniany wcześniej kod mikrokontrolera inteligentnego domu odpowiedzialny za włączanie i wyłączanie światła na podstawie pory dnia i wykrycia ruchu :

 public class SmartHomeController { public DateTime LastMotionTime { get; private set; } public void ActuateLights(bool motionDetected) { DateTime time = DateTime.Now; // Ouch! // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); } } }

Auć! Mamy ten sam rodzaj ukrytego problemu z wprowadzaniem danych DateTime.Now — jedyną różnicą jest to, że znajduje się on nieco wyżej na poziomie abstrakcji. Aby rozwiązać ten problem, możemy wprowadzić kolejny argument, ponownie delegując odpowiedzialność za dostarczenie wartości DateTime wywołującemu nową metodę z podpisem ActuateLights(bool motionDetected, DateTime dateTime) . Ale zamiast ponownie przenosić problem na wyższy poziom w stosie wywołań, zastosujmy inną technikę, która pozwoli nam zachować zarówno ActuateLights(bool motionDetected) , jak i jej klientów w testowalności: Inversion of Control lub IoC.

Inversion of Control to prosta, ale niezwykle użyteczna technika rozdzielania kodu, a w szczególności testowania jednostkowego. (W końcu utrzymywanie luźnych elementów jest niezbędne, aby móc je analizować niezależnie od siebie.) Kluczowym punktem IoC jest oddzielenie kodu decyzyjnego ( kiedy coś zrobić) od kodu akcji ( co zrobić, gdy coś się wydarzy ). Ta technika zwiększa elastyczność, czyni nasz kod bardziej modułowym i zmniejsza sprzężenie między komponentami.

Inversion of Control można wdrożyć na wiele sposobów; spójrzmy na jeden konkretny przykład — Dependency Injection przy użyciu konstruktora — i jak może pomóc w zbudowaniu testowalnego API SmartHomeController .

Najpierw stwórzmy interfejs IDateTimeProvider , zawierający sygnaturę metody w celu uzyskania pewnej daty i czasu:

 public interface IDateTimeProvider { DateTime GetDateTime(); }

Następnie spraw, aby SmartHomeController odwoływał się do implementacji IDateTimeProvider i przekaż jej odpowiedzialność za uzyskanie daty i godziny:

 public class SmartHomeController { private readonly IDateTimeProvider _dateTimeProvider; // Dependency public SmartHomeController(IDateTimeProvider dateTimeProvider) { // Inject required dependency in the constructor. _dateTimeProvider = dateTimeProvider; } public void ActuateLights(bool motionDetected) { DateTime time = _dateTimeProvider.GetDateTime(); // Delegating the responsibility // Remaining light control logic goes here... } }

Teraz możemy zobaczyć, dlaczego Inversion of Control nazywa się tak: kontrola jakiego mechanizmu do odczytu daty i czasu została odwrócona i teraz należy do klienta SmartHomeController , a nie do samego SmartHomeController . Tym samym wykonanie metody ActuateLights(bool motionDetected) w pełni zależy od dwóch rzeczy, którymi można łatwo zarządzać z zewnątrz: argumentu motionDetected oraz konkretnej implementacji IDateTimeProvider przekazanej do konstruktora SmartHomeController .

Dlaczego ma to znaczenie dla testów jednostkowych? Oznacza to, że różne implementacje IDateTimeProvider mogą być używane w kodzie produkcyjnym i kodzie testów jednostkowych. W środowisku produkcyjnym zostanie wstrzyknięta część rzeczywistej implementacji (np. taka, która odczytuje rzeczywisty czas systemowy). W teście jednostkowym możemy jednak wstrzyknąć „fałszywą” implementację, która zwraca stałą lub wstępnie zdefiniowaną wartość DateTime odpowiednią do testowania konkretnego scenariusza.

Fałszywa implementacja IDateTimeProvider może wyglądać tak:

 public class FakeDateTimeProvider : IDateTimeProvider { public DateTime ReturnValue { get; set; } public DateTime GetDateTime() { return ReturnValue; } public FakeDateTimeProvider(DateTime returnValue) { ReturnValue = returnValue; } }

Za pomocą tej klasy możliwe jest odizolowanie SmartHomeController od czynników niedeterministycznych i wykonanie testu jednostkowego opartego na stanie. Sprawdźmy, czy w przypadku wykrycia ruchu czas tego ruchu jest rejestrowany we właściwości LastMotionTime :

 [TestMethod] void ActuateLights_MotionDetected_SavesTimeOfMotion() { // Arrange var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true); // Assert Assert.AreEqual(new DateTime(2015, 12, 31, 23, 59, 59), controller.LastMotionTime); }

Świetnie! Taki test nie był możliwy przed refaktoryzacją. Teraz, gdy wyeliminowaliśmy czynniki niedeterministyczne i zweryfikowaliśmy scenariusz oparty na stanie, czy uważasz, że SmartHomeController jest w pełni testowalny?

Zatruwanie bazy kodów skutkami ubocznymi

Pomimo tego, że rozwiązaliśmy problemy spowodowane niedeterministycznymi ukrytymi danymi wejściowymi i byliśmy w stanie przetestować pewną funkcjonalność, kod (lub przynajmniej jego część) jest nadal nietestowalny!

Przyjrzyjmy się kolejnej części metody ActuateLights(bool motionDetected) odpowiedzialnej za włączanie i wyłączanie światła:

 // If motion was detected in the evening or at night, turn the light on. if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { BackyardLightSwitcher.Instance.TurnOn(); } // If no motion was detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { BackyardLightSwitcher.Instance.TurnOff(); }

Jak widać, SmartHomeController deleguje odpowiedzialność za włączanie i wyłączanie światła do obiektu BackyardLightSwitcher , który implementuje wzorzec Singleton. Co jest nie tak z tym projektem?

Aby w pełni przetestować ActuateLights(bool motionDetected) , oprócz testowania opartego na stanie, powinniśmy przeprowadzić testy oparte na interakcjach; to znaczy powinniśmy zadbać o to, aby metody włączania i wyłączania światła były wywoływane wtedy i tylko wtedy, gdy spełnione są odpowiednie warunki. Niestety, obecny projekt nam na to nie pozwala: metody TurnOn() i TurnOff() BackyardLightSwitcher wywołują pewne zmiany stanu w systemie, czyli innymi słowy, wywołują skutki uboczne . Jedynym sposobem sprawdzenia, czy te metody zostały wywołane, jest sprawdzenie, czy rzeczywiście wystąpiły odpowiadające im skutki uboczne, co może być bolesne.

Załóżmy, że czujnik ruchu, podwórkowa latarnia i inteligentny domowy mikrokontroler są połączone w sieć Internetu Rzeczy i komunikują się za pomocą jakiegoś protokołu bezprzewodowego. W takim przypadku test jednostkowy może podjąć próbę odebrania i przeanalizowania tego ruchu sieciowego. Lub, jeśli elementy sprzętowe są połączone przewodem, test jednostkowy może sprawdzić, czy napięcie zostało przyłożone do odpowiedniego obwodu elektrycznego. Albo w końcu może sprawdzić, czy światło faktycznie się włączyło lub zgasło za pomocą dodatkowego czujnika światła.

Jak widać, metody testowania jednostkowego powodujące skutki uboczne mogą być równie trudne, jak te niedeterministyczne, a nawet niemożliwe. Każda próba doprowadzi do problemów podobnych do tych, które już widzieliśmy. Wynikowy test będzie trudny do wdrożenia, niewiarygodny, potencjalnie powolny i nie do końca jednorodny. A poza tym miganie światła za każdym razem, gdy uruchamiamy zestaw testowy, w końcu doprowadzi nas do szaleństwa!

Ponownie, wszystkie te problemy z testowalnością są spowodowane złym interfejsem API, a nie zdolnością programisty do pisania testów jednostkowych. Bez względu na to, jak dokładnie zaimplementowano sterowanie oświetleniem, interfejs API SmartHomeController cierpi na te już znane problemy:

  • Jest ściśle powiązany z konkretnym wdrożeniem. Interfejs API opiera się na zakodowanej, konkretnej instancji BackyardLightSwitcher . Nie jest możliwe ponowne użycie metody ActuateLights(bool motionDetected) do przełączania innego światła niż na podwórku.

  • Narusza to zasadę pojedynczej odpowiedzialności. API ma dwa powody do zmiany: po pierwsze, zmiany w wewnętrznej logice (takie jak wybór włączania światła tylko w nocy, ale nie wieczorem) i po drugie, jeśli mechanizm przełączania światła zostanie zastąpiony innym.

  • Kłamie o swoich zależnościach. Deweloperzy nie mogą wiedzieć, że SmartHomeController zależy od zakodowanego na stałe komponentu BackyardLightSwitcher , poza zagłębianiem się w kod źródłowy.

  • Trudno to zrozumieć i utrzymać. Co się stanie, jeśli światło odmówi włączenia się w odpowiednich warunkach? Moglibyśmy spędzić dużo czasu próbując naprawić SmartHomeController bezskutecznie, tylko po to, by zdać sobie sprawę, że problem był spowodowany błędem w BackyardLightSwitcher (lub, co jeszcze zabawniejsze, przepaloną żarówką!).

Nic dziwnego, że rozwiązaniem zarówno testowalności, jak i problemów z interfejsami API niskiej jakości jest oderwanie od siebie ściśle powiązanych komponentów. Podobnie jak w poprzednim przykładzie, zastosowanie Dependency Injection rozwiązałoby te problemy; wystarczy dodać zależność ILightSwitcher do SmartHomeController , przekazać mu odpowiedzialność za przełączanie przełącznika światła i przekazać fałszywą, tylko testową implementację ILightSwitcher , która zarejestruje, czy odpowiednie metody zostały wywołane w odpowiednich warunkach. Jednak zamiast ponownie używać Dependency Injection, przyjrzyjmy się ciekawemu alternatywnemu podejściu do rozdzielenia odpowiedzialności.

Naprawianie API: funkcje wyższego rzędu

Takie podejście jest opcją w każdym języku obiektowym, który obsługuje funkcje pierwszej klasy . Skorzystajmy z funkcji funkcjonalnych języka C# i sprawmy, aby ActuateLights(bool motionDetected) akceptowała jeszcze dwa argumenty: parę delegatów Action , wskazujących na metody, które powinny być wywoływane w celu włączania i wyłączania światła. To rozwiązanie przekształci metodę w funkcję wyższego rzędu :

 public void ActuateLights(bool motionDetected, Action turnOn, Action turnOff) { DateTime time = _dateTimeProvider.GetDateTime(); // Update the time of last motion. if (motionDetected) { LastMotionTime = time; } // If motion was detected in the evening or at night, turn the light on. string timeOfDay = GetTimeOfDay(time); if (motionDetected && (timeOfDay == "Evening" || timeOfDay == "Night")) { turnOn(); // Invoking a delegate: no tight coupling anymore } // If no motion is detected for one minute, or if it is morning or day, turn the light off. else if (time.Subtract(LastMotionTime) > TimeSpan.FromMinutes(1) || (timeOfDay == "Morning" || timeOfDay == "Noon")) { turnOff(); // Invoking a delegate: no tight coupling anymore } }

Jest to rozwiązanie bardziej funkcjonalne niż klasyczne podejście Dependency Injection, które widzieliśmy wcześniej; jednak pozwala nam osiągnąć ten sam wynik przy mniejszej ilości kodu i większej wyrazistości niż Dependency Injection. Nie jest już konieczne implementowanie klasy zgodnej z interfejsem w celu dostarczenia SmartHomeController wymaganej funkcjonalności; zamiast tego możemy po prostu przekazać definicję funkcji. Funkcje wyższego rzędu można traktować jako inny sposób implementacji Inversion of Control.

Teraz, aby wykonać oparty na interakcji test jednostkowy otrzymanej metody, możemy przekazać do niej łatwe do zweryfikowania fałszywe działania:

 [TestMethod] public void ActuateLights_MotionDetectedAtNight_TurnsOnTheLight() { // Arrange: create a pair of actions that change boolean variable instead of really turning the light on or off. bool turnedOn = false; Action turnOn = () => turnedOn = true; Action turnOff = () => turnedOn = false; var controller = new SmartHomeController(new FakeDateTimeProvider(new DateTime(2015, 12, 31, 23, 59, 59))); // Act controller.ActuateLights(true, turnOn, turnOff); // Assert Assert.IsTrue(turnedOn); }

Wreszcie, sprawiliśmy, że interfejs SmartHomeController API jest w pełni testowalny i jesteśmy w stanie wykonywać dla niego zarówno testy jednostkowe oparte na stanie, jak i na interakcji. Ponownie zauważ, że oprócz lepszej testowalności, wprowadzenie szwu między kodem decyzyjnym a kodem akcji pomogło rozwiązać problem ścisłego sprzężenia i doprowadziło do czystszego interfejsu API wielokrotnego użytku.

Teraz, aby osiągnąć pełne pokrycie testów jednostkowych, możemy po prostu zaimplementować kilka podobnie wyglądających testów, aby zweryfikować wszystkie możliwe przypadki — nic wielkiego, ponieważ testy jednostkowe są teraz dość łatwe do zaimplementowania.

Nieczystość i testowalność

Niekontrolowany niedeterminizm i skutki uboczne mają podobny destrukcyjny wpływ na bazę kodów. Kiedy są używane niedbale, prowadzą do zwodniczego, trudnego do zrozumienia i utrzymania, ściśle powiązanego, nienadającego się do ponownego użycia i nietestowalnego kodu.

Z drugiej strony metody, które są zarówno deterministyczne, jak i wolne od skutków ubocznych, są znacznie łatwiejsze do przetestowania, uzasadnienia i ponownego wykorzystania do tworzenia większych programów. Z punktu widzenia programowania funkcjonalnego takie metody nazywamy czystymi funkcjami . Rzadko mamy problem z testowaniem czystej funkcji; wystarczy przekazać kilka argumentów i sprawdzić poprawność wyniku. To, co naprawdę czyni kod nietestowalnym, to zakodowane na stałe, nieczyste czynniki, których nie można zastąpić, przesłonić ani oddzielić w inny sposób.

Nieczystość jest toksyczna: jeśli metoda Foo() zależy od niedeterministycznej lub powodującej skutki uboczne metody Bar() , wtedy Foo() również staje się niedeterministyczna lub powodująca skutki uboczne. W końcu możemy zatruć całą bazę kodu. Pomnóż wszystkie te problemy przez rozmiar złożonej, rzeczywistej aplikacji, a staniemy się obciążeni trudną do utrzymania bazą kodu pełną zapachów, antywzorców, sekretnych zależności i wszelkiego rodzaju brzydkich i nieprzyjemnych rzeczy.

przykład testu jednostkowego: ilustracja

Jednak nieczystość jest nieunikniona; każda rzeczywista aplikacja musi w pewnym momencie odczytywać i manipulować stanem poprzez interakcję ze środowiskiem, bazami danych, plikami konfiguracyjnymi, usługami sieciowymi lub innymi systemami zewnętrznymi. Więc zamiast dążyć do całkowitego wyeliminowania nieczystości, dobrym pomysłem jest ograniczenie tych czynników, unikanie ich zatruwania bazy kodu i łamanie zakodowanych na sztywno zależności tak bardzo, jak to możliwe, aby móc analizować i testować rzeczy niezależnie.

Typowe znaki ostrzegawcze dotyczące trudnego do przetestowania kodu

Masz problemy z pisaniem testów? Problem nie tkwi w twoim zestawie testowym. Jest w twoim kodzie.
Ćwierkać

Na koniec przejrzyjmy kilka typowych znaków ostrzegawczych wskazujących, że nasz kod może być trudny do przetestowania.

Właściwości statyczne i pola

Statyczne właściwości i pola lub po prostu stan globalny mogą skomplikować zrozumienie kodu i testowalność, ukrywając informacje wymagane do wykonania zadania przez metodę, wprowadzając niedeterminizm lub promując szerokie wykorzystanie efektów ubocznych. Funkcje, które odczytują lub modyfikują zmienny stan globalny, są z natury nieczyste.

Na przykład, trudno zrozumieć następujący kod, który zależy od właściwości dostępnej globalnie:

 if (!SmartHomeSettings.CostSavingEnabled) { _swimmingPoolController.HeatWater(); }

Co się stanie, jeśli metoda HeatWater() nie zostanie wywołana, gdy jesteśmy pewni, że powinna być? Ponieważ dowolna część aplikacji mogła zmienić wartość CostSavingEnabled , musimy znaleźć i przeanalizować wszystkie miejsca modyfikujące tę wartość, aby dowiedzieć się, co jest nie tak. Ponadto, jak już widzieliśmy, nie jest możliwe ustawienie niektórych właściwości statycznych do celów testowych (np. DateTime.Now lub Environment.MachineName ; są one tylko do odczytu, ale nadal nie są deterministyczne).

Z drugiej strony, niezmienny i deterministyczny stan globalny jest całkowicie OK. W rzeczywistości jest na to bardziej znana nazwa — stała. Wartości stałe, takie jak Math.PI , nie wprowadzają żadnego niedeterminizmu, a ponieważ ich wartości nie można zmienić, nie powodują żadnych skutków ubocznych:

 double Circumference(double radius) { return 2 * Math.PI * radius; } // Still a pure function!

Singletony

Zasadniczo wzorzec Singleton jest po prostu kolejną formą państwa globalnego. Singletony promują niejasne interfejsy API, które kłamią o rzeczywistych zależnościach i wprowadzają niepotrzebnie ścisłe sprzężenie między komponentami. Naruszają również zasadę pojedynczej odpowiedzialności, ponieważ oprócz swoich podstawowych obowiązków kontrolują własną inicjalizację i cykl życia.

Singletony mogą z łatwością uzależnić testy jednostkowe od kolejności, ponieważ przenoszą stan przez cały czas życia całej aplikacji lub zestawu testów jednostkowych. Spójrz na następujący przykład:

 User GetUser(int userId) { User user; if (UserCache.Instance.ContainsKey(userId)) { user = UserCache.Instance[userId]; } else { user = _userService.LoadUser(userId); UserCache.Instance[userId] = user; } return user; }

In the example above, if a test for the cache-hit scenario runs first, it will add a new user to the cache, so a subsequent test of the cache-miss scenario may fail because it assumes that the cache is empty. To overcome this, we'll have to write additional teardown code to clean the UserCache after each unit test run.

Using Singletons is a bad practice that can (and should) be avoided in most cases; however, it is important to distinguish between Singleton as a design pattern, and a single instance of an object. In the latter case, the responsibility of creating and maintaining a single instance lies with the application itself. Typically, this is handed with a factory or Dependency Injection container, which creates a single instance somewhere near the “top” of the application (ie, closer to an application entry point) and then passes it to every object that needs it. This approach is absolutely correct, from both testability and API quality perspectives.

The new Operator

Newing up an instance of an object in order to get some job done introduces the same problem as the Singleton anti-pattern: unclear APIs with hidden dependencies, tight coupling, and poor testability.

For example, in order to test whether the following loop stops when a 404 status code is returned, the developer should set up a test web server:

 using (var client = new HttpClient()) { HttpResponseMessage response; do { response = await client.GetAsync(uri); // Process the response and update the uri... } while (response.StatusCode != HttpStatusCode.NotFound); }

However, sometimes new is absolutely harmless: for example, it is OK to create simple entity objects:

 var person = new Person("John", "Doe", new DateTime(1970, 12, 31));

It is also OK to create a small, temporary object that does not produce any side effects, except to modify their own state, and then return the result based on that state. In the following example, we don't care whether Stack methods were called or not — we just check if the end result is correct:

 string ReverseString(string input) { // No need to do interaction-based testing and check that Stack methods were called or not; // The unit test just needs to ensure that the return value is correct (state-based testing). var stack = new Stack<char>(); foreach(var s in input) { stack.Push(s); } string result = string.Empty; while(stack.Count != 0) { result += stack.Pop(); } return result; }

Static Methods

Static methods are another potential source of non-deterministic or side-effecting behavior. They can easily introduce tight coupling and make our code untestable.

For example, to verify the behavior of the following method, unit tests must manipulate environment variables and read the console output stream to ensure that the appropriate data was printed:

 void CheckPathEnvironmentVariable() { if (Environment.GetEnvironmentVariable("PATH") != null) { Console.WriteLine("PATH environment variable exists."); } else { Console.WriteLine("PATH environment variable is not defined."); } }

However, pure static functions are OK: any combination of them will still be a pure function. Na przykład:

 double Hypotenuse(double side1, double side2) { return Math.Sqrt(Math.Pow(side1, 2) + Math.Pow(side2, 2)); }

Benefits of Unit Testing

Obviously, writing testable code requires some discipline, concentration, and extra effort. But software development is a complex mental activity anyway, and we should always be careful, and avoid recklessly throwing together new code from the top of our heads.

As a reward for this act of proper software quality assurance, we'll end up with clean, easy-to-maintain, loosely coupled, and reusable APIs, that won't damage developers' brains when they try to understand it. After all, the ultimate advantage of testable code is not only the testability itself, but the ability to easily understand, maintain and extend that code as well.