Testowanie

Synthroid Without Prescription Inderal No Prescription Nexium For Sale Prevacid Generic Buy Elimite Online Prevacid Without Prescription Ultram No Prescription Prevacid For Sale Ultram Generic Buy Prednisone Online

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!

Ćwiczenia na deser