czwartek, 29 grudnia 2011

Clean code - rules & tools

1. Wprowadzenie

Dokument przedstawia zasady tworzenia kodu, który obok realizowania założone funkcji, spełnia dodatkowe kryteria, takie jak czytelność, możliwość rozwijania, powtórnego wykorzystania, łatwość testowania, odporność na błędy (readability, extensability, reusablity, testabilitiy, robustness).

Oczywiście temat ten nie jest w tym dokumencie zamykany. O zasadach tworzenia czystego kodu powstało wiele opracowań (najbardziej godne polecenia to (4) Fowlera). Zagadnienia, które zostaną opisane można najogólniej zawrzeć w 4 punktach: konwencje nazewnicze, komentarze i dokumentowanie kodu, przekształcenia refaktoryzacyjne i narzędzia do zautomatyzowanej analizy i poprawy kodu.

W "Refactoring: Improving the Design of Existing Code" (4) w pierwszym rozdziale znajduje się zdanie: "Każdy idiota napisze kawałek kodu zrozumiały dla kompilatora. Dobry programista napisze coś zrozumiałego dla drugiego człowieka". Może to posłużyć jako motto całego artykułu.

2. How does it smell?

"Code smells" to termin wprowadzony przez Kenta Becka. Oznacza konstrukcje w kodzie, które nie spełniają założeń opisanych w pierwszym punkcie. Innym określeniem tego samego zjawiska są "złe praktyki". Tworząc kod unikamy takich konstrukcji, a także staramy się poświęć czas na refaktoryzację stworzonego rozwiązania.

Konkretne przykłady zostały zaczerpnięte z wykładów z (3), które stanowią uporządkowaną i usystematyzowany zbiór przykładów do tez zawartych w (4).

Zanim przytoczę wszystkie przykłady warto zwrócić uwagę na istotną kwestię. Celem tworzenia kodu jest realizacja pewnej funkcjonalności. Przekształcenia refaktoryzacyjne nie mogą w żadnych stopniu wpływać na jej poprawność. Czystość kodu jest ważna, ale nie jest priorytetem - wkład w jej zachowanie nie może przesłonić podstawowego celu pisania.

a. Symptomy złego kodu

i. Powielenie kodu - Identyczny lub podobny kod znajduje się w wielu miejscach systemu.
Rozwiązanie - wyłączenie metody, przeniesienie kodu do klasy nadrzędnej, delegacja wywołania metody do innej klasy

ii. Długa metoda - Funkcja ma realizować jedną i tylko jedną funkcję. Każda funkcja, która realizuje wiele czynności jest "dluga". Problem może wynikać ze złej architektury systemu - braku podzialu na warstwy
Rozwiązanie - wyłączenie metody, usuwanie zmiennych lokalnych i zmniejszanie listy parameterów poprzez wprowadzanie nowych klas

iii. Duża klasa - Opisana w artykule (5) zasada dotycząca klas wyraźnie narzuca ograniczenie pojedynczej klasy do pojedynczej odpowiedzialności.
Rozwiązanie - wyróżnienie poszczególnych odpowiedzialności i stworzenie adekwatnych klas, następnie odwoływanie się do tych klas za pomocą referencji, dziedziczenia lub polimorfizmu

iv. Długa lista parametrów - Często związana z sytuacją zbyt dużej metody. Funkcja otrzymuje zbyt wiele danych.
Rozwiązanie - wprowadzenie klasy zawierające zbiór parametrów przekazywany do funkcji, rozważenie podziału funkcji

v. Nadmiar komentarzy - Jeżeli czujesz potrzebę wyjaśniania działania swojej metody, zastanów się najpierw, czy nie istnieje łatwiejsze rozwiązanie problemu, które nie będzie wymagać wyjaśnień. W radykalnym pojdejściu do tej zasady kod w ogóle nie powinien zawierać komentarzy - dzięki jego przejrzystości i przestrzeganiu zasad powinien "komentować się sam".

vi. Skomplikowane instrukcje warunkowe - Metoda zawiera złożoną, wielopoziomową instrukcję if lub switch. Ogólnie zbyt zagnieżdżona instrukcja zawsze powoduje problem z czytelnością, należy zatem takich konstrukcji unikać

vii. Łańcuchy wywołania metod - Naruszenie "prawa Demeter" (6). Klasa w swoich metodach powinna korzytać tylko z metod do których posiada referencje. Innymi słowy należy ufać tylko swojemu bezpośredniemu sąsiadowi.
Rozwiązanie - wyodrębnienie metod w klasie sąsiedniej i ukrycie w nich nadmiarowych wywołań

viii. Pojemnik na dane - Klasa, której jedyną funkcją jest przechowywanie danych.

ix. Zbitki danych - Zbiory danych występujących wspólnie i mających logiczne powiązanie (np. "Autor", "Tytuł", "Wydawnictwo")

x. Odrzucony spadek - Klasa nie wykorzystuje metod i pól, które zawarte są w nadklasie

xi. Niewłaściwa hermetyzacja - Klasa odwołuje się do wewnętrznych pól innej klasy
Rozwiązanie - Przesunięcie metody do właściwej klasy

xii. Bezużyteczna klasa - Klasa nie realizująca żadnej odpowiedzialności. Przykładem jest "Pojemnik na dane".
Rozwiązanie - rozdzielić pola pomiędzy istniejące klasy, usunąć klasę

xiii. Zazdrość o funkcje - Metoda zdecydowanie częściej odwołuje się do funkcji spoza swojej klasy macierzystej. Sytuacja analogiczna do "Niewłaściwej hermetyzacji". Oba problemy są związane z łamaniem ogólniejszej zasady pojedynczej odpowiedzialności. Metody odwołujące się do "obcych" pól czy metod powinny należeć do innych klas
Rozwiązanie - przeniesienie metody do właściwej klasy

xiv. Zmiana z wielu przyczyn - Stan klasy jest modyfikowany z pod wpływem wielu czynników. Ponownie występuje problem z zasadą pojedynczej odpowiedzilaności.

xv. Spekulacyjne uogólnienie - Tworzenie abstrakcji nie mającej uzasadnienia w obecnych założeniach biznesowych.

b. Przekształcenia refaktoryzacyjne

Pełna lista przekształceń znajduje się w (4). Plecam eksperymetowanie z poszczególnymi przekształceniami za pomocą np. ReSharpera (menu ReSharper -> Refactor). Przykładowe przekształcenia to m.in. "Extract method", "Add parameter", "Move method", "Replace conditional with polymorphism". W następnym punkcie przedstawiony zostanie ciąg przekształceń dla kodu realizującego funkcjonalność wypożyczalni filmów. Przykład został zaczerpnięty z (4).

3. Scenariusz refaktoryzacyjny.

Rozważmy funkcjonalność składającą się z trzech klas - Movie, Rental i Client. Pozwalają one na obliczanie opłaty za korzystanie z wypożyczalni filmów. Klasa Client zawiera następującą metodę obliczającą wspomniany koszt:


public String statement()
{
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements())
{
double thisAmount = 0;
Rental each = (Rental)rentals.nextElement();
//determine amounts for each line
switch (each.getMovie().getPriceCode())
{
case Movie.REGULAR:
thisAmount += 2;
if (each.getDaysRented() > 2)
thisAmount += (each.getDaysRented() - 2) * 1.5;
break;
case Movie.NEW_RELEASE:
thisAmount += each.getDaysRented() * 3;
break;
case Movie.CHILDRENS:
thisAmount += 1.5;
if (each.getDaysRented() > 3)
thisAmount += (each.getDaysRented() - 3) * 1.5;
break;
}
// add frequent renter points
frequentRenterPoints++;
// add bonus for a two day new release rental
if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
each.getDaysRented() > 1) frequentRenterPoints++;
//show figures for this rental
result += "\t" + each.getMovie().getTitle() + "\t" +
String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
//add footer lines
result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints) +
" frequent renter points";
return result;
}


Funkcja oblicza koszt dla każdego zamówienia i korzysta z danych klasy Rental, a nie Client. Pierwsza informacja sugeruje wyodrębnienie obliczania kosztu zamówienia (ExtractMethod - tworzymy metodę getCharge). Wyodrębnioną metodę należy przenieść do odpowiedniej klasy, czyli do Rental. Po tych przekształceniach kod wygląda następująco:


public String statement()
{
double totalAmount = 0;
int frequentRenterPoints = 0;
Enumeration rentals = _rentals.elements();
String result = "Rental Record for " + getName() + "\n";
while (rentals.hasMoreElements())
{
double thisAmount = 0;
Rental each = (Rental)rentals.nextElement();
thisAmount = each.getCharge();
// add frequent renter points
frequentRenterPoints++;
// add bonus for a two day new release rental
if ((each.getMovie().getPriceCode() == Movie.NEW_RELEASE) &&
each.getDaysRented() > 1) frequentRenterPoints++;
//show figures for this rental
result += "\t" + each.getMovie().getTitle() + "\t" +
String.valueOf(thisAmount) + "\n";
totalAmount += thisAmount;
}
//add footer lines
result += "Amount owed is " + String.valueOf(totalAmount) + "\n";
result += "You earned " + String.valueOf(frequentRenterPoints) +
" frequent renter points";
return result;
}


W metodzie getCharge() wciąż mamy do czynienia ze źle wyglądającym kodem. Użyta została konstrukcja "switch - case" względem typu filmu. Taką konstrukcję można zastąpić polimorfizmem, co sprowadzi metodę w klasie Rental do następującej, prostej postaci:


double getCharge()
{
return movie.getCharge(daysRented);
}


Zmienna "movie" reprezentować będzie ogólny typ, za który podstawiane będą konkretne implementacje, takie jak RegularMovie, ChildrenMovie, NewReleaseMovie.

Dalsze przekształcenia są oczywiście możliwe, ale celem powyższego przykładu było przedstawienie kilu kroków refaktoryzacji i sposobu analizowania kodu, pod kątem wyszukiwania obszarów wymagających poprawy.

4. Narzędzia

Przedstawienie kompletnego opracowania na temat aplikacji wspierających programistę byłoby zadaniem bardzo pracochłonnym i jak sądzę pozbawionym sensu. Celem dokumentu nie jest stworzenie rankingu takich programów, ale wykazanie, że przestrzeganie zasad czystości kodu ma bardzo duże znaczenia dla skutecznej pracy w zespołowym projekcie. Przedstawię przegląd rozwiązań, które mogą ułatwić realizację tego celu. Opisy mają charakter subiektywnych wrażeń i spostrzeżeń z użytkowania tych narzędzi. Zachęcam każdego, do podjęcia samodzielnej decyzji o wyborze konkretnego produktu oraz do dzielenia się wrażeniami i spostrzeżeniami z jego stosowania.

a. ReSharper

- Refaktoryzacja z poziomu struktury projektu



Na powyższym przykładzie, przenoszę interfejs do nowego folderu. Wykonując taką operację za pomocą przekształcenia "Move" automatycznie uaktualniam nazwy namcespace, odnośniki w innych klasach i unikam dzięki temu konieczności dokonywania zmian w kodzie.

Inne możliwe przekształcenia, widoczne są w menu kontekstowym na screenie.

- Refaktoryzacja kodu



Powyższy screen przedstawia zbiór przekształceń dostępnych w menu ReSharpera. Aplikacja automatycznie wybiera te operacje, które mają sens, dla danego fragmentu kodu.

- Testy jednostkowe

Ta opcja ReSharpera nie jest bezpośrednio związana z refaktoryzacją, ale należy pamiętać o konieczności używania testów do weryfikowania kodu po refaktoryzacji. Mamy możliwość tworzenia dowolnych podzbiorów testów i grupowania ich w sesje.



Bibliografia

1. http://msdn.microsoft.com/en-us/library/ms229002.aspx
2. Scott Bellware, C# Code Style Guide. [Online] http://www.sourceformat.com/pdf/cs- coding-standard-bellware.pdf
3. dr inż. Bartosz Walter, Zaawansowane projektowanie obiektowe, wykłady [Online]
http://wazniak.mimuw.edu.pl/images/d/d8/Zpo-8-wyk.pdf
http://wazniak.mimuw.edu.pl/images/3/33/Zpo-9-wyk.pdf
http://wazniak.mimuw.edu.pl/images/e/ee/Zpo-10-wyk.pdf
http://wazniak.mimuw.edu.pl/images/b/b8/Zpo-11-wyk.pdf
4. Fowler Martin, Refactoring: Improving the Design of Existing Code

5. http://premium-hands.blogspot.com/2011/12/techniki-pracy-z-kodem-zarzadzanie.html

6. http://www.ccs.neu.edu/research/demeter/papers/law-of-demeter/oopsla88-law-of- demeter.pdf

niedziela, 25 grudnia 2011

Silverlight - koncepcja logowania z użyciem MembershipProviders oraz WCF RIA cz.1

W dzisiejszym wpisie postaram się przedstawić moją koncepcję logowania do aplikacji Silverlightowej z wykorzystaniem MembershipProviders oraz własnego AuthenticationService.
Zacznijmy od stworzenia bazy danych, w której będziemy przetrzymywać informacje o użytkownikach - hasła, role itp. Bazę danych stworzymy przy użyciu narzędzia aspnet_regsql.exe, które to wygeneruje schemat bazy danych dostosowany do możliwości SqlMembershipProvider-a. aspnet_regsql.exe znajduje się w katalogu C:\WINDOWS\Microsoft.NET\Framework\wersjaFrameworka\aspnet_regsql.exe. Czyli w moim przypadku jest to C:\WINDOWS\Microsoft.NET\Framework\v4.0.30319\aspnet_regsql.exe.


Klikając podwójnie na ikonę aplikacji pojawia nam się następujące okno

Klikamy w przycisk "Dalej". W kolejnym oknie mamy do wyboru dwie opcje

  • Configure SQL Server for application services

  • Remove application services from an existing database


Pierwsza opcja odpowiedzialna jest za stworzenie struktury danych do naszego sytemu logowania, natomiast druga usuwa z bazy danych tabele służące do logowania itp. Wybieramy opcję bramkę nr 1. W kolejnym oknie musimy podać connection string do naszego serwera bazy danych oraz login i hasło. W moim przypadku wygląda to w następujący sposób:



Jeżeli wszystko pójdzie OK pokaże się nam następujące okno, w którym to możemy zobaczyć, że została stworzona baz danych aspnetdb



Ostatecznie klikamy na przycisk "Next",a następnie w "Finish" aby dokończyć działanie kreatora



Mając gotową bazę danych możemy przystąpić do właściwego kodowania. Zacznijmy od stworzenia nowego projektu typu "Silverlight Application"


po kliknięciu przycisku "OK" włączamy WCF RIA Service


Mając stworzony szkielet aplikacji możemy zabrać się za napisanie własnego AuthenticationService. Do projektu webowego (nazwaprojektu.Web) dodajmy nowy element typu "Domain Service". PPM na projekt, następnie "Add new item", w oknie które się otworzy wybieramy "Domain Service". Nadajemu mu nazwę CustomAuthenticationService.cs





Nasza właśnie dodana klasa powinna wyglądać mniej więcej w taki sposób


namespace MainModule.Web
{
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.ServiceModel.DomainServices.Hosting;
using System.ServiceModel.DomainServices.Server;


// TODO: Create methods containing your application logic.
[EnableClientAccess()]
public class CustomAuthenticationService: DomainService
{
}
}

W następnym kroku zaimplementujmy w klasie CustomAuthenticationService interfejs
IAuthentication<T> where T: IUser. Interfejs ten wygląda w następujący sposób:

public interface IAuthentication<T> where T: IUser
{
T GetUser();
T Login(string userName, string password, bool isPersistent, string customData);
T Logout();
void UpdateUser(UserDTO user);
}

Widzimy zatem, że zanim go zaimplementujemy musimy posiadać obiekt implementujący inny interfejs - IUser. Przystąpmy zatem do stworzenia naszej klasy modelującej użytkownika - nazwijmy ją UserDTO.Stwórzmy nowy projekt typu "Class library". W projekcie tym stwórzmy klasę UserDTO, która będzie dziedziczyła po klasie UserBase znajdującej się w przestrzeni nazw System.ServiceModel.DomainServices.Server.ApplicationServices

public class UserDTO : UserBase
{
public string Email { get; set; }

public string DisplayName { get; set; }
}


Mając gotową klasę reprezentującą użytkownika zaimplementujmy w końcu w klasie CustomAuthenticationService interfejs IAuthentication. Po implementacji interfejsu nasz serwis wygląda w następujący sposób:

[EnableClientAccess()]
public class CustomAuthenticationService: DomainService, IAuthentication
{
public UserDTO GetUser()
{
throw new NotImplementedException();
}

public UserDTO Login(string userName, string password, bool isPersistent, string customData)
{
throw new NotImplementedException();
}

public UserDTO Logout()
{
throw new NotImplementedException();
}

public void UpdateUser(UserDTO user)
{
throw new NotImplementedException();
}
}


Pozostaje nam "tylko" zaimplementować odpowiednie metody. Funkcja która nas najbardziej interesuje to oczywiście funkcja logowania. Przy jej implementacji posłuży się mechanizmem znanym z ASP mianowicie z MembershipProviders. Klasa MembershipProvider jest to abstrakcyjna klasa posiadająca szereg funkcji mającej na celu walidowanie poprawnego użytkownika, rejestrację itp. Stwórzmy zatem własną klasę providera dziedziczącą po klasie MembershipProvicer. .NET dostarcza nam domyślny provider, którym jest SqlMembershipProvider, jest on napisany w taki sposób, aby mógł porozumiewać się z bazą, którą stworzyliśmy na początku. Aby wykorzystać wspomnianego wcześniej providera musimy odpowiednio zmodyfikować plik Web.config. Po pierwsze dodajemy do niego ConnectionString do naszej bazy danych, w moim przypadku będzie to wyglądało w następujący sposób

<connectionStrings>
<add name="aspnetdbConnectionString" connectionString="Data Source=TOMEKKOMPUTER\SQLEXPRESSRC2;Initial Catalog=aspnetdb;Integrated Security=True"
providerName="System.Data.SqlClient" />
</connectionStrings>

Następnie musimy "pokazać" naszej aplikacji, że będziemy używać FormsAuthentication oraz MembershipProvider-ow. Dorzućmy zatem do config następujące rzeczy:(w sekcji system.web)

<authentication mode="Forms" />

następnie dorzucamy do kolekcji providerów SqlMembershipProvider

<membership
defaultProvider="PremiumHandsMembershipProvider"
userIsOnlineTimeWindow="20">
<providers>
<clear/>
<add name="PremiumHandsMembershipProvider"
connectionStringName="aspnetdbConnectionString"
type="System.Web.Security.SqlMembershipProvider"
enablePasswordRetrieval="false"
enablePasswordReset="false"
requiresQuestionAndAnswer="true"
passwordFormat="Hashed"
applicationName="/" />
</providers>
</membership>


We wpisach tych dorzuciliśmy możliwość autentykacji poprzez providery, ustawiliśmy SqlMembershipProvider jako domyślny provider, oraz dorzuciliśmy go do listy wszystkich providerów. W celu sprawdzenia czy nasza konfiguracja jest poprawna możemy posłużyć się mechanizmem dostarczonym nam przez Visual Studio. Klikamy "Projekt", a następnie ASP.NET Configuration

Zostaniemy przeniesieni do domyślnej przeglądarki internetowej, a naszym oczom ukaże się następująca strona.

Z poziomu tej stronki możemy dodawać użytkowników, przydzielać im role itp, dzięki czemu możemy szybko sprawdzić czy podstawowe funkcje naszego providera działają, oraz czy nasza konfiguracja została przeprowadzona prawidłowo. Wracając natomiast do naszego systemu logowania, pozostało nam jedynie odpowiednio zmodyfikować funkcję odpowiedzialną za logowanie.Robimy to w następujący sposób:

protected UserDTO ValidateCredentials(string name, string password, string customData, out string userData)
{
UserDTO user = null;
userData = null;
if (Membership.Provider.ValidateUser(name, password))
{
userData = name;
user = new UserDTO {DisplayName = name,Name = name,Email = name};
}

if (user != null) userData = name;

return user;
}

public UserDTO Login(string userName, string password, bool isPersistent, string customData)
{

string userData;
UserDTO user = ValidateCredentials(userName, password, customData, out userData);

if (user != null)
{
FormsAuthenticationTicket ticket = new FormsAuthenticationTicket(/* version */ 1, userName,
DateTime.Now, DateTime.Now.AddMinutes(30),
isPersistent,
userData,
FormsAuthentication.FormsCookiePath);

string encryptedTicket = FormsAuthentication.Encrypt(ticket);
HttpCookie authCookie = new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket);

HttpContextBase httpContext = (HttpContextBase)ServiceContext.GetService(typeof(HttpContextBase));
httpContext.Response.Cookies.Add(authCookie);
}
else
{
HttpContextBase httpContext = (HttpContextBase)ServiceContext.GetService(typeof(HttpContextBase));
httpContext.AddError(new FormsAuthenticationLogonException("Nieprawidłowy login lub hasło"));
}

return user;
}

W funkcji ValidateCredentials odbywa się sprawdzanie czy użytkownik podał właściwe hasło oraz login. Do walidacji wykorzystujemy dodany wcześniej SqlMembershipProvider - właściwość Membership.Provider zawsze zwraca obiekt domyślnie zdefiniowanego providera. W przypadku gdy walidacja się powiedzie tworzymy obiekt użytkownika, a następnie tworzymy i szyfrujemy FormsAuthenticationTicket oraz tworzymy ciasteczko (cookie), które przesyłamy w responsie wysyłanym przez serwer do klienta. Mechanizm logowania jest już prawie gotowy. Musimy jedynie zaimplementować jeszcze pozostałe funkcje interfejsu IAuthentication.Nie ma raczej w nich nic trudnego

public UserDTO Logout()
{
FormsAuthentication.SignOut();
return null;
}

public UserDTO GetUser()
{
IPrincipal currentUser = ServiceContext.User;
if ((currentUser != null) && currentUser.Identity.IsAuthenticated)
{
FormsIdentity userIdentity = currentUser.Identity as FormsIdentity;
if (userIdentity != null)
{
FormsAuthenticationTicket ticket = userIdentity.Ticket;
if (ticket != null && !ticket.Expired)
{
return new UserDTO { DisplayName = currentUser.Identity.Name, Name = currentUser.Identity.Name };
}
}
}

return null;
}

Funkcja Logout po prostu wywołuje funkcję SingOut z klasy FormsAuthentication. Natomiast funkcja GetUser zwraca zalogowanego użytkownika. W funkcji tej najpierw sprawdzamy czy użytkownik jest zalogowany (do tego celu używamy zmiennej IsAuthenticated), a następnie sprawdzamy czy przypadkiem jego sesja nie wygasła. W przypadku gdy wszystko jest OK zwracamy obiekt typu UserDTO,w przeciwnym razie zwracamy null.

Podsumowując, udało nam się stworzyć mechanizm logowania. Jak na razie gotowa jest strona serwera.
W następnym wpisie pokażę w jaki sposób należy wywołać logowanie od strony klienta. Przedstawię również pomysł w jaki sposób zmieniać providerów przez które użytkownik ma się logować. Może to być przydatne jeżeli chcielibyśmy logować się np. poprzez inne serwisy jak Google, Facebook itp.

piątek, 16 grudnia 2011

Coded UI Tests - cz. 2

Podstawy Coded UI Test zostały omówione w poprzednim poście tego bloga tutaj, jednak warto rozwinąć ten temat i trochę usystematyzować wiedzę. W tym poście zakładam, że każdy wie jak stworzyć prosty test oraz czym jest Coded UI Test Builder. Zacznijmy od tego że aby nasze testy przechodziły musimy odpalić instancję testowanej aplikacji :). Możemy oczywiście robić to za każdym razem ręcznie, jednak jest to skrajnie beznadziejne rozwiązanie. Najlepiej przygotować sobie metodę, która będzie odpalać instancję naszej aplikacji jeżeli nie jest ona uruchomiona.

Ok przyjrzyjmy się strukturze plików i sposobowi budowania testów. Przede wszystkim do projektu testowego możemy dodać 2 obiekty. Pierwszy to "Coded UI Test" (CUIT) - czyli klasa opatrzona atrybutem [CodedUITest]. Jest to nasza właściwa klasa testowa. Tutaj właśnie tworzymy nasze testy. Drugi obiekt to klasa "Coded UI Test Map". Nie musimy jej dodawać ręcznie (poprzez Add->New Item...->Coded UI Test Map), bo zostanie ona stworzona automatycznie jeżeli wygenerujemy kod przy pomocy Coded UI Test Builder'a. Wszystkie wygenerowane przez nas akcje i asserty zapisywane są w takiej Mapie. Tworząc CUIT tak naprawde odwołujemy się do wygenerowanego kodu znajdującego się w mapie. W dużych aplikacjach modułowych powinniśmy używać wielu map, gdzie każda reprezentuje inny moduł.
Dla każdej wygenerowanej metody tworzona jest klasa, której instancja przechowuje wartości oczekiwane lub wprowadzane przez użytkownika. Dzięki temu mamy możliwość manipulacji warunkami dla każdej akcji. Instancja każdej z takich klas wystawiona jest jako właściwość w obiekcie naszej mapy.

Jak pisać testy? Jest to bardzo dobre pytanie. Przede wszystkim każdy CUIT opiera się na metodach które wygenerujemy. Każda wygenerowana metoda to akcja którą nagraliśmy za pomocą Coded UI Test Builder. Czyli powinniśmy nagrywać możliwie krótkie akcje, dzięki czemu możemy później kombinować je ze sobą tworząc wiele testów opartych na pojedyńczych akcjach. Wówczas jeżeli zmieni się coś w interfejsie użytkownika np zwykły button zostanie zastąpiony przez kontrolke innego typu, wówczas nie musimy nagrywać ponownie wszystkich testów które korzystały z danego buttona. Wystarczy wygenerować od nowa metode "WcisnijButton" i ewentualnie zmienić jej nazwę na inną (co jest standardową czynnością, którą można błyskawicznie wykonać). Mówiąc bardziej ogólnie: Nie powinno się utożsamiać CUIT z nagraną akcją dostępną w UIMap. CUIT powinien składać się z wielu uniwersalnych akcji nagranych przez użytkownika. Takimi pojedynczymi akcjami mogą być:
-kliknięcie przycisku
-sprawdzenie że kontrolka przyjęła odpowiedni stan
-wprowadzenie textu do TextBoxa
itd.

Ok przykład. Mamy aplikacje napisaną w WPF. Nie wnikamy w jej sens. użytkownik ma do dyspozycji 2 TextBox'y oraz TextBlock. W TextBlock'u wyświetlamy złączone napisy wprowadzone do TextBoxów. Kod aplikacji wygląda mniej więcej tak:
<Window x:Class="WpfApplication3.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="MainWindow" Height="350" Width="525">
<StackPanel>
<TextBox Name="TextBox1"/>
<TextBox Name="TextBox2"/>
<TextBlock Name="TextBlock">
<TextBlock.Text>
<MultiBinding StringFormat="{}{0}{1}">
<Binding Path="Text" ElementName="TextBox1"/>
<Binding Path="Text" ElementName="TextBox2"/>
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</StackPanel>
</Window>

Chcemy przetestować czy TextBlock rzeczywiście wyświetla złączone napisy z TextBoxów. Tworzymy zatem klasę testową. Następnie generujemy akcje wpisywania tekstu do TextBox1 oraz akcje wpisywania textu do TextBox2. Tworzymy metodę (assert), która porównuje czy w TextBocku jest wpisany "CustomText". Ok czyli w rezultacie mamy 3 metody. Wpisany text do textboxów został przez nas skonkretyzowany podczas nagrywania akcji (dla przykładu można przyjąć że jest to "Custom Text"). Możemy teraz przetestować czy napis w TextBlock jest prawidłowy. Musimy podmienić text który jest testowany. Można to zrobić za pomocą klas parametrów tworzonych automatycznie podczas generowania kodu.
[TestMethod]
public void CodedUITestMethod1()
{
// Arrange
Just.EnterTextIntoTextBox1Params.UITextBox1EditText = "Coded";
Just.EnterTextIntoTextBox2Params.UITextBox2EditText = "UI";
Just.VerifyTextBlockTextExpectedValues.UITextBlockTextDisplayText = "CodedUI";
// Act
Just.EnterTextIntoTextBox1();
Just.EnterTextIntoTextBox2();
// Assert
Just.VerifyTextBlockText();
}

W powyższym przykładzie zmieniłem domyślną nazwę UIMap na Just.
Teraz wyobraźmy sobie że chcemy zrobić testy seryjne. Mamy np całą tabelę z danymi i chcielibyśmy ją "wstrzyknąć" do naszego testu razem z wynikami. Nic prostrzego. W tym wypadku możemy zastosowac dokładnie ten sam mechanizm, który znamy z Unit Testów czyli DataSource.
Najpierw musimy dodać do klasy testowej CUIT właściwość TestContext:
private TestContext testContextInstance;
public TestContext TestContext
{
get { return testContextInstance; }
set { testContextInstance = value; }
}

Instancja klasy TestContext zostanie stworzona automatycznie po uruchomieniu naszych testów, dlatego bez problemu możemy się odwoływac do tej właściwości w naszych metodach testowych.
Aby stworzyć testy seryjne musimy dodać atrybut DataSource do naszej metody testowej. Atrybut ten wskazuje źródło danych, które są dostępne za pomocą właściwości TestContext. Teraz nasz test wykona się dla wszystkich wartości jakie znajdują się w naszym źródle danych. Plik z danymi podpinamy do projektu testowego. Klikamy na niego PPM wybieramy właściwości i ustawiamy "Build Action" na "Content" oraz "Copy to Output Directory" na "Copy if newer".
Plik "Data.csv"
Input1,Input2,ExpectedResult
a,b,ab
one,two,onetwo
, ,

kod:
[DeploymentItem("Tests\\Data.csv"),
DataSource("Microsoft.VisualStudio.TestTools.DataSource.CSV","|DataDirectory|\\Data.csv", "Data#csv",DataAccessMethod.Sequential),
TestMethod]
public void CodedUITestMethod1()
{
// Arrange
Just.EnterTextIntoTextBox1Params.UITextBox1EditText = TestContext.DataRow["Input1"].ToString();
Just.EnterTextIntoTextBox2Params.UITextBox2EditText = TestContext.DataRow["Input2"].ToString();
Just.VerifyTextBlockTextExpectedValues.UITextBlockTextDisplayText = TestContext.DataRow["ExpectedResult"].ToString();
// Act
Just.EnterTextIntoTextBox1();
Just.EnterTextIntoTextBox2();
// Assert
Just.VerifyTextBlockText();
}

niedziela, 11 grudnia 2011

Konwencje nazewnicze

1. Wprowadzenie

Ten krótki dokument wyodrębniony został z opracowania na temat jakości kodu. Opublikowany zostanie osobno ze względu na spójność zagadnienia i konieczność zebrania i wyróżnienia reguł nazewniczych dla kodu.

Dokument przedstawia zasady opisane w (1), przykłady kodu oraz konfigurację narzędzi do wspomagania stosowania konwencji nazewniczych.

Konwencje nazewnicze służą zachowaniu spójności i czytelności tworzonego kodu. Stosowania ich ułatwia przegląd kodu innego programisty, pozwala na szybsze określenie jego funkcji i odnalezienie potencjalnych błędów. Ustandaryzowane nazwy bardzo ułatwiają też nawigację po dużym projekcie.

2. Opis reguł i przykłady

Obowiązującą dla projektu konwencję można znaleźć w całości na stronie (1). Przedstawione zostaną najbardziej istotne zasady dotyczące struktury kodu, reguł językowych i gramatycznych stosowanych przy tworzeniu nazw.

a) Wielkość liter

Dwie stosowane reguły to "camel case" i "Pascal convention".

- "camel case" - rozpoczęcie małą literą, każde następne słowo wielką, np. private TestContext testContextInstance;

Jest stosowana do identyfikatorów (nazw zmiennych) i parametrów funkcji. UWAGA! Dla pól prywatnych często stosowana jest konwencja z podkreśleniem (private TestContext _testContextInstance;). NIE JEST ona zgoda z oficjalnymi zasadami zwartymi w MSDN. Spotkałem się z wyjaśnieniem, że zaniknęła ona ze względu na możliwość automatycznego definiowania properties (np. public string Name { get; set; } ). Sądzę, że należy konsekwentnie stosować konwencję z MSDN i podkreśleń unikać.

- "Pascal convention" - każde słowo rozpoczyna się wielką literą, np. public String SomeLongName { get; set; }. Zasada stosowana dla klas, typów wyliczeniowych, wartości typów wyliczeniowych, zdarzeń, klas wyjątków, pól statycznych tylko do odczytu, interfejsów, metod, Namespace, Properties

- akronimy - jeśli akronim zawiera więcej niż 2 litery, stosujemy Pascal convention, w przeciwnym wypadku skrót piszemy wielkimi literami, przykłady:
HttpWebService (parametr nazwiemy httpWebService),
XmlWriter (xmlWriter),
DBContainer (dbContainer),
IOStream (ioStream)

- UWAGA! Nie stosujemy "Pascal convention" dla nazw, które językowo składają się z dwóch członów, ale stanowią jedno słowo, np. hastable zamiast hashTable, Runtime zamiast RunTime. Na szczęście kod nie powstaje w języku niemiecki, gdzie ten problem staje się dość paskudną zagadką logiczną.

Poniżej fragment kodu zawierający przykłady poprawnego zastosowania konwencji nazewniczych.


namespace OneCardMaster // Pascal
{
public class OneCardMasterGame : IOneCardMasterGame /*interface name always begin with I*/
{

private readonly List playerList = new List();
private readonly IDeck deck;
private int playerLimit; // camel (not _playerLimit !)

public event EventHandler GameStateChanged; // Pascal

public CardValue CardValue; // Pascal

public static readonly int Number = 10; // Pascal

public string SomePropertyName { get; set; } // Pascal

public OneCardMasterGame()
{
deck = new Deck();
playerLimit = 10;
CardValue = CardValue.Ace;
}

public void AddPlayer(IPlayer playerParameter)// parameter name - camel, method name - Pascal
{
if (playerList.Count < playerLimit)
{
playerList.Add(playerParameter);
}
}


b) Ogólne zasady nazewnicze

Przy tworzeniu nazw zawsze przedkładamy czytelność ponad zwięzłość. Nazwa CanScrollHorizontally jest lepsza niż ScrollableX .

W kodzie używamy słów języka angielskiego. Należy upewnić się co do pisowni takich słów. Nietrudno sobie wyobrazić problemy z wyszukiwaniem metod, które zawierają "literówki".

Należy unikać "magicznych trzyliterowych skrótów" i innych akronimów, o których nie jesteśmy pewni że zostaną dobrze zrozumiane.

Nie należy używać podkreśleń, apostrofów, ani żadnych innych niestandardowych znaków.

c) Biblioteki, namespace

Schemat nazywania bibliotek to <Company>.<Component>.dll - w naszym przypadku proponuję jako "Company" używać "PremiumHands".

Namespace tworzy się wg następującego schematu: <Company>.(<Product>|<Technology>)[.<Feature>][.<Subnamespace>], czyli np. Microsoft.WindowsMobile.DirectX lub PremiumHands.OneCardMaster.Administration.

d) Klasy, struktury, interfejsy

Nazwami klas powinny być rzeczowniki w liczbie pojedynczej. W przypadku dziedziczenia warto rozważyć zawarcie w nazwie klasy, nazwy klasy bazowej, np. CardException. Nie jest to jednak niepodważalna zasada. Łatwo sobie wyobrazić strukturę dziedziczenia uniemożliwiającą stosowanie takiej konwencji.

Interfejsy nazywają się podobnie jak klasy, ich nazwy rozpoczynają się od "I", np. IOneCardMasterGame.

Nazwa typu wyliczeniowego powinna być rzeczownikiem w liczbie mnogiej (np. CardColors). Poszczególne wartości to rzeczowniki w liczbie pojedynczej (np. Heart, Spade, Club, Diamond).

e) Pola, metody, właściwości (properties), zdarzenia

Główne informacje zawarte zostały w punkcie a).

Metody to najczęściej krótkie zdania, w których czasownik występuje w formie bezokolicznika, np. GetEmployeeRecord.

Podobnie wygląda nazewnictwo zdarzeń. W tym wypadku należy zwrócić uwagę na czas gramatyczny. Przeszły określa, że zdarzenie jest wywoływane w następstwie pewnej sytuacji, np. FormClosed. Zdarzenie FormClosing oznacza, że sytuacja powodująca wykonanie zdarzenia nie jest dokonana i może np. zostać przerwana. Własne typy zdarzeń powinny swoją nazwę kończyć na EventHandler, np. WindowEventHandler. Argumenty dla event handler to zawsze Object sender i e typu dziedziczącego po EventArgs.

g) Nazewnictwo testów

Dla nazewnictwa metod testowych, podobnie jak w przypadku zwykłych metod, używamy stylu "Pascalowego". Na końcu metody dodajemy przyrostek "Test". Nazwa powinna opisywać rodzaj wykonywanego testu, np. AgeCalculationWithNoBirthDateTest().

3. Konfiguracja ReSharpera

ReSharper pozwala na wyróżnianie problemów z konwencjami nazewniczymi. Przykładowy błąd wyróżniany jest w sposób przedstawiony na ilustracji:



Należy zwrócić uwagę, że wskazywany błąd, tak naprawdę nie jest w tym wypadku sytuacją sprzeczną ze stosowanymi w projekcie konwencjami nazewniczymi. Konieczna jest zatem zmiana w konfiguracji ReSharpera. Wybieramy menu [ReSharper] -> [Options], następnie w opcjach "Languages" -> "C#" znajdujemy "C# Naming Style". Okno dialogowe pozwalające na dokonywanie zmian wygląda następująco:



Resharper pozwala również na definiowanie własnych, bardziej skomplikowanych zasad określanych na podstawie poziomu dostępu, rodzaju kodu. Możliwe jest też wyłączenie asystenta.

4. Odnośniki

(1) MSDN Libary, "Guidelines for Names", http://msdn.microsoft.com/en-us/library/ms229002.aspx

(2) ReSharper 6, "JetBrains ReSharper Revievier's Guide", http://www.jetbrains.com/resharper/documentation/reviewers_guide.html

(3) Scott Belware, "C# Code Style Guide", http://www.sourceformat.com/pdf/cs-coding-standard-bellware.pdf

poniedziałek, 5 grudnia 2011

TDD cz.2

Drugą część zaczniemy od kilku słów na temat testów integracyjnych. Później powiem coś na temat wzorców testowania jednostkowego. A na koniec opiszę pare zagadnień, które z różnych powodów nie zostały opisane wcześniej

I. Testy integracyjne

Ogólnie rzecz biorąc testowanie integracyjne wykonywane jest w celu wykrycia błędów w interfejsach i interakcjach pomiędzy modułami.Testy jednostkowe natomiast testują metody wybranej klasy, a klasy z nią współpracujące mockują. Testem jednostkowym nie jest więc test, który:
  • Komunikuje się z bazą danych
  • Komunikuje się po przez sieć
  • Pracuje z systemem plików
  • Nie może być uruchomiony równolegle z innymi testami
  • Musisz dokonać zmian w środowisku uruchomieniowym, aby zadziałał
Najczęściej spotykanym obszarem wykorzystania testów integracyjnych jest dostęp do danych. Testując integracyjnie dostęp do danych należy pamiętać o kilku ważnych rzeczach:
  • Testowana baza danych zazwyczaj nie jest idealnym odzwierciedleniem bazy produkcyjnej
  • Tworząc bazę testową należy wziąć pod uwagę następujące czynniki:
    • Jak bardzo ustawienia bazy testowej są zbliżone do bazy produkcyjnej
    • Na ile łatwo jest współdzielić konfigurację w zespole
    • Na ile łatwy jest dostęp do bazy
  • Zawsze czyść bazę danych przed testem, a nie po jego wykonaniu
  • Stwórz klasę wspierającą testowanie, która będzie zajmowała się czyszczeniem bazy danych
W celu odseparowania warstwy dostępu do danych używamy wzorca DAO. Możemy wtedy mockować dostęp do danych.

II. Wzorce testowania jednostkowego

1) Wzorce asercji

Wzorce te zostały zebrane i opisane przez Gerarda Meszaros http://xunitpatterns.com/

Wzorce asercji można podzielić na:

  • Asercja stanu końcowego – sprawdza stan końcowy
  • Asercja pomocnicza - programista upewnia się, że obiekt, który tworzy, ma odpowiedni stan
  • Asercja delta – badana jest różnica między stanem początkowym a końcowym
  • Własna asercja
  • Asercja interakcji

Programowa realizacja tych wzorców przejawia się w metodach klasy Assert. Metody te zostały opisane w podpunkcie IV.2

http://premium-hands.blogspot.com/2011/11/normal-0-21-false-false-false-pl-x-none.html

My skupimy się tylko na dwóch ostatnich typach.

Własna asercja (Custom Assertion) – jeśli używasz wielokrotnie tej samej asercji lub asercja jest złożona warto wyodrębnić metodę – własną asercję. Może się to wydawać oczywiste, ale warto o tym wspomnieć. Asercja własna jest najczęściej wynikiem refaktoryzacji kodu. Przypomnijmy, refaktoryzacji nie podlega tylko właściwy kod aplikacji, ale także kod testowy.

Asercja interakcji (Interaction Assertion) – zamiast badać stan bada się interakcję obiektów testowanych. Do tego celu głównie używa się Mock Objects. Tym sposobem możemy sprawdzić wywołanie konkretnej metody, parametry z jakimi została wywołana, a także kolejność wywołań. Testowanie interakcji stosuje się dla testów złożonych, gdzie ze względu na trudność realizacji testowanie stanu jest bardzo trudne.

2. Wzorce otoczenia testu

Otoczenie testu (test fixture) – otoczenie testu to warunki początkowe wspólne dla kilku testów. Otoczenie testu nadaje kontekst dla testów, które do niego przynależą. Jest realizowane po przez pola klasy testu i inicjację w metodzie oznaczonej atrybutem [SetUp] TODO: Poszukać atrybutu dla VS TeamTest. Otoczenie testu minimalizuje duplikację, wyodrębnia cechy wspólne wielu testów w formie pól i metody SetUp. Dobrze przemyślane rozwiązanie spowoduje, iż metody testowe będą się składać z prostych wywołań i asercji. Warto także wspomnieć, iż atrybut opisujący klasę testową w NUnit to [TestFixture].

Metoda fabryki (Parameterized Creation Method) – zamiast budować stan obiektów za każdym razem wypełniając wiele pól, warto wyodrębnić metodę pomocniczą, która pomoże stworzyć wymagane obiekty

  • Często podczas testowania należy tworzyć wiele pomocniczych obiektów
  • Stwórz metodę pomocniczą – metodę fabryki, która zajmie się szczegółami przygotowania obiektów do testu, które nie mają większego znaczenia
  • Czasami wystarczy wykorzystać odpowiedni konstruktor, jeśli istnieje lub stworzyć nowy

Klasy pomocnicze

  • Dodatkowo przydają się również pomocnicze klasy, które upraszczają tworzenie obiektów, np. generator unikalnych identyfikatorów (np. generator imion i nazwisk)

Matka obiektów

  • Klasa fabryki stworzona na potrzeby testów
  • Zawiera metody kreacyjne
  • Zawiera metody zmieniające stan obiektów (np. zamówione – złożone, zaakceptowane oczekujące zrealizowane)
  • Zbiera metody pomocnicze ułatwiające tworzenie obiektów na potrzeby testów
  • Może być to jedna klasa lub kilka klas
  • Klasy te mogą powstawać w efekcie refaktoryzacji testów

Automatyczne porządki (Automated teardown)

  • Sprzątanie po teście w przypadku złożonych danych jest nietrywialnie – łatwo zapomnieć o stworzonych obiektach
  • Tworzy się rejestr obiektów, do którego trafiają wszystkie obiekty tworzone w [SetUp], które należy usunąć po zakończeniu testu [TearDown]

3. Wzorce klas testowych:

Autopodstawienie (Self-Shunt)

  • Klasa testowa implementuje interfejs, który ma stanowić imitację (Test double)
  • Pozwala uniknąć tworzenia dodatkowych klas
  • Zaciemnia konstrukcję
  • Przydatne w prostych przypadkach

Uprzywilejowany dostęp (Privileged Access)

  • Bezpośrednie testowanie metod prywatnych jest w NUnit niemożliwe
  • Można skorzystać z refleksji do wywołania metod prywatnych
  • Nie powinno się nadużywać tej techniki
  • Zbyt wiele metod prywatnych, które należy przetestować, może sugerować refaktoryzację Wydzielenie klasy.

Dodatkowy konstruktor (Extra Constructor)

  • Jeśli kod, który testujesz ma zależności zaszyte wewnątrz klasy, trudno będzie go testować w izolacji
  • Stwórz dodatkowy konstruktor, który pozwoli dostarczać dane z zewnątrz
  • W teście użyj nowego konstruktora

III. Elementy testowanego kodu

1. Kompozycja zamiast dziedziczenia

Dziedziczenie jest jednym ze sposobów rozdzielenia odpowiedzialności między klasami. Umożliwia dodanie lub zmianę zachowania w klasie. Wadą jest to, że przeciążane metody mają tendencję do dużej zależności od klasy, w której się znajdują. Oznacza to, że zazwyczaj obciążone są pewnymi założeniami (np. metoda intensywnie korzysta z wewnętrznego stanu obiektu). Klasy dziedziczące mają tendencję także do rozrastania się. Często okazuje się że pewne elementy z klas nadrzędnych nie są potrzebne w klasach podrzędnych. Klasy są ze sobą mocno powiązane dziedziczeniem co utrudnia używanie test double.

Dzięki kompozycji klasa deleguje założone zadania do innych komponentów. Następuje lepsze rozłożenie odpowiedzialności. W testach możemy używać test doubles w miejscu klas zależnych. Poniważ klasy są mniejsze łatwiej testować je jednostkowo.

2. Unikanie elementów statycznych

Elementy statyczne mają szereg wad, które mogą utrudniać testowanie:

  • Elementy statyczne mają charakter globalny (są związane z klasą a nie z obiektem)
  • Elementy statyczne nie podlegają dziedziczeniu
  • Nie można zastosować do nich test double
  • Testowanie w pełni jednostkowe jest niemożliwe
  • Zależność jest zaszyta wewnątrz metody
3. Architektura Warstwowa

  • Architektura warstwowa sprzyja testowaniu
  • Powinny być wydzielone przynajmniej 3 warstwy
    • Interfejs użytkownika
    • Logika biznesowa
    • Dostęp do danych
  • Klasy wizualne interfejsu nie powinny zawierać żadnej logiki przetwarzania. Logikę UI należy przenieść do obiektów pomocniczych
IV. Różne różności

W ostatniej części tego wpisu chciałem wspomnieć o kilku rzeczach, na które nie znalazło się miejsce w poprzednich rozdziałach.

1. Złote zasady testów automatycznych

  • Automatyczne – wykonują się bez udziału człowieka
  • Zupełne – obejmują wszystkie istotne przypadki testowe
  • Powtarzalne – mogą być uruchamiane wielokrotnie
  • Niezależne – nie powinny zależeć od zmian w środowisku zewnętrznym testu
  • Profesjonalnie – kod testu powinien być napisany równie starannie, jak kod produkcyjny
2. Strategie wyboru testów

a) Ogół – szczegół
b) Znane – nieznane
c) Ścieżka pozytywna – ścieżka negatywna

Ad. a

- Ogół

Najpierw testy wysokiego poziomu – tworzonych jest wiele testów bez początkowego wchodzenia w szczegóły.

Zalety:

  • Większe zrozumienie całościowe systemu

- Szczegół

Dany test jest rozwijany tak długo, aż nie osiągnie się ostatecznego, pełnego rozwiązania.

Zalety:

  • Szybka eliminacja ryzyka, gdyż rozpoznawane są szczegóły problemu


Ad.b Znane - nieznane

- Znane

Najpierw wybierane są te testy, które sprawdzają typowe elementy funkcjonalności, które są łatwo przewidywalne,

Zalety:

  • Jednoznaczne ścieżka prowadząca dająca szybkie efekty

- Nieznane

Najpierw wybierane są te testy, które obejmują mniej typowe przypadki

Zalety:

  • Wczesna analiza i potencjalna eliminacja ryzyka – zostaną szybko wykryte te elementy których nie da się wykonać lub będzie to nieopłacalne

Note:

Nie warto stosować kiedy nietypowe przypadki są bardzo mało prawdopodobne lub nie mają wpływu na system.

Ad.c Ścieżka pozytywna ścieżka negatywna

- Ścieżka pozytywna

Testowane są scenariusze typowego, zakończonego powodzeniem działania metody lub funkcji.

Zalety:

  • Testowane jest to, co przydarza się najczęściej

- Ścieżka negatywna

Testowane są scenariusze sytuacji wyjątkowych, na które system powinien być przygotowany

Zalety:

  • Minimalizowane jest ryzyko wynikające z błędów użytkownika lub programisty

Note:

Zazwyczaj najpierw warto wybrać testowanie z użyciem ścieżki pozytywnej, a następnie ścieżki negatywnej

Jak to połączyć w praktyce?

  1. Zacznij od prostego i oczywistego testu (sunny day scenario)
    - proste podstawienia, typowy scenariusz
  2. Testuj każdą funkcję z uwagą.
    - Zastanów się czy dana funkcja jest istotna z punktu widzenia systemu i jeśli tak, to, dlaczego
  3. Najpierw testuj ogólnie, a następnie szczegółowo
    - W ten sposób odnajdziesz najbardziej newralgiczne punkty systemu
  4. Po przetestowaniu podstawowego scenariusza, wybierz trudne ścieżki (nieznane lub negatywne)




Inversion of Control/Dependency Injection & StructureMap cz.1

Inversion of Controle jest techniką programowania w której przepływ kontroli aplikacji jest odwrócony. Inversion of Controle zakłada odwrócenie zależności pomiędzy warstwami aplikacji. Aby to osiągnąć odwrócenie zależności wymaga dwóch technik:
1. Dependency Injection - wstrzykiwanie zależności
2. Service Locator - lokalizator usług.
Dependency Injection jest wzorcem realizującym założenia IOC. Stosujemy go po to aby izolować klasy od konkretnych implementacji poprzez zamienianie tych implementacji na luźne powiązania. Cel ten uzyskiwany jest poprzez stosowanie klas abstrakcyjnych i interfejsów.
Główne zasady DI to:
- moduły wysokiego poziomu nie powinny zależeć od modułów poziomu niskiego. Obydwa powinny zależeć od abstrakcji
- abstrakcja nie powinna zależeć od szczegółów. Natomiast szczegóły powinny zależeć od abstrakcji.
Stosowanie zasad DI powoduje, że zmiany w modułach na niskim poziomie nie powodują konieczności wprowadzenia zmian na poziomach wyższych.
Istnieją trzy możliwości wstrzykiwania zależności:
- konstruktor wstrzykujący - może być niewydajny gdy w klasie mamy bardzo dużo zależności
- pole strzykujące (setter injection)
- metoda wstrzykująca

Zalety stosowanie DI:
- niezależność kodu
Zależności, które zostały zamienione na obiekty interfejsów są niezależne. W każdej chwili do takiej zależności mogą być podstawione alternatywne obiekty. Dodatkowo posiadając metody, konstruktory i pola wstrzykujące nie trzeba zmieniać klas implementujących te interfejsy.

- elastyczność
Zmiana modułów niższego poziomu nie powoduje potrzeby zmian w klasach rzędu wyższego. Jeśli moduły wyższego rzędu implementują klasy niższego rządu w postaci interfejsów to zależności te nie zawierają konkretnego obiektu. Nie musimy do takich zależności podstawiać obiektów o konkretnym typie.

- separacja współpracy
Ten punkt dotyczy tego, iż powinniśmy tak tworzyć oprogramowanie aby klasy niższego rzędu wykonywały określone zadanie np. zapisanie jakiego obiektu. Dzięki w klasach wyższego rzędu tylko wywołujemy określone metody pewnych zależności.

- oprogramowanie tworzone zgodnie z zasadami DI mogą być ponownie wykorzystywane
Klasy stworzone zgodnie z zasadami DI mogę być ponownie wykorzystane z dowolną liczbą nowych implementacji obiektów niższego rzędu bez konieczności zmiany już istniejących.

- Łatwość konserwacji
Dzięki DI uzyskujemy przejrzystość kodu. W klasach wyższego rządu w łatwy sposób możemy odnaleźć zależności i w łatwy i szybki sposób je zweryfikować

Ideą lokalizatora usług jest stworzenie obiektu, który może przetrzymywać wszystkie usługi, które dana aplikacja może potrzebować. Obiekt taki powinien mieć metodę, która zwraca określoną usługę gdy jest ona potrzebna aplikacji. Lokalizator usług jest również techniką wstrzykiwania zależności ale działającą trochę w inny sposób. Ideę Service Locator'a postaram się przybliżyć w następnym artykule.

Kontenery IOC służą do wstrzykiwania poprawnych zależności. Dzięki zastosowaniu kontenerów IOC w klasach wyższego rzędu nie musimy dokładnie określać, które obiekty zostaną zaimplementowane do określonych zależności. Wystarczy, że zadeklarujemy to w kontenerze IOC. To kontener zwraca określoną instancję klasy.
W kontenerze IOC znajduje się wiedza w jaki sposób tworzymy określone obiekty. To kontener wie jaka właściwa impelemntacja zostanie użyta. Nie musimy tego zapisywać w klasach wyższego rzędu.

Przykłady użycia StructureMap

W pierwszym poście o StructureMap napisałem, że za pomocą StructureMap możemy sobie zdefiniować jaki obiekt ma nam zostać zwrócony dla określonego interfejsu. Ale co jeśli klasa którą chcemy zwrócić w swoim konstruktorze posiada jakiś parametr np. connectionString? Otóż za pomocą StructureMap możemy również podać parametr do konstruktora. Załóżmy, że mamy klasę ResponseService dziedziczącą po interfejsie IResponseService, która w konstruktorze posiada parametr connectionString.


public interface IResponseService
{
string GetMessage();
}

public class ResponseService : IResponseService
{
public string _ConnectionString {get; private set;}

public ResponseService(string connectionString)
{
this._ConnectionString = connectionString;
}

public string GetMessage()
{
return "Hello";
}
}

Napiszmy teraz BootStraper’a, który poda określonego connectionStringa:


public class DependencyRegistry : Registry
{
public DependencyRegistry()
{
For<IResponseService>().Use<ResponseService>();
}
}

public static class BootStrapper
{
public static void Bootstrap()
{
ObjectFactory.Initialize(x => x.ForRequestedType<IResponseService>().Use<ResponseService>().WithCtorArg("connectionString").EqualTo("localHost:9800"));
}
}


W metodzie WithCtorArg podajemy nazwę parametru z konstruktora klasy ResponseService natomiast w metodzie EqualTo wpisujemy connectionString, który chcemy żeby został podany do kontruktora.

Możemy również podać connectionString, który zapisany jest w pliku app.config. Załóżmy, że napisaliśmy sobie taki oto plik app.config:








Napiszmy teraz BootStraper’a, który pobierze wartość ConnectionString z app.config i poda ją do konstruktora klasy ResponseService:

  
public static class BootStrapper
{
public static void Bootstrap()
{
ObjectFactory.Initialize(x => x.ForRequestedType<IResponseService>().Use<ResponseService>().WithCtorArg("connectionString").EqualToAppSetting("ConnectionString"));
}
}


Na stronie StructureMap można znaleźć jeden z ciekawszych przykładów zastosowania i działania StructureMap. Aby pokazać istotę tego przykładu dopiszmy do naszych klas nową:


public class MyClass
{
public IResponseService _ResponseSerive { get; private set; }

public MyClass(IResponseService responseService)
{
this._ResponseSerive = responseService;
}
}

Wszystkie inne klasy, które napisaliśmy wcześniej pozostają bez zmian. Teraz spróbujmy wywołać instancję klasy MyClass za pomocą ObjectFactory:

var obj = ObjectFactory.GetInstance<MyClass>();


Oto jak wygląda nasz obiekt obj:



Jak widać z przedstawionego przykładu, wystarczy, że w BootStrapper’e zadeklarujemy że do interfejsu IResposeService jest przyporządkowany obiekt ResponseService i jeśli spróbujemy za pomocą ObjectFactory uzyskać instancję obiektu (w naszym przypadku MyClass), który w konstruktorze jako parametr pobiera obiekt interfejsu IResponseService, StructureMap sam poda odpowiedni obiekt do konstruktora.

niedziela, 4 grudnia 2011

Scrum i Team Foundation Server cz.6 - Proces TDD

W kilku ostatnich wpisach przedstawiłem czym jest metodyka Scrum, po co i jak ją stosować przy użyciu Team Foundation Server. W teorii moglibyśmy już rozpocząć projekt i z powodzeniem go prowadzić. Możliwe, że więcej wiedzy nie było by Wam szybko potrzebne, ale podejrzewam, że całkiem szybko zaczęły się pojawiać pytania o zarządzanie i przepływ zadań, np.:
-  Który rodzaj zadania jest  z czym powiązany?
- Czy jak dodamy buga to mamy dodać do niego też taska?
- do czego służy test case a do czego impediment?
- itd. Itp.
Takie pytania są nieuchronne, każdy Scrum Master będzie musiał je wysłuchiwać po 100 razy. Można jednak ich częstotliwość zmniejszyć poprzez spisanie dokumentu, zawierającego przykładowy przepływ pomiędzy zadaniami oraz zasady tworzenia dokumentów. Dzięki niemu będziemy mogli w krytycznej sytuacji bez większych wyrzutów sumienia odpowiedzieć „zajrzyj sobie do dokumentacji”.

W Scrum centralnym elementem opisu biznesowego przypadku jest Historia Użytkownika (User Story) zwana również „elementem rejestru produktu” (Product Backlog Item – PBI). Opisuje ona wartość biznesową, która ma zostać dodana do projektu np. ”Jako użytkownik gry One Card Master chcę, zobaczyć informację o aktualnej liście uczestników gry, abym wiedział z kim gram”.
Schemat można było by opisać (tak jak przedstawiono we wpisie) przez:
JAKO <Osoba, rola>
CHCĘ <Funkcjonalność, czynność>
ABY <Uzasadnienie biznesowe>
W teorii pisanie historii użytkownika wydaje się sprawą zupełnie prostą, ale w praktyce okazuje się, że jest zupełnie inaczej. Same zasady tworzenia historii użytkownika są materiałem na osobny wpis.
No ale wracając do tematu procesu. Tak jak wspomniałem we wpisie Scrum jest mocno powiązany z metodyką TDD. Podążając z jej zasadami, będzie nam dużo łatwiej prowadzić projekt, oraz zarządzać przepływem. We wspomnianym wpisie powiedziałem, że każdy PBI powinien mieć wyraźnie i dokładnie opisane kryteria akceptacji – czyli warunki, które muszą zostać spełnione, żeby zadanie zostało zaakceptowane.

Same historie użytkownika opisują jedynie ogólne aspekty realizacji biznesowego problemu. Aby kompleksowo zamodelować proces wytwarzania funkcjonalności konieczne jest dodanie do nich konkretnych elementów tj:
Źródło: Crispin Parker's Blog

- Task – relacja „Implemented-By” – czyli opisuje wszystko to co jest konieczne do tego, żeby od strony programistycznej zaimplementować historię użytkownika
- Acceptance Test – relacja „Tested-By” – opisuje testy akceptacyjne, czyli to w jaki sposób historia użytkownika będzie testowana
- Bug Report – relacja „Failed – By” – opisuje co poszło nie tak przy testach akceptacyjnych
- Impediment – relacja „Impeded-By” – przeszkoda, Opisuje problemy, które wystąpiły przy implementacji historii użytkownika, może to być np. sytuacja, że przy tworzeniu algorytmu nie wzięliśmy pod uwagę jakiegoś aspektu, którego zaimplementowanie wymaga dodatkowej analizy

Co z sytuacjami gdy odnajdziemy inne błędy, które nie zostały ujęte w testy akceptacji? Dorzucamy do rejestru produktu (ewentualnie rejestru spritu jeżeli jest on kluczowy dla jego oddania) element typu Bug.  Ma on identyczną strukturę zależności jak historia użytkownika. Powinien również posiadać zadanie, opis testów, które będą dokonywane przy weryfikacji tego buga, może zawierać Impediment oraz raport o błędzie, który mówi o tym, że rozwiązanie błędu nie zostało zaakceptowane.

Paczkę koniecznej wiedzy teoretycznej już uzyskaliśmy, możemy teraz przejść do przykładu. Będzie on pokazany na Team Foundation Server 11 DP oraz Visual Studio 11 DP. Załóżmy, że tworzymy znaną z innych wpisów grę One Card Master. Mamy już zaimplementowaną część kodu odpowiadającego za zarządzanie graczami. Dostępne już są klasy:

- informacje o graczu:

public class Player
{
    public string Name { get; set; }
}

- zarządzajanie graczami, pozwalające na ich dodawanie

public interface IPlayersManager
{
    IList<Player> GetPlayers();

    void AddPlayer(Player player);
}

public class PlayersManager : IPlayersManager
{
    private readonly IList<Player> _playersList = new Listt<Player>();
        
    public void AddPlayer(Player player)
    {
        _playersList.Add(player);
    }

    public IList<Player> GetPlayers()
    {
        return _playersList;
    }
}

- dostępne są również klasy pozwalające na drukowanie informacji na ekranie

public interface IPrinter
{
    void Print(string text);
}

public class ConsolePrinter : IPrinter
{
    public void Print(string text)
    {
        Console.WriteLine(text);
    }
}

Klient zażyczył sobie, że musimy dodać funkcjonalność wyświetlania aktualnej listę graczy. 
Załóżmy, że znajdujemy się na etapie planowania sprintu. Pierwszym krokiem, który powinniśmy zrobić jest dodanie nowej historii użytkownika. Dokonujemy tego poprzez kliknięcie menu jak na poniższym obrazku.


Historia użytkownika

Ukaże nam się okno definicji historii użytkownika:


Nazywamy naszą historię użytkownika "Wyświetlanie aktualnej listy graczy" podajemy jego opis:

Jako użytkownik gry One Card Master chcę, zobaczyć informację o aktualnej liście uczestników gry, abym wiedział z kim gram”

Podajemy kryteria akceptacji:

1. Po dołączeniu nowego gracza system powinien wyświetlić zaktualizowaną listę użytkowników.
2. W obecnej wersji powinien wyświetlać informacje na konsoli.
3. Informacja o użytkowniku powinna być zapisana w formacie "{Lp}. Nazwa: {Nazwa}".

Przypadek testowy

Zapisujemy historię użytkownika, ale nie zamykamy okna tylko przechodzimy do zakładki "Test cases" i naciskamy przycisk "New".


Dodajemy w ten sposób test akceptacji. Nazywamy go "Test wyświetlania aktualnej listy graczy" i naciskamy "OK". Otworzy się nam widok przypadku testowego. Zapisujemy go. Dostaniemy teraz pełen dostęp do opcji tworzenia testu historii użytkownika.
Jak łatwo zauważyć cały czas postępujemy tutaj zgodnie z metodyką TDD:
- najpierw opisaliśmy co chcemy (historia użytkownika),
- następnie co musi być spełnione, żeby uznać funkcjonalność za poprawnie działającą (kryteria akceptacji),
- teraz napiszemy jak będziemy to testować.


Naciskamy przycisk "Edit with Microsoft Test Manager". Po tej akcji przejdziemy do zewnętrznego programu przygotowanego przez Microsoft specjalnie do planowania, zarządzania oraz przeprowadzania testów (postaram się go przedstawić w osobnym wpisie). Nie wgłębiając się specjalnie w szczegóły - posłuży on nam do zdefiniowania kroków naszego testu.


Dodajemy trzy kroki (poprzez uzupełnienie odpowiednich pól w tabelce "Steps"):

"1. Dodajemy gracza "Jan Kowalski" poprzez metodę AddPlayer klasy PlayersManager - Wyświetlona informacja: "01. Nazwa: Jan Kowalski"
2. Dodajemy gracza "Krzysztof Krawczyk" poprzez metodę AddPlayer klasy PlayersManager - Wyświetlone informacje: "01. Nazwa: Jan Kowalski", "02. Nazwa: Krzysztof Krawczyk"
3. Dodajemy gracza "Eustachy Janicki" poprzez metodę AddPlayer klasy PlayersManager - Wyświetlone informacje: "01. Nazwa: Jan Kowalski", "02. Nazwa: Krzysztof Krawczyk", "03. Eustachy Janicki" "

Przechodzimy do zakładki Summary i przeklejamy tam tekst z kryteriów akceptacji, zapisujemy zmiany i zamykamy Test Managera. Po odświeżeniu widoku Test Case'a powinny pojawić się na nim wpisane przez nas dane.

Zadanie

Wracamy ponownie do naszej historii użytkownika. Dodamy teraz zadanie developerskie poprzez przejście do zakładki Tasks i naciśnięcie przycisku "New". Nazywamy go "Implementacja wyświetlania aktualnej listy graczy".



Wypełniamy pola zgodnie z powyższym obrazkiem (opis jako: "Należy stworzyć taki mechanizm, który po dodaniu gracza poprzez metodę AddPlayer z klasy PlayerManager automatycznie wyświetli na ekranie aktualną listę graczy.") i przypisujemy go do osoby, która będzie go realizowała (np. do siebie) poprzez "Assigned To".
Gdy zaczniemy realizację zadania ustawiamy jego status (State) na "In Progress", oraz status PBI na "Approved".

Przeszkoda

Załóżmy, że okazało się, że nie wiemy jak coś zrobić. Specyfikacja jest niedokładna, mamy problem z wymyśleniem odpowiedniego rozwiązania,  albo problemy techniczne z komputerem.

Załóżmy, że nie mamy pojęcia jak ugryź kompletnie to zadanie, musimy przeprowadzić konsultacje. Przechodzimy wtedy do naszej historii użytkownika, do zakładki "Links" i naciskamy przycisk "New".


Uzupełniamy "Link Type" jako "Child" (bo Impediment będzie dzieckiem naszej historii użytkownika) oraz "Work Item Type" jako Impediment. Nazywamy go "Problem z metodą automatycznego wyświetlania aktualnej listy graczy" i naciskamy OK.


Pojawi się widok Impedimentu, dopisujemy opis (np. "Mam problem z wymyśleniem metody automatycznego wyświetlania aktualnej listy graczy, potrzebuję konsultacji z kimś bardziej doświadczonym."). Zapisujemy go i wracamy do widoku PBI. Musimy na nim zaznaczyć, że prace nad nim zostały wstrzymane. Dokonujemy tego poprzez oznaczenie jego pola "Blocked" na "Yes".

Załóżmy, że udało nam się ustalić, że powinniśmy rozwiązać problem
automatycznego wyświetlania listy gracz przy pomocy wzorca obserwatora. Przechodzimy do utworzonej wcześniej Przeszkody, wpisujemy nasze rozwiązanie w zakładkę "Resolution" i zmieniamy status na zamknięty ("Closed").

Możemy teraz przystąpić do programowania.

Implementacja

Implementację zaczynamy oczywiście od napisania testu akceptacyjnego (przykład będzie mocno uproszczony bo nie o to tutaj chodzi - więcej na temat testów jednostkowych możecie znaleźć we wpisach tutaj i tu). Postępujemy zgodnie z tym co zawarliśmy w testach akceptacyjnych oraz Impedimencie. Opis wzorca obserwator można znaleźć przykładowo tutaj.
Test może wyglądać następująco:

[TestMethod]
public void AddPlayersPrintValidInformations()
{
    var mocks = new MockRepository();

    var playersManagers = new PlayersManager();
    var printer = mocks.StrictMock<Player>();
    var playersObserver = new PlayersObserver(printer);

    playersObserver.Observe(playersManagers);
            
    playersManagers.Attach(playersObserver);
            
    //Dodanie pierwszego gracza
    Expect.Call(()=> printer.Print("1: Jan Kowalski"));
            
    //Dodanie drugiego gracza
    Expect.Call(() => printer.Print("1: Jan Kowalski"));
    Expect.Call(() => printer.Print("2: Krzysztof Krawczyk"));
            
    //Dodanie trzeciego gracza
    Expect.Call(() => printer.Print("1: Jan Kowalski"));
    Expect.Call(() => printer.Print("2: Krzysztof Krawczyk"));
    Expect.Call(() => printer.Print("3: Eustachy Janicki"));

    mocks.ReplayAll();

    //Dodanie pierwszego gracza
    playersManagers.AddPlayer(new Player { Name = "Jan Kowalski" });
            
    //Dodanie drugiego gracza
    playersManagers.AddPlayer(new Player { Name = "Krzysztof Krawczyk" });
            
    //Dodanie trzeciego gracza
    playersManagers.AddPlayer(new Player { Name = "Eustachy Janicki" });

    mocks.VerifyAll();
}

Oczywiście przy pisząc test postępujemy zgodnie z metodologią TDD, po kolei uzupełniając definicje klas i metod. Przykładowa ich implementacja mogła by wyglądać:

public class PlayersObserver : IPlayersObserver
{
    private readonly IPrinter _printer;
    private IPlayersManager _playersManager;

    public PlayersObserver(IPrinter printer)
    {
        _printer = printer;
    }

    public void Observe(IPlayersManager playersManager)
    {
        _playersManager = playersManager;
    }


    public void Update()
    {
        var players = _playersManager.GetPlayers();

        var i = 0;

        foreach (var player in players)
        {
            _printer.Print(
                String.Format(
                    "{0}: {1}", ++i, player.Name));
        }
    }

    public void OnPrintState(string info)
    {
        Console.WriteLine(info);
    }
}

public class PlayersManager : IPlayersManager
{
    private readonly IList<IPlayersObserver> _observers 
        = new List<IPlayersObserver>();

    private readonly IList<Player> _playersList = new List<Player>();

    public void Attach(IPlayersObserver observer)
    {
        _observers.Add(observer);
    }

    public void Detach(IPlayersObserver observer)
    {
        _observers.Remove(observer);
    }

    public void AddPlayer(Player player)
    {
        _playersList.Add(player);
        foreach(var observer in _observers)
        {
            observer.Update();
        }
    }

    public IList<Player> GetPlayers()
    {
        return _playersList;
    }
}

W tym momencie kończy się proces implementacji. Nasze testy przechodzą. Możemy zrobić Check in naszych zmian. Przechodzimy do Team Explorera, naciskamy "Pending Changes".


Pierwszą rzeczą jest dodanie komentarza (nie zapominajmy do tym!). Kolejną powiązanie naszych zmian z zadaniami. Naciskamy "Add Work Item by ID" i dodajemy zarówno nasze zadanie jak i PBI. Przy wiązaniu zmian mamy dwie opcje:
- Associate - po prostu "doklejamy" informację o tym, że zmiana dotyczy danego Work Itema (WI)
- Resolve - robi to samo co Associate i dodatkowo zmienia status WI na rozwiązany
Ponieważ uznajemy nasze zadanie za skończone zaznaczamy je jako Resolve. Decyzję o zakończeniu historii użytkownika zgodnie z metodyką Scrum podejmuje Właściciel Produktu, dlatego też w przypadku PBI zaznaczamy tylko Associate.
Gdy nasze zmiany wejdą z sukcesem pozostaje nam tylko jedna rzecz do zrobienia - podpięcie naszego testu akceptacyjnego do zdefiniowanego Test Case'a. Otwieramy informacje o nim i przechodzimy do zakładki "Associated automation".


Wybieramy nasz test jednostkowy poprzez przycisk koło pola "Automated test name".
Poprawiamy wartość effort przy zadaniu oraz PBI i jeżeli wszystko zrobiliśmy poprawnie zakończyliśmy tym samym nasz proces implementacji.

Testy

Po zakończeniu implementacji (zwykle na koniec Sprintu) Właściciel Produktu testuje czy wszystkie historie użytkownika zostały zrealizowane poprawnie.
W naszym przypadku okazuje się, że wystąpił błąd. W opisie testu akceptacji było napisane, że:

"Dodajemy gracza "Jan Kowalski" poprzez metodę AddPlayer klasy PlayersManager - Wyświetlona informacja: "01. Nazwa: Jan Kowalski"
 
My w trakcie implementacji nie zauważyliśmy, że numery mniejsze od 10 mają mieć zero na początku. Proces zgłaszania i rozwiązania wygląda następująco:
1. Właściciel produktu dodaje teraz błąd powiązany z naszą historią użytkownika (w sposób analogiczny jak podpinaliśmy impediment).
2. My, po analizie błędu oceniamy ile czasu zajmie jego poprawienie, uaktualniamy effort dla PBI oraz buga
3. Dodajemy zadanie-dziecko do błędu (analogicznie jak zadanie do historii użytkownika)
4. Gdy rozwiążemy błąd i wrzucamy nasze zmiany spinamy je zarówno z zadaniem,błędem jak i historią użytkownika.
5. Gdy Właściciel Produktu zaakceptuje rozwiązanie zamyka błąd i oznacza historię użytkownika za zrealizowaną.

Podsumowanie

Mam nieodparte wrażenie, że ten artykuł jest z jednej strony za krótki, z drugiej, że za długi. Dlaczego? Za krótki gdyż temat jest tak rozległy, że można by było napisać książkę i to by było mało. Za długi, bo nie wiem czy ilość szczegółów nie zamaże obrazu całości.
Mam jednak nadzieję, że udało mi się jednak przekazać ideę oraz że lektura mojego artykułu pozwoli Wam zrozumieć jak można zamodelować proces tworzenia funkcjonalności w TFS zgodnie z TDD i metodyką Scrum.

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