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):
- Konfiguruje się kontekst
testu tworząc egzemplarze obiektów zastępczych.
- Specyfikowane są wymagania
dotyczące interakcji między obiektami oraz parametry pobierane i zwracane.
- Wywoływana jest testowana
metoda.
- 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.