Witaj na Zine.net online Zaloguj się | Rejestracja | Pomoc

Krok po kroku: programowanie sterowane testami

1. Wstęp.


We will write tests before we code, minute by minute. We will preserve these tests forever, and run them all together frequently.

Kent Beck [1]

W tekście tym przedstawię Wam przykładowy proces realizacji klas reprezentujących bank oraz konto bankowe metodą programowania sterowanego testami. Zgodnie z tą metodyką dla każdej funkcji systemu najpierw będę definiował test, który ją specyfikuje, a dopiero w następnym kroku wykonam jej implementację.

2. Podstawowe funkcje konta bankowego.

Klasa Account będzie reprezentowała konto bankowe. Podstawowymi operacjami na koncie bankowym, które chcemy zaprogramować, są wpłacanie i wypłacanie pieniędzy a także sprawdzanie stanu konta. Zaczynamy od napisania testów jednostek opisujących nasze wymagania. Na początek chcemy, aby stan konta po zainicjowaniu wynosił 0.

Przykład [C#] 2.1. Klasa zawierająca testy dla klasy Account. Pierwsze wymaganie specyfikuje metoda AccountSetUpTest.
[TestFixture]
public class AccountTests : Assert
{
    private Account account;

    [SetUp]
    public void SetUp()
    {
        this.account = new Account();
    }

    [Test]
    public void AccountSetUpTest()
    {
        AreEqual(0, this.account.Balance);
    }
}


Jest oczywistym, że ten test nawet się nie skompiluje. Nie zdefiniowaliśmy nigdzie klasy Account. Zdefiniujmy zatem klasę o takiej nazwie oraz dodajmy jej prywatne pole, które będzie przechowywało aktualny stan konta. Udostępnijmy również właściwość Balance, aby klienci klasy mogli sprawdzić stan konta.

Przykład [C#] 2.2. Definicja klasy Account, dla której test z przykładu 2.1 przechodzi.
public class Account
{
    private decimal balance = 0;
    public decimal Balance
    {
        get { return this.balance; }
        set { this.balance = value; }
    }
}


Teraz całość się skompiluje a test zakończy się powodzeniem. Zatem mamy zrealizowaną część funkcji systemu. W następnym kroku dodamy możliwość wpłacania pieniędzy na konto. W klasie z testami definiujemy następujący test:

Przykład [C#] 2.3. Test definiujący operację wpłacania pieniędzy na konto.
[Test]
public void DepositCashPositive()
{
    this.account.Deposit(10);
    AreEqual(10, this.account.Balance);
}


Test ten wymaga, by klasa Account miała metodę Deposit. Postępując zgodnie z zasadą najprostszej realizacji dodajemy do klasy Account następującą definicję metody Deposit:

Przykład [C#] 2.4. Definicja metody Deposit klasy Account.
public void Deposit(decimal amount)
{
    this.balance = amount;
}


Znów wszystko się kompiluje i testy przechodzą. Jednak jak łatwo zauważyć, to nie dokładnie to, o co nam chodziło. Metoda Deposit powinna dodawać podaną wartość do tej aktualnej a nie zastępować ją. Musimy w takim razie uszczegółowić nasze testy. Dodajemy kolejny.

Przykład [C#] 2.5. Drugi test definiujący operację wpłacania pieniędzy na konto.
[Test]
public void DepositCashTwoTimes()
{
    this.account.Deposit(10);
    this.account.Deposit(5);
    AreEqual(15, this.account.Balance);
}


Wykonujemy test i otrzymujemy pierwsze niepowodzenie. Nowy test nie przechodzi. Szybko dochodzimy do wniosku, że poprawna realizacja metody Deposit powinna wyglądać tak:

Przykład [C#] 2.6. Ulepszona definicja metody Deposit klasy Account.
public void Deposit(decimal amount)
{
    this.balance += amount;
}


Możemy powiedzieć, ze osiągnęliśmy sukces. Wszystkie testy znów przechodzą. Podobnie realizujemy metodę pobierania pieniędzy z konta. Zaczynamy od testów.

Przykład [C#] 2.7. Testy definiujące operację wypłacania pieniędzy z konta.
[Test]
public void SetBalanceAndWithdraw()
{
    this.account.Balance = 10;
    this.account.Withdraw(5);
    AreEqual(5, this.account.Balance);
}

[Test]
public void SetBalanceAndWithdrawTwoTimes()
{
    this.account.Balance = 10;
    this.account.Withdraw(5);
    this.account.Withdraw(4);
    AreEqual(1, this.account.Balance);
}


Ponieważ testy znów się nie kompilują realizujemy metodę Withdraw, której brak.

Przykład [C#] 2.8. Definicja metody Withdraw klasy Account.
public void Withdraw(decimal amount)
{
    this.balance −= amount;
}


Wszystko się zgadza. Testy się kompilują i przechodzą. W taki oto sposób zrealizowaliśmy podstawowe funkcje klasy Account. Należy jeszcze podkreślić, że pisząc testy przed realizacją myśleliśmy o tym, jaki publiczny interfejs powinna mieć klasa Account. Natomiast realizacja wynikła wprost z testów. Skoro konto ma dobrze określony interfejs, możemy go wyodrębnić tak, aby przyszli klienci mogli łatwo z niego korzystać.

Przykład [C#] 2.9. Interfejs klasy Account.
public interface IAccount
{
    decimal Balance { get; set; }
    void Withdraw(decimal amount);
    void Deposit(decimal amount);
}


3. Podstawowe funkcje banku.

Bank będzie klasą reprezentującą bank. Naszym wymaganiem w stosunku do banku jest możliwość przesyłania pieniędzy z konta na konto. Jako parametry podamy konto źródłowe, konto docelowe oraz kwotę do przelania. Jak zwykle zaczynamy od testu.

Przykład [C#] 3.1. Klasa zawierająca testy dla klasy Bank. Pierwsze wymaganie specyfikuje metoda TransferFoundsSuccessfully.
[TestFixture]
public class BankTests : Assert
{
    [Test]
    public void TransferFoundsSuccessfully()
    {
        IAccount sourceAccount = new Account();
        sourceAccount.Balance = 100;
        IAccount destinationAccount = new Account();
        destinationAccount.Balance = 70;

        Bank bank = new Bank();
        bank.TransferFounds(sourceAccount, destinationAccount, 30);

        AreEqual(70, sourceAccount.Balance);
        AreEqual(100, destinationAccount.Balance);
    }
}


W teście z przykładu 3.1 najpierw utworzyliśmy dwa konta: źródłowe i docelowe. Chcemy, aby metoda TransferFounds klasy Bank przyjmowała oba konta (źródłowe i docelowe), ale nie chcemy uzależniać jej od konkretnej realizacji konta. W tym celu wykorzystaliśmy interfejs klasy konta, a nie jej konkretną implementację. Przyczyny takiej decyzji projektowej wyjaśniono w p. 4. Na koniec testu sprawdzamy konta, aby zweryfikować, czy przelew wykonano poprawnie. Teraz kolej na realizację funkcji.

Przykład [C#] 3.2. Klasa Bank dla której test z przykładu 3.1 przechodzi.
public class Bank
{
    public void TransferFounds(IAccount sourceAccount, IAccount destinationAccount, decimal amount)
    {
        sourceAccount.Withdraw(amount);
        destinationAccount.Deposit(amount);
    }
}


Uruchamiając testy możemy się przekonać, że taka realizacja spełnia wymagania.

4. Technika testowania stanu.

W punktach 2 i 3 pokazano technikę testowania polegającą na testowaniu stanu obiektów. Schemat tej techniki wygląda następująco. Najpierw ustawiany jest kontekst testu. Następnie wykonywana jest testowana operacja. Na koniec wynik działania wykonanej operacji weryfikowany jest na podstawie stanu obiektów z kontekstu oraz obiektu testowanego. Prześledźmy jeszcze raz test metody TransferFounds klasy Bank z przykładu 3.1. Na początku ustawiany jest kontekst testu. W jego skład wchodzą dwa konta, źródłowe i docelowe. Z punktu widzenia testowanej funkcji są to klasy drugorzędne, które po pierwsze są wymagane do wykonania testu, a po drugie są potrzebne do zweryfikowania działania testu. Kolejnym krokiem jest wykonanie metody testowanej. Na koniec weryfikowany jest wynik działania tej metody poprzez sprawdzenie stanu kont.

Należy podkreślić jeszcze jedną rzecz. Większość przypadków testowych będzie wymagało użycia większej liczby klas, a nie tylko klasy testowanej. W wypadku testowania klasy Account nie potrzebowaliśmy nic więcej poza samą klasą. Natomiast podczas testowania klasy Bank nie mogliśmy się obejść bez klasy Account. Beck i Cunningham [2] zauważają, że:

...no object is an island. All objects stand in relationship to others, on whom they rely for services and control.


Może się zdarzyć tak, że klasy z kontekstu będą zbyt kosztowne, aby je tworzyć przy każdym wykonaniu testów. Może to być na przykład klasa, która otwiera połączenie z bazą danych lub innym wymagającym zasobem. W takich wypadkach z pomocą przychodzą nam namiastki (ang. stub). Namiastka to uproszczona, na potrzeby testu, realizacja klasy, która ma zastąpić kosztowną w utrzymaniu klasę z kontekstu. Ważną rolę w takim podejściu odgrywają interfejsy. Dzięki temu, ze metoda TransferFounds jako parametry przyjmuje egzemplarze klas, które implementują interfejs IAccount, to nie koniecznie musimy jej podawać egzemplarze klasy Account. Bez zastosowania interfejsów taka operacja nie byłaby w ogóle możliwa. Używanie innych klas poprzez ich interfejsy sprawia, ze klasy sytemu są ze sobą luźniej powiązane i łatwiej je ponownie użyć lub podmieniać.

5. Kolejne wymagania dla konta.

Jeśli, z powodu braku wystarczających środków, wykonywana na koncie operacja nie może zostać zakończona, to klasa konta powinna zasygnalizować to zgłaszając odpowiedni wyjątek. Zaczynamy od napisania testów, które sprawdzają przypadki, w których taka sytuacja jest możliwa.

Przykład [C#] 5.1. Testy specyfikujące operacje, po których bilans konta może być mniejszy niż 0.
[ExpectedException(typeof(BalanceCannotBeLessThanZero))]
[Test]
public void DepositToLessThanZero()
{
    this.account.Deposit(−10);
}

[ExpectedException(typeof(BalanceCannotBeLessThanZero))]
[Test]
public void WithdrawToLessThanZero()
{
    this.account.Withdraw(10);
}

[ExpectedException(typeof(BalanceCannotBeLessThanZero))]
[Test]
public void SetBalanceToLessThanZero()
{
    this.account.Balance = −10;
}


Testy z przykładu 5.1 specyfikują sytuacje, w których może nastąpić przekroczenie dostępnych środków. Użyto atrybutu ExpectedException, aby określić typ wyjątku, jaki powinien zostać zgłoszony. Testy znów się nie kompilują, ponieważ nie mamy realizacji klasy wyjątku.

Przykład [C#] 5.2. Definicja wyjątku BalanceCannotBeLessThanZero.
public class BalanceCannotBeLessThanZero : Exception {}

Teraz wszystko się kompiluje, ale zdefiniowane przed chwilą testy nie przechodzą. Uzupełniamy realizację klasy Account.

Przykład [C#] 5.3. Ulepszone metody klasy Account dla testów z przykładu 5.1.
public decimal Balance
{
    get { return this.balance; }
    set {
        if (value < 0)
            throw new BalanceCannotBeLessThanZero();
        this.balance = value;
    }
}

public void Deposit(decimal amount)
{
    if (this.balance + amount < 0)
        throw new BalanceCannotBeLessThanZero();
    this.balance += amount;
}

public void Withdraw(decimal amount)
{
    if (this.balance − amount < 0)
        throw new BalanceCannotBeLessThanZero();
    this.balance −= amount;
}


6. Kolejne wymagania dla banku.

Konto potrafi już sygnalizować brak dostępnych środków. Teraz chcielibyśmy, aby bank odpowiednio zareagował na takie zdarzenie. Scenariusz jest następujący: jeśli podczas wykonywania przelewu nie będzie wystarczających środków na koncie, to wyślij wiadomość e-mail do pracownika banku, aby ten mógł przygotować odpowiednią ofertę kredytu odnawialnego. Powyższe wymaganie specyfikujemy za pomocą kolejnego testu.

Zanim napiszemy test zastanówmy się jeszcze przez chwilę nad pewna sprawą. Do tej pory wynik testu weryfikowaliśmy sprawdzając stany odpowiednich obiektów kontekstu. Teraz sprawa się trochę komplikuje. Jak sprawdzić, czy wysłany e-mail doszedł do adresata? Cechą charakterystyczną testów jednostek jest automatyczne klasyfikowanie wyników oraz krótkie wykonanie. Z tego powodu żadna forma ręcznego sprawdzania, czy wysłany e-mail doszedł do adresata nie wchodzi w grę. Podobnie wszelkie formy programowego odpytywania np. serwera poczty też nie są wystarczające, ponieważ operacja taka zajęłaby dużo czasu. Potrzebna jest nam jakaś inna forma weryfikacji. Przeanalizujmy poniższy test:

Przykład [C#] 6.1. Test metody TransferFounds klasy Bank specyfikujący przypadek, gdy na koncie źródłowym nie ma wystarczających środków, by dokonać przelewu.
[Test]
public void TransferFoundsWithInsufficientOnSource()
{
    //zainicjowanie kontekstu
    IAccount sourceAccount = new Account();
    sourceAccount.Balance = 10;
    IAccount destinationAccount = new Account();
    destinationAccount.Balance = 70;
    MockRepository mocks = new MockRepository();
    IMailService mailServiceMock = mocks.CreateMock<IMailService>();

    //wymagane interakcje z otoczeniem
    mailServiceMock.SendMail(””, ””, ””);
    LastCall.IgnoreArguments();
    mocks.ReplayAll();

    //inicjacja testowanej klasy
    Bank bank = new Bank();
    bank.MailService = mailServiceMock;

    //wykonanie testowanej metody
    bank.TransferFounds(sourceAccount, destinationAccount, 30);

    //weryfikacja testu
    mocks.VerifyAll();
    AreEqual(10, sourceAccount.Balance);
    AreEqual(70, destinationAccount.Balance);
}


Na początku testu tworzymy kontekst inicjując dwa konta, które są potrzebne do wykonania operacji przelewu. Jednak tym razem nie możemy zweryfikować powodzenia testu tylko poprzez sprawdzenie stanu kont. Projektując ten test odkryliśmy, że bank musi skorzystać z usługi, która umożliwi mu wysłanie wiadomości e-mail. Odkryliśmy nową klasę i jej interfejs. Jest on następujący:

Przykład [C#] 6.2. Interfejs IMailService.
public interface IMailService
{
    void SendMail(string sender, string address, string body);
}


W następnej kolejności nie zajęliśmy się realizacją tej usługi, gdyż to odciągnęłoby nas od realizacji aktualnego scenariusza. Na razie jej interfejs nam w zupełności wystarczy. Należy podkreślić, że nie używamy tutaj konkretnej realizacji usługi. Specyfikujemy tylko, że bank ma wysyłać wiadomość, ale nie obchodzi go jak i przez kogo będzie ona dostarczona. Zadowala go interfejs, dzięki któremu będzie mógł nadać wiadomość. Z tego powodu musimy umożliwić wskazanie odpowiedniego egzemplarza usługi z zewnątrz. Obiekt klasy Bank przyjmuje poprzez właściwość MailService obiekt usługi, ale skąd mamy wziąć ten obiekt, skoro realizacje usługi odłożyliśmy na później?

W teście z przykładu 6.1 użyto obiektów zastępczych (ang. mock objects) [3]. Są to pewnego rodzaju namiastki, o których była mowa w p. 4. Namiastki z reguły służą do tego, aby zastąpić dany obiekt, który jest wymagany podczas wykonywania testowanej metody, ale nie mają znaczenia jeśli chodzi o wynik testu. Są tylko po to, aby kontekst testu był kompletny. Obiekty zastępcze są dużo bardziej wyrafinowane. Są używane wtedy, gdy chcemy specyfikować interakcje, które mają zachodzić między testowaną klasą a obiektami z kontekstu. Dzięki nim możemy specyfikować nasze oczekiwania co do wywoływanych metod, przekazywanych parametrów i zwracanych wyników. W przykładzie 6.1 użyto biblioteki RhinoMocks [4]. Biblioteka ta umożliwia generowanie obiektów zastępczych implementujących konkretne interfejsy. W naszym przypadku potrzebowaliśmy obiektu, który implementowałby interfejs usługi IMailService. W następnym kroku określiliśmy nasze wymagania co do interakcji między bankiem a usługą. Zdefiniowaliśmy, że spodziewamy się wywołania metody SendMessage i nie dbamy o parametry, które zostaną jej przekazane. Na razie chodzi nam jedynie o interakcję. Specyfikacje wymagań interakcji kończymy wywołaniem mocks.ReplayAll(). Kolejnym krokiem jest wykonanie właściwego testu. Wykonujemy metodę transferu z odpowiednio dobranymi parametrami tak, żeby na koncie źródłowym zabrakło środków. Na koniec weryfikujemy rezultaty wykonania testowanej metody. Poza sprawdzeniem stanów kont, chcemy również sprawdzić, czy wymagana interakcja między bankiem i usługą zaszła. Sprawdzenie to wykonujemy wywołaniem mocks.VerifyAll().

Mając gotowy test pozostało nam ulepszyć realizację metody TransferFounds.

Przykład [C#] 6.3. Ulepszona definicja metody TransferFounds, dla której test z przykładu 6.1 przechodzi.
public void TransferFounds(IAccount sourceAccount, IAccount destinationAccount, decimal amount)
{
    try
    {
        sourceAccount.Withdraw(amount);
        destinationAccount.Deposit(amount);
    }
    catch (BalanceCannotBeLessThanZero)
    {
        this.mailService.SendMail(”Bank”, ”Guard”, ” Insufficientfounds”);
    }
}


7. Technika testowania interakcji.

The term ’Mock Objects’ has became a popular one to describe special case objects that mimic real objects for testing. However the term
mock was not originally meant as a more catchy name for stub, but to introduce a different approach to unit testing.

Martin Fowler [5]


W p. 6. przedstawiono przypadek testowy, co do którego nie mogliśmy zastosować metody testowania sprawdzającej stan obiektów z kontekstu. Pojawiła się sytuacja, w której nie potrafiliśmy sprawdzić stanu pewnego obiektu. Co więcej, sam obiekt tej informacji nie miał. Nie mogliśmy jej automatycznie sprawdzić. Takich przypadków jest więcej. Przypuśćmy, że nad danym systemem pracują dwie grupy. Jedna z nich implementuje interfejs użytkownika, a druga klasy logiki biznesowej. Klasy logiki biznesowej będą wymagały klas interfejsu użytkownika, aby prezentować dane. Oba zespoły pracują równolegle. To powoduje, że w momencie, gdy zespół zajmujący się logiką potrzebuje klas interfejsu, to nie są one jeszcze gotowe. Zespół ten nie może testować swoich klas nie mając klas interfejsu użytkownika. W tej sytuacji z pomocą przychodzą im obiekty zastępcze. Dzięki nim zespoły nie muszą czekać jeden na drugi. Prace mogą być wykonywane równolegle tak, jakby wymagane klasy były dostępne.

Technika testowania posługująca się obiektami zastępczymi nazywa się testowaniem interakcji. Szablonowy proces przebiegu takiego testu wygląda następująco (możemy go prześledzić na podstawie testu z przykładu 6.1):

  1. Konfiguruje się kontekst testu tworząc egzemplarze obiektów zastępczych.
  2. Specyfikowane są wymagania dotyczące interakcji między obiektami oraz parametry pobierane i zwracane.
  3. Wywoływana jest testowana metoda.
  4. Weryfikowane jest działanie testowanej metody.


Testując daną operację często można się spotkać z tym, że obiekty z kontekstu testu nie udostępniają wystarczających informacji do jej zweryfikowania. Aby nie naruszać obudowy (ang. encapsulation) tych klas, trzeba te informacje wydobyć w jakiś inny sposób. W takiej sytuacji dobrze sprawdzają się obiekty zastępcze. Działają od wewnątrz testowanej klasy, dając możliwość obserwacji jej wewnętrznego stanu jak i interakcji. Umożliwiają wyizolowanie jej od otoczenia dostarczając jednocześnie wartościowych informacji.

Dla zobrazowania tej sytuacji przypomnijmy sobie test z przykładu 3.1. Bazował on na tym, że interfejs IAccount udostępnia właściwość Balance, dzięki której możemy ustawiać stan konta na interesującą nas wartość. Jednak, jak łatwo zauważyć, w rzeczywistym koncie nie ma takiej możliwości. Możemy jedynie wpłacać i wypłacać pieniądze oraz sprawdzać stan konta. Jest to funkcja nadmiarowa. Gdyby jej nie było mielibyśmy problem podczas tworzenia takiego testu, gdyż nie moglibyśmy odpowiednio przygotować kontekstu. Przykład 7.1 prezentuje alternatywną realizację testu z przykładu 3.1 wykorzystującą obiekty zastępcze i nie odwołującą się do właściwości Balance.

Przykład [C#] 7.1. Realizacja testu z przykładu 3.1 wykorzystująca obiekty zastępcze.
[Test]
public void TransferFoundsSuccessfullyMocks()
{
    decimal amountToTransfer = 30;
    MockRepository mocks = new MockRepository();

    IAccount sourceAccount = mocks.CreateMock<IAccount>();
    sourceAccount.Withdraw(amountToTransfer);
    IAccount destinationAccount = mocks.CreateMock<IAccount>();
    destinationAccount.Deposit(amountToTransfer);

    mocks.ReplayAll();

    Bank bank = new Bank();
    bank.TransferFounds(sourceAccount, destinationAccount, amountToTransfer);
   
    mocks.VerifyAll();
}


Test z przykładu 7.1 zamiast inicjować egzemplarze klasy Account odpowiednimi kwotami a następnie, po wykonaniu testowanej operacji, sprawdzać stan tych kont, specyfikuje jedynie interakcje między obiektem banku a obiektami kont. Bank powinien wywołać metodę Withdraw na obiekcie konta źródłowego oraz metodę Deposit na obiekcie konta docelowego z taka samą kwotą podaną jako parametr wywołań. Taka specyfikacja umożliwia odseparowanie testu klasy Bank od konkretnych realizacji klasy Account. Dzięki temu błędne działanie klasy Account nie wpływa na wyniki wykonania testów klasy Bank. Pozwoli to na szybszą lokalizację ewentualnych błędów.

Istnieją jeszcze inne przypadki, w których obiekty zastępcze znacznie ułatwiają testowanie. Czasami bywa tak, że klasa wymagana w kontekście testu jest zbyt kosztowna, aby ja tworzyć dla każdego testu. Może to być na przykład klasa utrzymująca połączenie z bazą danych. Taką klasę, o ile jest określona interfejsem, można łatwo zastąpić używając obiektów zastępczych.

Kolejnym problemem jest testowanie sytuacji wyjątkowych. Takie sytuacje zazwyczaj bardzo ciężko zreprodukować w teście. Używając obiektów zastępczych, w prosty sposób możemy podmienić obiekt, który ma spowodować sytuację wyjątkową i zmusić go do jej zgłoszenia. Podobną sytuację możemy zaobserwować w teście klasy Bank z przykładu 6.1. Sprawdza on reakcję metody TransferFounds na sytuację, gdy na koncie źródłowym nie będzie wystarczających środków. Gdyby spowodowanie takiej sytuacji było sprawą trudną, moglibyśmy użyć obiektu zastępczego i zmusić go do spowodowania sytuacji wyjątkowej. Alternatywną realizację testu z przykładu 6.1 prezentuje przykład 7.2.

Przykład [C#] 7.2. Alternatywna realizacja testu z przykładu 6.1, wykorzystująca obiekty zastępcze.
[Test]
public void TransferFoundsWithInsufficientOnSource2()
{
    decimal amountToTransfer = 30;
    MockRepository mocks = new MockRepository();

    IAccount sourceAccount = mocks.CreateMock<IAccount>();
    sourceAccount.Withdraw(amountToTransfer);
    LastCall.Throw(new BalanceCannotBeLessThanZero());
    IAccount destinationAccount = mocks.CreateMock<IAccount>();
    IMailService mailServiceMock = mocks.CreateMock<IMailService>();
    mailServiceMock.SendMail(””, ””, ””);
    LastCall.IgnoreArguments();

    mocks.ReplayAll();

    Bank bank = new Bank();
    bank.MailService = mailServiceMock;
    bank.TransferFounds(sourceAccount, destinationAccount, amountToTransfer);

    mocks.VerifyAll();
}


Test ten specyfikuje, że wywołanie metody Withdraw ma zgłosić wyjątek BalanceCannotBeLessThanZero. Dzięki temu nie musimy dokładnie reprodukować sytuacji wyjątkowej.

Obiekty zastępcze do złudzenia przypominają namiastki. Mają jednak kilka cech znacznie je różnicujących. Przede wszystkim dają większą kontrolę nad testowanym obiektem. Pozwalają definiować wymagania dotyczące zachodzących interakcji oraz specyfikować parametry i wartości zwracane przez wywoływane metody. Poza tym, biblioteki obiektów zastępczych umożliwiają w łatwy i dynamiczny sposób generowanie obiektów zastępczych, co znacznie upraszcza kod testów. Ręczne pisanie namiastek sprawia, że kod testów jest bardziej złożony i bardziej podatny na błędy. Ponadto jest trudniejszy w utrzymaniu.

Testując z wykorzystaniem obiektów zastępczych jeszcze bardziej skupiamy się na interfejsach klas a nie na ich realizacjach. Odkrywamy również interfejsy klas dostarczających usługi. W przykładzie z bankiem stwierdziliśmy, że będzie on potrzebował usługi, za pomocą której można wysyłać wiadomości e-mail. Zdefiniowaliśmy interfejs tej usługi. To jest część projektu systemu, która również została pokierowana przez testy. Należy podkreślić, że proces projektowania z wykorzystaniem obiektów zastępczych prowadzi do luźnych powiązań między klasami poprzez użycie interfejsów. Ponadto rezygnuje się z obiektów globalnych na rzecz tych przekazywanych do klasy jako parametry. Jeśli klasa potrzebuje jakiejś usługi, to dostarcza się ją z zewnątrz. Podejście takie promuje ponowne wykorzystanie klas a także zwalnia nas od myślenia o wszystkim na raz. Testując jedną klasę nie musimy się martwić o pozostałe, od których ta testowana klasa zależy. Dzięki temu możemy się skupić na konkretnej funkcji. Pozostałe klasy będą miały swoje własne zestawy testów, które dogłębnie przetestują ich działanie.

Tym, którzy chcieliby zobaczyć większy przykład programowania sterowanego testami, polecam książkę Rona Jeffriesa pt: "Programowanie ekstremalne w C#" link


[1] Beck K.: Extreme programming explained: embrace change. Boston, MA. Addison-Wesley, 2000.
[2] Beck K., Cunningham W.: A laboratory for teaching object oriented thinking. In: OOPSLA ’89: Conference proceedings on Object-oriented programming systems, languages and applications. New York, NY. ACM Press. 1–6.
[3] Mackinnon T., Freeman S., Craig P.: EndoTesting: Unit Testing with Mock Objects, eXtreme Programming and Flexible Processes in Software Engineering - XP2000, 2000.
[4] Eini O.: RhinoMocks, 2006. http://www.ayende.com/projects/rhino-mocks.aspx.
[5] Fowler M.: Inversion of Control Containers and the Dependency Injection pattern, 2004. http://www.martinfowler.com/articles/injection.html.

Opublikowane 27 maja 2007 00:45 przez nuwanda

Komentarze:

# re: Krok po kroku: programowanie sterowane testami

28 maja 2007 09:20 by arkadiusz.wasniewski

Gratulacje za tekst. Jeśli zaś chodzi o RhinoMocks to niestety nie pozwala ona testować klas i interfejsów niepublicznych, a szkoda bo zdecydowanie bardziej mi się podoba od klasy Mock z NUnit.

# re: Krok po kroku: programowanie sterowane testami

28 maja 2007 09:39 by nuwanda

O tym, że RhinoMocks nie wspierają klas i interfejsów prywatnych nie wiedziałem, dzięki. Dla mnie RhinoMocks mają jedną zasadniczą przewagę: używa się ich w modelu record&replay, czyli specyfikację wymagań wykonuje się przez rzeczywiste wywoływanie metod, a nie podawanie ich nazw. To zdecydowanie ułatwia tworzenie i utrzymywanie testów, zwiększa ich czytelność, a co najważniejsze ogranicza możliwości pomyłki.

# re: Krok po kroku: programowanie sterowane testami

28 maja 2007 10:13 by arkadiusz.wasniewski

Zgadza się. I dlatego też cierpię z powodu ograniczeń RhinoMocks.

# re: Krok po kroku: programowanie sterowane testami

28 maja 2007 21:07 by nuwanda

Arku, tak na marginesie, to piszesz testy jednostek w pracy, czy tylko prywatnie?

# re: Krok po kroku: programowanie sterowane testami

29 maja 2007 09:56 by arkadiusz.wasniewski

W pracy. Korzystam z NUnit (również Mock).

A swoją drogą jestem ciekaw, czy planujesz coś napisać jak zorganizować projekt dla testów: chodzi mi o to, jak według Ciebie testy powinny być powiązane z klasami, które testują.

Arek

# re: Krok po kroku: programowanie sterowane testami

29 maja 2007 10:44 by mgrzeg

Tu jest pare ciekawych tematow do poruszenia - chocby infrastruktura. Mnie interesuje jak testujecie kod dostepu do bazy danych i sama baze, bo nie wierze, ze wszystko na mockach. A zatem - przy cyklicznym buildzie obsluga bazy - przywracanie do poprzedniego stanu, skrypty roznicowe, etc - ciekaw jestem jak to macie zorganizowane.

A tekst - bardzo dobry! :)

# re: Krok po kroku: programowanie sterowane testami

29 maja 2007 11:08 by nuwanda

Co do testowania DB to osobiście nie mam doświadczenia (piszę testy jednostek tylko prywatnie) to jednak czytałem trochę o tym zagadnieniu. Przypomniałem sobie, że MbUnit ma specjalne atrybuty przeznaczone do wykonywania testów na DB. Pomysł był Roya Osherove (http://weblogs.asp.net/rosherove/articles/dbunittesting.aspx), a zaimplementowano go właśnie w MbUnit (http://weblogs.asp.net/astopford/archive/2006/07/25/MbUnit-database-testing_2C00_-and-thanks-to-Roy.aspx). A swoją drogą ciekaw jestem jak to jest u Ciebie w firmie Arku.

# re: Krok po kroku: programowanie sterowane testami

29 maja 2007 11:37 by arkadiusz.wasniewski

U mnie problem z testowaniem bazy danych praktycznie nie istnieje. Klasy korzystające z danych otrzymują tylko interfejs, czyli w testach Mock. Natomiast z racji dziedziny, którą się zajmuję, korzystam głównie z baz plikowych własnej roboty oraz z mobilnych wersji MS SQL, a ta posiada tylko jedną bibliotekę-silnik, która spokojnie może być wykorzystywana w ramach testów.

# re: Krok po kroku: programowanie sterowane testami

15 kwietnia 2011 13:17 by janusz

A jak wygląda sprawa testów GUI?

Komentarze anonimowe wyłączone