Extreme Programming w Praktyce
Dariusz Cieślak
Streszczenie: Praktyczne zastosowanie metodologii Extreme Programming na przykładzie prostego klienta news (protokół NNTP) zaimplementowanego w języku Python. Pokazanie techniki "Test przed implementacją".
Nowoczesne środowiska tworzenia aplikacji to nie wszystko. W jaki sposób należy tworzyć oprogramowanie, by było ono podatne na zmianę wymagań, zmianę wykorzystywanych bibliotek? Na te i wiele innych pytań próbuje odpowiedzieć metodologia Extreme Programming.
Extreme Programming
XP powstało w środowisku programistów języka Smalltalk. Na początku lat 90-tych dwaj programiści: Kent Beck i Ward Cunnigham zdefiniowali kilka praktycznych reguł. Prawdziwym "chrztem bojowym" dla metodologii stał się projekt dla firmy DaimlerChrysler rozpoczęty w 1996 r. XP zakłada, że istnieją cztery możliwości polepszenia jakości projektu:
- Należy usprawnić komunikację w zespole i zespołu z klientem.
- Dążyć do maksymalnej prostoty.
- Nie bać się zmian (bezlitosna refaktoryzacja).
- Należy zapewnić sprzężenie zwrotne, które będzie pokazywać jakość wykonanej pracy.
Komunikacja. Często pojawiają się głosy oskarżające XP o zachęcanie do porzucania dokumentacji na rzecz kodu systemu. Takie opinie wynikają ze złego pojmowania dynamicznej natury tej metodologii. Nie ma wyróżnionej fazy projektowania jak w klasycznym modelu Waterfall. Rolę dokumentacji częściowo przejmują dobra architektura systemu powstająca w procesie tzw. "refaktoryzacji" oraz wiedza o wymaganiach pozyskiwana na bieżąco przez programistów w postaci tzw. "User Stories" (Stąd nacisk na dobrą komunikację). Jak zostanie pokazane w przykładzie specyfikacje dla modułów zapisuje się w postaci testów jednostkowych - zmniejsza to znaczenie opisu słownego. XP postuluje mniejszą ilość dokumentacji i tylko tam, gdzie ona jest rzeczywiście potrzebna i przydatna.
Prostota i refaktoryzacja. Jeśli kod wydaje się nam zagmatwany i nieelegancki (pojawiają się tzw. "Code Smells") należy bezwzględnie przebudować go tak, by jego działanie było oczywiste dla osoby, która przegląda źródła. Dlaczego? Otóż w wyniku zmian w kodzie (które na pewno nastąpią) owa działająca funkcjonalność może zostać uszkodzona. Kod powinien łatwo poddawać się w takim wypadku modyfikacjom. Gdy czujemy, że np. nazwy metod nie odpowiadają wykonywanej przez nie pracy czy odpowiedzialność pomiędzy klasami nie jest rozłożona prawidłowo należy podjąć kroki naprawcze. Dążymy jednocześnie do maksymalnej prostoty rozwiązań (Antoine de Saint-Exupery, francuski pisarz i konstruktor samolotów, powiedział, że "konstruktor wie, iż osiągnął doskonałość nie wtedy, gdy nic już nie można dodać, lecz wtedy, gdy już nic nie da się ująć").
Sprzężenie zwrotne natomiast realizowane jest przez zestawy zautomatyzowanych testów, które pracują jak kompilator, ale na wyższym poziomie. O ile kompilator sprawdza tylko składnię programu, to testy odpowiadają za prawidłowe działanie (semantykę) interfejsu klasy. Kiedy testy powstają przed kodem to mamy do czynienia z "siatką", w którą będziemy łapać powstałe błędy. Ponadto testy zabezpieczą nas przed przypadkowym złamaniem kontraktu - podczas sprawdzania zostaną wygenerowane wyjątki. Waga sprawdzanego dynamicznie kontraktu wzrasta w środowiskach, gdzie nie ma statycznej kontroli typów (języki skryptowe). To może wyjaśniać, dlaczego XP zrodził się w środowisku programistów Smalltalk - język ów jest przykładem typowego języka skryptowego z dynamiczną typizacją. Możemy np. wstępnie stwierdzić, że w kodzie, który zostanie wykonany podczas testów liczba argumentów metod zgadza się z tymi podanymi przy wywołaniu (Pojawia się tu problem pokrycia, ale to już temat na odrębny artykuł).
Definicja wymagań
Wyżej wymienione zasady XP zostaną teraz zilustrowane przykładem - prosty program, którego zadaniem jest spełnić pewne wymagania klienta. Oto fikcyjne zlecenie:
Szanowny panie Dariuszu! Od niedawna korzystam w domu z internetu. Chciałbym ściągnąć oferty pracy z "pl.praca.oferowana" dla mechanika samochodowego. Korzystam z połączenia telefonicznego, więc nie chcę ściągać wszystkich, tylko te, które dotyczą mojego zawodu. Chcę, żeby zostały zgrane tylko te wiadomości, które w tytule mają słowa: "mechanik" lub "samochód". To oszczędzi czas i pieniądze za telefon. Uszanowanko. Pan Włodek.
Jak widzimy nacisk został położony na ekonomię rozwiązania (szybkie ściąganie wybranych postów). Dodatkowo, nasz klient zapewne aktualnie szuka pracy - nie będzie chciał czekać zbyt długo na nasz program. Dobrze by było, gdyby otrzymał go za kilka dni, nie za miesiąc.
Jak w takim wypadku zastosować reguły XP? Załóżmy, że wykonawca (czyli my) pracuje w pojedynkę (Do takiego projektu jedna osoba w zupełności wystarczy). Normalne podejście do problemu polegało by na napisaniu kodu i testowaniu go na działającym serwerze NEWS. Błędy, które zostały popełnione podczas tworzenia projektu należało by pracowicie usuwać polegając na obserwacji wyników działania programu. Oznaczało by to konieczność sprawdzenia wszystkich założeń po każdej wprowadzonej poprawce (Poprawianie błędów często prowadzi do powstania nowych, które mogą się od razu nie ujawnić).
Co proponuje XP? Przede wszystkim: zacząć od zdefiniowania testów. Testy będą pilnowały nam zachowania żądanej funkcjonalności w procesie poprawiania programu. Są formalną (wykonywaną) specyfikacją tego, co chcemy uzyskać.
Aby mówić o zautomatyzowanych testach należy zadbać o powtarzalność zachowania środowiska programu (w tym przypadku jest to źródło wiadomości - serwer NNTP) oraz o możliwość odebrania i sprawdzenia wyników (u nas jest to zapisywanie plików z wiadomościami w wybranym katalogu).
Do implementacji został wybrany język Python ze względu na swoje walory składniowe (czytanie i pisanie kodu to czysta przyjemność), modularność (mechanizm klas i modułów) oraz bogatą bibliotekę standardową (jest nawet serwer HTTP!). Do uruchomienia programu używam wersji 1.5, chociaż powinien działać bez problemu na 1.4.
Biblioteka do komunikacji z serwerem news to nntplib - standardowy moduł języka Python. Poniżej pokazano typowe wykorzystanie tego modułu:
from nntplib import NNTP
s = NNTP('news.cwi.nl',119)
resp, count, first, last, name = s.group('comp.lang.python')
resp, subs = s.xhdr('subject', first + '-' + last)
for id, sub in subs[-10:]: print id, sub
s.quit()
Z tego przykładu dowiadujemy się jak połączyć się do serwera na dany port (konstruktor obiektu NNTP). Metoda group() pozwala przełączyć się ma wybraną grupę. (Zapis z przecinkami oznacza, przypisanie do wielu zmiennych - metoda zwróci listę wartości.) Xhdr() spowoduje pobranie z serwera pola subject wiadomości o numerach z podanego zakresu. Operator + w kontekście tekstu odpowiada konkatenacji (połączeniu). Następnie, używając metody body() pobierzemy ostatnie 10 wiadomości (zapis subs[-10:]) i wypiszemy tytuły na ekran (print). Metoda quit() zamyka połączenie.
Testy
Ponieważ zależy nam na powtarzalności operacji i szybkości wykonywania testów (będą one uruchamiane przy KAŻDYM uruchomieniu programu, przynajmniej wtedy, gdy nad nim pracujemy) należy w celach testowych podmienić oryginalny obiekt NNTP na specjalnie spreparowany obiekt tzw. Mock Object. Nazwą tą przyjęło się nazywać obiekty, które udają obiekty rzeczywiste a tak naprawdę nie wykonując żadnej konkretnej pracy. Służą za zdefiniowane źródło danych dla testowanego obiektu. Pozbywamy się w ten sposób niepewności otoczenia (environment noise) - rzeczywisty serwer może być przeciążony, lista artykułów może się zmieniać.
Ktoś mógłby spytać: dlaczego nie zainstalować lokalnie rzeczywistego serwera NNTP i nie skonfigurować na nim odpowiednich wiadomości do testowania? Otóż dążymy do tego, by testy były razem z kodem źródłowym (i tak rzeczywiście jest, zwykle lądują w jednym pliku). Moim zdaniem dużo łatwiej skorygować jeden plik tekstowy niż normalnym czytnikiem newsów przygotować odpowiednie zestawy danych na serwerze. Poza tym zależy nam na prędkości - kto by nie chciał, by jego projekt w C++ kompilował się w ułamku sekundy zamiast w pół minuty (a testy uruchamiamy często)?. Oto poszukiwana klasa realizująca Mock Object dla klasy NNTP:
class TestNNTP:
def group(self, sGroupName):
return ("Response body","6","3239","3244",sGroupName)
def xhdr(self, sHeader, sFirstLast):
should(sFirstLast == "3241-3244")
return ('Response body',
[
("3241","Warszawa / mechanik"),
("3242","Lublin / sekretarka"),
("3243","Wrocław / samochody - naprawa"),
("3244","Poszukujemy programisty"),
])
def body(self, sArticleNumber):
return ("Response","<article@id>",
sArticleNumber,
["nr: " + sArticleNumber])
def quit(self):
pass
W powyższym kodzie korzystamy z dynamizmu języka Python - w językach ze statyczną kontrolą typów (C++, Java) należało by zdefiniować abstrakcyjną klasę bazową i "opakować" klasę biblioteczne. Tu zaś wystarczy stworzyć metody takie jak w oryginalnej klasie. Widzimy tu zapisane zachowanie serwera po wywołaniu określonych metod. Zauważmy, że w wywołaniu procedury should(...) (odpowiadającej z grubsza znanemu z języka C assert()) występuje sprawdzenie wartości przekazanego argumentu. Zasygnalizowany zostanie błąd, jeśli testowany obiekt wywoła metodę z innym parametrem niż podany. Fakt, że xhdr powinien być wywołany z takim a nie innym argumentem wyniknie z tego, że w kodzie testującym (będzie pokazany poniżej) zażądamy ściągnięcia wiadomości od numeru 3241 wzwyż. Pomimo, że dostępne są na serwerze wiadomości o niższych numerach, to nie powinny zostać ściągnięte. Widzimy tu w jaki sposób można w obiekty typu Mock wbudować dodatkowy stopień kontroli podczas testów. Na podobnej zasadzie można sprawdzić, czy np. określona metoda nie jest wywoływana dwukrotnie.
Do celów testowych zdefiniowano kilka tytułów wiadomości (metoda xhdr()), treść wiadomości jest budowana w sposób automatyczny w metodzie body() na podstawie numeru (pozwoli to później sprawdzić, czy treści są przyporządkowane do prawidłowych numerów). Metoda kończąca komunikację z serwerem nie robi absolutnie nic (Słowo kluczowe pass jest elementem składni języka Python - oznacza brak operacji).
Teraz należałoby zadać pytanie: w jaki sposób można sprawdzić, czy odpowiednie wiadomości zostały pobrane i zapisane?
class Tester(SequenceChecker):
def __init__(self):
SequenceChecker.__init__(self)
def saveArticle(self, nArticle, sSubject, sBody):
self.log("saveArticle(" + nArticle` + "," +\
sSubject + "," +\
sBody + ")")
def advance(self, nPercent, sDescription):
self.log("progress(" + `nPercent` +\
"," + sDescription + ")")
Powyższa klasa realizuje Mock Object jednocześnie dla dwóch zadań: zapisywania artykułów (metoda saveArticle()) i pokazywania stopnia zaawansowania procesu (metoda advance()). zapis "class Tester(SequenceChecker):" oznacza definicję klasy Tester, która dziedziczy po klasie Sequence Checker. Nie wdając się zbyt głęboko w szczegóły można odpowiedzialność klasy SequenceChecker określić jako: zbieranie informacji o działaniu programu (metoda log() wykorzystana w powyższej klasie) oraz sprawdzaniu, czy sekwencja zebrana przez metodę log() pasuje do zadanej sekwencji (metoda check(), która będzie wywołana w kodzie uruchamiającym testy).
W powyższym przypadku działalność obiektów klasy Tester ogranicza się do zbierania informacji o kolejności i argumentach wywołania poszczególnych metod. Zauważmy, że odpada nam żmudne sprawdzanie zawartości plików wynikowych. Tu sprawdzenie będzie przerzucone na barki klasy Sequence Checker.
Teraz, zgodnie z zaleceniami XP zabierzemy się za testy. Kod testujący znajdzie się w tym samym module, co nasze klasy. Klasę główną, której zachowanie będziemy testować nazwiemy Batch News. Procedura, która wykona testy będzie nazwana test BatchNews().
def test_BatchNews():
print "test_BatchNews()"
nntp = TestNNTP()
tester = Tester()
batchNews = BatchNews(nntp, "pl.praca.oferowana", 3241)
batchNews.addSubjectKeywordToSearchFor("MECHANIK")
batchNews.addSubjectKeywordToSearchFor("SAMOCHOD")
should(batchNews._subjectMatches("Samochodowy"))
should(batchNews._subjectMatches("Potrzebny mechanik"))
should(batchNews._subjectMatches("Mechanik samochodowy"))
batchNews.downloadMatchingArtykuly(tester,tester)
tester.check([
'saveArticle(3241,Warszawa / mechanik,nr: 3241)',
'progress(50,Warszawa / mechanik),
'saveArticle(3243,Wrocław / samochody - naprawa,nr: 3243)',
'progress(100,Wrocław / samochody - naprawa)'
])
Widzimy tu utworzenie instancji naszych testowych klas Test NNTP i Tester. Obiekt klasy Batch News będzie odczytywał poprzez obiekt nntp wiadomości z grupy pl.praca.oferowana począwszy od wiadomości o numerze 3241.
Kolejne linie to dodanie słów kluczowych, które mają być poszukiwane w nagłówkach (addSubjectKeywordToSearchFor()). Tu słowa kluczowe zapisane zostały dużymi literami, żeby sprawdzić, czy program poszukuje bez zwracania uwagi na wielkość liter (tytuły w testowym zestawie wiadomości miały małe litery). Zwróćmy uwagę na dość długą nazwę metody - opisowa nazwa pozwala na pominięcie dokumentacji słownej. Jest to, być może nie od razu oczywiste, zastosowanie zasady "raz i tylko raz": informacja zapisana w nazwach metod nie powinna być duplikowana w dokumentacji, bo w przypadku zmian będzie trzeba modyfikować w dwóch miejscach. Czytelne nazwy i rozsądna struktura przejmują część zadań dokumentacji projektu.
Następnie wywołamy kilka razy metodę prywatną ( subjectMatches()), żeby upewnić się, że sam mechanizm szukania słów kluczowych działa bez zarzutu. Tu mamy pokazane jak można łączyć testowanie jednostkowe typu black box z testowaniem white box - wywołujemy metodę, która normalnie nie jest widoczna dla zewnętrznego kodu aplikacji.
Nadeszła wreszcie pora, by ściągnąć z serwera pliki. Wykonywane jest to metodą downloadMatchingArtykuly(), która jako argumenty przyjmnie obiekt realizujący zapis danych poprzez metodą saveArticle(), oraz obiekt realizujący uwidacznianie stopnia zaawansowania procesu poprzez metodę advance(). W naszym przypadku oba zadania spełnia obiekt klasy "Tester". Dzięki wydzieleniu tych czynności poza klasę Batch News uzyskuje się architekturę testowalną i przy okazji podatną na modyfikacje w przyszłości.

Konfiguracja podczas testowania
Zgodnie z wcześniej pokazaną implementacja obiekt klasy Tester zachowa nam informację o wywołanych metodach, więc teraz można dokonać sprawdzenia, czy zarejestrowany ślad ("trace") jest identyczny z zakładanym. Zakończenie check() bez rzucenia wyjątku pozwala stwierdzić, że:
- Batch News uwzględnił początkowy numer artykułu i nie zapisywał wcześniejszych, pomimo, że są dostępne.
- We właściwej kolejności zapisał plik i poinformował o tym.
- Wybrał tylko wiadomości pasujące do zadanych słów kluczowych, ignorując przy tym wielkość liter.
- Prawidłowo wskazał procentowe zaawansowanie procesu (50%, potem 100%).
- Treści wiadomości pasowały do nagłówków.
Spróbujmy teraz zaprojektować testy dla klasy, która będzie realizowała zapisywanie wiadomości do plików.
def test_FileSaver():
fs = FileSaver('.')
fs.saveArticle(5678,"Warszawa / <murarz>","Body")
sFile = "005678 Warszawa _ _murarz_.txt"
f = open(sFile,"rt")
should_eq(f.readline(),"Warszawa / <murarz>\n")
should_eq(f.readline(),"\n")
should_eq(f.readline(),"Body")
should_eq(f.readline(),"")
f.close()
os.unlink(sFile);
W zamierzeniu nazwa pliku jest połączeniem numeru (by pliki były prawidłowo posortowane alfabetycznie) oraz tytułu (By po nazwie pliku poznać treść wiadomości). Wywołujemy zapis artykułu ze "złośliwymi" znakami, by sprawdzić, czy te znaki w nazwie pliku zostaną zamieniane na znaki podkreślenia. W pierwszej linii powinien znaleźć się tytuł wiadomości, następnie oddzielona pustą linią jej treść. Oto przykładowa implementacja spełniająca wymagania:
Implementacja
Mając zdefiniowane testy dla systemu możemy zabrać się za najważniejszą klasę w naszym projekcie: Batch News.
class BatchNews:
def __init__(self, nntp, sGroup, nFirstArticle):
self._nntp = nntp
self._keywords = []
(sResponse,sArtykuly,sFirst,sLast,sGroup2) = \
nntp.group(sGroup)
self._nFirst = string.atoi(sFirst)
if self._nFirst < nFirstArticle:
self._nFirst = nFirstArticle
self._nLast = string.atoi(sLast)
Tu widzimy inicjalizację klasy: wczytanie zakresu numerów artykułów, ewentualne pominięcie początkowych artykułów (być może już wcześniej ściągniętych przez użytkownika).
def addSubjectKeywordToSearchFor(self, sText):
self._keywords.append(string.lower(sText))
Taka implementacja dodawania słów kluczowych zapewnia, że w tablicy będą słowa kluczowe zapisane małymi literami (string.lower()). Dla uproszczenia nie rozpatrujemy sprawy polskich liter.
def downloadMatchingArtykuly(self, saver, progress):
nntp = self._nntp
sFirstLast =