TDD w Pythonie
Test Driven Development (TDD) jest metodą tworzenia oprogramowania, która testowanie aplikacji traktuje jako główny czynnik napędzający rozwój. Inaczej niż w metodach klasycznych, programista najpierw zajmuje się pisaniem testu sprawdzającego działanie danej funkcjonalności, a dopiero potem tą funkcjonalność koduje. Rozwiązanie to ma szereg zalet. Pisanie testu pozwala nam dokładnie sprecyzować czego oczekujemy od aplikacji. Automatyczny test jest niezastąpionym pomocnikiem w fazie implementacji, gdyż na bieżąco śledzić możemy własne postępy. Przydaje się również później, gdy dodając nową funkcjonalność chcemy być pewni, że przy okazji niczego nie psujemy. Oczywiście by metoda była skuteczna, pisanie testów powinno być możliwie szybkie i proste. By zaspokoić tę potrzebę powstało wiele różnych bibliotek i środowisk testowych, pozwalających łatwo tworzyć i uruchamiać duże zestawy testów. Są dostępne praktycznie dla każdego języka programowania i potrafią różnić się tak bardzo, jak same języki. W artykule tym przedstawię proces rozwoju prostego modułu w Pythonie. Na bibliotekę testową wybrałem doctest, jako, że jest ona według mnie najbardziej pythonowa ze wszystkich dostępnych. Charakteryzuje się szczerą prostotą i łatwością użycia, tym samym podążając za duchem języka.
Naszym zadaniem będzie napisanie klasy, która będzie się zachowywać jak posortowany zbiór słów. Obiekty tej klasy jako tekst reprezentowane będą przez słowa rozdzielone pojedynczą spacją. Będzie można ich używać jako implementacji listy tzw. tagów, jakie spotkać można na serwisach takich jak delicious czy Technorati. Używając notacji interpretera Pythona, przykład użycia takiej klasy można zapisać następująco:
>>> T
'python ruby'
>>> T = TagList('ruby python ruby')
>>> T
'python ruby'
>>> T.append('lisp')
>>> T
'lisp python ruby'
Jak widać, inicjować będzie można tę klasę zarówno za pomocą listy słów, jak i za pomocą pojedynczego stringa zawierającego słowa rozdzielone spacjami. Znamy już podstawowe wymagania. Nasza klasa musi:
- automatycznie się sortować
- automatycznie usuwać zduplikowane elementy
- być reprezentowana jako ciąg słów rozdzielonych spacjami
Jako, że każde z tych zadań stanowi wyzwanie samo w sobie, nasz główny cel osiągniemy implementując wymienione podzadania po kolei, jedno po drugim. Najpierw dodamy tekstową reprezentację, następnie pozbywając się duplikatów uczynimy listę zbiorem, na końcu implementując automatyczne sortowanie. Do dzieła.
Uwaga: W tekście pojawiać się będą tylko fragmenty kodu wynikające z aktualnego kontekstu. Wycięte części oznaczone są wielokropkiem. Na końcu artykułu dostępny jest ostateczny kod źródłowy całego modułu.
Pisanie i uruchamianie testów
Zgodnie z opisem TDD najpierw formułujemy swoje wymagania za pomocą testu. Najciekawsze w bibliotece doctest jest to, że testy piszemy za pomocą wyimaginowanych sesji interpretera. Formułujemy więc własne oczekiwania przy pomocy wyrażeń i wartości, jakie chcemy by przyjmowały. Przykładowa sesja interpretera, którą zacytowałem powyżej, jest więc w rzeczywistości naszym pierwszym testem! Testy umieszczamy w tzw. docstringach, czyli stringach umieszczanych zaraz po pierwszej linii definicji funkcji lub klasy, służących jako dokumentacja. Biblioteka doctest sama zajmie się rozpoznaniem i interpretacją tych testów. Definicja naszej klasy wygląda więc następująco:
"""Lista unikalnych słów (tagów).
Listę tagów możemy inicjować za pomocą listy lub stringa.
Wynik jest zawsze posortowany.
>>> T = TagList(['python', 'ruby'])
>>> T
'python ruby'
>>> T = TagList('python ruby python')
>>> T
'python ruby'
>>> T.append('lisp')
>>> T
'lisp python ruby'
"""
pass
Jak widać z przykładu nie musimy się martwić o poziomy wcięcia, czy o rozróżnienie naszych komentarzy od samych testów. Biblioteka doctest bez problemu sobie z tym radzi. Pisanie testów to jedno, ale chcielibyśmy je wreszcie odpalić. Jako że testujemy oddzielny moduł, a nie aplikację, wystarczy dodać na koniec pliku poniższe cztery linijki kodu. Importują one bibliotekę doctest i wskazują jej do przetestowania kod aktualnego modułu.
import doctest
doctest.testmod()
Zapisujemy nasz plik jako taglist.py i odpalamy:
$ python taglist.py ***************************************************************** Failure in example: T from line #5 of __main__.TagList Expected: 'python ruby' Got: ['python', 'ruby'] ***************************************************************** Failure in example: T from line #8 of __main__.TagList Expected: 'python ruby' Got: ['p', 'y', 't', 'h', 'o', 'n', ' ', 'r', 'u', 'b', 'y', ' ', 'p', 'y', 't', 'h', 'o', 'n'] ***************************************************************** Failure in example: T from line #11 of __main__.TagList Expected: 'lisp python ruby' Got: ['p', 'y', 't', 'h', 'o', 'n', ' ', 'r', 'u', 'b', 'y', ' ', 'p', 'y', 't', 'h', 'o', 'n', 'lisp'] ***************************************************************** 1 items had failures: 3 of 6 in __main__.TagList ***Test Failed*** 3 failures.
Spośród wszystkich linii interesują nas najbardziej te informujące o
tym dlaczego dany test się nie powiódł. Są to linie
Expected: i Got:, które mówią o tym
czego się spodziewano (ang. expected), a co zamiast tego
otrzymano (ang. got). W pierszym teście na przykład
spodziewaliśmy się reprezentacji 'python ruby', a
otrzymaliśmy zamiast tego ['python', 'ruby'] (czyli
standardową reprezentację list w Pythonie). Cóż, spróbujmy poprawić
wynik pierwszego testu, dopisując własną metodę zwracającą
reprezentację obiektu. Jej symboliczna nazwa to
__repr__.
# ...
def __repr__(self):
return ' '.join(self)
Łączymy wszystkie elementy listy za pomocą spacji. Wynik testu niestety jest wciąż niezadowalający (wyciąłem wyniki pozostałych testów, jako że są w tej chwili bez znaczenia):
Failure in example: T from line #5 of __main__.TagList Expected: 'python ruby' Got: python ruby
Brakuje cudzysłowów! Jeżeli chcemy, by lista zachowywała się
rzeczywiście jak ciąg znaków, musimy również zdefiniować dla niej metodę
__str__.
Konwersję do ciągu znaków wykonywać więc będziemy właśnie za pomocą
metody __str__, reprezentację obiektu uzyskując poprzez
zwróconego stringa.
# ...
def __repr__(self):
return repr(self.__str__())
def __str__(self):
return ' '.join(self)
Po tych zmianach nasz kod przechodzi pierwszy test:
***************************************************************** Failure in example: T from line #8 of __main__.TagList Expected: 'python ruby' Got: 'p y t h o n r u b y p y t h o n' ***************************************************************** Failure in example: T from line #11 of __main__.TagList Expected: 'lisp python ruby' Got: 'p y t h o n r u b y p y t h o n lisp' ***************************************************************** 1 items had failures: 2 of 6 in __main__.TagList ***Test Failed*** 2 failures.
Bierzemy się za następny. Widać w nim, że pojedyncze znaki są traktowane jako elementy listy. Wynika to z tego, że inicjalizator listy w Pythonie nie traktuje podanego stringa jako całości, ale jako listę znaków. Musimy więc samemu napisać funkcję inicjującą. Jej symboliczna nazwa to __init__.
# ...
def __init__(self, tags=None):
if isinstance(tags, basestring):
self.extend(tags.split())
elif isinstance(tags, list):
self.extend(tags)
Po uruchomieniu testu widać, że reprezentacja już jest prawidłowa, jednak brakuje nam dwóch istotnych elementów: sortowania i usuwania powtarzających się elementów.
***************************************************************** Failure in example: T from line #8 of __main__.TagList Expected: 'python ruby' Got: 'python ruby python' ***************************************************************** Failure in example: T from line #11 of __main__.TagList Expected: 'lisp python ruby' Got: 'python ruby python lisp'
Pisanie kodu
Jako, że drugi test sprawdza, czy lista nie zawiera duplikatów,
zaimplementujemy właśnie pozbywanie się powtarzających się elementów.
Zaczniemy od zdefiniowania własnej metody extend, która
przy dodawaniu pominie elementy już w liście zawarte.
# ...
def extend(self, iterable):
for element in iterable:
if element not in self:
self.append(element)
Jak widać, dodajemy tylko elementy, których na liście jeszcze nie ma.
Uruchomienie testu potwierdza poprawność właśnie napisanego kodu - drugi
test kończy się powodzeniem. Ostatnią rzeczą, jaka nam została jest
uczynienie listy automatycznie sortującą się. Przy okazji mamy obsłużyć
metodę append. Najprostszym sposobem jest wywoływanie
self.sort() po każdej modyfikacji. I ten właśnie sposób
wykorzystamy przy pisaniu metody append.
# ...
def append(self, object):
if object not in self:
list.append(self, object)
self.sort()
Jeżeli uruchomimy teraz test, przekonamy się, że moduł doctest
nie wykryje żadnych błędów. Nasz kod dla podanych przykładów działa
poprawnie. To oczywiście nie znaczy, że jest gotowy. Patrząc na metody
extend i append można zauważyć powtarzający
się warunek sprawdzający czy dodawanego elementu nie ma już na liście.
Jako, że samo extend korzysta z metody append,
warunek może zostać w niej pominięty. extend skraca się
więc do następującej postaci:
# ...
def extend(self, iterable):
for element in iterable:
self.append(element)
Jak po każdej zmianie, tak i po tej uruchamiamy test, by przekonać się, czy nadal wszystko działa jak należy. Szczęśliwie okazuje się to prawdą i możemy przejść dalej.
Cykl rozwoju kodu
Opisany powyżej proces rozwoju klasy TagList jest wzorcowym
przykładem stosowanego TDD. Najpierw napisaliśmy zestaw testów, które
obejmowały
wymaganą funkcjonalność, następnie napisaliśmy kod przechodzący testy z
wynikiem pozytywnym, by wreszcie na końcu skrócić i uogólnić nasz kod,
cały czas pamiętając o sprawdzaniu jego poprawności. Cykl ten można
zwięźle ująć w trzech krokach:
- napisz test
- napisz kod
- zrefaktoruj kod
Te trzy kroki powtarza się tak długo, aż uwzględnimy w naszych testach
całą wymaganą funkcjonalność, a zatem i tak długo, aż nasz kod
poprawnie ją zaimplementuje. Dynamizm tego procesu pozwala bardzo łatwo
i szybko reagować na zmiany w wymaganiach projektu. Do tego mamy
pewność, że każdy napisany kawałek kodu jest sprawdzany przez
odpowiadający mu test. Specyfika biblioteki testującej doctest
czyni zaś testy świetną dokumentacją. Chociaż nie każdy element da się
testować w ten sposób, dla większości zastosowań jest to rozwiązanie
naprawdę dobre. Wystarczy jednak pochwał dla TDD, zajmijmy się dalej
rozwojem klasy TagList.
Pierwsze, co rzuca się w oczy, to fakt, że modyfikować tablicę można nie
tylko przy pomocy metod extend i append.
Często spotyka się wyrażenia wykorzystujące operator dodawania, na
przykładzie czego napiszemy czwarty test.
"""
...
>>> T += ['lisp', 'haskell']
>>> T
'haskell lisp python ruby'
"""
Skrócony operator dodawania z przypisaniem obsługuje magiczna metoda __iadd__. Dokumentacja mówi nam:
x.__iadd__(y) <==> x+=y
Oznacza to, że zapis x += y spowoduje wywołanie metody
__iadd__ na obiekcie x z argumentem
y. Działanie tego operatora dla list jest identyczne jak
wywołanie metody extend, z tą różnicą, że funkcja
__iadd__ musi zwrócić wartość. Implementacja wygląda więc
następująco:
# ...
def __iadd__(self, iterable):
self.extend(iterable)
return self
Uruchomienie testów nie zgłasza błędów, możemy więc przejść do
implementacji kolejnych funkcji. Żeby uczynić klasę kompletną dodamy
obsługę wszystkich pozostałych metod modyfikujących listę. Tym razem
podejdziemy jednak bardziej restrykcyjnie niż do append.
Metoda insert wstawia podany element w określone miejsce
listy. Takie działanie nie ma sensu w przypadku samo sortującej się
listy. Na tej samej zasadzie nie ma sensu operacja odwrócenia kolejności
elementów takiej listy (metoda reverse). Zabronimy również
mnożenia listy przez liczbę (powielania jej elementów), ustawiania
konkretnych elementów (lista[3] = wartość), jak i jej
fragmentów (lista[1:3] = [17,19]). Za te operacje
odpowiadają magiczne metody __imul__, __setitem__
i __setslice__. Najlepszą rzeczą, jaką może zrobić obiekt
przy wywołaniu jednej z niedozwolonych metod, jest rzucenie wyjątkiem. W
tym przypadku będzie to wyjątek
TypeError.
Poniżej zapisałem testy dla każdej z metod.
"""
...
W przypadku wywołania niedozwolnej metody, obiekt rzuci
wyjątkiem TypeError.
>>> T = TagList()
>>> T.insert(3, "wartość")
Traceback (most recent call last):
...
TypeError: Nie można użyć metody insert na obiekcie typu TagList.
>>> T.reverse()
Traceback (most recent call last):
...
TypeError: Nie można użyć metody reverse na obiekcie typu TagList.
>>> T *= 3
Traceback (most recent call last):
...
TypeError: Nie można użyć metody __imul__ na obiekcie typu TagList.
>>> T[0] = "wartość"
Traceback (most recent call last):
...
TypeError: Nie można użyć metody __setitem__ na obiekcie typu TagList.
>>> T[0:2] = [17, 19]
Traceback (most recent call last):
...
TypeError: Nie można użyć metody __setslice__ na obiekcie typu TagList.
"""
Zanim przejdziemy do pisania metod przyjrzyjmy się jeszcze specjalnej
składni, jaką stosuje się przy opisywaniu wyjątków dla modułu
doctest. Najpierw występuje stała linijka
Traceback (most recent call last):, charakteryzująca
wszystkie wyjątki1. Dalej interpreter Pythona
zwyczajowo umieszcza ścieżkę wywołań (ang. stack trace), po
której dopiero dowiedzieć można się o typie i komunikacie wyjątku. Jako,
że kod może zostać wywołany na różne sposoby i z różnych miejsc, dobrze
jest po prostu pomijać ścieżkę wywołań i zastępować ją wielokropkiem.
doctest weźmie wtedy pod uwagę tylko typ i komunikat błędu.
Każda z omawianych metod powinna działać w ten sam sposób, ma rzucić
wyjątkiem TypError o określonym komunikacie błędu. Jako, że
komunikat różni się tylko jednym słowem uogólnimy wszystkie metody
prostym domknięciem (ang. closure):
# ...
def _bad_method(name):
def method(self, *args):
raise TypeError(
"Nie można użyć metody %s na obiekcie typu TagList." % name)
return method
insert = _bad_method("insert")
reverse = _bad_method("reverse")
__imul__ = _bad_method("__imul__")
__setitem__ = _bad_method("__setitem__")
__setslice__= _bad_method("__setslice__")
Funkcja _bad_method zwraca funkcję, która rzuca wyjątkiem o
komunikacie parametryzowanym argumentem name. W ten sposób
osiągnęliśmy nasz cel: wyniki działania wszystkich metod są identyczne
z wyjątkiem jednego słowa w komunikacie błędu. Uruchomienie testu na tak
uzupełnionym kodzie potwierdza nas o jego poprawności. Fazę
refaktoryzacji możemy tym razem pominąć, jako że kod wygląda całkiem
przyzwoicie i nie ma za bardzo co poprawiać.
Ostateczny szlif
Chociaż osiągnęliśmy podstawową funkcjonalność jest jeszcze kilka
szczegółów, które wymagają naszej uwagi. Na początku tego tekstu
zdefiniowaliśmy klasę TagList jako zbiór
słów. Logicznym jest więc, że elementami listy tagów nie
powinny być liczby, czy też inne obiekty nie będące napisami.
Tymczasem nie wprowadziliśmy
jeszcze żadnego sprawdzania typu argumentów przekazywanych do obiektów
typu TagList. Zaraz ten problem naprawimy.
"""
...
Elementy listy mogą być wyłącznie ciągami znaków.
>>> T = TagList([1, 2])
Traceback (most recent call last):
...
TypeError: Elementy listy muszą być stringami.
>>> T = TagList()
>>> T.append('python')
>>> T.append(u'lisp')
>>> T
u'lisp python'
>>> T.append(17)
Traceback (most recent call last):
...
TypeError: Elementy listy muszą być stringami.
>>> T += [42]
Traceback (most recent call last):
...
TypeError: Elementy listy muszą być stringami.
"""
Powyższy test sprawdza dwie rzeczy. Po pierwsze upewnia się, że jeżeli
na liście tagów znajdzie się choć jeden o typie unicode, to
reprezentacja obiektu również będzie w unikodzie. Ten test tak naprawdę
już ma pokrycie w kodzie, tak domyślnie działa łączenie stringów w
Pythonie. Zapisałem go jednak, bo może on uchronić nas przed przyszłymi
zmianami, które pożądane zachowanie mogłyby odwrócić. Pozostałe testy
sprawdzają czy obiekty poprawnie wykrywają próbę przekazania im
elementów innych niż stringi, zarówno na etapie inicjalizacji, jak i w
momencie wywołania metody. W związku z tym, że modyfikację listy typu
TagList dokonujemy wyłącznie za pomocą metody
append, do implementacji sprawdzania typów wystarczy dodać
w odpowiednim miejscu jeden warunek.
# ...
def append(self, object):
if object not in self:
if not isinstance(object, basestring):
raise TypeError("Elementy listy muszą być stringami.")
list.append(self, object)
self.sort()
Ostatnie już dwa szczegóły dotyczą pobierania kawałków (ang.
slices) listy i dodawania do siebie dwóch list, z których co
najmniej jedna jest typu TagList. W obu tych
przypadkach wynik ma być również typu TagList. Napisanie
testów dla tych przypadków jest bardzo proste.
"""
...
Pobieranie wycinka TagListy i dodawanie jej do innej listy
powinno zwracać nową TagListę.
>>> T = TagList('python lisp')
>>> N = T + ['ruby', 'haskell']
>>> N
'haskell lisp python ruby'
>>> isinstance(N, TagList)
True
>>> M = N[1:3]
>>> M
'lisp python'
>>> isinstance(M, TagList)
True
"""
Implementacja dodawania korzysta z magicznej metody __add__, zaś tworzenie wycinków z metody __getslice__. Sam kod zaś jest naprawdę trywialny.
# ...
def __add__(self, iterable):
return TagList(list.__add__(self, iterable))
def __getslice__(self, i, j):
return TagList(list.__getslice__(self, i, j))
Jeżeli zapuścimy test, przekonamy się, że powyższe dwie proste definicje
rzeczywiście załatwiają sprawę. Zanim jednak zakończymy ten krok, warto
wprowadzić drobną korektę do kodu i zastąpić wywołanie konstruktora
TagList poprzez wywołanie za pomocą
self.__class__. Jeżeli tego nie zrobimy, mogą pojawić się
problemy, gdy kiedyś utworzymy nową klasę dziedziczącą po
TagList.
# ...
def __add__(self, iterable):
return self.__class__(list.__add__(self, iterable))
def __getslice__(self, i, j):
return self.__class__(list.__getslice__(self, i, j))
I to już wszystko, klasa TagList jest gotowa. Oprócz
działającego kodu zawiera dokumentację i kompletny zestaw testów. Mieści
się w 127 linijkach, z czego ponad połowę stanowi jej
docstring. Jeżeli w przyszłości będziesz miał zamiar ją
modyfikować, bez strachu będziesz mógł rozszerzać jej definicję,
automatyczne testy czynią testowanie prawdziwą przyjemnością. Chociaż
może się wydawać, że cały proces powoduje wydłużenie czasu pisania kodu,
rzeczywistość jest odmienna. Oszczędzasz sobie bowiem czasu jaki
spędziłbyś nad debugowaniem niedokładnie przetestowanego i
nieudokumentowanego kodu. W bliskiej perspektywie TDD zdaje się być
nieopłacalne, im dłużej jednak zamierzasz rozwijać swój projekt, tym
więcej korzyści odniesiesz z jego stosowania. I to tyczy się nie
tylko Pythona i doctestu, ale praktycznie każdego środowiska
programistycznego, na jakie możesz natrafić. Opisany przeze mnie tandem
jest przykładem elegancji i użyteczności, dlatego polecam go każdemu
programiście. Utopia, jaką może zdawać się przetestowany i
udokumentowany kod, staje się faktem, jeżeli tylko weźmie się do serca
założenia TDD. Gorąco zachęcam do zainteresowania się tematem
nowoczesnych technik rozwoju oprogramowania. Odnośniki do kilku
reprezentatywnych materiałów poniżej.
Odnośniki
Ostateczny kod źrodłowy klasy TagList
Kod umieszczam w domenie publicznej. Można go również pobrać w oddzielnym pliku.
# -*- coding: utf-8 -*-
class TagList(list):
"""Lista unikalnych słów (tagów).
Listę tagów możemy inicjować za pomocą listy lub stringa.
Wynik jest zawsze posortowany.
>>> T = TagList(['python', 'ruby'])
>>> T
'python ruby'
>>> T = TagList('python ruby python')
>>> T
'python ruby'
>>> T.append('lisp')
>>> T
'lisp python ruby'
>>> T += ['lisp', 'haskell']
>>> T
'haskell lisp python ruby'
Elementy listy mogą być wyłącznie ciągami znaków.
>>> T = TagList([1, 2])
Traceback (most recent call last):
...
TypeError: Elementy listy muszą być stringami.
>>> T = TagList()
>>> T.append('python')
>>> T.append(u'lisp')
>>> T
u'lisp python'
>>> T.append(17)
Traceback (most recent call last):
...
TypeError: Elementy listy muszą być stringami.
>>> T += [42]
Traceback (most recent call last):
...
TypeError: Elementy listy muszą być stringami.
Pobieranie wycinka TagListy i dodawanie jej do innej listy
powinno zwracać nową TagListę.
>>> T = TagList('python lisp')
>>> N = T + ['ruby', 'haskell']
>>> N
'haskell lisp python ruby'
>>> isinstance(N, TagList)
True
>>> M = N[1:3]
>>> M
'lisp python'
>>> isinstance(M, TagList)
True
W przypadku wywołania niedozwolnej metody, obiekt rzuci
wyjątkiem TypeError.
>>> T = TagList()
>>> T.insert(3, "wartość")
Traceback (most recent call last):
...
TypeError: Nie można użyć metody insert na obiekcie typu TagList.
>>> T.reverse()
Traceback (most recent call last):
...
TypeError: Nie można użyć metody reverse na obiekcie typu TagList.
>>> T *= 3
Traceback (most recent call last):
...
TypeError: Nie można użyć metody __imul__ na obiekcie typu TagList.
>>> T[0] = "wartość"
Traceback (most recent call last):
...
TypeError: Nie można użyć metody __setitem__ na obiekcie typu TagList.
>>> T[0:2] = [17, 19]
Traceback (most recent call last):
...
TypeError: Nie można użyć metody __setslice__ na obiekcie typu TagList.
"""
def _bad_method(name):
def method(self, *args):
raise TypeError("Nie można użyć metody %s na obiekcie typu TagList." % name)
return method
insert = _bad_method("insert")
reverse = _bad_method("reverse")
__imul__ = _bad_method("__imul__")
__setitem__ = _bad_method("__setitem__")
__setslice__= _bad_method("__setslice__")
def __add__(self, iterable):
return self.__class__(list.__add__(self, iterable))
def __getslice__(self, i, j):
return self.__class__(list.__getslice__(self, i, j))
def __iadd__(self, iterable):
self.extend(iterable)
return self
def __init__(self, tags=None):
if isinstance(tags, basestring):
self.extend(tags.split())
elif isinstance(tags, list):
self.extend(tags)
def __repr__(self):
return repr(self.__str__())
def __str__(self):
return ' '.join(self)
def extend(self, iterable):
for element in iterable:
self.append(element)
def append(self, object):
if object not in self:
if not isinstance(object, basestring):
raise TypeError("Elementy listy muszą być stringami.")
list.append(self, object)
self.sort()
if __name__ == '__main__':
import doctest
doctest.testmod()
Przypisy
1
Prawie wszystkie. Może jeszcze wystąpić
Traceback (innermost last):, ale doctest
poradzi sobie, bez znaczenia którą wersję wybierzesz.
Autor: Michał Kwiatkowski
Data ostatniej modyfikacji: 30 kwietnia 2006
Licencja: Creative Commons Attribution-ShareAlike 2.5
Technorati tags: python programming testing