Nacházíte se zde: Domů Ponořme se do Pythonu 3

Úroveň obtížnosti: ♦♦♢♢♢

Unit Testing

Certitude is not the test of certainty. We have been cocksure of many things that were not so.
(Pocit jistoty není měřítkem jistoty. Byli jsme si skálopevně jisti mnoha věcmi, které takové nebyly.)
Oliver Wendell Holmes, Jr.

 

(Ne)ponořme se

Ta dnešní mládež. Jsou tak zkažení těmi rychlými počítači a módními „dynamickými“ jazyky. Rychle napsat, pak dodat a ladit až nakonec (jestli vůbec). Za mých časů jsme dodržovali disciplínu. Říkám disciplínu! Museli jsme psát programy ručně, na papír a cpát je do počítače na děrných štítcích. A ono se nám to líbilo! A cože? Že je ten nadpis anglicky? Buďte rádi, že není v ruštině. Mnozí z vás ani neví, jak přečíst jednotlivá písmenka azbuky. No dobrá, trochu zvážním. Dá se to přeložit jako „testování jednotek“ nebo „jednotkové testování“. Ještě se k tomu dostaneme.

V této kapitole si napíšeme a odladíme pár pomocných funkcí pro konverzi na a z římských čísel. Způsob tvorby a ověřování římských čísel jsme si ukázali v podkapitole Případová studie: Římská čísla. Teď si poodstoupíme a zvážíme, kolik by dalo práce rozšířit původní kód na obousměrné pomocné funkce.

Pravidla pro římská čísla vedla k řadě zajímavých postřehů:

  1. Existuje jen jeden správný způsob vyjádření konkrétního čísla římskými číslicemi.
  2. Platí také opak. Pokud je řetězec znaků platným římským číslem, reprezentuje jen jedno možné číslo (to znamená, že řetězec může být interpretován jen jedním způsobem).
  3. Římskými čísly lze vyjádřit jen omezený rozsah čísel, konkrétně od 1 do 3999. Římané používali několik způsobů vyjádření větších čísel. Tak například pruhem nad římským číslem vyjadřovali, že jeho číselná hodnota musí být vynásobená tisícem. Pro účely této kapitoly budeme uvažovat jen římská čísla od 1 do 3999.
  4. Neexistuje způsob, jak římskými číslicemi vyjádřit nulu.
  5. Neexistuje způsob, jak římskými číslicemi vyjádřit záporná čísla.
  6. Neexistuje způsob, jak římskými číslicemi vyjádřit zlomky nebo neceločíselné hodnoty.

Začněme mapovat, co by takový modul roman.py měl dělat. Bude obsahovat dvě hlavní funkce, to_roman() (na římské číslo) a from_roman() (z římského čísla). Funkce to_roman() by měla převzít celé číslo v intervalu od 1 do 3999 a vrátit jeho reprezentaci římskými číslicemi jako řetězec…

Hned tady se zastavíme. Teď uděláme něco trošku neočekávaného. Napíšeme si testovací příklad, který kontroluje, zda funkce to_roman() dělá to, co po ní chceme. Čtete dobře. Jdeme psát kód, který testuje jiný kód, který jsme ještě nenapsali.

Říká se tomu vývoj řízený testy (test-driven development) nebo TDD. (V anglické literatuře si potrpí na zavádění a používání zkratek.) Dvojice převodních funkcí — to_roman() a později from_roman() — může být napsána a testována jako jednotka (unit), odděleně od jakéhokoliv většího programu, který funkce importuje. V Pythonu najdeme rámec (framework) pro unit testing (tedy testování jednotek), který má podobu příhodně nazvaného modulu unittest.

Unit testing (testování jednotek) představuje důležitou součást celkové vývojové strategie založené na testování. Pokud testy jednotek píšete, je důležité, abyste je napsali brzy a abyste je udržovali v závislosti na změnách kódu a požadavků. Mnozí lidé se přimlouvají za to, aby se testy psaly dříve než kód, který mají testovat. V této kapitole si takový přístup předvedeme. Ale testy jednotek mají své výhody nezávisle na tom, kdy je napíšete.

Jediná otázka

Testovací případ (test case) odpovídá na jedinou otázku, která se testovaného kódu týká. Testovací případ by měl být schopen…

S ohledem na uvedené předpoklady začněme budovat testovací případ pro první požadavek:

  1. Funkce to_roman() by měla vracet reprezentaci římského čísla pro všechna celá čísla v intervalu 13999.

V prvním okamžiku není zřejmé, jak následující kód dělá… no vlastně cokoliv. Definuje třídu, která nemá žádnou metodu __init__(). Třída sice nějakou metodu, ale ta se nikdy nevolá. Celý skript obsahuje blok __main__, ale nenajdeme v něm odkaz ani na třídu, ani na její metodu. Ale on opravdu něco dělá. Za to ručím.

[stáhnout romantest1.py]

import roman1
import unittest

class KnownValues(unittest.TestCase):               
    known_values = ( (1, 'I'),
                     (2, 'II'),
                     (3, 'III'),
                     (4, 'IV'),
                     (5, 'V'),
                     (6, 'VI'),
                     (7, 'VII'),
                     (8, 'VIII'),
                     (9, 'IX'),
                     (10, 'X'),
                     (50, 'L'),
                     (100, 'C'),
                     (500, 'D'),
                     (1000, 'M'),
                     (31, 'XXXI'),
                     (148, 'CXLVIII'),
                     (294, 'CCXCIV'),
                     (312, 'CCCXII'),
                     (421, 'CDXXI'),
                     (528, 'DXXVIII'),
                     (621, 'DCXXI'),
                     (782, 'DCCLXXXII'),
                     (870, 'DCCCLXX'),
                     (941, 'CMXLI'),
                     (1043, 'MXLIII'),
                     (1110, 'MCX'),
                     (1226, 'MCCXXVI'),
                     (1301, 'MCCCI'),
                     (1485, 'MCDLXXXV'),
                     (1509, 'MDIX'),
                     (1607, 'MDCVII'),
                     (1754, 'MDCCLIV'),
                     (1832, 'MDCCCXXXII'),
                     (1993, 'MCMXCIII'),
                     (2074, 'MMLXXIV'),
                     (2152, 'MMCLII'),
                     (2212, 'MMCCXII'),
                     (2343, 'MMCCCXLIII'),
                     (2499, 'MMCDXCIX'),
                     (2574, 'MMDLXXIV'),
                     (2646, 'MMDCXLVI'),
                     (2723, 'MMDCCXXIII'),
                     (2892, 'MMDCCCXCII'),
                     (2975, 'MMCMLXXV'),
                     (3051, 'MMMLI'),
                     (3185, 'MMMCLXXXV'),
                     (3250, 'MMMCCL'),
                     (3313, 'MMMCCCXIII'),
                     (3408, 'MMMCDVIII'),
                     (3501, 'MMMDI'),
                     (3610, 'MMMDCX'),
                     (3743, 'MMMDCCXLIII'),
                     (3844, 'MMMDCCCXLIV'),
                     (3888, 'MMMDCCCLXXXVIII'),
                     (3940, 'MMMCMXL'),
                     (3999, 'MMMCMXCIX'))           

    def test_to_roman_known_values(self):           
        '''to_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       
            self.assertEqual(numeral, result)       

if __name__ == '__main__':
    unittest.main()
  1. Když chceme napsat nějaký testovací případ (test case), musíme nejdříve vytvořit třídu odvozenou od třídy TestCase z modulu unittest. Uvedená třída nám poskytuje řadu užitečných metod, které můžeme v našem testovacím případě využít pro testování specifických podmínek.
  2. Tohle je n-tice dvojic s celým číslem a s římským číslem, které jsem ověřil ručně. Obsahuje deset nejmenších čísel, největší číslo, každé číslo, které se vyjadřuje jednoznakovým římským číslem, a náhodnou sadu dalších platných čísel. Nemusíme testovat každý možný vstup, ale měli bychom se pokusit otestovat všechny zřejmé hraniční případy.
  3. Pro každý jednotlivý test je vytvořena jeho vlastní metoda. Metoda testu nemá žádné parametry, nevrací žádnou hodnotu a její jméno musí začínat čtyřmi písmeny test. Pokud testovací metoda skončí normálně, bez vyvolání výjimky, pokládáme test za úspěšný. Pokud metoda vyvolá výjimku, považujeme to za selhání testu.
  4. Tady voláme skutečnou funkci to_roman(). (Tu funkci jsme zatím nenapsali, ale jakmile ji jednou napíšeme, tento řádek ji zavolá.) Všimněte si, že jsme v tomto okamžiku pro funkci to_roman() definovali aplikační programové rozhraní (API). Musí přebírat celé číslo (převáděné číslo) a vrací řetězec (reprezentaci římského čísla). Pokud by rozhraní funkce bylo jiné, test by selhal. Všimněte si také, že při volání to_roman() žádnou výjimku neodchytáváme. Je to záměrné. Funkce to_roman() by při volání s platným vstupem žádnou výjimku vyvolat neměla a uvedené vstupní hodnoty jsou všechny platné. Pokud to_roman() vyvolá výjimku, bude se to považovat za selhání tohoto testu.
  5. Dejme tomu, že funkce to_roman() byla korektně definována, korektně volána, úspěšně skončila a vrátila výsledek. Pak nám jako poslední krok zbývá zkontrolovat, zda vrátila správnou hodnotu. Jde o obecně používaný dotaz. Ke kontrole, zda se dvě hodnoty shodují, poskytuje třída TestCase metodu assertEqual. Pokud výsledek (result) vrácený funkcí to_roman() neodpovídá očekávané známé hodnotě (numeral), vyvolá assertEqual výjimku a test selže. Pokud se ty dvě hodnoty shodují, neudělá assertEqual nic. Pokud všechny hodnoty vrácené funkcí to_roman() odpovídají očekávaným hodnotám, assertEqual nikdy výjimku nevyvolá, takže metoda test_to_roman_known_values nakonec normálně skončí. To znamená, že funkce to_roman() testem prošla.

Jakmile máme vytvořen testovací případ, začneme psát funkci to_roman(). Nejdříve ji nahradíme prázdnou funkcí a ověříme si, že test selhává. Pokud by test prošel, aniž jsme napsali nějaký kód, pak by testy náš kód vůbec netestovaly! Unit testing je jako tanec: testy vedou, kód následuje. Napište test, který selže, a pak programujte, dokud neprojde.

# roman1.py

def to_roman(n):
    '''convert integer to Roman numeral'''
    pass                                   
  1. V této fázi bychom rádi definovali rozhraní funkce to_roman(), ale nechceme zatím psát žádný kód. (Náš test musí nejdříve selhat.) Prázdné funkčnosti dosáhneme použitím pythonovského vyhrazeného slova pass, které dělá doslova nic.

Spuštění testu zajistíme provedením romantest1.py z příkazového řádku. Pokud jej zavoláme s volbou -v, dosáhneme podrobnějšího výstupu, takže přesně uvidíme, co se při běhu každého testovacího případu děje. S trochou štěstí by váš výstup měl vypadat nějak takto:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)                      
to_roman should give known result with known input ... FAIL            

======================================================================
FAIL: to_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest1.py", line 73, in test_to_roman_known_values
    self.assertEqual(numeral, result)
AssertionError: 'I' != None                                            

----------------------------------------------------------------------
Ran 1 test in 0.016s                                                   

FAILED (failures=1)                                                    
  1. Když skript spustíme, spustí se funkce unittest.main(), která zajistí provedení každého testovacího případu. Každý testovací případ je metodou třídy z romantest1.py. U testovacích tříd se nevyžaduje nějaká zvláštní organizace. Každá z nich může obsahovat jedinou metodu, nebo můžeme mít jednu třídu, která obsahuje množství testovacích metod. Jediným požadavkem je to, že každá testovací třída musí dědit z třídy unittest.TestCase.
  2. Pro každý testovací případ modul unittest vytiskne docstring metody a to, zda test prošel (pass) nebo selhal (fail). Tento test podle očekávání selhal.
  3. Pro každý testovací případ, který selhal, zobrazí unittest trasovací informaci, která přesně ukazuje, co se stalo. V tomto případě vyvolala metoda assertEqual() výjimku AssertionError, protože se očekávalo, že funkce to_roman(1) vrátí 'I', ale nevrátila. (Protože jsme v ní explicitně neuvedli příkaz return, vrátila funkce hodnotu None, což je pythonovský ekvivalent hodnoty null.)
  4. Po detailních výpisech každého testu zobrazí unittest souhrnně, kolik testů se provádělo a jak dlouho to trvalo.
  5. Testovací běh celkově selhal, protože minimálně jeden test neprošel. Pokud testovací případ neprojde, rozlišuje unittest mezi selháním (failure) a chybou (error). Selhání (failure) je důsledkem volání metody assertXYZ, jako je například assertEqual nebo assertRaises, která selhala, protože neplatí předepsaná podmínka nebo nebyla vyvolána očekávaná výjimka. Za chybu (error) se považuje jakýkoliv jiný druh výjimky, která vznikla uvnitř testované kódu nebo v kódu testovacího případu.

A teď už můžeme konečně napsat funkci to_roman().

[stáhnout roman1.py]

roman_numeral_map = (('M',  1000),
                     ('CM', 900),
                     ('D',  500),
                     ('CD', 400),
                     ('C',  100),
                     ('XC', 90),
                     ('L',  50),
                     ('XL', 40),
                     ('X',  10),
                     ('IX', 9),
                     ('V',  5),
                     ('IV', 4),
                     ('I',  1))                 

def to_roman(n):
    '''convert integer to Roman numeral'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     
            result += numeral
            n -= integer
    return result
  1. roman_numeral_map je n-tice n-tic, které definují tři věci: znakovou reprezentaci nejzákladnějších římských čísel, pořadí římských čísel (sestupně od M až po I), hodnotu každého římského čísla. Každá vnitřní n-tice je dvojicí (římské číslo, hodnota). Nejsou zde jen jednoznaková římská čísla. Jsou zde definována i dvojznaková čísla jako CM („o jedno sto méně než jeden tisíc“). Tím se kód funkce to_roman() zjednoduší.
  2. Zde je to místo, kde se bohatá datová struktura roman_numeral_map uplatní, protože díky ní k realizaci odečítacího pravidla nepotřebujeme žádnou speciální logiku. Při převodu na římské číslo jednoduše procházíme strukturou roman_numeral_map a hledáme největší celočíselnou hodnotu, která je menší nebo rovna vstupu. Jakmile ji nalezneme, přidáme její reprezentaci římským číslem na konec výstupu, odečteme odpovídající celočíselnou hodnotu od vstupu, namydlíme, opláchneme, zopakujeme.

Pokud vám pořád není jasné, jak funkce to_roman() pracuje, přidejte na konec cyklu while volání funkce print():


while n >= integer:
    result += numeral
    n -= integer
    print('subtracting {0} from input, adding {1} to output'.format(integer, numeral))

S ladicími příkazy print() vypadá výstup takto:

>>> import roman1
>>> roman1.to_roman(1424)
subtracting 1000 from input, adding M to output
subtracting 400 from input, adding CD to output
subtracting 10 from input, adding X to output
subtracting 10 from input, adding X to output
subtracting 4 from input, adding IV to output
'MCDXXIV'

Takže se zdá, že funkce to_roman() pracuje přinejmenším v tomto ručně zkoušeném případě. Ale projde testovacím případem, který jsme napsali?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok               

----------------------------------------------------------------------
Ran 1 test in 0.016s

OK
  1. Hurá! Funkce to_roman() prošla testovacím případem nazvaným „známé hodnoty“. Není sice všeobsažný, ale prověřil schopnosti funkce celou škálou vstupů, včetně vstupů, které produkují každé jednoznakové římské číslo, největší možný vstup (3999), a vstupu, který produkuje nejdelší možné římské číslo (3888). V tomto okamžiku už můžeme docela důvěřovat tomu, že funkce pracuje pro libovolnou správnou vstupní hodnotu, kterou bychom mohli zadat.

„Správný“ vstup? Hmm. A co takhle chybný vstup?

„Zastav a začni hořet“

Ono ale nestačí, když funkce uspějí při zadání správného vstupu. Musíme otestovat také to, že při chybném vstupu dojde k jejich selhání. Ale nemůže jít o jakýkoliv způsob selhání. Funkce musí selhat očekávaným způsobem.

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  
'MMMMMMMMM'
  1. Tohle určitě není to, co jsme chtěli. Vždyť se dokonce nejedná ani o platné římské číslo! Každé z těchto čísel leží ve skutečnosti mimo rozsah přijatelných vstupů, ale funkce pro ně stejně vrací falešné, vykonstruované hodnoty. Pokud potichu vracíme špatné hodnoty, je to velmi špatné. Pokud má program selhat, pak je mnohem lepší, když selže rychle a nahlas. Jak se říká, „zastav a začni hořet“. (Jde o překlad anglické fráze „Halt And Catch Fire“, která se při práci na úrovních blízkých hardwaru vztahuje k mechanismu velmi dobře pozorovatelného projevu nějaké neočekávané chyby. Vysvětlení původu této hlášky se různí, od skutečně kouřících přežhavených drátků feritové paměti při dynamické realizaci instrukce HALT, až po speciální nedokumentované strojové instrukce, které uvedou procesor do testovacího režimu.) Pythonovská signalizace typu „zastav a začni hořet“ spočívá ve vyvolání výjimky.

Měli byste si položit otázku: „Jak bychom to mohli vyjádřit formou testovatelného požadavku?“ Co kdybychom začali nějak takto:

Pokud funkci to_roman() zadáme celé číslo větší než 3999, měla by vyvolat výjimku OutOfRangeError.

Jak by vypadal příslušný test?

[stáhnout romantest2.py]

import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 
    def test_too_large(self):                                             
        '''to_roman should fail with large input'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  
  1. Podobně jako v předchozím testovacím případě vytvoříme třídu, která dědí z unittest.TestCase. Jedna třída sice může obsahovat více než jeden test (jak si ukážeme v této kapitole později), ale já jsem se rozhodl, že vytvořím novou třídu, protože tento test dělá něco jiného než ten minulý. Všechny testy správných vstupů budeme udržovat v jedné třídě a o všechny testy chybných vstupů se bude starat druhá třída.
  2. Vlastní test, stejně jako v předchozím testovacím případě, má podobu metody třídy. Její jméno začíná písmeny test.
  3. Třída unittest.TestCase poskytuje metodu assertRaises, která přebírá následující argumenty: očekávanou výjimku, testovanou funkci a argumenty, které jí chceme předat. (Pokud testovaná funkce očekává více než jeden argument, předejte je metodě assertRaises všechny v daném pořadí. Ona už se postará o jejich předání testované funkci.)

Věnujte zvláštní pozornost tomu poslednímu řádku kódu. Místo toho, abychom volali to_roman(), přímo a ručně zkontrolovali, že vyvolává konkrétní výjimku (obalením do bloku try...except), metoda assertRaises to vše udělá za nás. Musíme jí jen říct, jakou výjimku očekáváme (roman2.OutOfRangeError), předat funkci (to_roman()) a její argumenty (4000). Metoda assertRaises se postará o zavolání to_roman() a o kontrolu toho, že vyvolala výjimku roman2.OutOfRangeError.

Poznamenejme také, že funkci to_roman() předáváme jako argument. Nevoláme ji a ani nepředáváme její jméno jako řetězec. Zmínil jsem se už dříve o tom, jak je šikovné, že v Pythonu je vše objektem?

Takže co se stane, když spustíme sadu testů doplněnou o tento nový test?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ERROR                         

======================================================================
ERROR: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AttributeError: 'module' object has no attribute 'OutOfRangeError'      

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
  1. Asi jste očekávali, že dojde k selhání (protože zatím jsme nenapsali žádný kód, aby to prošlo), ale... ono to ve skutečnosti „neselhalo“ (fail). Místo toho došlo k „chybě“ (error). Je to sice jemný, ale důležitý rozdíl. Jednotkový test má ve skutečnosti tři návratové hodnoty: prošel (pass), selhal (fail) a chyba (error). „Pass“ (prošel) samozřejmě znamená, že test prošel. Kód dělá to, co jsme očekávali. „Fail“ (selhal) vyjadřuje to, co udělal minulý test (než jsme napsali kód, díky kterému prošel). Kód se provedl, ale výsledek neodpovídá tomu, co jsme očekávali. „Error“ (chyba) se objeví, když kód ani správně nedoběhl.
  2. A proč vlastně kód správně neproběhl? Vše se dozvíme z trasovacího hlášení. Testovaný modul vůbec nedefinuje výjimku zvanou OutOfRangeError (tj. hodnota mimo platný rozsah). Připomeňme si, že uvedenou výjimku jsme předali metodě assertRaises(), protože právě tohle má být výjimka, kterou má funkce vyvolat, když zadáme vstup mimo platný rozsah. Ale tato výjimka vůbec neexistuje, takže volání metody assertRaises() selhalo. Metoda neměla vůbec šanci otestovat funkci to_roman(). Tak daleko se vůbec nedostala.

K vyřešení zmíněného problému musíme v roman2.py doplnit definici výjimky OutOfRangeError.

class OutOfRangeError(ValueError):  
    pass                            
  1. Výjimky mají podobu tříd. Chyba „mimo platný rozsah“ je druhem chyby hodnoty. Hodnota argumentu se nachází mimo přijatelné meze. Z tohoto důvodu výjimka dědí ze zabudované výjimky ValueError. Není to nezbytně nutné (mohli bychom prostě dědit od bázové třídy Exception, tj. obecná výjimka), ale zdá se to být správné.
  2. Výjimky samy o sobě ve skutečnosti nic nedělají, ale potřebujete nejméně jeden řádek kódu, abychom definovali třídu. Volání pass sice nic nedělá, ale je to řádek pythonovského kódu, který zajistí, že třída vznikne.

Teď spustíme sadu testů znovu.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... FAIL                          

======================================================================
FAIL: to_roman should fail with large input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest2.py", line 78, in test_too_large
    self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)
AssertionError: OutOfRangeError not raised by to_roman                 

----------------------------------------------------------------------
Ran 2 tests in 0.016s

FAILED (failures=1)
  1. Nový test sice stále neprošel, ale už také nevrací chybu. Místo toho došlo k selhání testu. To je pokrok! To znamená, že volání metody assertRaises() tentokrát prošlo a rámec pro testování jednotek (unit test framework) skutečně testoval funkci to_roman().
  2. Funkce to_roman() zatím, samozřejmě, nevyvolává právě definovanou výjimku OutOfRangeError, protože jsme jí ještě neřekli, že to má dělat. To je ale výborná zpráva! Znamená to, že máme platný testovací případ — selhává (fails) před napsáním kódu, který zajistí, že projde.

Teď napíšeme kód, který zajistí, aby funkce testem prošla.

[stáhnout roman2.py]

def to_roman(n):
    '''convert integer to Roman numeral'''
    if n > 3999:
        raise OutOfRangeError('number out of range (must be less than 4000)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Přímočaré řešení: Pokud je daný vstup (n) větší než 3999, vyvolej výjimku OutOfRangeError. Tento jednotkový test nekontroluje, zda výjimku doprovází lidsky čitelný řetězec. Mohli bychom napsat další test, který by to kontroloval (ale pozor na problémy s internacionalizací; řetězce se mohou lišit v závislosti na jazyku uživatele a v závislosti na prostředí).

Vede úprava k tomu, že test projde? Pojďme to zjistit.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok                            

----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
  1. Hurá! Oba testy prošly. Protože jsme pracovali po krocích (přebíhali jsme mezi testováním a psaním kódu), můžeme si být jisti, že ty dva řádky kódu, které jsme právě napsali, byly příčinou toho, že se výsledek testu změnil z „fail“ (selhal) na „pass“ (prošel). Tento druh (sebe)důvěry sice nebyl zadarmo, ale během života našeho kódu se ještě vyplatí.

Více zastávek, více ohně

Spolu s testováním čísel, která jsou příliš velká, bychom měli testovat i čísla, která jsou příliš malá. Přesně jak jsme poznamenali v našich požadavcích na funkčnost, římská čísla nemohou vyjádřit nulu nebo záporná čísla.

>>> import roman2
>>> roman2.to_roman(0)
''
>>> roman2.to_roman(-1)
''

Hmm, tohle není dobré. Přidejme testy pro každou z těchto podmínek.

[stáhnout romantest3.py]

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''to_roman should fail with large input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  

    def test_zero(self):
        '''to_roman should fail with 0 input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     

    def test_negative(self):
        '''to_roman should fail with negative input'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    
  1. Metoda test_too_large() se od minulého kroku nezměnila. Ponechal jsem ji zde, abych ukázal, kam nový kód zapadá.
  2. Máme tu nový test, metodu test_zero(). Je to stejné jako u metody test_too_large(). Metodě assertRaises() z třídy unittest.TestCase říkáme, aby zavolala naši funkci to_roman() s parametrem 0 a zkontrolovala, zda vyvolá příslušnou výjimku OutOfRangeError.
  3. Metoda test_negative() je téměř shodná až na to, že funkci to_roman() předává hodnotu -1. Pokud kterýkoliv z těchto nových testů nevyvolá výjimku OutOfRangeError (protože funkce buď vrátí nějakou skutečnou hodnotu nebo vyvolá nějakou jinou výjimku), bude se to považovat za selhání testu.

Teď zkontrolujme, že testy selhávají:

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... FAIL

======================================================================
FAIL: to_roman should fail with negative input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 86, in test_negative
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)
AssertionError: OutOfRangeError not raised by to_roman

======================================================================
FAIL: to_roman should fail with 0 input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest3.py", line 82, in test_zero
    self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)
AssertionError: OutOfRangeError not raised by to_roman

----------------------------------------------------------------------
Ran 4 tests in 0.000s

FAILED (failures=2)

Výborně. Oba testy podle očekávání selhaly. Teď se přepněme na psaní kódu a uvidíme, co můžeme dělat, aby testy prošly.

[stáhnout roman3.py]

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):                                              
        raise OutOfRangeError('number out of range (must be 1..3999)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Tohle je pěkná pythonovská zkratka — více porovnání najednou. Je to ekvivalentní zápisu if not ((0 < n) and (n < 4000)), ale je to mnohem čitelnější. Tento řádek kódu by měl zachytit vstupy, které jsou příliš velké, záporné nebo nulové.
  2. Pokud podmínky změníte, nezapomeňte odpovídajícím způsobem upravit i lidsky čitelný řetězec. Rámci unittest je to jedno. Pokud by ale váš kód vyvolával nesprávně popsané výjimky, ztížilo by se tím ruční ladění.

Mohl bych vám ukázat celou sérii nesouvisejících příkladů, které ukazují, že zkratka umožňující několik porovnání najednou funguje. Místo toho ale spustím testy jednotek a dokážu vám to.

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.016s

OK

A ještě jedna věc…

Mezi požadavky na převod na římská čísla byl ještě jeden, který se týkal neceločíselného vstupu.

>>> import roman3
>>> roman3.to_roman(0.5)  
''
>>> roman3.to_roman(1.0)  
'I'
  1. A jéje, to je špatné.
  2. Jejda, tohle je ještě horší. V obou uvedených případech by měla být vyvolána výjimka. Místo toho produkují falešné výstupy.

Testování na neceločíselný vstup není obtížné. Nejdříve si definujeme výjimku NotIntegerError.

# roman4.py
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass

Dále napíšeme testovací případ, který kontroluje výskyt výjimky NotIntegerError.

class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''to_roman should fail with non-integer input'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)

Teď zkontrolujme, zda test správně selhává.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... FAIL
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

======================================================================
FAIL: to_roman should fail with non-integer input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest4.py", line 90, in test_non_integer
    self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)
AssertionError: NotIntegerError not raised by to_roman

----------------------------------------------------------------------
Ran 5 tests in 0.000s

FAILED (failures=1)

Napíšeme kód, který má zajistit, aby test prošel.

def to_roman(n):
    '''convert integer to Roman numeral'''
    if not (0 < n < 4000):
        raise OutOfRangeError('number out of range (must be 1..3999)')
    if not isinstance(n, int):                                          
        raise NotIntegerError('non-integers can not be converted')      

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Zabudovaná funkce isinstance() testuje, zda je daná proměnná určitého typu (nebo, z technického hlediska, nějakého z něj odvozeného typu).
  2. Pokud argument n není typu int, vyvolej naši zbrusu novou výjimku NotIntegerError.

Nakonec zkontrolujeme, že tento kód zajistil průchod testem.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
to_roman should give known result with known input ... ok
test_negative (__main__.ToRomanBadInput)
to_roman should fail with negative input ... ok
test_non_integer (__main__.ToRomanBadInput)
to_roman should fail with non-integer input ... ok
test_too_large (__main__.ToRomanBadInput)
to_roman should fail with large input ... ok
test_zero (__main__.ToRomanBadInput)
to_roman should fail with 0 input ... ok

----------------------------------------------------------------------
Ran 5 tests in 0.000s

OK

Funkce to_roman() prošla všemi testy a žádné další testy mě nenapadají. Takže nastal čas, abychom se přesunuli k from_roman().

Symetrie, která potěší

Převod řetězce vyjadřujícího římské číslo na číselnou hodnotu vypadá složitěji než převod čísla na římské číslo. Určitě budeme muset zajistit ověření platnosti. Zkontrolovat, zda je číslo rovno nule, je snadné. O něco obtížněji se kontroluje, zda je řetězec platným římským číslem. Jenže my už jsme zkonstruovali regulární výraz, který zkontroluje, zda jde o římské číslo. Takže tuhle část už máme hotovou.

Zbývá nám problém samotné konverze řetězce. Jak za chvíli uvidíme, díky existenci datové struktury, kterou jsme definovali pro převod určitých římských čísel na celočíselné hodnoty, bude jádro funkce from_roman() stejně přímočaré jako u funkce to_roman().

Ale nejdříve testy. Pro ověření správnosti konkrétních hodnot budeme potřebovat test „známých hodnot“. Naše testovací sada již tabulku známých hodnot obsahuje, takže ji využijme.

    def test_from_roman_known_values(self):
        '''from_roman should give known result with known input'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Najdeme zde potěšitelnou symetrii. Funkce to_roman() a from_roman() jsou vzájemně inverzní. První z nich převádí čísla na zvláštně formátované řetězce a druhá převádí zvláštně formátované řetězce na celá čísla. Teoreticky bychom měli být schopni dospět ke zvolenému číslu oklikou tak, že je nejdříve předáme funkci to_roman(). Získaný řetězec předáme funkci from_roman() a výsledné číslo by se mělo shodovat s počátečním.

n = from_roman(to_roman(n)) pro všechny hodnoty n

V tomto případě „všechny hodnoty“ znamená jakoukoliv hodnotu 1..3999, protože toto je platný rozsah vstupů pro funkci to_roman(). Tuto symetrii můžeme vyjádřit testovacím případem, který prochází všechny hodnoty 1..3999, volá to_roman(), volá from_roman() a kontroluje, zda se výstup shoduje s původním vstupem.

class RoundtripCheck(unittest.TestCase):
    def test_roundtrip(self):
        '''from_roman(to_roman(n))==n for all n'''
        for integer in range(1, 4000):
            numeral = roman5.to_roman(integer)
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Tyto nové testy zatím ani neselžou (fail). Zatím jsme vůbec nedefinovali funkci from_roman(), takže způsobí chyby (errors).

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 78, in test_from_roman_known_values
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

======================================================================
ERROR: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 103, in test_roundtrip
    result = roman5.from_roman(numeral)
AttributeError: 'module' object has no attribute 'from_roman'

----------------------------------------------------------------------
Ran 7 tests in 0.019s

FAILED (errors=2)

Problém vyřešíme rychlým vytvořením náhradní funkce.

# roman5.py
def from_roman(s):
    '''convert Roman numeral to integer'''

(Hej, všimli jste si toho? Definoval jsem funkci, která neobsahuje nic než docstring (dokumentační řetězec). Tohle je v Pythonu legální. Někteří programátoři vás ve skutečnosti zapřísahají: „Nepište náhrady. Dokumentujte!“)

Teď už testovací případy opravdu selžou (fail).

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
from_roman should give known result with known input
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 79, in test_from_roman_known_values
    self.assertEqual(integer, result)
AssertionError: 1 != None

======================================================================
FAIL: test_roundtrip (__main__.RoundtripCheck)
from_roman(to_roman(n))==n for all n
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest5.py", line 104, in test_roundtrip
    self.assertEqual(integer, result)
AssertionError: 1 != None

----------------------------------------------------------------------
Ran 7 tests in 0.002s

FAILED (failures=2)

Nastal čas napsat funkci from_roman().

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:  
            result += integer
            index += len(numeral)
    return result
  1. Základní vzorec je zde stejný jako u funkce to_roman(). Procházíme datovou strukturou s římskými čísly (n-tice n-tic), ale místo hledání nejvyšších možných číselných hodnot se snažíme hledat řetězec znaků s „nejvyšším“ možným římským číslem.

Pokud vám pořád není jasné, jak funkce from_roman() pracuje, přidejte na konec cyklu while volání funkce print:

def from_roman(s):
    """convert Roman numeral to integer"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('found', numeral, 'of length', len(numeral), ', adding', integer)
>>> import roman5
>>> roman5.from_roman('MCMLXXII')
found M of length 1, adding 1000
found CM of length 2, adding 900
found L of length 1, adding 50
found X of length 1, adding 10
found X of length 1, adding 10
found I of length 1, adding 1
found I of length 1, adding 1
1972

Nastal opět čas ke spuštění testů.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
.......
----------------------------------------------------------------------
Ran 7 tests in 0.060s

OK

Máme tady dvě vzrušující zprávy. Ta první je, že funkce from_roman() funguje pro správné vstupy — přinejmenším pro všechny známé hodnoty. Ta druhá zpráva je, že test „kruhovým voláním“ (round trip test) také prošel. Když to zkombinujeme dohromady, můžeme si být docela jistí tím, že jak funkce to_roman(), tak funkce from_roman() pracují správně pro všechny možné správné hodnoty. (Není to ale zaručeno. Teoreticky je možné, že to_roman() obsahuje chybu, která pro určité hodnoty vstupů produkuje špatná římská čísla, a současně funkce from_roman() obsahuje obrácenou chybu, která produkuje stejná, ale špatná čísla přesně pro tu množinu římských čísel, která funkce to_roman() vygenerovala nesprávně. V závislosti na vaší aplikaci a na požadavcích by vám to mohlo dělat starosti. Pokud tomu tak je, napište obsažnější testovací případy, které vaše starosti rozptýlí.)

Více špatných vstupů

Teď, když už funkce from_roman() pracuje správně pro korektní vstup, nastal čas k umístění posledního kousku skládanky — zajištění správné funkce pro špatné vstupy. To znamená, že musíme najít způsob, jak se podívat na řetězec a určit, zda je platným římským číslem. To už je ze své podstaty obtížnější než ověřování správnosti číselného vstupu ve funkci to_roman(). Ale máme k dispozici mocný nástroj — regulární výrazy. (Pokud regulární výrazy neznáte, pak je vhodná doba na to, abyste si přečetli kapitolu o regulárních výrazech.)

V podkapitole Případová studie: Římská čísla jsme viděli, že existuje několik jednoduchých pravidel pro konstrukci římského čísla, která jsou založena na využití písmen M, D, C, L, X, V a I. Pojďme si tato pravidla zopakovat:

Takže jeden z užitečných testů bude ověřovat, že by funkce from_roman() měla selhat (fail) v případě, kdy jí předáme řetězec s příliš mnoha opakujícími se římskými číslicemi. Co znamená „příliš mnoho“, závisí na konkrétní číslici.

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''from_roman should fail with too many repeated numerals'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Další užitečný test bude založen na kontrole, že se neopakují některé vzory. Například IX je 9, ale IXIX je vždy neplatné.

    def test_repeated_pairs(self):
        '''from_roman should fail with repeated pairs of numerals'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Třetí test by mohl kontrolovat, zda se číslice objevují ve správném pořadí, od nejvyšších k nejnižším hodnotám. Například CL je 150, ale LC je vždy neplatné, protože číslice pro 50 se nesmí nikdy vyskytovat před číslicí pro 100. Tento test zahrnuje náhodně zvolenou množinu nesprávných předchůdců: I před M, V před X a tak dále.

    def test_malformed_antecedents(self):
        '''from_roman should fail with malformed antecedents'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Každý z těchto testů spoléhá na to, že funkce from_roman() vyvolává novou výjimku InvalidRomanNumeralError, kterou jsme ještě nedefinovali.

# roman6.py
class InvalidRomanNumeralError(ValueError): pass

Všechny tři testy by měly selhat (fail), protože funkce from_roman() momentálně neprovádí žádnou kontrolu platnosti. (Pokud by neselhaly teď, tak co by vlastně testovaly?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
from_roman should fail with malformed antecedents
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 113, in test_malformed_antecedents
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_repeated_pairs (__main__.FromRomanBadInput)
from_roman should fail with repeated pairs of numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 107, in test_repeated_pairs
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

======================================================================
FAIL: test_too_many_repeated_numerals (__main__.FromRomanBadInput)
from_roman should fail with too many repeated numerals
----------------------------------------------------------------------
Traceback (most recent call last):
  File "romantest6.py", line 102, in test_too_many_repeated_numerals
    self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)
AssertionError: InvalidRomanNumeralError not raised by from_roman

----------------------------------------------------------------------
Ran 10 tests in 0.058s

FAILED (failures=3)

Fajn. Teď už do funkce from_roman() potřebujeme přidat jen regulární výraz, který testuje platnost římských čísel.

roman_numeral_pattern = re.compile('''
    ^                   # začátek řetězce
    M{0,3}              # tisíce - 0 až 3 M
    (CM|CD|D?C{0,3})    # stovky - 900 (CM), 400 (CD), 0-300 (0 až 3 C),
                        #        nebo 500-800 (D následované 0 až 3 C)
    (XC|XL|L?X{0,3})    # desítky - 90 (XC), 40 (XL), 0-30 (0 až 3 X),
                        #        nebo 50-80 (L následované 0 až 3 X)
    (IX|IV|V?I{0,3})    # jednotky - 9 (IX), 4 (IV), 0-3 (0 až 3 I),
                        #        nebo 5-8 (V následované 0 až 3 I)
    $                   # konec řetězce
    ''', re.VERBOSE)

def from_roman(s):
    '''convert Roman numeral to integer'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))

    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index : index + len(numeral)] == numeral:
            result += integer
            index += len(numeral)
    return result

A znovu spustíme testy…

you@localhost:~/diveintopython3/examples$ python3 romantest7.py
..........
----------------------------------------------------------------------
Ran 10 tests in 0.066s

OK

A cenu za zklamání roku dostává… slovo „OK“, které modul unittest zobrazí poté, co všechny testy prošly.

© 2001–11 Mark Pilgrim