Nacházíte se zde: Domů ‣ Ponořme se do Pythonu 3 ‣
Úroveň obtížnosti: ♦♦♦♦♢
❝ After one has played a vast quantity of notes and more notes, it is simplicity that emerges as the crowning reward of art. ❞
(Poté, co jste zahráli ohromné množství not a ještě více not, se jako vrcholná odměna umění objeví jednoduchost.)
— Frédéric Chopin
K chybám dochází, ať se vám to líbí nebo ne. Chyby se objeví navzdory vašemu nejlepšímu úsilí o vytvoření všezahrnujících testů jednotek (unit test). Co vlastně myslím slovem „chyba“? Chybou rozumím testovací případ (test case), který jste ještě nenapsali.
>>> import roman7
>>> roman7.from_roman('') ①
0
InvalidRomanNumeralError
stejně jako jiné posloupnosti znaků, které nevyjadřují platné římské číslo.
Jakmile chybu umíte navodit, měli byste napsat testovací případ (test case) ještě dříve, než ji opravíte. Tím chybu popíšete.
class FromRomanBadInput(unittest.TestCase):
.
.
.
def testBlank(self):
'''from_roman should fail with blank string'''
self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, '') ①
from_roman()
s prázdným řetězcem a ujišťujeme se, že vyvolává výjimku InvalidRomanNumeralError
. Nalezení chyby je obtížnou částí úkolu. Pokud už o ní víme, představuje její otestování snadnou část úkolu.
Protože náš kód obsahuje chybu a protože už máme k dispozici testovací případ, který ji popisuje, dojde k jeho selhání:
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v from_roman should fail with blank string ... FAIL from_roman should fail with malformed antecedents ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ok to_roman should give known result with known input ... ok from_roman(to_roman(n))==n for all n ... ok to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok ====================================================================== FAIL: from_roman should fail with blank string ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest8.py", line 117, in test_blank self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, '') AssertionError: InvalidRomanNumeralError not raised by from_roman ---------------------------------------------------------------------- Ran 11 tests in 0.171s FAILED (failures=1)
Teď už chybu můžeme opravit.
def from_roman(s):
'''convert Roman numeral to integer'''
if not s: ①
raise InvalidRomanNumeralError('Input can not be blank')
if not re.search(romanNumeralPattern, s):
raise InvalidRomanNumeralError('Invalid Roman numeral: {}'.format(s)) ②
result = 0
index = 0
for numeral, integer in romanNumeralMap:
while s[index:index+len(numeral)] == numeral:
result += integer
index += len(numeral)
return result
raise
.
{0}
, kterým se odkazujeme na první parametr metody format()
, můžeme jednoduše použít {}
a Python doplní správný poziční index za nás. Funguje to pro libovolný počet argumentů. První {}
se chápe jako {0}
, druhý výskyt {}
znamená {1}
a tak dále.
you@localhost:~/diveintopython3/examples$ python3 romantest8.py -v from_roman should fail with blank string ... ok ① from_roman should fail with malformed antecedents ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ok to_roman should give known result with known input ... ok from_roman(to_roman(n))==n for all n ... ok to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok ---------------------------------------------------------------------- Ran 11 tests in 0.156s OK ②
Tento přístup k programování opravu chyb nijak neusnadňuje. Jednoduché chyby (jako je tato) vyžadují jednodušší testovací případy, složité chyby povedou k složitým testovacím případům. V prostředí soustředěném kolem testů se může zdát, že oprava chyby trvá déle. Musíme chybu přesně popsat v kódu (tj. musíme napsat testovací případ) a teprve potom ji opravit. Pokud testovací případ hned neprojde, musíme zjistit, zda jsme udělali chybu v opravě, nebo zda je chyba v kódu testovacího případu. Ale z dlouhodobého hlediska se střídavá tvorba testovacího a testovaného kódu vyplatí, protože se tím zvyšuje pravděpodobnost správné opravy chyb napoprvé. S vaším novým testem se také snadno opakovaně spouštějí všechny testy. Proto je málo pravděpodobné, že opravou nového kódu pokazíte původní kód. Dnešní test jednotky (unit test) je zítřejším regresním testem.
⁂
Navzdory vašemu nejlepšímu úsilí o připíchnutí zákazníka k zemi, poté co z něj při bolestivé proceduře zahrnující hrůzné odpornosti (jako jsou nůžky a horký vosk) vytáhnete přesné požadavky... ty požadavky se změní. Většina zákazníků neví, co chce, dokud to neuvidí. A dokonce když už to vidí, nejsou dost dobří na to, aby vyjádřili, co chtějí, tak přesně, aby to k něčemu bylo. A dokonce i když se vyjádří přesně, v příští verzi toho stejně budou chtít víc. Takže v souvislosti s měnícími se požadavky buďte připraveni na úpravy svých testovacích případů (test case).
Dejme tomu, že bychom například chtěli rozšířit rozsah funkce pro převod římských čísel. V římských číslech se žádný znak nemůže opakovat víc než třikrát. Ale Římané byli ochotni připustit výjimku z tohoto pravidla a reprezentovat hodnotu 4000
uvedením čtyř M
za sebou. Pokud takovou změnu provedeme, budeme schopni rozšířit rozsah převáděných čísel z 1..3999
na 1..4999
. Ale nejdříve provedeme úpravy testovacích případů.
class KnownValues(unittest.TestCase):
known_values = ( (1, 'I'),
.
.
.
(3999, 'MMMCMXCIX'),
(4000, 'MMMM'), ①
(4500, 'MMMMD'),
(4888, 'MMMMDCCCLXXXVIII'),
(4999, 'MMMMCMXCIX') )
class ToRomanBadInput(unittest.TestCase):
def test_too_large(self):
'''to_roman should fail with large input'''
self.assertRaises(roman8.OutOfRangeError, roman8.to_roman, 5000) ②
.
.
.
class FromRomanBadInput(unittest.TestCase):
def test_too_many_repeated_numerals(self):
'''from_roman should fail with too many repeated numerals'''
for s in ('MMMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'): ③
self.assertRaises(roman8.InvalidRomanNumeralError, roman8.from_roman, s)
.
.
.
class RoundtripCheck(unittest.TestCase):
def test_roundtrip(self):
'''from_roman(to_roman(n))==n for all n'''
for integer in range(1, 5000): ④
numeral = roman8.to_roman(integer)
result = roman8.from_roman(numeral)
self.assertEqual(integer, result)
4000
. Přidali jsme 4000
(nejkratší), 4500
(druhé nejkratší), 4888
(nejdelší) a 4999
(největší).
to_roman()
s hodnotou 4000
očekávala chyba. Teď se ale rozsah 4000–4999
považuje za správné hodnoty, proto musíme hranici zvýšit na 5000
.
from_roman()
se vstupem 'MMMM'
očekávala chyba. Teď je MMMM
považováno za platné římské číslo. Testovací hodnotu musíme zvětšit na 'MMMMM'
.
1
až 3999
. Rozsah se teď rozšířil, takže cyklus for
musíme upravit, aby se dostal až k 4999
.
Teď máme testovací případy upraveny ve shodě s novými požadavky, ale kód zatím ne. Takže se dá čekat, že některé z testů selžou.
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v from_roman should fail with blank string ... ok from_roman should fail with malformed antecedents ... ok from_roman should fail with non-string input ... ok from_roman should fail with repeated pairs of numerals ... ok from_roman should fail with too many repeated numerals ... ok from_roman should give known result with known input ... ERROR ① to_roman should give known result with known input ... ERROR ② from_roman(to_roman(n))==n for all n ... ERROR ③ to_roman should fail with negative input ... ok to_roman should fail with non-integer input ... ok to_roman should fail with large input ... ok to_roman should fail with 0 input ... ok ====================================================================== ERROR: from_roman should give known result with known input ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest9.py", line 82, in test_from_roman_known_values result = roman9.from_roman(numeral) File "C:\home\diveintopython3\examples\roman9.py", line 60, in from_roman raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s)) roman9.InvalidRomanNumeralError: Invalid Roman numeral: MMMM ====================================================================== ERROR: to_roman should give known result with known input ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest9.py", line 76, in test_to_roman_known_values result = roman9.to_roman(integer) File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman raise OutOfRangeError('number out of range (must be 0..3999)') roman9.OutOfRangeError: number out of range (must be 0..3999) ====================================================================== ERROR: from_roman(to_roman(n))==n for all n ---------------------------------------------------------------------- Traceback (most recent call last): File "romantest9.py", line 131, in testSanity numeral = roman9.to_roman(integer) File "C:\home\diveintopython3\examples\roman9.py", line 42, in to_roman raise OutOfRangeError('number out of range (must be 0..3999)') roman9.OutOfRangeError: number out of range (must be 0..3999) ---------------------------------------------------------------------- Ran 12 tests in 0.171s FAILED (errors=3)
from_roman()
selže v okamžiku, kdy se dostane k hodnotě 'MMMM'
. Funkce from_roman()
si totiž pořád myslí, že jde o neplatné římské číslo.
to_roman()
selže v okamžiku, kdy se narazí na hodnotu 4000
, protože to_roman()
ji stále považuje za hodnotu mimo rozsah.
4000
, protože to_roman()
ji považuje za hodnotu mimo rozsah.
Máme tedy testovací případy, které selhávají v důsledku nových požadavků, a můžeme uvažovat o opravení kódu do odpovídajícího stavu. (Když s psaním testů jednotek (unit test) začínáte, můžete mít divný pocit, že testovaný kód nikdy „nepředbíhá“ testovací případy. Dokud je pozadu, máme pořád nějakou práci před sebou. Jakmile doběhne testovací případy, přestaneme jej upravovat. Jakmile si na to jednou zvyknete, budete se divit, jak jste vůbec dříve mohli programovat bez testů.)
roman_numeral_pattern = re.compile('''
^ # začátek řetězce
M{0,4} # tisíce - 0 až 4 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 to_roman(n):
'''convert integer to Roman numeral'''
if not isinstance(n, int):
raise NotIntegerError('non-integers can not be converted')
if not (0 < n < 5000): ②
raise OutOfRangeError('number out of range (must be 1..4999)')
result = ''
for numeral, integer in roman_numeral_map:
while n >= integer:
result += numeral
n -= integer
return result
def from_roman(s):
.
.
.
from_roman()
nemusíme vůbec upravovat. Změna se týká jen vzorku roman_numeral_pattern. Při podrobnějším pohledu zjistíte, že jsem v první části regulárního výrazu změnil maximální počet nepovinných znaků M
z 3
na 4
. Tím povolíme čísla odpovídající hodnotě až 4999
místo původní 3999
. Samotná funkce from_roman()
je zcela obecná. Zkrátka jen hledá opakující se znaky římského čísla a sčítá odpovídající hodnoty. Nestará se o to, kolikrát se opakují. Dříve nezvládala 'MMMM'
pouze z toho důvodu, že jsme ji explicitně zastavili na základě porovnání s regulárním výrazem.
to_roman()
si vyžádá jen jednu malou změnu v místě kontroly rozsahu. Kde jsme dříve testovali 0 < n < 4000
, budeme teď kontrolovat 0 < n < 5000
. A hlášení o chybě vyvolávané příkazem raise
změníme tak, aby odpovídalo novému povolenému rozsahu (1..4999
místo 1..3999
). Zbytek funkce nemusíme měnit. Nové případy zvládá. (Vesele přidává 'M'
pro každou nalezenou tisícovku. Když dostane 4000
vychrlí 'MMMM'
. Dříve tento případ nezvládala jen proto, že jsme ji explicitně zastavili při kontrole rozsahu.)
Možná pochybujete o tom, že by tyhle dvě malé změny vyřešily vše, co potřebujeme. Nemusíte mi to věřit. Zkontrolujte si to sami.
you@localhost:~/diveintopython3/examples$ python3 romantest9.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 12 tests in 0.203s
OK ①
Při používání obsáhlých testů jednotek nemusíte spoléhat na programátora, který říká: „Věř mi.“
⁂
Na komplexním používání testů jednotek (unit testing) není nejlepší to, jak se cítíte, když všechny testovací případy nakonec projdou, dokonce ani to, jak se cítíte, když vás někdo nařkne, že jste mu pokazili jeho kód, a vy ve skutečnosti můžete dokázat, že tomu tak není. Na testech jednotek je nejlepší věcí to, že vám dává volnost nemilosrdně refaktorizovat.
Refaktorizace je činností, kdy vezmete fungující kód a uděláte z něj ještě lepší. „Lepší“ obvykle znamená „rychlejší“, ale může to taky znamenat „používající méně paměti“ nebo „používající menší diskový prostor“ nebo je prostě „elegantnější“. Refaktorizace je z hlediska dlouhodobého zdraví každého programu důležitá, ať už to znamená cokoliv pro vás, pro váš projekt nebo pro vaše okolí.
V případě našeho kódu bude „lepší“ znamenat jak „rychlejší“, tak „snadněji udržovatelný“. Konkrétně funkce from_roman()
je pomalejší a složitější, než by se mi líbilo. Je to dáno oním velkým, hnusným regulárním výrazem, který se používá pro ověřování, zda jde o římské číslo. Teď si možná pomyslíte: „No jo. Ten regulární výraz sice je velký a střapatý, ale jak jinak by se dalo ověřit, zda je libovolný řetězec platným římským číslem?“
Odpověď zní: Těch čísel je jen 5000. Proč bychom pro ně prostě nemohli vytvořit vyhledávací tabulku? Ta myšlenka se vám bude líbit ještě víc, když zjistíte, že vůbec nebudeme potřebovat regulární výrazy. Při budování vyhledávací tabulky pro převod čísel na římská čísla můžeme současně vytvářet opačnou vyhledávací tabulku pro konverzi římských čísel na celá čísla. Při testu, zda je libovolný řetězec platným římským číslem, budeme mít k dispozici všechna platná římská čísla. „Ověření platnosti“ se redukuje na jedno vyhledání ve slovníku.
A ze všeho nejlepší je, že už máme k dispozici úplnou sadu testů jednotek (unit test). V modulu můžeme vyměnit klidně polovinu kódu, ale testy jednotek zůstanou stejné. To znamená, že můžete dokázat — sami sobě a ostatním —, že nový kód funguje stejně dobře jako ten původní.
class OutOfRangeError(ValueError): pass
class NotIntegerError(ValueError): pass
class InvalidRomanNumeralError(ValueError): pass
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))
to_roman_table = [ None ]
from_roman_table = {}
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000):
raise OutOfRangeError('number out of range (must be 1..4999)')
if int(n) != n:
raise NotIntegerError('non-integers can not be converted')
return to_roman_table[n]
def from_roman(s):
'''convert Roman numeral to integer'''
if not isinstance(s, str):
raise InvalidRomanNumeralError('Input must be a string')
if not s:
raise InvalidRomanNumeralError('Input can not be blank')
if s not in from_roman_table:
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
return from_roman_table[s]
def build_lookup_tables():
def to_roman(n):
result = ''
for numeral, integer in roman_numeral_map:
if n >= integer:
result = numeral
n -= integer
break
if n > 0:
result += to_roman_table[n]
return result
for integer in range(1, 5000):
roman_numeral = to_roman(integer)
to_roman_table.append(roman_numeral)
from_roman_table[roman_numeral] = integer
build_lookup_tables()
Rozdělme si to na stravitelné kousky. Prokazatelně nejdůležitějším řádkem je ten poslední:
build_lookup_tables()
Jistě si všimnete, že jde o volání funkce. Ale není tu žádný obalující příkaz if
. Tady nejde o blok uvnitř if __name__ == '__main__'
. Funkce se zavolá v okamžiku importu modulu. (Zde je důležité vědět, že se moduly importují jen jednou a poté se pamatují ve vyrovnávací paměti (cache). Pokud importujeme už jednou importovaný modul, nic se neděje. Takže uvedený kód bude zavolán jen při prvním importu tohoto modulu.)
Co vlastně funkce build_lookup_tables()
dělá? To jsem rád, že se ptáte.
to_roman_table = [ None ]
from_roman_table = {}
.
.
.
def build_lookup_tables():
def to_roman(n): ①
result = ''
for numeral, integer in roman_numeral_map:
if n >= integer:
result = numeral
n -= integer
break
if n > 0:
result += to_roman_table[n]
return result
for integer in range(1, 5000):
roman_numeral = to_roman(integer) ②
to_roman_table.append(roman_numeral) ③
from_roman_table[roman_numeral] = integer
to_roman()
je definována výše. Vyhledává hodnoty ve vyhledávací tabulce a vrací je. Ale funkce build_lookup_tables()
si pro realizaci převodu vytváří svou vlastní definici funkce to_roman()
(stejnou, jaká se používala v předchozích případech, než jsme přidali vyhledávací tabulku). Uvnitř funkce build_lookup_tables()
se bude volat ta redefinovaná verze funkce to_roman()
. Jakmile funkce build_lookup_tables()
skončí, redefinovaná verze zmizí. Její definice je platná jen lokálně, uvnitř funkce build_lookup_tables()
.
to_roman()
, která ve skutečnosti vytváří římské číslo.
to_roman()
), přidáme číslo a jemu odpovídající římské číslo do obou vyhledávacích tabulek.
Jakmile jsou vyhledávací tabulky naplněny, je zbývající kód jednoduchý a rychlý.
def to_roman(n):
'''convert integer to Roman numeral'''
if not (0 < n < 5000):
raise OutOfRangeError('number out of range (must be 1..4999)')
if int(n) != n:
raise NotIntegerError('non-integers can not be converted')
return to_roman_table[n] ①
def from_roman(s):
'''convert Roman numeral to integer'''
if not isinstance(s, str):
raise InvalidRomanNumeralError('Input must be a string')
if not s:
raise InvalidRomanNumeralError('Input can not be blank')
if s not in from_roman_table:
raise InvalidRomanNumeralError('Invalid Roman numeral: {0}'.format(s))
return from_roman_table[s] ②
to_roman()
provede stejné kontroly hraničních případů (jako dříve) a potom jednoduše najde odpovídající hodnotu ve vyhledávací tabulce a vrátí ji.
from_roman()
je redukována na kontroly a jeden řádek kódu. Už žádné regulární výrazy. Už žádné cykly. Převod na a z římského čísla se složitostí O(1) — tj. v konstantním čase.
Ale funguje to? Proč se ptáte? Jasně že funguje. A můžu to dokázat.
you@localhost:~/diveintopython3/examples$ python3 romantest10.py -v
from_roman should fail with blank string ... ok
from_roman should fail with malformed antecedents ... ok
from_roman should fail with non-string input ... ok
from_roman should fail with repeated pairs of numerals ... ok
from_roman should fail with too many repeated numerals ... ok
from_roman should give known result with known input ... ok
to_roman should give known result with known input ... ok
from_roman(to_roman(n))==n for all n ... ok
to_roman should fail with negative input ... ok
to_roman should fail with non-integer input ... ok
to_roman should fail with large input ... ok
to_roman should fail with 0 input ... ok
----------------------------------------------------------------------
Ran 12 tests in 0.031s ①
OK
to_roman()
a from_roman()
. A protože se při testech provádí několik tisíc volání funkcí (jen samotný kruhový test jich provede 10 000), úspory se rychle nasčítají!
A jak zní ponaučení?
⁂
Unit testing (testování jednotek) představuje mocný koncept, který při správné implementaci vede u dlouhodobých projektů jak k redukci nákladů na údržbu, tak ke zvýšení pružnosti. Současně si ale musíme uvědomit, že testování jednotek není všelék. Napsat dobré testové případy není jednoduchá věc a udržet je v aktuálním stavu vyžaduje disciplínu (zvlášť když zákazníci vřískají, aby byly opraveny kritické chyby). Unit testing není náhradou ostatních forem testování, zahrnujících testování funkčnosti celého systému, integrační testování (tj. test spolupráce jednotek) a uživatelské akceptační testy. Testy jednotek jsou ale přesto rozumné, fungují, a když už je jednou uvidíte v činnosti, budete se divit, jak jste se bez nich mohli obejít.
V pár posledních kapitolách jsme se šířeji zabývali základy, z nichž mnohé dokonce nejsou specifické jen pro Python. Rámce pro testování jednotek (unit testing frameworks) jsou dostupné pro mnoho jazyků a všechny vyžadují, abyste porozuměli týmž konceptům:
© 2001–11 Mark Pilgrim