Wytwarzanie oprogramowania II - techniki programowania
Posty z tej serii:
- Wytwarzanie oprogramowania I - paradygmaty programowania
- Wytwarzanie oprogramowania II - techniki programowania
- Wytwarzanie oprogramowania III - architektury systemów informatycznych
- Wytwarzanie oprogramowania IV - NServiceBus - framework, który zmienia zasady gry
Drugi wpis z serii „Wytwarzanie oprogramowania” zawiera przegląd wybranych technik programistycznych pozwalających dostarczać ciut lepszej jakości programy. Wszystkie przykłady napisane są w języku C#
i dotyczą paradygmatu programowania obiektowego.
Testowanie
Testy jednostkowe
Pierwszą rzecz, którą chcemy zrobić po napisaniu nowego kawałka kodu, jest sprawdzenie, czy ten kod działa zgodnie z dostarczonymi wymaganiami. Najszybszym sposobem, aby się o tym przekonać, jest napisanie tzw. testów jednostkowych. W wielkim skrócie testy jednostkowe to również kod, który uruchamia inny kod z podanymi parametrami wejściowymi i sprawdza, czy wartości zwrócone przez ten kod zgadzają się z wartościami oczekiwanymi. Główną charakterystyką testów jednostkowych jest to, że można je uruchamiać w odseparowaniu od zewnętrznych źródeł, np. dostępu do plików, bazy danych, sieci lub innych. Takie podejście wymusza odpowiednią konstrukcję kodu, który ma być testowany. Pozwala też na sprawniejsze i pewniejsze wprowadzanie zmian do kodu oraz szybkie przekonanie się, czy zmiany te działają zgodnie z oczekiwaniami i czy nie popsuły tego, co już działało. Przykładowo: załóżmy, że mamy do zakodowania funkcjonalność wyświetlającą listę graczy, z którymi można umówić się na mecz tenisa. Gracze ci mają spełniać obowiązkowe warunki:
- zgłaszają chęć rozegrania meczu
- posiadają aktualne badania medyczne uprawniające do gry w tenisa
- mieszczą się w tym samym zakresie w rankingu graczy np. miejsca między 100 a 200
Opis algorytmu realizującego taką funkcjonalność może wyglądać tak:
- pobierz wszystkich graczy z bazy danych
- wybierz tych, którzy spełniają obowiązkowe warunki
- wyświetl na ekranie dostępnych graczy
W powyższym algorytmie można zauważyć dwa odwołania do zewnętrznych źródeł: bazy danych i interfejsu użytkownika, przez co punkty 1 i 3 nie są dobrymi kandydatami do napisania dla nich testów jednostkowych. Natomiast punkt 2 jak najbardziej. Przykładowa implementacja metody sprawdzającej warunki, może wyglądać tak:
Metoda CanBeInvitedToPlay
interfejsu IPlayerChecker
na wejściu bierze trzy parametry, każdy z nich określa, czy gracz posiada daną cechę czy nie (true, false
) i zwraca wartość true
, kiedy wszystkie obowiązkowe warunki są spełnione. Można zauważyć, że w sumie do sprawdzenia jest 8 przypadków. Z graczem można zagrać wtedy i tylko wtedy, gdy posiada on wszystkie obowiązkowe cechy. Jeśli chociaż jeden z warunków nie jest spełniony, to z takim graczem grać nie można. Przykładowe testy jednostkowe sprawdzające poprawność implementacji z wykorzystaniem biblioteki NUnit, wspomagającej pisanie takich testów, mogą wyglądać na przykład tak:
Takie testy uruchamia się jednym poleceniem i w ciągu chwili dostajemy wynik, czy nasza zakodowana funkcjonalność działa czy nie. Oczywiście testy to również kod i jak w każdym kodzie, również w tym mogą zdarzyć się błędy, dlatego tak ważne jest aby, dzielić kod na małe jednostki, testować te jednostki osobno, a następnie składać większe kawałki z tych mniejszych (jak klocki lego ;)), wtedy prawdopodobieństwo popełnienia błędu spada. Testy jednostkowe mogą być uruchamianie na serwerze build’ów przez co kod jest automatycznie testowany po wprowadzeniu każdej zmiany do systemu kontroli wersji.
Testy integracyjne
Wiedząc, że poszczególne kawałki kodu działają, od razu nasuwa się pytanie „no dobrze, ale czy te poskładane kawałki działają jako całość ?” Tutaj z pomocą przychodzą testy integracyjne, których celem jest przetestowanie całości funkcjonalności wraz z dostępem do zewnętrznych źródeł danych. W odróżnieniu od testów jednostkowych, testy te wymagają większej pracy, ponieważ trzeba dla nich przygotować zestaw rzeczywistych danych do przetestowania. Dodatkowo środowisko testowe musi być przygotowane do wykonywania takich testów, np. ustawiony dostęp do bazy danych, uprawnienia do plików, skonfigurowane URL’e do zasobów w sieci itp. Czasami bardzo trudno jest zestawić wszystkie potrzebne parametry na raz, więc pewnym półśrodkiem, który dość dobrze się sprawdza, jest pisanie manualnych testów integracyjnych. Polega to na wykorzystaniu technik testów jednostkowych, tzn. wydzielenia kawałku kodu, który chcemy przetestować w całości, napisania testu, oznaczenia testu specjalnym atrybutem, aby test ten nie był uruchamiany automatycznie, uruchomieniu testu ręcznie. Przykładowo dla wymagania - pobierz wszystkich graczy z bazy danych - test taki może wyglądać na przykład tak:
Klasa QueryPlayers
zawiera logikę pobierania graczy z rzeczywistej bazy danych. Podając w konstruktorze „connection string” do tej bazy, możemy sprawdzić, czy zapytanie faktycznie się wykonuje i czy zwraca jakieś wyniki.
Testy końcowe
Jeszcze innym powodem pisania testów jest czas, w jakim jesteśmy w stanie, znaleźć błąd w kodzie. Pisząc testy jednostkowe oraz integracyjne, możemy wykryć potencjalne błędy bez potrzeby angażowania działu testów oraz końcowych użytkowników, co w znacznym stopniu skraca czas realizacji funkcjonalności. Kiedy już stwierdzimy, że funkcjonalność jest skończona, warto uruchomić system i chociaż raz przejść taką funkcjonalność, tak jak będzie to robił końcowy użytkownik. Może się wtedy okazać, że jednak o czymś zapomnieliśmy, a czego testy nie wykazały np. nadać jeszcze jakieś uprawnienie. Daje nam to całościowy obraz tego, jak funkcjonalność działa i kontekst do rozmowy z użytkownikiem w razie, gdy coś jednak będzie nie tak.
W zależności od rodzaju funkcjonalności, którą realizujemy, mogą być wymagane jeszcze inne rodzaje testów, np:
- wydajnościowe
- bezpieczeństwa
- ergonomii użytkowania
- …
Warto przeprowadzać testy, wtedy zwiększa się prawdopodobieństwo tego, że funkcjonalność będzie działać stabilnie na środowisku produkcyjnym.
Wzorce projektowe
W programowaniu nie ma jednego, jedynego słusznego podejścia do konstruowania kodu. Najprostszym sposobem, jaki można sobie wyobrazić, jest utworzenie jednej klasy oraz jednej metody w tej klasie i zakodowanie całej funkcjonalności w tej metodzie. Odpadają wtedy wszystkie dylematy związane z dzieleniem kodu:
- ile klas utworzyć ?
- jak podzielić to na metody ?
- jak definiować zależności między klasami ?
- …
Jednak kodując w ten sposób, bardzo szybko dochodzi się do większych problemów:
- ten kod jest nieczytelny!
- nie da się wprowadzić zmiany do tego kodu!
- nie da się napisać testów dla tej metody!
Warto więc poświęcić czas na przemyślenie konstrukcji kodu, zwłaszcza pod kątem jego późniejszych zmian. W niektórych przypadkach okazuje się, że rozwiązanie zostało już wymyślone, dobrze opisane i nazwane. Warto więc sięgnąć do tego rozwiązania, dostosowując je do własnych potrzeb. Tak właśnie jest z tzw. wzorcami projektowymi. Dobrze przemyślana konstrukcja kodu przydaje się również w sytuacji, gdy mamy do zrealizowania kolejną funkcjonalność i z opisu wymagań wynika, że „gdzieś już realizowałem bardzo podobną funkcjonalność i wymyślone rozwiązanie sprawdziło się”. Czasami przydaje się implementacja jeden to jeden zgodna z definicją wzorca, a czasami wystarcza własna implementacja dostosowana do potrzeb.
Factory method - Creational pattern
We wcześniejszym przykładzie dotyczącym testów jednostkowych do utworzenia obiektu do przetestowania wykorzystany jest wzorzec Factory method
:
Prosta metoda GetPlayerChecker()
zwraca konkretną implementację interfejsu IPlayerChecker
. Jeśli np. chcielibyśmy zastąpić implementację klasy PlayerChecker
inną implementacją (PlayerChecker2
;)), to wystarczy zwrócić w tej metodzie obiekt klasy PlayerChecker2
i uruchomić testy. Jeśli mamy więcej testów, a obiekt klasy PlayerChecker
byłby tworzony w każdym z tych testów, to trzeba by wprowadzić tyle zmian, ile jest metod z testami (czasochłonne/pracochłonne).
Proxy - Structural pattern
Wzorzec o nazwie Proxy
jest użyteczny w sytuacji gdy potrzebujemy odwołać się do zewnętrznego zasobu. Przykładowo, mogą to być dane, które udostępniane są przez „zewnętrznego” dostawcę przy użyciu technologii Web Service
, co wymaga sięgnięcia po te dane na zdalny serwer poprzez np. protokół SOAP
. W takim przypadku Proxy
może być reprezentowane przez klasę, która będzie zawierała szczegóły implementacji, zarówno tłumaczenia obiektów na reprezentację XML
oraz zdalnego wywołania, jak i tłumaczenia powrotnego - odpowiedzi i danych na obiekty.
Przykładowa implementacja Proxy
opakowująca funkcjonalność pobierania informacji o graczach może wyglądać tak:
Interfejs IProxyQueryPlayers
reprezentuje deklarację funkcjonalności pobierania danych o graczach.
Klasa SoapHttpClientProtocol
należy do standardowej biblioteki .NET Syste.Web.Services
. Jest ona implementacją protokołu SOAP. Klasa ProxyQueryPlayers
dziedziczy po klasie SoapHttpClientProtocol
oraz interfejsie IProxyQueryPlayers
i jest implementacją Proxy
. Konstruktor tej klasy w parametrze przyjmuje URL do zdalnego zasobu, z którego można pobrać informacje o graczach. Metoda Execute
wywołuje metodę Invoke
, która to metoda wykonuje zdalne wywołanie oraz tłumaczy wynik z formatu XML
na listę obiektów typu Player
.
Przykładowe użycie takiego Proxy
może wyglądać tak:
Template - Behavioral pattern
Wzorzec ten przydaje się w sytuacjach, gdy kodujemy funkcjonalność, która składa się z powtarzalnych kroków. Część z tych kroków jest wspólna, a część jest specyficzna dla konkretnej sytuacji. Najlepiej jak kroki te są „stabilne” tzn. istnieje małe prawdopodobieństwo, że będą się zmieniać w czasie. Przykładowo, pozostając w domenie gry w tenisa, uderzenie piłki z końca linii w trwającej wymianie można opisać w krokach:
- przygotowanie pozycji wyjściowej
- przygotowanie do uderzenia (skręt ciała)
- złapanie rakiety odpowiednim uchwytem
- zamach
- uderzenie
- wykończenie
Część z tych kroków jest niezależna od typu uderzenia (forehand, backhand) oraz gracza. Zawsze trzeba zacząć od pozycji wyjściowej i zawsze trzeba uderzyć piłkę. Pozostałe kroki są ściśle powiązane z typem gracza oraz z typem uderzenia. Inny jest skręt ciała do forehand’u a inny do backhand’u. Są różne rodzaje uchwytów pozwalających celnie uderzyć piłkę itd. Tenis jako gra cały czas ewoluuje, natomiast jest mało prawdopodobne by ten bazowy wzorzec kroków zmienił się radykalnie. Implementacja takiego wzorca, z prostą funkcjonalnością wypisującą na standardowe wyjście wybrane kroki uderzenia może wyglądać tak:
Wspólne kroki uderzenia, niezależnie od tego, czy jest to forehand czy backhand, reprezentowane są przez metody ReadyPosition()
oraz ContactPoint
abstrakcyjnej klasy o nazwie Groundstroke
, którą to klasę można traktować jako Template
. Kroki różniące się w zależności od rodzaju uderzenia to kroki reprezentowane przez metody Preparation()
oraz FollowThrough()
, których implementacja znajduje się w klasach Forehand
oraz Backhand
dziedziczących po klasie Groundstroke
.
Metoda HitBall()
należąca do interfejsu IGroundstroke
reprezentuje uderzenie piłki tenisowej.
Wzorce aplikacyjne
Przy konstruowaniu kodu równie pomocne mogą okazać się Wzorce aplikacyjne, które oprócz wskazania jak można układać kod w klasy, opisują, w jaki sposób można wyodrębniać i łączyć różne elementy składające się na działanie systemu, np. funkcjonalności interfejsu użytkownika, logiki działania oraz dostępu do bazy danych. Podobnie jak w przypadku wzorców projektowych, w pewnych sytuacjach przydaje się pełna implementacja zgodna z definicją, a w innych wystarcza częściowa.
Query Object
We wcześniejszych przykładach dotyczących pobierania danych o graczach wykorzystywany jest obiekt Query
. W implementacji klasy takiego obiektu można zaszyć zarówno logikę pobierania danych, np. z bazy danych, jak i klasę reprezentującą pobrane dane. W ten sposób całość możemy „zamknąć” w jednej klasie i traktować Query
jak komponent odpowiedzialny za pobranie konkretnych danych.
Patrząc na wcześniejszy przykład wzorca Proxy
, można zauważyć, że do jego implementacji został wykorzystany wzorzec Query
dostosowany do funkcjonalności pobierania danych ze zdalnego zasobu, także nic nie stoi na przeszkodzie, aby łączyć ze sobą różne wzorce w celu uzyskania pożądanego efektu.
Model View Controller (MVC)
Ostatni opisany wzorzec, w tym już dość długim poście, to wzorzec pomagający w zaprojektowaniu interakcji między interfejsem użytkownika (UI), jego logiką a pozostałą częścią systemu. Większość technologii UI opiera się na wyświetlaniu zawartości (grafiki, treści, ..) oraz interakcji z użytkownikiem poprzez zdarzenia. Użytkownik klika guzik, generując zdarzenie, po którym wyświetla mu się odpowiednia zawartość strony (treść). Innym przykładem jest wypełnienie formularza, a następnie kliknięcie guzika „wyślij”, po którym następuje przesłanie zawartości formularza do przetworzenia. Mieszając kod logiki z kodem interfejsu użytkownika, łatwo można wpaść w pułapkę niemożliwości przetestowania funkcjonalności bez uruchomienia systemu i jego „przeklikaniu” (co samo w sobie jest bardzo wskazane, zwłaszcza w końcowej fazie realizacji). Wzorzec MVC (lub jego odmiana Model View Presenter) umożliwia uniknięcie tej pułapki. Dzięki odseparowaniu zachowania danego interfejsu od sposobu jego wyświetlania, można nawet zacząć kodowanie od pierwszej części, skupiając się na poprawności działania, bez konieczności natychmiastowego kodowania kontrolek UI. Trzymając się przykładu pobierania informacji o graczach tenisa, wzorzec MVC można zakodować na przykład tak:
Interfejs IViewShowPlayers
reprezentuje deklarację widoku, który będzie wyświetlał informacje o graczach po wywołaniu metody Show(IList<PresenterShowPlayers.PlayerViewModel> players)
. Metodę tę wywołuje prezenter, którego deklarację reprezentuje interfejs IPresenterShowPlayers
a definicję klasa PresenterShowPlayers
. Jeśli wymaganie opisuje ładowanie informacji o graczach po kliknięciu guzika na interfejsie, to w momencie, gdy użytkownik klika guzik widok wywołuje metodę Show
prezentera. Prezenter pobiera dane o graczach przez Proxy
ze zdalnego serwera lub przez Query
bezpośrednio z bazy oraz mapuje wyniki na model PlayerViewModel
, który to model przekazywany jest do widoku poprzez wywołanie metody Show
na tym widoku. W ten sposób można odseparować renederowanie danych od sposobu pobierania danych. Klasę PresenterShowPlayers
można przetestować testami jednostkowymi bez konieczności uruchamiania systemu.
Podsumowanie
Powyższe przykłady są tylko niewielką częścią zagadnień, na temat których powstało wiele książek oraz artykułów. Oczywiście nie ma żadnego przymusu stosowania wzorców w każdej implementacji, a nawet stosowania testów czy to jednostkowych, czy też integracyjnych. Czasami samo „przeklikanie” funkcjonalności w zupełności wystarcza, natomiast kod powinien być zawsze tak napisany, aby w każdym momencie można było do niego łatwo dopisać testy. Gdy jednak okazuje się, że w kodowanej funkcjonalności jest wiele ścieżek przebiegu, testy jednostkowe pozwalają uzyskać efekt „o k…! jak bym to puścił/puściła na produkcję to byłoby niewesoło, dobrze, że napisałem/napisałam do tego testy!”. Podobnie jest ze wzorcami. Jeśli funkcjonalność jest prosta, to może do jej realizacji wystarczą dwie metody, zamiast implementacja złożonego wzorca, natomiast jak funkcjonalność jest bardziej złożona, lub z prostej staje się złożona, to warto przeanalizować rozwiązanie pod kątem użycia konkretnego wzorca. Uniwersalną zasadą jest zasada, aby kod był prosty i czytelny, tak aby w późniejszym czasie można było go w miarę łatwo modyfikować, dlatego w trakcie kodowania warto zadawać sobie pytanie: „Czy jakbym miał/miała zmieniać coś w tym kodzie za pół roku, to będzie to dla mnie przyjemnością czy męką :)”. Kolejna część cyklu będzie dotyczyć rodzajów aplikacji oraz sposobów ich realizacji.
=