sobota, 3 grudnia 2011

Budowanie z modułów - kilka słów o wzorcach konstrukcji

I. Wstęp

W tym poście chciałbym przedstawić bardzo ogólną ideę projektowania aplikacji modułowych.

W pierwszej kolejności wspomnę o tym, co będziemy rozumieli przez aplikacje modułowe czy też modularność (II. Kilka słów o modularności). Wprowadzenie jest raczej ogólne, czytelnik z choć podstawowym zrozumieniem problematyki z czystym sumieniem może je opuścić.

Dalej zajmę się przedstawieniem wybranych wzorców projektowych stosowanych w tego typu aplikacjach. Wspomnę o podstawowych elementach, które można przyjąć za charakterystyczne ogólnie dla modułów. Jak zobaczymy świetnie odzwierciedla je architektura wzorca MVVM.

Nie będę się jednak zajmował wzorcem MVVM (o którym sporo można znaleźć w innych postach tego bloga). Omówię natomiast wzorce projektowe stosowane na platformie SharePoint, która charakteryzuje się modularnością budowy.

Mimo że niniejszy blog poświęcony jest technologii .NET przykłady, ten temat będzie raczej abstrahował od konkretnej technologii. Nie szczegóły implementacji są tu istotne, ale architektura aplikacji, którą zrealizować można w sposób dowolny. Dzięki tym kilku ogólnym ideom związanym z łączeniem modułów mamy większe szanse, że nie skończymy w taki sposób:

II. Kilka słów o modularności

Co rozumiemy przez aplikację modułową? Jest to oprogramowanie złożone (surprise! surprise!) z modułów - to jest niezależnych całości. (We frameworku Prism powiedzielibyśmy, że jest to klasa implementująca interfejs IModule). Ich funkcjonalność nie zależy od kontekstu użycia. Ściślej - nie zależy od konkretnego kontekstu użycia. Moduł w różnych kontekstach może zachowywać się nieco inaczej, ale jeśli dany kontekst jest przystosowany do modułów danego typu to całość będzie funkcjonowała. Sprzężenie między modułem a kontekstem jest bardzo swobodne.

Cel jaki przyświeca tego typu architekturze aplikacji ponowne wykorzystanie raz napisanego kodu.

W przypadku modularności wchodzimy na nieco wyższy poziom ponownego wykorzystania kodu. Nasz moduł to, powiedzmy podprogram, czy miniaplikacja, która w całości może zostać zaimportowana do "składanego" programu.

Istotnie, moduły możemy potraktować jak bloczki, z których budujemy aplikację. By posłużyć się przykładem, przyjmijmy, że chcemy zrobić prostą aplikację do robienia szybkich notatek. Niech umożliwia rysowanie na prostym panelu, ale też pisanie notatek tekstowych i prostą edycję tekstu. Dalej chcielibyśmy, żeby notatkę można było przesłać dalej.

Tutaj nasz pierwszy moduł stanowiłby panel, który przyjmuje dowolny content (tekst, rysunki, zdjęcia). (Dobrym kandydatem mógłby być np. Canvas z WPF, zaimplementowany tak, by mógł przyjmować tekst z klawiatury ale miał też zaimplementowane Ink API, by użytkownik, mógł na nim rysować. Zaznaczam, że jest to tylko propozycja i szczegóły implementacji modułów nie są istotne dla omówienia idei modularności.)

Mamy panel na który wprowadzamy treść. Osobno potrzebujemy modułu, który zawierałby zestaw kilku pędzli i kilku kolorów (uproszczony paint?). Trzeci moduł to prosty edytor tekstu (kilka czcionek i ich wielkości, podkreślenie tekstu itp.)

Już teraz widać, że każdy z tych modułów może być wstawiony w inną aplika (ale tylko pod warunkiem spełnienia pewnych wymogów - o czym już za chwilę).

Mamy połączone moduły, które ze sobą współpracują i pozwalają stworzyć prostą notatkę. Wspomniałem wyżej o jeszcze jednym module - przesyłanie notatki. Nasza aplikacja zawiera miejsce na moduł przesyłu. Może on być dostosowany do przesyłania wiadomości w postaci maila (wtedy nasz moduł miałby pole np. na adres e-mail i podpis nadawcy). Ale moglibyśmy chcieć wypuścić naszą aplikację na telefon, nie zmieniamy wtedy całego programu, a jedynie podmieniamy moduł komunikacji, na taki, który przyjmuje określony typ treści i jest przystosowany do wysyłania MMSa. Inna opcja, to moduł, który konwertuje naszą notkę do rozszerzenia pdf. Rozwiązań jest sporo. Dzięki budowie modułowej jedna aplikacja może być szybko dostosowana do innej platformy, w łatwy sposób programista może też zmienić/poszerzyć jej funkcjonalność.

III. Wzorce projektowe architektury modułowej

Aplikacje modułowe wymagają nieco innego podejścia niż aplikacje, powiedzmy "klasyczne" (czyt. niemodułowe). Założeniem jest wykorzystanie bardzo dużych fragmentów już istniejących aplikacji. Moduły muszą więc mieć zbliżoną formę, przepływ danych między modułami muszą kontrolować odpowiednio zaprojektowane interfejsy itp.

Moduł aplikacji możemy rozbić na cztery elementy, od których zależy:

  1. Nawigacja - wiąże się z poruszaniem się w obrębie modułu a także między modułami

  2. Widok - czyli kwestia wyświetlania danych użytkownika

  3. Działania użytkownika - przede wszystkim praca na danych, ich aktualizacja itp.

  4. Powiązanie z danymi - osobna warstwa danych pozwala uniezależnić dane od widoku i działań użytkownika

Powyższy rozkład modułu w zasadzie niewiele różni się od WPFowego MVVM. Dlatego też ten wzorzec architektoniczny świetnie nadaje się do tworzenia aplikacji modułowych.

Szczegółowe omówienie wzorca MVVM można znaleźć w innych postach niniejszego bloga (przede wszystkim kategorie MVVM i WPF). Tutaj chciałbym się zająć mniej popularnymi wzorcami projektowymi, czy też wzorcami wyznaczającymi konstrukcję modułów, ich powiązania i role jakie spełniają. Przedstawione wzorce wykorzystywane są w aplikacjach dla platformy SharePoint.

  1. Applet Pattern - moduł w całości odpowiada za pełne przetworzenie otrzymanego obiektu. Poza tym moduł przetwarza tylko jeden typ elementów wejściowych.

  2. Tiered Pattern - moduł zbiera dane z modułów pokrewnych i łączy te dane w jedną całość. Wzorzec wymaga przejrzystej struktury każdego z modułów. Przykładowe wykorzystanie wzorca może wyglądać następująco, gdybyśmy mieli kilka modułów, zawierających różne gry planszowe, to moduł zbierający dane mógłby wyświetlać wyniki, zwycięzców itp.

  3. Personal Pattern - widok modułu składany jest z części kilku innych widoków, w zależności od użytkownika, który z niego korzysta. Taka organizacja modułu pozwala użytkownikowi swobodnie dostosować środowisko pracy do własnych preferencji. Np. w aplikacji do wysyłania wiadomości od preferencji użytkownika zależy czy załaduje do swego widoku moduł rysowania, czy tylko moduł tekstowy.

  4. Secure Separation Patterns - moduły dzielimy w zależności od poziomu bezpieczeństwa jaki jest wymagany. Informacje, do których dostęp ma tylko ich autor trzymane i przetwarzane są w jednym module. Inny służy do pracy na danych współdzielonych, jeszcze inny - do obróbki informacji publicznych. Dla aplikacji implementującej grę w karty osobny moduł służyłby do wyświetlenia kart użytkownika (wysoki poziom prywatności), w osobnym gracze widzieliby wyrzucone karty (średni poziom prywatności), a jeszcze inny pokazywałby nazwy graczy siedzących przy stoliku (poziom publiczny)

  5. Master Pattern - wzorzec zakłada jedną, wspólną warstwę danych, które są rozdzielane między różne widoki. Rozwiązanie zapewnić ma spójność danych i ich aktualność. Jest to szczególnie istotne, kiedy do danych jednoczesny dostęp ma wielu użytkowników.

  6. Knowledge Pattern - wzorzec wykorzystywany w sytuacji, kiedy moduł wykorzytuje dane przechowywane w różnych miejscach. Dostęp do danych odbywa się wtedy na różne sposoby. Moduł powinien widzieć w źródłach danych tylko te informacje, które są dla niego przeznaczone. Należy tu rozstrzygnąć, jak dane dla modułu będą oznaczane (dodatkowe tagi, osobna kolumna w tabeli itp.)

  7. Distributed Process Pattern - wzorzec powiązany z rozproszonym przetwarzaniem danych. Ideą wzorca jest zachowanie danych w jednym miejscu, podczas gdy rozproszone przetwarzanie odbywa się na rozproszonych widokach. Jednocześnie dane mogą być przetwarzane w kilku miejscach (na kilku widokach)

IV. Zakończenie

Powyższe zalecenia co do konstruowania aplikacji z gotowych modułów z powodzeniem można stosować do różnych platform i framewoków. Jak we wszystkich wzorcach projektowych szczegóły implementacji schodzą na plan dalszy. Rzeczą drugorzędną jest to czy przez moduł rozumiemy stronę internetową, region, widok, pełną aplikację itp.

W przygotowaniu tego posta bardzo pomocne były następujące artykuły:

- seria artykułów o MVVM i Prism autorstwa Marka J. Millera. Tekst o modularności dostępny pod adresem: http://www.developmentalmadness.com/archive/2009/10/15/mvvm-with-prism-101-ndash-part-3b-view-injection-and.aspx

- artykuł Modular Appliaction Design Pattersn for SharePoint utworzony przez firmę CorasWorks, a dostępny pod adresem: http://www.corasworks.net/files/DesignPatterns_White_Paper.pdf

Scrum i Team Foundation Server cz.5 - Continuous Integration

1. Trochę teorii

Każdy z nas w trakcie zawodowej kariery miał (nie)przyjemność pracować z systemami kontroli wersji. Codziennie w trakcie swojej pracy wrzucamy, pobieramy z nich pliki, rozwiązujemy konflikty. Wiemy po co je używać, ale czy wiemy jak? Tak jak w każdej dziedzinie życia pewne rzeczy można robić lepiej lub gorzej. W tym artykule postaram się pokazać kilka dobrych zasad ciągłej integracji (Continuous Integration), które pozwolą usprawnić trochę ten proces. Na koniec przedstawię przykład jak wdrożyć te zasady w Team Foundation Server.

Systemy kontroli wersji są czymś o czym powinniśmy pomyśleć na samym początku projektu. Mają wiele zalet, nie można się bez nich obyć, "ale...". Tym "ale" jest najbardziej zawodny element procesu - ludzie. 
Każdy z nas nie raz klął na kolegę, który wrzucił niebudujący się kod do repozytorium, drżał przed nadchodzącymi konfliktami przy pobraniu nowej wersji repozytorium. Myślę, że każdy z nas mógłby wymienić z imion współpracowników specjalizujących się we wrzucaniu niebudującego się kodu. 

Na bazie swoich doświadczeń uważam, że sporo z tych błędów można wyeliminować poprzez stosowanie się do kilku prostych zasad:

1. W repozytorium znajduje się tylko kompilujący się kod - przed wrzuceniem swoich zmian, poświęćmy czas i przebudujmy chociaż projekt. Nie ma nic bardziej irytującego niż niebudujący się kod. Nieszczęśliwcowi, który ściągnie rewizję w takiej sytuacji zwykle pozostaje cofnięcie się do poprzedniej działającej wersji, lub zrobienie hacku.

2. W repozytorium znajduje się tylko działający kod - jest to rozwinięcie pierwszej zasady. Sam fakt, że kod się buduje nie oznacza, że działa poprawnie. Przed wrzuceniem wersji poświęćmy chwilę na sprawdzenie czy nasz kod działa poprawnie: odpalmy testy, przeklikajmy się przez modyfikowane moduły. Często wydaje nam się to stratą czasu, bo "przecież to była tylko mała zmiana", a potem okazuje się, że zmianę zrobiliśmy na szybko i wrzuciliśmy kod, który wywala pół aplikacji. 
Oczywiście, niektórzy powiedzą, że czasami nie da się tego zrobić, gdy więcej niż jedna osoba pracuje nad tym samym kodem i muszą się nim dzielić. Repozytoria kodu mają mechanizmy pozwalające na rozwiązanie tych problemów: tworzenie osobnych gałęzi kodu (branch), odkładanie kodu na półki (shelve). Jeżeli zastosowanie tych rozwiązań jest zbyt kłopotliwe - zróbmy wszystko by okres "zepsutego" repozytorium trwał najkrócej.

3. Wrzucajmy regularnie i często nasze zmiany - oczywiście musimy zachować pierwsze dwie zasady. Dzielmy nasz kod na małe fragmenty (units of code). Oprócz takich zysków jak lepsza jakość kodu, zmniejszamy szansę na kłopotliwe rozwiązywanie złożonych konfliktów.

4. Im częściej robimy update - tym lepiej - jest to szczególnie kluczowe, gdy robimy dużą zmianę, mającą duży wpływ na działanie systemu. Musimy przeprowadzić dużo testów, nie możemy wtedy szybko wrzucić zmian. Im częściej będziemy pobierać zmiany tym szybciej połączymy się z kodem innych i szansa na konflikty zdecydowanie zmaleje.

5. Piszmy komentarze gdy wrzucamy kod - niby drobna rzecz, ale bardzo pomaga gdy musimy znaleźć przyczynę modyfikacji fragmentu kodu. Napiszmy z jakim zadaniem nasze zmiany są związane, co zmodyfikowaliśmy, dodaliśmy, usunęliśmy tym lepiej. Krótko ale treściwie.

Niestety teoria teorią, a praktyka praktyką. Często okazuje się, że sporo ludzi podchodzi do nich nonszalancko. Czasami skomplikowanie systemu nie pozwala w prosty i szybki sposób na sprawdzenie wpływu naszych zmian na jego działanie.

Z pomocą przychodzą nam zasady i systemy Continuous Integration. Oprócz zasad dobrego używania kodu (m.in. tych, które wymieniłem powyżej) główną ideą jest to, że zmiany w kodzie powinny być ciągle integrowane ze sobą. Powinna być regularnie sprawdzana jego spójność, integralność oraz poprawność działania. 

Dokonuje się tego przez regularne buildy projektu, uruchamianie testów itd. Powinny się one odbywać w środowisku identycznym do docelowego klienckiego (eliminujemy w ten sposób błędy wynikające z tego, że programiści mają różne konfiguracje swoich systemów). Każda zmiana powinna zostać przetestowana. Jeżeli nie spełni kryteriów akceptacji to powinna nie zostać dopuszczona do repozytorium, lub zespół projektowy powinien zostać niezwłocznie powiadomiony o pojawieniu się błędnego kodu.

Postaram się pokazać jak te cele można osiągnąć przy pomocy Team Foundation Server. Pokażę jak utworzyć dwie definicje buildów, które powinny rozwiązać większość podstawowych problemów:
- build dzienny - będzie raz na dobę sprawdzał czy projekt buduje się, czy wszystkie testy przechodzą. Ponieważ może on trwać długo, uruchamiany będzie w nocy - by nie utrudniać pracy programistom.
- build wejśćiowy (gated build) - będzie sprawdzał czy kod wrzucany jest poprawny. Jeżeli nie spełni zadanych kryteriów, nie dostanie się do repozytorium. Jakie warunki będziemy sprawdzać? Pierwszym będzie kompilacja projektu, drugim przejście najważniejszych testów (uruchamianie wszystkich nie było by dobrym posunięciem, bo trwało by zdecydowanie za długo).

2. Trochę praktyki


Pierwszym krokiem jest utworzenie projektu i dorzucenie go do Team Foundation Server. Bazował będę na przykładzie opisanym w "Scrum i Team Foundation Server cz.4 - Tworzymy projekt".

Pierwszą czynnością jest utworzenie folderu, do którego będą wrzucane DLLki budowanych solucji. Na nim będzie działał serwer. Załóżmy folder na dysku C: o nazwie "BuildFolder". Konieczne jest nadanie maksymalnych uprawnień do folderu procesowi buildów TFS (dla mnie będzie to "Local Service"). Oprócz tego musimy udostępnić w sieci lokalnej ten folder.


Mając już skonfigurowany folder możemy przejść do właściwej konfiguracji. Otwieramy Visual Studio i otwieramy Team Explorera (Menu => View => Team Explorer)


Przechodzimy do menu konfiguracji buildów naciskając przycisk "Builds". Aby dodać nową definicję naciskamy przycisk "New Build Definition". Dodajemy dwie definicje:

1. Build dzienny 

W  pierwszym oknie wpisujemy jego nazwę (np. "OneCardMaster - Daily" i ustalamy, że ma być aktywny zaznaczając opcję "Enabled".

Przechodzimy do zakładki "Trigger". 


W niej definiowane wyzwalacze dla naszego buildu. Chcemy, żeby uruchamiał się codziennie o godzinie 3 w nocy, nawet jeżeli kod nie zmienił się. Zaznaczamy opcje jak na obrazku powyżej. 
Przechodzimy do zakładki Build Defaults. I podajemy w niej adres sieciowy utworzonego przez nas wcześniej katalogu do buildów.


W zakładkach Workspace, Process i Retention Policy zostawiamy wartości domyślne.
Naciskamy przycisk zapisz i mamy skonfigurowany pierwszy build.


2. Build wejściowy


Tworzymy kolejną definicję builda poprzez Team Explorera. Podajemy jej nazwę "One Card Master - Gated". Zakładki Workspace, Build Defaults, Retention Policy tak samo jak w przypadku builda dziennego.
Przechodzimy do zakładki Trigger i ustalamy w niej opcję "Gated Check-in".




Następnie naciskamy w Process. Rozwijamy kolejno sekcje: Basic, Automated Tests, Test Assembly.




Ustalamy, że check-in ma być odrzucony jeśli wybrane przez nas testy nie przejdą ("Fail Build On Test Failure" - True). 
Aby wybrać testy, które mają być wywoływane wpisujemy w opcję "Category Filter" jej nazwę np. "VIT" (Very Important Tests).
Zapisujemy builda i "eto wsio".


3. Trochę przykładów

Aby zaprezentować jak działa nasz build utwórzmy projekty o strukturze.

Projekt OneCardMaster jest to "Class Library", OneCardMasterTest - "Test Project".
Dodajemy klasę ClassToTest wyglądającą:
public static class ClassToTest
{
    public static bool VeryImportantFunction()
    {
        return true;
    }

    public static bool Function1()
    {
        return true;
    }

    public static bool Function2()
    {
        return true;
    }

    public static bool Function3()
    {
        return true;
    }
}

Jak widać jest to prosta klasa, która jedynie symuluje działanie systemu. Mamy tutaj cztery funkcje w tym jedną bardzo ważną. Do projektu testowego dodajemy klasę sprawdzającą poprawność jej działania.
[TestClass]
public class ClassToTestTest
{
    private TestContext testContextInstance;

    public TestContext TestContext
    {
        get
        {
            return testContextInstance;
        }
        set
        {
            testContextInstance = value;
        }
    }

    [TestMethod]
    [TestCategory("VIT")]
    public void VeryImportantFunction_ReturnsTrue()
    {
        Assert.IsTrue(ClassToTest.VeryImportantFunction());
    }

    [TestMethod]
    public void Function1_ReturnsTrue()
    {
        Assert.IsTrue(ClassToTest.Function1());
    }

    [TestMethod]
    public void Function2_ReturnsTrue()
    {
        Assert.IsTrue(ClassToTest.Function2());
    }

    [TestMethod]
    public void Function3_ReturnsTrue()
    {
        Assert.IsTrue(ClassToTest.Function3());
    }
}

Do każdej metody dodana została funkcja sprawdzająca jej poprawność. Jak łatwo zauważyć na ten moment wszystkie testy przechodzą.
Przechodzimy do Team Explorera i naciskamy przycisk Check In. Po zaakceptowaniu pojawi nam się nowe okno (jest to zmiana w stosunku do tego co mieliśmy przed konfiguracją builda wejściowego).




Informuje nas ono o tym, że nasze pliki zostaną wrzucone "na półkę". TFS sprawdzi poprawność zmian i jeżeli weryfikacja przebiegnie poprawnie to doda je do repozytorium. Naciskamy przycisk Build Changes.
W naszym systemie zostanie uruchomiony wątek sprawdzający czy build się nie zakończył.




Gdy status builda się zmieni otrzymamy o tym informację w postaci wyskakującego okna. Jeżeli wszystko zrobiliśmy poprawnie powinno wyglądać następująco:




Mamy dwie opcje do wyboru:
- Reconcile - odświeża status naszej lokalnej kopii repozytorium, czyści zmiany z pending changes itd.
- Ignore - nic nie robi - nie odświeża repozytorium. Dopiero gdy zrobimy update to uaktualni nam się wersja


Spróbujmy teraz zmienić metodę VeryImportantFunction na:


public static bool VeryImportantFunction()
{
    return false;
}


Po takiej zmianie nasz test z kategorii "VIT" nie będzie przechodził i build wejściowy nie powinien się udać.
Przejdźmy do Team Explorer i zróbmy Check In. Po zakończeniu builda powinniśmy zobaczyć okno informujące nas o niepowodzeniu:




Podsumowanie

W powyższym artykule starałem się przedstawić zasady dobrego korzystania z repozytorium, podstawy Continuous Integration oraz przykład jak zastosować to w praktyce przy użyciu TFS. Oczywiście jest to tylko wstęp, bo temat jest długi jak rzeka. Raczej nie będziecie znać po jego lekturze wszystkich odpowiedzi, ale liczę, że będziecie chociaż wiedzieć o co pytać.

UIAutomation - czyli testy automatyczne w .NET

Biblioteka Microsoft UI Automation ujrzała światło dzienne wraz z premierą .NET 3.0 - jednakże pozostała ona w cieniu swoich większych braci WPF oraz WPF, które również zostały wprowadzone do Frameworka 3.0. Microsoft UI automation zapewnia nam dostęp do wszystkich elementów drzewa wizualnego aplikacji. Dzięki czemu mamy możliwość:

  • Znajdowania wybranych przez nas kontrolek

  • Interakcji z kontrolkami - wpisywanie tekstów do TextBox-ów, klikanie w przyciski itp

  • Wczytywania wartości już wprowadzonych do kontrolek


Każda kontrolka znajdująca się na widoku jest traktowana przez UI Automation jako AutomationElement. Rootem naszego drzewa wizualnego pulpit, dostęp do niego uzyskujemy poprzez statyczną właściwość

AutomationElement.RootElement

Mając dostęp do roota możemy następnie poruszać się po drzewie tak aby znaleźć interesujące nas elementy. Załóżmy, że napisaliśmy prostą aplikację WPF-ową, która wykonuje podstawowe obliczenia matematyczne (pomysł na prostą aplikację do testowania zaczerpnięty z tej strony - kod jednak pisałem po swojemu :D).
Wizualnie aplikacja ta przedstawia się w następujący sposób:

<Window x:Class="UIAutomation.Views.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Height="350" Width="525" Title="{Binding Title}">

<Grid x:Name="SomeUniqueName1">
<Grid.RowDefinitions>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
<RowDefinition></RowDefinition>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
<ColumnDefinition></ColumnDefinition>
</Grid.ColumnDefinitions>
<Label Content="Pierwsza liczba:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.ColumnSpan="2"></Label>
<Label Content="Druga liczba:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="1" Grid.ColumnSpan="2"></Label>
<Label Content="Wynik:" HorizontalAlignment="Center" VerticalAlignment="Center" Grid.Row="2" Grid.ColumnSpan="2"></Label>
<Button Name="btnAdd" Grid.Column="0" Grid.Row="3" Content="Dodaj" Command="{Binding AddCommand}" />
<Button Name="btnSubtract" Grid.Column="1" Grid.Row="3" Content="Odejmij" Command="{Binding SubstractCommand}" />
<Button Name="btnMultiply" Grid.Column="2" Grid.Row="3" Content="Pomnóż" Command="{Binding MultiplyCommand}" />
<Button Name="btnDivide" Grid.Column="3" Grid.Row="3" Content="Podziel" Command="{Binding DivideCommand}" />
<TextBox Name="txtFirstNumber" Grid.Column="2" VerticalAlignment="Center" Grid.ColumnSpan="2" Text="{Binding FirstNumber,Mode=TwoWay}"></TextBox>
<TextBox Name="txtSecondNumber" Grid.Column="2" Grid.Row="1" HorizontalAlignment="Stretch" VerticalAlignment="Center" Text="{Binding SecondNumber,Mode=TwoWay}" Grid.ColumnSpan="2"></TextBox>
<TextBox Name="txtResult" Grid.Column="2" Grid.Row="2" HorizontalAlignment="Stretch" VerticalAlignment="Center" Text="{Binding Result,Mode=TwoWay}" Grid.ColumnSpan="2"></TextBox>
</Grid>
</Window>

W celu poruszania się po drzewie wizualnym możemy skorzystać z dwóch metod

public AutomationElement FindFirst(TreeScope scope, Condition condition);
public AutomationElementCollection FindAll(TreeScope scope, Condition condition);

pierwsza z nich zwraca nam konkretny element, który spełnia podane przez nas kryterium. Pierwszy parametr funkcji TreeScope scope określa nam sposób przeszukiwania drzewa wizualnego. Dostępne opcje to:

  • Element - wyszukiwanie odbywa się tylko na danym elemencie

  • Children - wyszukiwanie odbywa się na wszystkich dzieciach danego elementu - nie zagłębiamy się rekurencyjnie,sprawdzamy tylko jeden poziom w dół od danego elementu

  • Descendants - wyszukiwanie odbywa się na wszystkich potomkach - przeszukiwanie rekurencyjne w dół

  • Subtree - wyszukiwanie odbywa się na wszystkich potomkach, oraz elemencie od którego zaczynamy

  • Ancestors - wyszukiwanie odbywa się na wszystkich rodzicach - rekurencyjnie w górę drzewa


Drugim z parametrów jakie musimy podać jest to UIAutomation.Condition. Parametr ten określa jaki warunek musi spełnić dany element aby został uwzględniony w wyniku wyszukiwania. Sama klasa Condition jest klasą abstrakcyjną, z której dziedziczą wyspecjalizowane klasy. Do określenia warunków najczęściej będziemy korzystać z potomka klasy Condition, mianowicie z

PropertyCondition

określa ona jaka właściwość kontrolki/aplikacji będzie uwzględniana przy wyszukiwaniu wyniku.

Pierwszym krokiem przy testowaniu aplikacji będzie oczywiście znalezienie głównego okna programu. Mając w solucji stworzony projekt z aplikacją WPF-ową, dodajmy drugi projekt - tym razem będzie to projekt typu ConsoleApplication.Następnie musimy dodać referencję do odpowiednich ddl-ek. Potrzebujemy następujących bibliotek:

  • UIAutomation.dll

  • UIAutomationClient.dll

  • UIAutomationType.dll


Następnie w naszym projekcie konsolowym w metodzie main odpalamy aplikację, którą chcemy testować. W moim przypadku wygląda to w następujący sposób:

Console.WriteLine("Odpalam aplikacje");
Process p = Process.Start("..\\..\\..\\UIAutomation\\bin\\Debug\\UIAutomation.exe");


Następnie musimy dostać się do głównego okna testowanej aplikacji. Możemy to zrobić używając funkcji FindFirst:

AutomationElement mainForm = AutomationElement.RootElement.FindFirst(TreeScope.Children,
new PropertyCondition(AutomationElement.ProcessIdProperty, p.Id));

lub możemy uzyskać dostęp do okna przy pomocy funkcji

AutomationElement mainForm =
AutomationElement.FromHandle(p.MainWindowHandle);

W pierwszym przypadku wykorzystując funkcję FindFirst przeszukujemy wszystkie dzieci (TreeScope.Children) roota (czyli pulpitu). Jako warunek wyszukiwania podaliśmy obiekt klasy PropertyCondition. Jako właściwość do porównywania podaliśmy id processu (AutomationElement.ProcessIdProperty). W drugim przypadku po prostu uzyskujemy dostęp do okna dzięki znajomości jego uchwytu (właściwość MainWindowHandle z klasy Process). Według mnie lepiej skorzystać z funkcji pierwszej, zwłaszcza gdy odpalamy aplikację, która się długo uruchamia. W pierwszym przypadku wystarczy zrobić odpowiednią funkcję oczekującą na odpalanie (gdyż funkcja zwróci nam null gdy okna jeszcze nie będzie), natomiast druga opcja rzuci nam wyjątek. Oczekiwania na załadowanie okna może wyglądać w następujący sposób

AutomationElement mainForm;
while ((mainForm = AutomationElement.RootElement.FindFirst(TreeScope.Children,new PropertyCondition(AutomationElement.ProcessIdProperty,p.Id)))==null)
{
Thread.Sleep(100);
}

Mając dostęp do głównego okna możemy przeprowadzić testy - przetestujemy czy po wpisaniu danych do tekstboksów i przeprowadzeniu odpowiednich akcji (dodawanie,usuwanie,odejmowanie,dzielenie) w polu wynik pojawi się odpowiednia wartość. Zacznijmy od zlokalizowania textboxów. Posłużmy się tutaj przedstawioną wcześniej funkcją FindFirst.

var firstTextbox = mainForm.FindFirst(TreeScope.Children, new PropertyCondition(AutomationElement.AutomationIdProperty, "txtFirstNumber"));

Wyszukiwanie odbywa się po właściwości AutomationElement.AutomationIdProperty - właściwość ta jest zawsze taka sama jak nazwa kontrolki - chyba, że ktoś ją zmieni przy pomocy AttachedProperty AutomationProperties.AutomationId. Odpalając powyższy kod okazuje się, że nasza zmienna firstTextbox jest nullem - czyli framework nie był w stanie znaleźć naszego textboxa. Dlaczego tak się stało?? Odpowiedzi może nam dostarczyć aplikacja Snoop, dzięki której możemy podejrzeć drzewo wizualne naszego programu.

Na powyższym screenie widzimy, że szukany textbox nie jest bezpośrednim dzieckiem MainWindow, jest natomiast dzieckiem grida (tego można było się spodziewać zwłaszcza, że umieściliśmy textbox w gridzie). Zauważmy jednak, że MainWindow nie jest bezpośrednim rodzicem grida - znajduje się on dopiero na poziomie 4 w drzewie. Z racji dużego skomplikowania drzewa wizualnego aplikacji według mnie bezpiecznie jest używać funkcji Find z parametrem TreeScope.Descendants. Unikniemy dzięki temu przykrych niespodzianek z nieodnalezionymi kontrolkami. Nasza funkcja zatem powinna wyglądać w następujący sposób.

var firstTextbox = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "txtFirstNumber"));

W analogiczny sposób znajdujemy pozostałe przyciski na naszej formie, a następnie postarajmy się wprowadzić do nich jakiś text. W celu interakcji z kontrolkami wykorzystamy tzw. Patterns
Patterns definiują określone funkcjonalności, które wspiera nasza kontrolka. Do najczęściej używanych patterns należą:

  • SelectionPattern - używany do manipulacjami kontrolkami wspierającymi zaznaczanie np.ListBox-ami

  • TextPattern - używany do manipulacjami kontrolkami wspierającymi edycję

  • ValuePattern - używany do pobierania i ustawiania wartości kontrolek nie wspierających wielokrotnych wartości

  • InvokePattern - używane do kontrolek wspierających wywołania - np. przyciski (wywołanie przyciśnięcia)

  • ScrollPattern - używane do kontrolek posiadających ScrolBar-y

  • RangeValuePattern - używany do kontrolek mogących posiadać jakiś zakres wartości np. ComboBox


Zatem w celu ustawienia wartości w textbox-ie posłużymy się następującym kodem

var pattern = txtFirstNumber.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern;
pattern.SetValue("50");

W analogiczny sposób ustawiamy zawartość drugiego textbox-a

var pattern = txtSecondNumber.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern;
pattern.SetValue("50");

Mając wprowadzone dane do textbox-ów wypadałoby teraz przeprowadzić obliczenia. Zatem musimy w jakiś sposób aby "nacisnąć" jeden z naszych czterech przycisków. Znajdźmy zatem nasze przyciski:

var btnAdd = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnAdd"));
var btnSubtract = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnSubtract"));
var btnMultiply = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnMultiply"));
var btnDivide = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnDivide"));

Następnie wykorzystując wspomniany wcześniej InvokePattern naciśnijmy przycisk "Dodaj".

InvokePattern ipClickButton1 = (InvokePattern)btnAdd.GetCurrentPattern(InvokePattern.Pattern);
ipClickButton1.Invoke();

Ostatnim krokiem będzie zweryfikowanie czy wartość w textbox-ie txtResult jest zgodna z oczekiwaniami. W celu wyciągnięcia wartości z txtResult po raz kolejny posłużę się ValuePattern

str value = (txtResult.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern).Current.Value

Wynik otrzymany w textbox-ie porównujemy z oczekiwanym rezultatem

Console.Write("Dodawanie test OK ? , {0}",value == "100");

Na koniec najlepiej wrzucić nasz test w jakąś pętle, losowo generować wartości oraz porównywać je z wartościami oczekiwanymi. Dobrym pomysłem jest również stworzenie sobie jakiejś klasy pomocniczej, w której zostałyby umieszczone funkcje do wypełniania textbox-ow, klikania w buttony itp - gdyż jak widać dużo kodu jest powtarzalna. Ostatecznie aplikacja testowa może wyglądać w następujący sposób:

class Program
{
static void Main(string[] args)
{
Random rand = new Random();
try
{
Console.WriteLine("Odpalam aplikacje");
Process p = Process.Start("..\\..\\..\\UIAutomation\\bin\\Debug\\UIAutomation.exe");
AutomationElement mainForm;
while ((mainForm = AutomationElement.RootElement.FindFirst(TreeScope.Children,new PropertyCondition(AutomationElement.ProcessIdProperty,p.Id)))==null)
{
Thread.Sleep(100);
}
var txtFirstNumber = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "txtFirstNumber"));
var txtSecondNumber = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "txtSecondNumber"));
var txtResult = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "txtResult"));

var btnAdd = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnAdd"));
var btnSubtract = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnSubtract"));
var btnMultiply = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnMultiply"));
var btnDivide = mainForm.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.AutomationIdProperty, "btnDivide"));


for (int i = 0; i < 200; i++)
{
double firstNumber = rand.Next(1, 1000);
double seconNumber = rand.Next(1, 1000);
Console.WriteLine("\n\n Test dla wartości: {0} , {1}", firstNumber, seconNumber);
txtFirstNumber.SetTextBoxValue(firstNumber.ToString());
txtSecondNumber.SetTextBoxValue(seconNumber.ToString());
btnAdd.Click();
Console.WriteLine("Dodawanie test OK ? , {0}", txtResult.GetTextBoxValue().ConvertToDouble() == firstNumber + seconNumber);
btnDivide.Click();
Console.WriteLine("Dzielenie test OK ? , {0}", Math.Round(txtResult.GetTextBoxValue().ConvertToDouble(), 2) == Math.Round(firstNumber / seconNumber, 2));
btnSubtract.Click();
Console.WriteLine("Odejmowanie test OK ? , {0}", txtResult.GetTextBoxValue().ConvertToDouble() == firstNumber - seconNumber);
btnMultiply.Click();
Console.WriteLine("Mnożenie test OK ? , {0}", txtResult.GetTextBoxValue().ConvertToDouble() == firstNumber * seconNumber);
}

Console.WriteLine("\nEnd test run\n");
Console.ReadKey();
p.Close();
p.Kill();
}
catch (Exception ex)
{
Console.WriteLine("Fatal error: " + ex.Message);
Console.WriteLine("\nEnd test run\n");
Console.ReadKey();
}
}
}

oraz klasa pomocnicza

public static class UiAutomationHelper
{
public static string GetTextBoxValue(this AutomationElement automationElement)
{
return (automationElement.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern).Current.Value;
}

public static void SetTextBoxValue(this AutomationElement automationElement,string value)
{
(automationElement.GetCurrentPattern(ValuePattern.Pattern) as ValuePattern).SetValue(value);
}

public static void Click(this AutomationElement automationElement)
{
InvokePattern ipClickButton1 = (InvokePattern)automationElement.GetCurrentPattern(InvokePattern.Pattern);
ipClickButton1.Invoke();
Thread.Sleep(100); // dajmy czas na pokazanie okna, wykonanie funkcji itp
}

public static double ConvertToDouble(this string str)
{
double number;
if (double.TryParse(str.Replace('.',','), out number ))
return number;
throw new Exception("Niepoprawna wartość");
}
}

piątek, 2 grudnia 2011

TDD cz.1


Może na wstępie kilka słów o TDD. TDD to technika tworzenia oprogramowania zaliczana do metodyk zwinnych. Od tradycyjnego sposobu wytwarzania oprogramowania odróżnia ją przede wszystkim cykl programowania.


Tradycyjny cykl programowania

Taki sposób tworzenia oprogramowania obciążony jest kilkoma wadami:

  • Testowanie
    • Testerzy testują kod nie znając go zbyt dobrze
    • Brak zaangażowania testerów
    • ­­­­­Na czas testowania kod jest zamrożony
    • Naprawa błędu po zakończeniu implementacji jest zazwyczaj 2-krotnie wyższa niż w jej trakcie
  • Utrzymanie
    • Testy „po” sprawdzają wybiórczo kod
    • Testy nie przyczyniają się do zwiększenia jakości kodu, a jedynie sprawdzają, jak działa napisany kod
Ponieważ w podejściu TDD ważniejsze jest "co", a nie "jak" cykl programowania jest odwrócony.

Cykl programowania TDD

Przepis na TDD jest prosty:

  1. Najpierw napisz test, który nie przechodzi.
  2. Stwórz najprostsze możliwe rozwiązanie
  3. Refaktoryzuj
  4. Wróć do kroku 1

Najpierw napisz test

  • W TDD jest to ważne narzędzie – tutaj powstają pierwsze decyzje projektowe.
  • Programista musi przemyśleć jak kod zostanie użyty
  • Test wymusi odnalezienie tego, co rzeczywiście jest potrzebne w systemie
  • Testy stają się dokumentacją

Stwórz najprostsze możliwe rozwiązanie

  • Napisz dokładnie to, co jest wymagane przez test.
  • Nie dodawaj fajerwerków do kodu
  • Kod musi przejść stworzone testy – tyle wystarczy.

Refaktoryzuj

  • Przyjrzyj się swojemu rozwiązaniu
  • Zastanów się, co należy zrobić lepiej
  • Zmieniaj to, co rzeczywiście pomoże lepiej zrealizować wymagania
  • Popraw brzydkie fragmenty kodu

Ważnym pojęciem w TDD jest programowanie przyrostowe. Metoda najmniejszych kroków. Programowanie przyrostowe odnosi się zarówno do kodu programu jak i testów, które stają się coraz pełniejsze wraz z każdą iteracją.

W danym momencie dokonuj tylko jednej zmiany w systemie (możliwie najmniejszej)

Taka technika programowania posiada kilka zalet:

  • W każdym momencie masz w pełni działający kod
  • Małe zmiany powodują, że łatwiej nad mini zapanować
  • Małe zmiany powodują, że łatwiej znaleźć błędy, gdyż powodów błędów jest mniej.
  • Pracowanie na mniejszych fragmentach kodu daje większą kontrolę programiście
  • Projekt powstaje ewolucyjnie – programista może czerpać doświadczenie z tego, co napisał do tej pory.

W dalszej części tego dokumentu zauważycie, że metody przyrostowe wykorzystywane są na każdym kroku w TDD. Zaczynamy od rzeczy najprostszych i najprostszych rozwiązań

Przyczyną opracowania tej techniki programowania są ograniczenia pola świadomości:

  • Ludzki świadomy umysł jest w stanie przetwarzać 5-7 informacji w danym momencie
  • Dzieląc problem na mniejsze części ułatwiasz sobie pracę
  • Mniej szczegółów musisz zapamiętać
  • Jesteś bardziej świadomy konsekwencji wprowadzonych zmian

Test-Driven Development – jak to się robi

Pierwszą rzeczą, jaką należy zrobić przystępując od tworzenia projektu jest spis funkcjonalności. Możemy tutaj posłużyć się szablonem User Story, który pozwala na opisanie funkcjonalności w sposób najbardziej ogólny:

Jako <<osoba, rola>>,

chcę <<funkcjonalność, czynność>>,

aby <<uzasadnienie biznesowe&gt>;

As <<osoba, rola&gt>;,

I want <<funkcjonalność, czynność>>,

so that <<uzasadnienie biznesowe>>

Tutaj zahaczamy trochę o Behaviour-Driven Development. Jest to popularny pomysł na sterowanie procesem pisania kodu. Skupia się na wymaganiach użytkownika i interesariuszy. Określa formę zapisu wymagań, które są przekształcane w testy.

W BDD osią rozwoju systemu jest zachowanie systemu. Testy powinny przede wszystkim opisywać (i testować) zachowanie systemu. Formułowanie zachowania staje się sposobem definiowania wymagań i zarazem ich dokumentacją.

Tutaj znajdziecie ogólny opis BDD http://www.araneo.pl/blog/behaviour-driven-development

Posiadając już taką listę User Story „Musisz odpowiedzieć sobie na jedno zajebiście, ale to zajebiście ważne pytanie: co lubisz w życiu robić? Jaka jest następna najważniejsza rzecz, którą system powinien robić? A potem zacznij to robić.”

Jednym słowem należy wybrać najważniejszą z punktu widzenia użytkownika funkcjonalność i stworzyć dla niej kryteria akceptacyjne. Kryteria akceptacyjne formułowane są jako scenariusze. Możemy skorzystać tutaj z szablonu:

Zakładając że <<opis kontekstu>>

Gdy <<opis zdarzenia w systemie>>

Wtedy <<spodziewany rezultat>>

Diven <<opis kontekstu>>

when <<opis zdarzenia w systemie>>

then <<spodziewany rezultat>>

Kryteria akceptacyjne powinny być wykonywalne. Aby kryteria akceptacyjne miały największą wartość użytkową powinny mieć odzwierciedlenie w kodzie. Istnieje wiele frameworków wspierających BDD

  • JBehave (Java)
  • NBehave (.NET)
  • Spec (Ruby)
  • i wiele innych …
BDD wspiera tworzenie specyfikacji poprzez przykład (specification by example). Wymagania funkcjonale mogą być definiowane w formie kryteriów akceptacyjnych, które stają się jednocześnie dokumentacją.

Teraz przyszedł czas na testy akceptacyjne.

Ogólny schemat testowania akceptacyjnego

Testy akceptacyjne testują funkcjonalność z punktu widzenia użytkownika. Skupiają się na perspektywie użytkownika końcowego. Symulują one interakcję z systemem zewnętrznym (np. z użytkownikiem, usługą sieciową):

  • poprzez interakcję z użytkownikiem
  • wysyłając komunikaty symulujące system zewnętrzny
  • wywołując usługi sieciowe
  • parsując generowane raporty

Testy akceptacyjne odpowiadają za tzw. jakość zewnętrzną, czyli w jakim stopniu system zaspokaja potrzeby klienta i użytkowników. Pozwalają nam także sterować testami jednostkowymi, które to z kolei odpowiadają, za jakość wewnętrzną, czyli w jakim stopniu system zaspokaja potrzeby programistów i administratorów. (Testy integracyjne są gdzieś pośrodku J)

To jak już tyle wiemy to można już coś zrobić.

Od czego zacząć?

  • Zbuduj infrastrukturę, aby zautomatyzować proces
    build – deploy – test
  • Zwiększasz ilość uzyskiwanej informacji zwrotnej
  • Dzięki temu od razu będziesz mógł weryfikować budowanie i wdrażanie systemu
  • Szkielet – implementacja bardzo wąskiego wycinka systemu, który można zbudować, wdrożyć i przetestować
  • Zobacz jak system działa najszybciej jak to tylko możliwe
  • Jeśli istnieje system zewnętrzny, którego trudno użyć, zbuduj jego namiastkę

Przydatne jest także stworzenie zgrubnego projektu, czyli wysokopoziomowej struktury aplikacji lub części nad którą pracujesz. Przemyśl główne komponenty i ich powiązania. Jak komponenty będą się komunikować. Na stworzenie zgrubnego projektu przeznacz co najwyżej kilkanaście minut – przydatny jest flipcharta lub tablicy.


Dobre praktyki

  • Rozpocznij implementację funkcjonalności od testu akceptacyjnego
    • Wyrażone w języku użytkownika
    • Pozwala zrozumieć system przed przystąpieniem do implementacji
  • Oddziel testy, które wskazują postęp od testów regresyjnych
    • Testy regresyjne zawsze muszą przechodzić
    • Testy wskazujące postęp mogą nie działać w czasie rozwoju
  • Rozpocznij od najprostszego przypadku
    • Podstawowy scenariusz pozytywny
    • Implementacja powinna być prosta, ale nie zdegenerowana (zbyt prosta)
  • Pisz testy, które miałbyś ochotę przeczytać
    • Czytelny test wspiera nas w odnajdywaniu błędów i oszczędza nasz czas
  • Obserwuj, w jaki sposób test nie przechodzi
    • Zmodyfikuj komunikaty o błędach, aby były proste i jednoznaczne
  • Testuj zachowanie a nie metody
    • Przykładowo test testMyMethod nie mówi, jaki jest cel testu
    • Maksymalne pokrycie kodu nie jest dobrym celem
    • Skup się na funkcjach, które metoda realizuje
  • Jeśli trudno napisać test
    • Nie zastanawiaj się jak go napisać
    • A raczej co sprawia, że trudno jest przetestować
    • Najczęściej kiepski kod trudno jest przetestować
      • Przeanalizuj jakość kodu i go zrefaktoryzuj
  • Praktykuj ciągłe doskonalenie
    • Bezustannie analizuj swoje techniki testowania
    • Odnajduj słabe punkty swoich strategii i zmieniaj je
    • Szukaj balansu pomiędzy testami jednostkowymi a testami integracyjnymi

Na początku tego dokumentu wspominałem, że podejście przyrostowe można napotkać na każdym kroku TDD. Implementując daną funkcjonalność zaczynamy od najprostszego przypadku (testu akceptacyjnego), a następnie go rozwijamy. Przykładowo nasza wspaniała aplikacja karciana będzie umożliwiać użytkownikowi wyświetlenie listy gier, jakie rozegrał.

Jako Użytkownik

Chcę zobaczyć listę gier,

Aby móc przeglądać historię rozegranych partii.

To by było nasze User Story J. Dodatkowymi wymaganiami byłoby, że listę można sortować, a wszystkie przegrane oznaczone są na czerwono.

Przykładowa lista testów w inkrementacjach mogłaby wyglądać tak:

  • Pusta lista wyników (dla użytkownika bez rozegranych partii)
  • Lista wyników z kilkoma grami
  • Lista wyników z wyróżnionymi porażkami
  • Lista wyników z sortowaniem

Cały proces testowania zaczyna się i kończy na teście akceptacyjnym. Jakby to powiedzieli mądrzy ludzie, test akceptacyjny jest klamrą dopinającą cały proces implementacji funkcjonalności. Pisząc test akceptacyjny, zauważamy braki w naszym projekcie. (Brak metod, klas itd. Przypomnijmy, że na początku posiadamy jedynie szkielet aplikacji.) Co robimy, jeżeli zauważymy brak danej metody? Piszemy dla niej test (oczywiście nie zapominając o specyfikacji i podejściu przyrostowym). Potem implementacja, znowu może nam czegoś brakować i znowu testy i znowu implementacja i tak w kółko, aż dojdziemy do końca.

Wspominałem wcześniej, że testy akceptacyjne sterują testami jednostkowymi. Zaczynami od testów najwyższego poziomu. W trakcie ich powstawania powstają testy niżej poziomowe. Po ich implementacji wracamy z powrotem do testu wysokopoziomowego. Może to trochę słabo opisałem, ale mam nadzieję, że wszyscy wiedzą, co chciałem powiedzieć.

Techniki pracy z kodem – Zarządzanie złożonością

Zarządzanie złożonością to problem, z którym styka się każdy programista. Przez większość czasu pracujemy nad wcześniej napisanym kodem dodając nowe funkcjonalności lub poprawiamy błędy niż piszemy nowy kod. Z czasem liczba wykorzystywanych klas i obiektów rośnie. Jeżeli kod napisany jest niedbale, bez zwracania uwagi na jego strukturę, złożoność to prowadzi to do kodu, który nie jest odporny na zmiany, ciężko przewidzieć efekt zmian w kodzie i nie można wykorzystywać ponownie napisanych elementów. Z drugiej strony dobre zarządzanie złożonością gwarantuje nam, że napisany kod łatwo adaptuje się do przyszłych zmian, jest elastyczny i łatwiejszy w utrzymaniu. U podstaw każdego kodu, który spełnia powyższe warunki leżą zasady znane pod akronimem S.O.L.I.D.

1. Zasada jednej odpowiedzialności (Single Responsibility Principle)

2. Zasada otwarty/zamknięty (Open/Closed Principle)

3. Zasada podstawienia Liskov (Liskov Substitution Principle)

4. Zasada separacji interfejsów (Interface Segregation Principle)

5. Zasada odwrócenia zależności (Dependency Inversion Principle)

Zasada jednej odpowiedzialności

Zasada pojedynczej odpowiedzialności mówi, że klasa powinna mieć jeden i tylko jeden powód do zmiany. Odpowiedzialność definiujemy jako zestaw logicznie połączonych ze sobą metod i zmiennych. Kiedy zmieniają się wymagania dotyczące działania kodu odbijają się one w zmianie odpowiedzialności wśród klas. Im klasa jest odpowiedzialna za więcej rzeczy tym więcej potencjalnych zmian może sprawić, że będziemy musieli zmieniać napisany kod. Osiągniemy to poprzez sprawdzanie czy interfejs klasy (metoda, property) jest spójny z nazwą klasy, w przeciwnym wypadku należy zastanowić się nad przeniesieniem metody do innej klasy.

Efektem stosowanie się do zasady pojedynczej odpowiedzialności są projekty, w których klasy są małe i odpowiedzialne za ściśle określone zadania. Liczba używanych klas może wzrosnąć dość znacznie, ale nie jest to tak poważny problem jak złe zarządzanie złożonością i można go rozwiązać używając przestrzeni nazw i folderów do organizowania plików. Dodatkowo projekt jest łatwiejszy do zrozumienia oraz prostszy w utrzymaniu.

Często pojawiającym się pytaniem jest czy w tym wypadku klasa może mieć tylko jedną metodę? Odpowiedzią jest oczywiście: nie. Klasa może mieć więcej metod jednak wszystkie powinny skupiać się nad jednym konceptem. Należy również pamiętać, że ludzie mogą rozdzielać odpowiedzialności w różny sposób.

Przykład: Załóżmy, że mamy do napisania grę karcianą. Zasady gry są proste: każdy z graczy dostaje losowo jedną kartę z talii. Gracz, który otrzyma najwyższą kartę wygrywa. Definiujemy następujące klasy.

   public class Card
{
//reprezentacja karty
}

public class Player
{
public string Name { get; set; }
public List<Card> Hand { get; set; }
}

public class Deck
{
List<Card> cards;

public void ShuffleCards()
{
//tasowanie kart
}

public Card DealCard(Player player)
{
//rozdanie jednej karty dla gracza
}

}
Klasa Deck zawiera listę obiektów Card i zawiera dwie metody: do tasowania kart i rozdawania. Na pierwszy rzut oka wszystko wydaje się dość proste i oczywiste. Jednak klasa Deck łamie zasadę pojedynczej odpowiedzialności (jak się później okaże nie tylko tą). Metoda DealCard nie powinna znajdować się klasie Deck. Klasa Deck odpowiedzialna jest za przechowywanie informacji o kartach oraz za ich rozdawania. O wiele lepszym rozwiązaniem jest umieszczenie metody DealCard w osobnej klasie.
   public class Dealer
{
Deck deck;

public Card DealCard(Player player)
{
//rozdanie jednej karty dla gracza
}
}
W ten sposób rozdzielamy odpowiedzialności i ograniczamy zakres ewentualnych zmian do minimum. Klasa Deck nie musi być zmieniana jeżeli będziemy musieli zmienić sposób rozdawania kart.

Wskazówka – użycie regionów

Jeżeli w klasie lub metodzie do organizacji kodu używamy regionów to najprawdopodobniej łamiemy zasadę jednej odpowiedzialności.

Zasada otwarty/zamknięty

Stosując się do tej zasady powinniśmy projektować aplikację w taki sposób, żeby móc dodawać nowe funkcjonalności bez konieczności modyfikacji istniejącego kodu. Można to osiągnąć poprzez izolowanie obszarów, w których mogą wystąpić zmiany. Nie należy jednak popadać w skrajność i nie izolować się na każdą możliwą zmianę, a na te najbardziej prawdopodobne.

Klasa Dealer narusza tą zasadę. Co w przypadku, gdy będziemy chcieli rozdać 2 karty dla każdego gracza lub dowolną ilość kart? Za każdym razem, gdy będziemy chcieli zmienić rodzaj rozdawania (mając na uwadze inne gry karciane) musimy zmieniać kod w już stworzonej klasie. Rozwiązujemy ten problem poprzez wyizolowanie kodu, który potencjalnie będzie się często zmieniać. Osiągamy to poprzez abstrakcję i/lub wykorzystanie interfejsów.

   public class Dealer
{
IDealCards dealCards;
Deck deck;

public Card DealCards(List<Player> players)
{
dealCards.DealCards(players);
}

}

interface IDealCards
{
void DealCards(List<Player> players);
}

Tworzymy interfejs IDealCards z jedną metodą odpowiedzialną za rozdanie kart dla listy graczy. Teraz w prosty sposób możemy tworzyć nowe obiekty implementujące ten interfejs i bez konieczności zmiany kodu w klasie Dealer zmieniać rodzaj sposobu rozdawanych kart.


public class TexasHoldemDeal : IDealCards
{

public void DealCards(List<Player> players)
{
//rozdanie kart do Texas Holdem
}
}

public class PotLimitOmaha : IDealCards
{

public void DealCards(List<Player> players)
{
//rozdanie kart do Pot Limit Omaha
}
}

Zasada podstawienia Liskov

Zasada podstawienia Liskov mówi o kontraktach/interfejsach klas. Jej użycie przydaje się przy wyborze sposobu rozszerzenia funkcjonalności klasy: dziedziczenia lub kompozycji.

Zasada podstawienia Liskov mówi, że sposób korzystania z klasy potomnej powinien być analogiczny do wywoływania klasy bazowej. Inaczej mówiąc powinniśmy być w stanie zawsze użyć klasy bazowej zamiast konkretnej implementacji i wciąż otrzymywać poprawny rezultat. Problemy z łamaniem tej zasady mają miejsce w przypadku tworzenia rozbudowanej hierarchii klas. By ich unikać trzeba zwracać uwagę, żeby jedynie rozszerzać działanie metod klas bazowych oraz dbać o poprawne modelowanie zagadnień.

Problem przy nie stosowaniu zasady podstawienia Liskov pojawia się w momencie, gdy kod myśli, że wywołuję metodę typu A, a w rzeczywistości wywołuje metodę typu B. Gdzie B:A. Zasada może być złamana na dwa sposoby:
  • Jeżeli metody klas mają inne parametry. Interfejsy klas są nie spójne.
    public class Deck
{
List<Card> cards;

public void ShuffleCards()
{
//tasowanie kart
}
}

public class DoubleDeck : Deck
{
List<Card> cards;

public void ShuffleCards(bool shuffleDecksSeparately)
{
//tasowanie kart
}
}

//wywołanie metody
Deck deck = new DoubleDeck();
deck.ShuffleCards();
Na powyższym przykładzie interfejsy klasy bazowej i pochodnej nie zgadzają się. DoubleDeck przyjmuje dodatkowo parametr bool przez co łamie zasadę LSP.
  • Jeżeli metody nie mają logicznego sens
Obrazuje to pewnie znany wszystkim przykład z klasą Rectangle i Square. Square dziedziczy po Rectangle. Klasa Rectangle ma dwie metody SetWidth(int width) oraz SetLenght(int length). Jak łatwo się domyślić w klasie Square działanie tych metod nie ma sensu ponieważ zmiana jednej warunkuje zmianę drugiej łamiąc w ten sposób LSP na drugi możliwy sposób. Przykład.

Czy to oznacza, że klasa musi mieć dokładnie takie same metody jak bazowa? Czy może mieć więcej metod?

Nie, ale interfejsy oraz logiczne działanie metod występujących w hierarchii dziedziczenia powinna być zgodna. Z tą wiedzą, oceniając zgodność naszych klas z zasadą LSP możemy stwierdzić kiedy kompozycja jest bardziej właściwym mechanizmem do rozszerzania funkcjonalności klas, a kiedy jest nim dziedziczenie.

Zasada separacji interfejsów

Zasada mówi żeby tworzone przez programistę interfejsy były odpowiedzialne za jak najmniejsza funkcjonalność. Użytkownik chcąc zaimplementować taki interfejs nie powinien pisać metod, których nie potrzebuje. Jeśli znajdują się w nim niepotrzebne metody to wtedy nazywamy go interfejsem “fat” lub “polluted”.

Do zobrazowania na czym polega segregacja interfejsów posłużę się lekko zmodyfikowanym przykładem interfejsu z artykułu Hands on Mocking.
public interface IOneCardMasterGame
{
void AddPlayer(Player player);
void RemovePlayer(Player player);
void SetPlayerLimit(int number);
void PerformGame();
Player GetWinner();
IList<Player> GetGameState();
int GetPlayerCount();
}
Za co odpowiedzialny jest ten interfejs? Na pierwszy rzut oka widać, że ma za dużo odpowiedzialności. Jest odpowiedzialny zarządzanie graczami, wykonywaniem gry oraz pobieraniem stanu. Zasada separacji interfejsów jest konsekwencją zasady pojedynczej odpowiedzialności. Bardziej elastycznym rozwiązaniem było by rozbicie go na 3 osobne interfejsy.

public interface IOneCardMasterGame
{
void PerformGame();
}

public interface IPlayers
{
void AddPlayer(Player player);
void RemovePlayer(Player player);
int GetPlayerCount();
}

public interface IGameSettings
{
void SetPlayerLimit(int number);
Player GetWinner();
IList<Player> GetGameState();
}

Oczywiście są to rozważania ogólne i rozwiązanie mogłoby wyglądać zupełnie inaczej w zależności od wymagań i założeń programu oraz pod pewnymi warunkami wykorzystanie pierwotnego mogłoby być uzasadnione. Jest to jednak sprawa drugorzędna. Należy jednak zwrócić uwagę na przewagę drugiego rozwiązania. Interfejsy odpowiedzialne są za mniejsze, lepiej sprecyzowane role. Ponowne wykorzystanie interfejsów jest teraz bardziej prawdopodobne.

Zasada odwrócenia zależności

Stosując zasadę odwrócenia zależności piszemy kod w taki sposób żeby:

· Kod z warstw wyższego poziomu nie zależał od kodu z niższych warstw. Obie warstwy powinny zależeć od abstrakcji.

· Abstrakcje nie powinny zależeć od konkretnej implementacji.

· Implementacje powinny zależeć od abstrakcji.

Rozważmy poniższy przykład:

public class CardGame
{
List<Player> players;
Deck deck;
Dealer dealer;

public CardGame()
{
deck = new Deck();
players = new List<Player>();
dealer = new Dealer();
}

public void Start()
{
//rozpoczęcie gry
}
}

Stworzyliśmy klasę CardGame, w której zdefiniowaliśmy listę graczy, talię do gry i dealera odpowiedzialnego za rozdawania kart. Problem polega na tym, że klasa CardGame jest ściśle powiązana z klasami Deck, Player oraz Dealer. Jest zależna od ich implementacji. Żeby to naprawić musimy zmienić naszą klasę w następujący sposób:

public class CardGame
{
List<Player> players;
Deck deck;
Dealer dealer;

public CardGame(Deck deck, List<Player> players, Dealer dealer)
{
this.deck = deck;
this.players = players;
this.dealer = dealer;
}

public void Start()
{
//rozpoczęcie gry
}
}

Należy zauważyć tutaj jedną kluczową zmianę. Klasa CardGame dalej używa wszystkie klasy, ale nie jest odpowiedzialna za ich tworzenie. Odpowiedzialność za tworzenie i przekazywanie odpowiednich obiektów przeniesiona jest w inne miejsce – najczęściej odpowiedzialne są za to konstrukcyjne wzorce projektowe takie jak Fabryka lub IOC.

 
Design by Free WordPress Themes | Bloggerized by Lasantha - Premium Blogger Themes | Online Project management