Testowanie

Dzisiaj rozszerzymy odrobinę tematykę wykładu i zajmiemy się
pisaniem testów dla kodu, który tworzymy. W większości ćwiczeń,
gdzie za zadanie mamy stworzyć funkcję wykonującą jakieś konkretne
przekształcenie autorzy podają przykład jej użycia - przekazane
argumenty i oczekiwany wynik. My zajmiemy się przekształceniem
tych przykładów w wykonywalne specyfikacje, które automatycznie
będą sprawdzać poprawność naszych procedur.

Uwaga techniczna

Do uruchomienia przykładów znajdujących się w tym wpisie będziesz
potrzebował interpretera
mzscheme
i rozszerzenia standardu
srfi-78.
Kod umieść w pliku rational.scm, w tym samym katalogu
co plik check.scm, a na jego początku zamieść instrukcje
wczytujące niezbędne do testowania biblioteki:

(require (lib "23.ss" "srfi") (lib "42.ss" "srfi"))
(load "check.scm")

Program uruchamiaj poleceniem:

mzscheme -r rational.scm

1. Specyfikacja (nie)formalna

Na wykładzie określono interfejs liczb wymiernych poprzez funkcje
make-rat, numer i denom,
zaś oczekiwane ich działanie zostało streszczone w poniższych
dwóch wyrażeniach:

(numer (make-rat n d)) ; => n
(denom (make-rat n d)) ; => d

Mówiąc po ludzku: dla dowolnych liczb całkowitych n
i d, licznik (ang. numerator) ułamka
utworzonego przy pomocy wywołania procedury
(make-rat n d) ma być równy n, zaś
mianownik (ang. denominator) ma być równy
d.

Jako, że wykład skupia się głównie na wykorzystaniu tak uzyskanego
interfejsu ja chciałbym spojrzeć na rzecz od strony
implementatora. Wcielimy się teraz w rolę George’a, który ma
procedury make-rat, numer i
denom napisać. Jeszcze raz przyjrzyjmy się założeniom
jakie nasza implementacja ma spełniać:

  1. procedura make-rat powinna przyjmować dwa
    liczbowe argumenty i zwracać obiekt reprezentujący ułamek
  2. procedura numer powinna przyjmować jeden argument
    będący ułamkiem utworzonym przez make-rat i
    zwracać jego licznik
  3. procedura denom powinna przyjmować jeden argument
    będący ułamkiem utworzonym przez make-rat i
    zwracać jego mianownik

Mając tak dokładnie sprecyzowane wymagania można już usiąść do
implementacji. Ja proponuję jednak chwilę się z tym wstrzymać i
zastanowić się nad sposobem w jaki możemy podejść do, może
nie udowodnienia, ale przetestowania poprawności naszego
przyszłego rozwiązania. Mając pod ręką zestaw prostych testów
obejmujących gamę możliwych argumentów, podejdziemy do
implementacji o wiele pewniej. W miarę jak nasz kod stopniowo
będzie przechodził kolejne testy będziemy pewni, że kierunek w
którym zmierzamy jest właściwy. Kompletność zaś poszczególnych
testów pomoże nam sprostać wymaganiom ustalonej wcześniej
specyfikacji.

2. Pierwsze testy

Do zakodowania testów użyjemy oczywiście samego języka
Scheme, a dokładniej skorzystamy z prostej biblioteki do
testowania opisanej w
SRFI-78: Lightweight testing“.
Wciąż powtarza się tutaj słowo “testowanie”, ja jednak wolę myśleć
o tych kawałkach kodu jako o specyfikacji zapisanej w taki sposób,
że można ją wykonać. Podobnie jak w przedmowie do Wizard Booka
ujęli to autorzy, “możliwość ich wykonania przez maszyny
powinna być traktowana jako coś dodatkowego”
.

Składnia testów zdefiniowanych w SRFI-78 jest bardzo prosta.
W praktyce wystarczy znajomość składni makra check.
Jest ona następująca:

(check wyrażenie-testowane => wartość-oczekiwana)

Podczas testowania wyrażenie jest obliczane
i jego wartość jest porównywana z wartością oczekiwaną. Jeżeli
obie się różnią wyświetlany jest błąd. To wszystko. O tym jak
wykorzystać ten mechanizm w praktyce - poniżej.

Zapis specyfikacji z punktu pierwszego wygląda następująco:

(define rational (make-rat x y))
(check (numer rational) => x)
(check (denom rational) => y)

Powyższy kod jest zapisem naszej specyfikacji z jednym małym
wyjątkiem - brakuje tutaj określenia “dla dowolnego” liczbowego
argumentu. Spróbujmy uwzględnić tę kwestię w naszym teście.

(define (check-rat x y)
(define rational (make-rat x y))
(check (numer rational) => x)
(check (denom rational) => y))

(for-each (lambda (pair-of-args) (apply check-rat pair-of-args))
any-pair-of-arguments)

Pomocnicza procedura check-rat sprawdza poprawność wyników
generowanych przez procedury make-rat, numer
i denom dla zadanych argumentów x
i y. Argumenty te pochodzą z generatora
any-pair-of-arguments. Niestety zbiór liczb całkowitych
jest nieskończony, nie możemy więc wymagać, by nasz program zakończył
testowanie, jeżeli zaimplementujemy any-pair-of-arguments
jako nieskończony zbiór par (x, y). Zmuszeni jesteśmy
ograniczyć testowanie do pewnego skończonego zbioru danych testowych.
Wypróbujmy najpierw prosty dodatni ułamek 1/2:

(define any-pair-of-arguments '((1 2)))

Uruchamiamy test poprzez zwykłe wywołanie naszego skryptu - moduł
srfi-78 i jego makro check zajmują się resztą:

mzscheme -r rational.scm

Otrzymujemy błąd:

reference to undefined identifier: make-rat

Rzeczywiście, nie zdefiniowaliśmy jeszcze żadnej z wymaganych procedur.
Zróbmy to teraz, trochę jednak oszukując, bo nasze procedury będą
zawsze zwracały wartość prawda (#t):

(define (make-rat x y) #t)
(define (numer rational) #t)
(define (denom rational) #t)

Zobaczmy jak zachowa się nasz test:

(numer rational) => #t ; *** failed ***
 ; expected result: 1

(denom rational) => #t ; *** failed ***
 ; expected result: 2

Kod nie spełnia specyfikacji i nasz test to wykrywa. Dobrze,
spróbujmy go naprawić. Zmienię definicję numer
i denom:

(define (numer rational) 1)
(define (denom rational) 2)

Jaki będzie wynik testów?

(numer rational) => 1 ; correct
(denom rational) => 2 ; correct

Test uznaje nasze procedury za poprawne, chociaż my wiemy,
że nasz kod nie jest poprawny. To wyraźny znak, że nasze testy nie są
wystarczające. Dodajmy więc dwa nowe zestawy testowe:

(define any-pair-of-arguments
'((1 2) (3 4) (5 6)))

Tym razem nasz kod powoduje spodziewane błędy:

(numer rational) => 1 ; correct
(denom rational) => 2 ; correct
(numer rational) => 1 ; *** failed ***
 ; expected result: 3
(denom rational) => 2 ; *** failed ***
 ; expected result: 4
(numer rational) => 1 ; *** failed ***
 ; expected result: 5
(denom rational) => 2 ; *** failed ***
 ; expected result: 6

Żeby przejść wszystkie te przypadki testowe nie możemy już oszukiwać
i jesteśmy zmuszeni zapamiętać przekazane do make-rat
liczby. Skorzystamy z przedstawionych na wykładzie par:

(define (make-rat x y) (cons x y))
(define (numer rational) (car rational))
(define (denom rational) (cdr rational))

Gratulacje, nasz kod przechodzi wszystkie testy:

(numer rational) => 1 ; correct
(denom rational) => 2 ; correct
(numer rational) => 3 ; correct
(denom rational) => 4 ; correct
(numer rational) => 5 ; correct
(denom rational) => 6 ; correct

Moduł srfi-78 umożliwia wypisanie podsumowania wszystkich testów.
Wystarczy dodać na koniec pliku następującą linijkę:

(check-report)

3. Skracanie ułamków

W tej chwili mamy już działające procedury do tworzenia ułamków
i związane z nimi automatyczne testy. Zazwyczaj kod i testy
umieszcza się w oddzielnych plikach, wydzielmy więc z pliku
rational.scm wszystko co związane z testami i umieśćmy
w pliku rational-test.scm, pamiętając o dodaniu
dyrektywy wczytującej definicje z oryginalnego pliku
rational.scm: (load "rational.scm").
Po wykonaniu tych czynności pliki rational.scm
i rational-test.scm powinny wyglądać następująco:

;; plik rational.scm

(define (make-rat x y) (cons x y))
(define (numer rational) (car rational))
(define (denom rational) (cdr rational))

;; plik rational-test.scm

(require (lib "23.ss" "srfi") (lib "42.ss" "srfi"))
(load "check.scm")

(load "rational.scm")

(define (check-rat x y)
(define rational (make-rat x y))
(check (numer rational) => x)
(check (denom rational) => y))

(define any-pair-of-arguments
'((1 2) (3 4) (5 6)))

(for-each (lambda (pair-of-args) (apply check-rat pair-of-args))
any-pair-of-arguments)

(check-report)

Mając już gotową podstawową funkcjonalność zajmiemy się jej
stopniowym rozszerzaniem. Zaczniemy od skracania ułamków.
Mówiąc dokładnie, chcemy, by dla ułamka utworzonego przy pomocy
(make-rat 2 4), procedura numer
zwracała 1, zaś procedura denom zwracała 2
(tym samym skracając 2/4 do 1/2). Jak zwykle zacznijmy od
zdefiniowania nowego testu.

(let ((rational (make-rat 2 4)))
(check (numer rational) => 1)
(check (denom rational) => 2))

Wynik testu:

(numer rational) => 2 ; *** failed ***
 ; expected result: 1

(denom rational) => 4 ; *** failed ***
 ; expected result: 2

Czas wrócić do kodu i zaimplementować skracanie. Gdzie to skracanie
zaimplementujemy nie ma znaczenia - możemy skracać zarówno przy
tworzeniu (make-rat), jak i przy odczycie (procedury
numer i denom). Ja pójdę na łatwiznę
i przepiszę z książki skracanie przy tworzeniu. ;-)

(define (make-rat n d)
(let ((g (gcd n d)))
(cons (/ n g) (/ d g))))

Dodajmy jeszcze jeden test dla pewności:

(let ((rational (make-rat 15 25)))
(check (numer rational) => 3)
(check (denom rational) => 5))

Nasz kod wciąż przechodzi wszystkie testy, implementacja jest więc
rzeczywiście poprawna.

W naszych testach można jednak zauważyć nieprzyjemne powtórzenia.
Warto więc poświęcić chwilę na uogólnienie naszych przypadków
testowych. Zmieńmy odrobinę napisaną wcześniej pętlę
for-each tak, by zamiast listy par argumentów
przyjmowała listy argumentów i oczekiwanych wyników.

(define (check-rat arguments expected-numer expected-denom)
(define rational (apply make-rat arguments))
(check (numer rational) => expected-numer)
(check (denom rational) => expected-denom))

(for-each (lambda (args) (apply check-rat args)) test-cases)

Definicja przypadków testowych wyglądać będzie teraz następująco:

(define test-cases
(list (list '(1 2) 1 2)
(list '(3 4) 3 4)
(list '(5 6) 5 6)))

Dodanie ostatnich dwóch przypadków na skracanie jest trywialne:

(define test-cases
(list (list '(1 2) 1 2)
(list '(3 4) 3 4)
(list '(5 6) 5 6)
;; skracanie licznika z mianownikiem
(list '(2 4) 1 2)
(list '(15 25) 3 5)))

4. Normalizacja znaków

Mamy już zdefiniowane i przetestowane procedury dotyczące ułamków
w takiej formie, jak określono je na wykładzie. Teraz zajmiemy się
ich dalszym rozszerzaniem, jako źródło natchnienia traktując
ćwiczenie 2.1.
Naszym zadaniem jest takie zmodyfikowanie procedury make-rat,
by radziła sobie zarówno z dodatnimi, jak i z ujemnymi argumentami.
Dodatkowo znak całego ułamka musi być normalizowany - ułamek ujemny
ma mieć ujemny licznik i dodatni mianownik, zaś ułamek dodatni ma
mieć dodatni zarówno licznik, jak i mianownik. Zapiszmy wszystkie cztery
możliwe przypadki w postaci testów:

;; ...
;; normalizacja znakow
(list '(1 1) 1 1)
(list '(-1 1) -1 1)
(list '(1 -1) -1 1)
(list '(-1 -1) 1 1)))

Gdy uruchomimy nasze testy przekonamy się, że nasz kod przechodzi
pierwsze dwa z nich, generuje jednak błąd dla dwóch ostatnich.
Dwa ostatnie przypadki są więc tymi, które musimy naprawić. Dla
argumentów (1 -1) oczekujemy licznika równego -1
i mianownika równego 1. Dla argumentów (-1 -1)
oczekujemy licznika i mianownika równego 1. Łącząc te dwa przypadki
otrzymujemy zależność: jeżeli drugi argument make-rat
jest ujemny, odwróć znaki obu argumentów
. Ten właśnie warunek
zapiszemy w kodzie.

(define (make-rat n d)
(if (negative? d)
(make-rat (- n) (- d))
(let ((g (gcd n d)))
(cons (/ n g) (/ d g)))))

Gotowe. Wszystkie testy przechodzą bez błędów. Wygląda więc na to,
że udało nam się sprytnie zaimplementować wymaganą funkcjonalność
nie psując niczego po drodze. Pewność, że niczego nie popsuliśmy
dają nam właśnie testy - o wiele szybciej i prościej pisać je
w trakcie pisania samego kodu, niż później debugować błędy
wywołane zmianami w większym systemie. Pisanie testów jest więc
rodzajem inwestycji. Pisanie kodu zajmuje nam teraz trochę więcej
czasu, ale oszczędzamy czas (i nerwy) w przyszłości. Do tego, jak
widzieliśmy przed chwilą, testy pomagają przy samym pisaniu
programu, ułatwiając zdefiniowanie zachowania jakiego oczekujemy
od naszych procedur, a czasami nawet wskazując prostsze sposoby
rozwiązania problemów.

5. Skrajne przypadki

Nasza implementacja ułamków potrafi skracać licznik z mianownikiem
i normalizować ich znak. Brakuje teraz jeszcze tylko jednej rzeczy
- obsługi sytuacji wyjątkowych. Zakładając poprawne typy argumentów
sytuacja taka w przypadku ułamków jest tylko jedna - dzielenie przez
zero. W tej chwili make-rat nie protestuje, gdy jako
mianownik przekażemy mu zero. Procedura powinna jednak zgłosić
błąd przy pomocy standardowej procedury error. By
przetestować sytuację wyjątkową skorzystamy z makra
with-handlers,
które zajmuje się obsługą wyjątków.

(check (with-handlers ((exn? (lambda (e) 'error)))
(make-rat 1 0)) => 'error)

W powyższym wyrażeniu każdy wyjątek rzucony przez make-rat
zostanie przechwycony przez makro
with-handlers,
a wyrażenie przyjmie wartość zwróconą przez podaną funkcję (w naszym
przypadku wartością tą będzie symbol error). Jako, że
spodziewamy się wyjątku, piszemy => 'error.

Uruchomienie testu pokazuje, że spodziewany wyjątek nie został rzucony:

(with-handlers ((exn? (lambda (e) (quote error)))) (make-rat 1 0)) 
    => (1 . 0) ; *** failed ***
 ; expected result: error

Wracamy do definicji make-rat i dopisujemy przypadek
dla mianownika równego zero:

(define (make-rat n d)
(cond ((negative? d)
(make-rat (- n) (- d)))
((zero? d)
(error "Mianownik nie moze byc zerem."))
(else (let ((g (gcd n d)))
(cons (/ n g) (/ d g))))))

Gdy uruchomimy test ponownie przekonamy się, że nasze zadanie
zostało wykonane - wyjątek jest rzucany. Tym samym skutecznie
przetestowaliśmy sytuację wyjątkową.

Podsumowanie

Pisanie automatycznych testów równolegle z kodem ma wiele zalet.
Zbyt mało jest miejsca na tym blogu, by opisać je
wszystkie, zachęcam zatem do zapoznania się z tematem na własną
rekę. Technikę tę nazwano Test Driven
Development (w skrócie TDD), czyli programowanie
sterowane testami. Materiałów po polsku niestety nie ma zbyt wiele,
pozwolę wymienić sobie trzy artykuły. Każdy z nich opisuje testowanie
w innym języku programowania, zahacza jednak (mniej lub bardziej)
również o samą technikę.

Powodzenia w testowaniu!

Komentuj wpis