Zanurkuj w Pythonie/Testowanie zdroworozsądkowe

Dość często zdarza się, że pewien fragment kodu zawiera zbiór funkcji powiązanych ze sobą; zwykle są to funkcje konwertujące, z których pierwsza przekształca A do B, a druga przekształca B do A. W takim przypadku rozsądnie jest utworzyć "zdroworozsądkowe sprawdzenie", dzięki któremu upewnimy się, że możemy przekształcić A do B i z powrotem do A bez utraty dokładności, bez wprowadzania błędów zaokrągleń i bez powodowania jakichkolwiek błędów innego typu.

Rozważmy następujące wymaganie:

  1. Jeśli mamy pewną wartość liczbową, którą przekształcamy na reprezentację w zapisie rzymskim, a tę przekształcamy z powrotem do wartości liczbowej, powinniśmy otrzymać wartość, od której rozpoczynaliśmy przekształcenie. A więc fromRoman(toRoman(n)) == n dla każdego n w przedziale 1..3999.

Przykład 13.5. Testowanie toRoman względem fromRoman

class SanityCheck(unittest.TestCase):        
    def testSanity(self):                    
        """fromRoman(toRoman(n))==n for all n"""
        for integer in range(1, 4000):                                    #(1) #(2)
            numeral = roman.toRoman(integer) 
            result = roman.fromRoman(numeral)
            self.assertEqual(integer, result)                             #(3)
  1. Funkcję range widzieliśmy już wcześniej, z tym, że tutaj wywołana jest ona z dwoma parametrami, dzięki czemu zwraca listę kolejnych liczb całkowitych z przedziału od wartości będącej pierwszym argumentem funkcji (1) do wartości będącej drugim argumentem funkcji (4000), bez tej wartości. Zwróci więc kolejne liczby z przedziału 1..3999, które stanowią zakres poprawnych wartości wejściowych do funkcji konwertującej na notację rzymską.
  2. Jeśli już tu jesteśmy, to wspomnę tylko, że integer nie jest słowem kluczowym języka Python; zostało ono użyte po prostu jako nazwa zmiennej.
  3. Właściwa logika testująca jest oczywista: bierzemy liczbę całkowitą (integer), przekształcamy ją do reprezentacji rzymskiej (numeral), następnie reprezentację tą przekształcamy z powrotem do wartości całkowitej (result) i upewniamy się, że otrzymaliśmy tę samą wartość, od której rozpoczęliśmy przekształcenia. Jeśli nie jest to prawdą, wówczas assertEqual rzuci wyjątek, a test natychmiast zakończy się niepowodzeniem. Jeśli zaś każda liczba po przekształceniach jest równa wartości początkowej to assertEqual zakończy się prawidłowo, również testSanity zakończy się prawidłowo, a test zakończy się powodzeniem.

Ostatnie dwa wymagania różnią się od poprzednich, ponieważ wydają się arbitralne i trywialne zarazem:

  1. Funkcja toRoman powinna zwracać napis reprezentujący liczbę w notacji rzymskiej przy użyciu wyłącznie wielkich liter.
  2. Funkcja fromRoman powinna akceptować na wejściu napisy reprezentujące liczby w notacji rzymskiej pisane wyłącznie wielkimi literami (tj. powinna zakończyć się niepowodzeniem, gdy w napisie wejściowym znajdują się małe litery).

Nie da się ukryć, że wymagania te są trochę arbitralne. Moglibyśmy przecież ustalić, że fromRoman przyjmuje zarówno napisy składające się z małych liter, jak również napisy zawierające zarówno małe, jak i duże litery. Z drugiej strony, wymagania te nie są całkowicie arbitralne: jeśli toRoman zawsze zwraca napisy składające się z wielkich liter, wówczas fromRoman musi akceptować na wejściu przynajmniej te napisy, które składają się wyłącznie z wielkich liter, inaczej "zdroworozsądkowe sprawdzenie" (wymaganie #6) zakończy się niepowodzeniem. Ustalenie, że na wejściu przyjmujemy napisy złożone wyłącznie z wielkich liter, jest arbitralne, jednak - jak potwierdzi to każdy integrator systemów - wielkość znaków ma zawsze znaczenie, a więc warto od razu tę kwestię wyspecyfikować. A skoro warto ją wyspecyfikować, to warto ją również przetestować.

Przykład 13.6. Testowanie wielkości znaków

class CaseCheck(unittest.TestCase):                   
    def testToRomanCase(self):                        
        """toRoman should always return uppercase"""  
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            self.assertEqual(numeral, numeral.upper())                    #(1)

    def testFromRomanCase(self):                      
        """fromRoman should only accept uppercase input"""
        for integer in range(1, 4000):                
            numeral = roman.toRoman(integer)          
            roman.fromRoman(numeral.upper())                              #(2) #(3)
            self.assertRaises(roman.InvalidRomanNumeralError,
                              roman.fromRoman, numeral.lower())           #(4)
  1. Najciekawsze w powyższym teście jest to, jak wielu rzeczy on nie testuje. Nie testuje tego, czy wartość zwrócona przez toRoman jest prawidłowa czy choćby spójna; na te pytania odpowiadają inne przypadki testowe. Ten przypadek testowy sprawdza wyłącznie wielkość liter. Ponieważ zarówno on jak i "sprawdzenie zdroworozsądkowe" przebiegają przez wszystkie wartości z zakresu i wywołują toRoman, to możecie spotkać się z pokusą, aby obydwa te przypadki połączyć w jeden[1]. Jednak działanie takie pogwałciłoby jedną z podstawowych zasad testowania: każdy przypadek testowy powinien odpowiadać na dokładnie jedno pytanie. Wyobraźmy sobie, że połączyliśmy sprawdzenie wielkości liter ze sprawdzeniem zdroworozsądkowym, a nowopowstały przypadek testowy zakończył się niepowodzeniem. W takiej sytuacji stanęlibyśmy przed koniecznością głębszego przeanalizowania tego przypadku, aby dowiedzieć się, w której części testu pojawił się problem, a więc co tak naprawdę owo niepowodzenie oznacza. Jeśli musicie analizować wyniki testów po to, aby dowiedzieć się, co one oznaczają, to jest to oczywisty znak, że wasze przypadki testowe zostały źle zaprojektowane.
  2. Podobną lekcję otrzymujemy w tym miejscu: nawet, jeśli "wiemy", że funkcja toRoman zawsze zwraca wielkie litery, to aby przetestować, że fromRoman przyjmuje napis złożony z wielkich liter, tutaj jawnie przekształcamy wartość wynikową toRoman do wielkich liter. Dlaczego to robimy? Otóż dlatego, że zwracanie przez toRoman wielkich liter wynika z niezależnego wymagania. Jeśli to wymaganie zostanie zmienione tak, że na przykład, funkcja ta będzie zawsze zwracała małe litery, to choć testToRomanCase będzie musiał się zmienić, ten test będzie wciąż działał. To kolejna z podstawowych zasad testowania: każdy przypadek testowy musi działać niezależnie od innych przypadków. Każdy test jest wyspą.
  3. Zauważcie, że wartości zwracanej przez fromRoman nigdzie nie przypisujemy. W języku Python taka składnia jest poprawna; jeśli funkcja zwraca pewną wartość, ale nikt nie jest nią zainteresowany, Python po prostu tę wartość wyrzuca. W tym przypadku właśnie tego chcemy. Ten przypadek testowy w żaden sposób nie testuje wartości zwracanej; testuje jedynie to, czy fromRoman akceptuje napis złożony z wielkich liter i nie rzuca przy tym wyjątku.
  4. Ta linijka, choć skomplikowana, bardzo przypomina to, co zrobiliśmy w testach ToRomanBadInput i FromRomanBadInput. W tym teście upewniamy się, że wywołanie pewnej funkcji (roman.fromRoman) z pewnym szczególnym parametrem (numeral.lower(), bieżąca wartość rzymska pochodząca z pętli, pisana małymi literami) rzuci określony wyjątek (roman.InvalidRomanNumeralError). Jeśli tak się stanie (dla każdej wartości z pętli), test zakończy się powodzeniem; jeśli zaś przynajmniej raz zdarzy się coś innego (zostanie rzucony inny wyjątek lub zostanie zwrócona wartość bez rzucania wyjątku), test zakończy się niepowodzeniem.

W następnym rozdziale zobaczymy, jak napisać kod, który wszystkie te testy przechodzi.


Przypisy

  1. "Oprę się wszystkiemu za wyjątkiem pokusy." --Oscar Wilde