Itt vagy: Kezdőlap Ugorj fejest a Python 3-ba

Nehézségi szint: ♦♦♢♢♢

Egységtesztelés

A bizonyosság nem egyenlő a bizonysággal. Rengeteg dologban voltunk halálosan biztosak, amelyek másképp voltak.
Oliver Wendell Holmes, Jr.

 

(Ne) ugorj fejest

Ezek a mai fiatalok. Úgy elkényeztetik őket ezek a gyors számítógépek és az elegáns „dinamikus” nyelvek. Írd meg, add ki, keress hibákat (már ha egyáltalán). Az én időmben fegyelem volt. Mondom fegyelem! A programokat kézzel írtuk papírra, és a számítógépbe lyukkártyákon vittük be. És szerettük!

Ebben a fejezetben római számokat oda-vissza átalakító segédfüggvényeket fogsz írni, és megkeresed a hibáikat. Az „Esettanulmány: római számok” fejezetben láttad a római számok összeállításának és ellenőrzésének működését. Most tegyél egy lépést hátra, és gondold át, mi kellene ennek kétirányú segédprogrammá fejlesztéséhez.

A római számok szabályai számos érdekes megfigyelésre vezetnek:

  1. Egy adott számot pontosan egy módon lehet leírni római számként.
  2. Ennek fordítottja is igaz: ha egy karakterlánc érvényes római szám, akkor pontosan egy számot képvisel (azaz csak egyféleképpen értelmezhető).
  3. Római számként csak egy korlátozott tartomány írható le, azaz az 1 és 3999 közti számok. A rómaiak több módon is le tudtak írni nagyobb számokat, például a szám fölé húzott vonallal jelölték, hogy annak normális értékét meg kell szorozni 1000-rel. Ezen fejezet szempontjából elég csak az 1 és 3999 közötti római számokkal foglalkozni.
  4. Római számokkal nem lehet kifejezni a 0-t.
  5. Római számokkal nem lehet kifejezni a negatív számokat.
  6. Római számokkal nem lehet kifejezni a tört vagy nem egész számokat.

Kezdjük el feltérképezni, mit kell egy roman.py modulnak csinálnia. Két fő függvénye lesz, a to_roman() és a from_roman(). A to_roman() függvény egy 1 és 3999 közti egészet vár, és visszaadja a római számokkal írt változatát karakterláncként…

Itt álljunk meg. Csináljunk valami enyhén váratlant: írjunk egy tesztesetet, amely ellenőrzi, hogy a to_roman() függvény azt csinálja-e, amit vársz tőle. Jól olvastad: olyan kódot fogsz írni, amely a még meg sem írt kódodat teszteli.

Ezt tesztvezérelt fejlesztésnek vagy TDD-nek hívják. A két átalakítási függvény – to_roman() és később a from_roman() – megírható és tesztelhető egy egységként, függetlenül bármely nagyobb programtól, amely importálja. A Python rendelkezik egy keretrendszerrel az egységteszteléshez, ezt a unittest modul tartalmazza.

Az egységtesztelés a tesztközpontú fejlesztési stratégia fontos része. Ha egységteszteket írsz, akkor fontos azokat időben megírni, és a követelmények változásával együtt frissíteni. Sokan népszerűsítik a tesztek megírását a tesztelendő kód megírása előtt, és ezt a stílus mutatom be ebben a fejezetben. De az egységtesztek hasznosak, akármikor is írod meg azokat.

Egyetlen kérdés

Egy teszteset egyetlen kérdést válaszol meg az általa tesztelt kóddal kapcsolatban. Egy tesztesetnek képesnek kell lennie...

Ezeket figyelembe véve készítsünk egy tesztesetet az első követelményhez:

  1. A to_roman() függvénynek vissza kell adnia az 1 és 3999 közti egészek római számokkal való ábrázolását.

Nem azonnal nyilvánvaló, hogy az alábbi kód hogyan is csinál… nos, bármit. Definiál egy osztályt, amelynek nincs __init__() metódusa. Az osztály rendelkezik egy másik metódussal, de az soha nem kerül meghívásra. A teljes parancsfájlnak van egy __main__ blokkja, de az nem hivatkozik az osztályra vagy annak a metódusára. De valamit azért csinál, ígérem.

[a romantest1.py letöltése]

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):           
        '''a to_roman ismert eredményt kell adjon ismert bemenetre'''
        for integer, numeral in self.known_values:
            result = roman1.to_roman(integer)       
            self.assertEqual(numeral, result)       

if __name__ == '__main__':
    unittest.main()
  1. Teszteset írásához először is származtass egy osztályt a unittest modul TestCase osztályából. Ez az osztály sok hasznos metódust biztosít, amelyeket a tesztesetedben adott helyzetek tesztelésére használhatsz.
  2. Ez olyan egész/római szám párok tuple-ja, amelyeket saját kezűleg ellenőriztem. Tartalmazza a tíz legkisebb számot, a legnagyobb számot, minden egy karakterből álló római számmal leírható számot, és néhány véletlenszerűen választott érvényes számot. Nem kell minden lehetséges bemenetet letesztelned, de meg kell próbálnod letesztelni az összes nyilvánvaló szélsőséges esetet.
  3. Minden egyes teszt a saját metódusa. A tesztmetódusnak nincsenek paraméterei, nem ad vissza értéket, és nevének a test szóval kell kezdődnie. Ha egy tesztmetódus normálisan lép ki, kivétel dobása nélkül, akkor sikeresnek tekintjük, ha kivételt dob, akkor sikertelennek.
  4. Itt hívod meg a tényleges to_roman() függvényt. (Illetve a függvény még nincs megírva, de ha meg lesz, akkor majd ez a sor fogja meghívni. Figyeld meg, hogy most definiáltad a to_roman() függvény API-ját: egy egész számot (az átalakítandó számot) vár, és egy karakterláncot ad vissza (a római számokkal való ábrázolást). Ha az API ettől eltér, akkor a teszt sikertelennek lesz tekintve. Figyeld meg azt is, hogy nem fogsz el kivételeket a to_roman() hívásakor. Ez szándékos. A to_roman() függvénynek nem kell kivételt dobnia, amikor érvényes bemenettel hívod, és ezek a bemeneti értékek mind érvényesek. Ha a to_roman() kivételt dob, akkor a teszt sikertelennek lesz tekintve.
  5. Feltételezve, hogy a to_roman() függvény helyesen lett definiálva, helyesen lett meghívva, sikeresen befejeződött, és visszaadott egy értéket, az utolsó lépés annak ellenőrzése, hogy a helyes értéket adta-e vissza. Ez egy gyakori kérdés, és a TestCase osztály biztosítja az assertEqual metódust, amely ellenőrzi, hogy a két érték egyenlő-e. Ha a to_roman() által visszaadott eredmény (result) nem egyezik a várt értékkel (numeral), akkor az assertEqual kivételt dob, és a teszt sikertelen lesz. Ha a két érték egyenlő, akkor az assertEqual nem csinál semmit. Ha a to_roman() által visszaadott összes érték egyezik a vár ismert értékkel, akkor az assertEqual soha nem dob kivételt, így a test_to_roman_known_values végül normálisan lép ki, azaz a to_roman() átment ezen a teszten.

Ha már van egy teszteseted, akkor elkezdheted a to_roman() függvény megírását. Először is egy üres csonkot kell létrehoznod, és meg kell győződnöd róla, hogy a tesztek nem sikerülnek. Ha a tesztek az előtt sikerülnek, hogy bármilyen kódot is írtál volna, akkor a tesztjeid egyáltalán nem tesztelik a kódod! Az egységtesztelés egy tánc: a tesztek vezetnek, a kód követi. Írj egy sikertelen tesztet, majd kódolj amíg nem sikerül.

# roman1.py

def to_roman(n):
    '''egész szám római számmá alakítása'''
    pass                                   
  1. Ebben a lépésben definiálni kell a to_roman() függvény API-ját, de még nem kell megírni. (Először a tesztnek sikertelennek kell lennie. A csonk elkészítéséhez használd a Python pass foglalt szavát, amely semmit nem csinál.

Futtasd a romantest1.py fájlt a parancssorból a teszt futtatásához. Ha a -v parancssori kapcsolóval hívod, akkor részletesebb kimenetet ad, így pontosan láthatod, mi történik az egyes tesztesetek futtatásakor. Kis szerencsével a kimenet valahogy így fog kinézni:

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)                      
a to_roman ismert eredményt kell adjon ismert bemenetre ... FAIL            

======================================================================
FAIL: a to_roman ismert eredményt kell adjon ismert bemenetre
----------------------------------------------------------------------
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. A parancsfájl futtatása a unittest.main() metódust futtatja, amely lefuttatja az egyes teszteseteket. Minden teszteset egy metódus a romantest.py egy osztályán belül. A tesztosztályokat nem kell rögzített módon szervezni: mindegyik tartalmazhat egy tesztmetódust vagy többet is. Az egyetlen követelmény, hogy minden egyes tesztosztálynak a unittest.TestCase osztályból kell származnia.
  2. Minden tesztesethez a unittest modul ki fogja írni a metódus docstring-jét, és hogy a teszt sikerült-e. Ahogy vártuk, ez a teszteset nem sikerült.
  3. Minden sikertelen tesztesethez a unittest megjeleníti a nyomkövetési információkat, amelyekből kiderül, hogy mi történt. Ebben az esetben az assertEqual() hívás AssertionError kivételt dobott, mert a to_roman(1) hívásnak az 'I' értéket kellett volna visszaadnia, de nem ez történt. (Mivel nem volt megadva a return utasítás, a függvény a None-t, a Python null értékét adta vissza.)
  4. Az egyes tesztek részletei után a unittest összegzi, hogy hány tesztet hajtott végre, és ez mennyi ideig tartott.
  5. Összességében a teszt futtatása nem sikerült, mert legalább egy teszteset nem volt sikeres. Amikor egy teszteset nem sikeres, akkor a pass, unittest megkülönbözteti a sikertelenséget és a hibát. A sikertelenség egy assertXYZ metódus hívását jelenti, mint az assertEqual vagy assertRaises, amely azért sikertelen, mert a kijelentésben szereplő feltétel nem igaz, vagy a metódus nem dobott egy várt kivételt. A hiba egy tetszőleges típusú egyéb kivétel, amely a tesztelt kódban vagy magában az egységtesztesetben keletkezett.

Most, végre megírhatod a to_roman() függvényt.

[a roman1.py letöltése]

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):
    '''egész szám római számmá alakítása'''
    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:                     
            result += numeral
            n -= integer
    return result
  1. A roman_numeral_map egy tuple-ket tartalmazó tuple, amely három dolgot definiál: a legalapvetőbb római számok karakteres ábrázolását, a római számok sorrendjét (érték szerint csökkenő sorrendben M-től I-ig) és az egyes római számok értékét. Minden belső tuple egy (római szám, érték) pár. Nem csak az egy karakterből álló római számok, hanem két karakterből álló párokat is definiál, mint a CM („százzal kevesebb ezernél”). Ez egyszerűbbé teszi a to_roman() függvény kódját.
  2. Itt fizetődik ki a roman_numeral_map gazdag adatszerkezete, mert nem kell speciális szabály a kivonási szabály kezeléséhez. A római számokká alakításhoz csak lépkedj végig a roman_numeral_map tuple-n a bemenetnél kisebb vagy egyenlő legnagyobb értéket keresve. Ha megvan, add hozzá a római számos ábrázolását a kimenethez, és vond ki a megfelelő egész értéket a bemenetből, és ezt ismételgesd.

Ha még nem teljesen világos, hogyan működik a to_roman() függvény, akkor adj egy print() hívást a while ciklus végéhez:


while n >= integer:
    result += numeral
    n -= integer
    print('{0} kivonása a bemenetből, {1} hozzáadása a kimenethez'.format(integer, numeral))

A hibakeresési print() utasításokkal a kimenet így néz ki:

>>> import roman1
>>> roman1.to_roman(1424)
1000 kivonása a bemenetből, M hozzáadása a kimenethez
400 kivonása a bemenetből, CD hozzáadása a kimenethez
10 kivonása a bemenetből, X hozzáadása a kimenethez
10 kivonása a bemenetből, X hozzáadása a kimenethez
4 kivonása a bemenetből, IV hozzáadása a kimenethez
'MCDXXIV'

Így a to_roman() függvény működni látszik, legalábbis ezen a kézi ellenőrzésen átmegy. De átmegy az általad írt teszteseten is?

you@localhost:~/diveintopython3/examples$ python3 romantest1.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok               

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

OK
  1. Hurrá! A to_roman() függvény átmegy az „ismert értékek” teszteseten. Ez nem átfogó, de ráküldi a függvényt különböző bemenetekre, beleértve az összes egy karakteres római számot, a legnagyobb lehetséges értéket (3999), és a leghosszabb lehetséges római számot (3888) előállító bemeneteket. Ezen a ponton meglehetősen biztos lehetsz abban, hogy a függvény bármely jó bemenetre működik, amit csak meg tudsz neki adni.

„Jó” bemenet? Hmm. Mi van a rossz bemenettel?

„Állj meg, és dobj el mindent”

Nem elengendő azt tesztelni, hogy a függvények működnek-e jó bemenet megadása esetén; arról is meg kell győződnöd, hogy rossz bemenet esetén megszakítják a működésüket. Ráadásul nem „csak úgy” szakítják meg a működésüket, hanem úgy, ahogyan azt várod.

>>> import roman1
>>> roman1.to_roman(4000)
'MMMM'
>>> roman1.to_roman(5000)
'MMMMM'
>>> roman1.to_roman(9000)  
'MMMMMMMMM'
  1. Ez határozottan nem az, amit akartál – ez még csak nem is érvényes római szám! Valójában ezen számok mind kívül esnek az elfogadható bemenet tartományán, de a függvény így is visszaad egy hibás értéket. A hibás értékek szó nélküli visszaadása rooooossz; ha egy program futása sikertelen lesz, akkor jobb, ha ez gyorsan és zajosan következik be. „Állj meg, és dobj el mindent”, ahogy mondani szoktuk. A megállás és mindent eldobás pythonos módja a kivételdobás.

A kérdés, amit fel kell tenned magadnak: „Hogyan fejezhetem ki ezt tesztelhető követelményként?” Kezdésnek mit szólnál ehhez:

A to_roman() függvénynek OutOfRangeError kivételt kell dobnia, ha a kapott egész szám nagyobb, mint 3999.

Hogy nézne ki ez a teszt?

[a romantest2.py letöltése]

import unittest, roman2
class ToRomanBadInput(unittest.TestCase):                                 
    def test_too_large(self):                                             
        '''a to_roman nem engedélyezhet túl nagy bemenetet'''
        self.assertRaises(roman2.OutOfRangeError, roman2.to_roman, 4000)  
  1. Az előző tesztesethez hasonlóan a unittest.TestCase-ből származó osztályt kell létrehoznod. Osztályonként több tesztünk is lehet (ahogyan a fejezetben később látni fogod), de itt új osztály létrehozását választottam, mert ez a teszt jelentősen különbözik az előzőtől. A jó bemenet tesztjeit egy osztályban tartjuk, a rossz bemenet tesztjeit pedig egy másikban.
  2. Az előző tesztesethez hasonlóan a teszt maga az osztály egy metódusa, a neve pedig a test szóval kezdődik.
  3. A unittest.TestCase osztály biztosítja az assertRaises metódust, amely a következő argumentumokat várja: a várt kivétel, a tesztelt függvény, a függvénynek átadott argumentumok. (Ha a tesztelt függvény több argumentumot vár, akkor add át sorrendben az összeset az assertRaises metódusnak, és az át fogja azokat adni a tesztelt függvénnyel együtt.)

Figyeld meg jól ezt az utolsó kódsort. A to_roman() közvetlen hívása és a bizonyos kivétel dobásának kézi ellenőrzése (egy try...except blokkba ágyazással) helyett az assertRaises metódus ezt mind elvégezte nekünk. Mindössze a várt kivételt (roman2.OutOfRangeError), a függvényt (to_roman()) és a függvény argumentumait (4000) kell megnevezni. Az assertRaises metódus elvégzi a to_roman() hívását, és annak ellenőrzését, hogy az dobott-eroman2.OutOfRangeError kivételt.

Vedd észre azt is, hogy a to_roman() függvényt magát argumentumként adod át, nem pedig meghívod, vagy karakterláncként adod át a nevét. Említettem korábban, hogy mennyire kézreálló, hogy Pythonban minden objektum?

Mi történik tehát, ha a tesztcsomagot ezzel az új teszttel együtt futtatod?

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... ERROR                         

======================================================================
ERROR: a to_roman nem engedélyezhet túl nagy bemenetet                          
----------------------------------------------------------------------
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. Számítanod kellett rá, hogy ez nem fog sikerülni, (mivel még nem írtál kódot, amely átmenne a teszten), de... ez nem „sikertelen” volt, hanem „hibát” adott. Ez egy apró, ám fontos különbség. Egy egységtesztnek valójában három visszatérési értéke van: sikeres, sikertelen és hiba. A sikeres természetesen azt jelenti, hogy átment a teszten – a kód azt csinálta, amit vártál. A „sikertelen” az, amilyen az előző teszteset volt (amíg nem írtad meg azt a kódot, ami átment) – a kód végre lett hajtva, de az eredmény nem az lett, amit vártál. A „hiba” azt jelenti, hogy a kód nem is hajtódott végre megfelelően.
  2. Miért nem hajtódott végre megfelelően a kód? A visszakövetés mindent elmond. A tesztelt modul nem rendelkezik OutOfRangeError nevű kivétellel. Emlékezz, ezt a kivételt átadtad az assertRaises() metódusnak, mert ez az a kivétel, amelyet a függvénnyel dobatni szeretnél, ha tartományon kívüli bemenetet kap. Azonban a kivétel nem létezik, így az assertRaises() metódus hívása nem sikerült. Soha nem volt lehetősége a to_roman() függvény tesztelésére, nem jutott el addig.

A probléma megoldásához definiálnod kell az OutOfRangeError kivételt a roman2.py fájlban.

class OutOfRangeError(ValueError):  
    pass                            
  1. A kivételek osztályok. A „tartományon kívüli érték” hiba egy fajta értékhiba – az argumentum értéke kívül esik az elfogadható tartományon. Így ez a kivétel a beépített ValueError (értékhiba) kivételből származik. Ez nem kötelező (származhatna épp az alap Exception osztályból is), de helyesnek érződik.
  2. A kivételek valójában nem csinálnak semmit, de legalább egy sor kell ahhoz, hogy osztályként lehessen kezelni. A pass hívása egész pontosan semmit sem csinál, de ez egy sor Python kód, így megvan az osztály.

Most futtassuk újra a tesztcsomagot.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... FAIL                          

======================================================================
FAIL: a to_roman nem engedélyezhet túl nagy bemenetet
----------------------------------------------------------------------
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. A teszt még mindig nem sikeres, de már nem is ad vissza hibát. Csak sikertelen, ez már haladás! Ez azt jelenti, hogy az assertRaises() metódus ez alkalommal sikeres volt, és az egységtesztelő keretrendszer ténylegesen tesztelte a to_roman() függvényt.
  2. Természetesen a to_roman() függvény még mindig nem dobja az imént definiált OutOfRangeError kivételt, mert még nem írtad meg az ehhez szükséges kódot. Kitűnő hír! Ez azt jelenti, hogy ez egy érvényes teszteset – sikertelen, mielőtt megírnád a kódot, amely átmegy rajta.

Itt az ideje megírni a kódot, amely átmegy a teszten.

[a roman2.py letöltése]

def to_roman(n):
    '''egész szám római számmá alakítása'''
    if n > 3999:
        raise OutOfRangeError('a szám kívül esik a tartományon (4000-nél kisebb kell legyen)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Ez magától értetődő: ha a megadott bemenet (n) nagyobb, mint 3999, akkor dobjon OutOfRangeError kivételt. Az egységteszt nem ellenőrzi a kivételt kísérő, emberek által olvasható karakterláncot, noha írhatnál egy másik tesztet, amely ezt ellenőrzi (de figyelj oda a felhasználó nyelvétől vagy környezetétől függő karakterláncokkal kapcsolatos nemzetköziesítési problémákra).

Ettől vajon átmegy a teszt? Nézzük meg.

you@localhost:~/diveintopython3/examples$ python3 romantest2.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... ok                            

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

OK
  1. Hurrá! Mindkét teszt sikeres volt. Mivel iteratívan dolgoztál, a tesztelés és kódolás között váltogatva, biztos lehetsz abban, hogy az imént írt két sor kód miatt változott a teszt állapota „sikertelenről” „sikeresre”. Ez a fajta magabiztosság nem jön olcsón, de a kód élettartama során megtérül.

Újabb megállás, újabb dobálás

A túl nagy számok tesztelésével együtt tesztelned kell a túl kicsi számokat is. Amint a funkcionális követelmények között megjegyeztük, a római számokkal nem lehet a 0-t vagy negatív számokat kifejezni.

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

Hát ez nem jó. Készítsünk teszteket ezen helyzetek mindegyikére.

[a romantest3.py letöltése]

class ToRomanBadInput(unittest.TestCase):
    def test_too_large(self):
        '''a to_roman nem engedélyezhet túl nagy bemenetet'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 4000)  

    def test_zero(self):
        '''a to_roman nem engedélyezheti a 0 bemenetet'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, 0)     

    def test_negative(self):
        '''a to_roman nem engedélyezhet negatív bemenetet'''
        self.assertRaises(roman3.OutOfRangeError, roman3.to_roman, -1)    
  1. A test_too_large() metódus nem változott az előző lépés óta. Csak azért van itt, hogy lásd, hova kerül az új kód.
  2. Itt egy új teszt: a test_zero() metódus. A test_too_large() metódushoz hasonlóan megadja az unittest.TestCase osztály assertRaises() metódusának, hogy hívja meg a to_roman() függvényt a 0 argumentummal, és ellenőrizze, hogy a megfelelő OutOfRangeError kivételt dobja-e.
  3. A test_negative() metódus majdnem azonos, kivéve hogy a -1 értéket adja át a to_roman() függvénynek. Ha ezen új tesztek valamelyike nem dob OutOfRangeError kivételt (vagy mert a függvény egy tényleges értéket ad vissza, vagy mert valami más kivételt dob), akkor a teszt sikertelennek tekintendő.

Most ellenőrizzük, hogy a tesztek nem sikerülnek:

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_negative (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet negatív bemenetet ... FAIL
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... ok
test_zero (__main__.ToRomanBadInput)
a to_roman nem engedélyezheti a 0 bemenetet ... FAIL

======================================================================
FAIL: a to_roman nem engedélyezhet negatív bemenetet
----------------------------------------------------------------------
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: a to_roman nem engedélyezheti a 0 bemenetet
----------------------------------------------------------------------
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)

Kitűnő. Mindkét teszt sikertelen, ahogy vártuk. Most váltsunk át a kódra, és nézzük meg, hogy mit tehetünk azért, hogy sikerüljenek.

[a roman3.py letöltése]

def to_roman(n):
    '''egész szám római számmá alakítása'''
    if not (0 < n < 4000):                                              
        raise OutOfRangeError('a szám kívül esik a tartományon (1 és 3999 közti kell legyen)')  

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. Ez egy szép pythonos rövidítés: egyszerre több összehasonlítás. Ez egyenértékű a következővel: if not ((0 < n) and (n < 4000)), de sokkal olvashatóbb. Ennek a kódsornak meg kell fognia a túl nagy, negatív vagy nulla bemeneteket.
  2. Ha módosítod a feltételeket, akkor az emberek által olvasható hibaüzeneteket is ezeknek megfelelően módosítsd. A unittest keretrendszert nem érdekli, de megnehezíti a kézi hibakeresést, ha a kódod helytelenül leírt kivételeket dob.

Egész sor független példát hozhatnék arra, hogy a „több összehasonlítás egyszerre” rövidítés működik, de inkább csak lefuttatom az egységteszteket, és bebizonyítom.

you@localhost:~/diveintopython3/examples$ python3 romantest3.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_negative (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet negatív bemenetet ... ok
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... ok
test_zero (__main__.ToRomanBadInput)
a to_roman nem engedélyezheti a 0 bemenetet ... ok

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

OK

És még egy dolog…

Volt még egy működési követelmény a számok római számokká alakításához: a nem egész számok kezelése.

>>> import roman3
>>> roman3.to_roman(0.5)  
''
>>> roman3.to_roman(1.0)  
'I'
  1. Jaj, ez rossz.
  2. Jaj, ez még rosszabb. Mindkét esetnek kivételt kellene dobnia. Ehelyett hibás eredményeket adnak.

A nem egész számok tesztelése nem nehéz. Először egy NotIntegerError kivételt kell definiálni.

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

Ezután írjunk egy tesztesetet, amely a NotIntegerError kivételt ellenőrzi.

class ToRomanBadInput(unittest.TestCase):
    .
    .
    .
    def test_non_integer(self):
        '''a to_roman nem engedélyezhet nem egész bemenetet'''
        self.assertRaises(roman4.NotIntegerError, roman4.to_roman, 0.5)

Most lássuk, hogy a teszt nem sikerül-e, ahogy azt várnánk.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_negative (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet negatív bemenetet ... ok
test_non_integer (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet nem egész bemenetet ... FAIL
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... ok
test_zero (__main__.ToRomanBadInput)
a to_roman nem engedélyezheti a 0 bemenetet ... ok

======================================================================
FAIL: a to_roman nem engedélyezhet nem egész bemenetet
----------------------------------------------------------------------
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)

Írjuk meg a kódot, amelynek hatására a teszt sikerülni fog.

def to_roman(n):
    '''egész szám római számmá alakítása'''
    if not (0 < n < 4000):
        raise OutOfRangeError('a szám kívül esik a tartományon (1 és 3999 közti kell legyen)')
    if not isinstance(n, int):                                          
        raise NotIntegerError('a nem egész számok nem alakíthatók át')      

    result = ''
    for numeral, integer in roman_numeral_map:
        while n >= integer:
            result += numeral
            n -= integer
    return result
  1. A beépített isinstance() függvény teszteli, hogy egy változó adott típusú-e (technikailag: bármely leszármazott típusú-e).
  2. Ha az n argumentum nem int, akkor az újonnan készített NotIntegerError kivételt dobja.

Végül ellenőrizzük, hogy a kód tényleg átmegy-e a teszten.

you@localhost:~/diveintopython3/examples$ python3 romantest4.py -v
test_to_roman_known_values (__main__.KnownValues)
a to_roman ismert eredményt kell adjon ismert bemenetre ... ok
test_negative (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet negatív bemenetet ... ok
test_non_integer (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet nem egész bemenetet ... ok
test_too_large (__main__.ToRomanBadInput)
a to_roman nem engedélyezhet túl nagy bemenetet ... ok
test_zero (__main__.ToRomanBadInput)
a to_roman nem engedélyezheti a 0 bemenetet ... ok

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

OK

A to_roman() függvény átmegy az összes tesztjén, és nem tudok kitalálni több tesztet, úgyhogy ideje a from_roman() függvénnyel folytatni.

Egy kellemes szimmetria

Egy karakterlánc római számból egészszé alakítása nehezebbnek hangzik, mint egy egész átalakítása római számmá. Minden bizonnyal itt van az ellenőrzés problémája. Egyszerű azt ellenőrizni, hogy egy egész nagyobb-e, mint 0, de egy kicsit nehezebb azt ellenőrizni, hogy egy karakterlánc érvényes római szám-e. Azonban már összeállítottunk egy reguláris kifejezést, amely a római számokat ellenőrzi, így ez a rész kész van.

Emiatt már csak a karakterlánc átalakításának problémáját kell megoldani. Ahogy egy perc múlva látni fogjuk, az egyes római számok egész értékekre való leképezéséhez definiált gazdag adatszerkezetnek köszönhetően, a from_roman() függvény igazán kemény része ugyanolyan magától értetődő, mint a to_roman() függvényé.

De előbb a tesztek. Szükségünk lesz egy „ismert értékek” tesztre a pontosság azonnali ellenőrzéséhez. A tesztcsomagunk már tartalmazza ismert értékek leképezését; használjuk újra azt.

    def test_from_roman_known_values(self):
        '''a from_roman ismert eredményt kell adjon ismert bemenetre'''
        for integer, numeral in self.known_values:
            result = roman5.from_roman(numeral)
            self.assertEqual(integer, result)

Van itt egy kellemes szimmetria. A to_roman() és a from_roman() függvények egymás inverzei. Az első egészeket alakít át speciálisan formázott karakterláncokká, a második speciálisan formázott karakterláncokat alakít egészekké. Elméletben képesnek kellene lennünk egy szám „körbejárására”: a to_roman() függvénynek átadva kapott karakterláncot átadva a from_roman() függvénynek ugyanazt az egész számot kellene visszakapnunk.

n = from_roman(to_roman(n)) az n minden értékére

Ebben az esetben a „minden érték” az 1 és 3999 közti tetszőleges számot jelent, mert ez a to_roman() függvény érvényes bemeneti tartománya. Ezt a szimmetriát kifejezhetjük egy olyan tesztesettel, amely végigfut az összes értéken 1..3999 között, meghívja a to_roman(), majd a from_roman() függvényt, és ellenőrzi, hogy a kimenet ugyanaz-e, mint az eredeti bemenet.

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

Ezek az új tesztek sikertelenek sem lesznek. Még egyáltalán nem definiáltunk from_roman() nevű függvényt, így eredményül csupán hibákat kapunk.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
E.E....
======================================================================
ERROR: test_from_roman_known_values (__main__.KnownValues)
a from_roman ismert eredményt kell adjon ismert bemenetre
----------------------------------------------------------------------
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 minden n-re
----------------------------------------------------------------------
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)

Egy gyors függvénycsonk megoldja ezt a problémát.

# roman5.py
def from_roman(s):
    '''római számok egésszé alakítása'''

(Hé, figyeled ezt? Egy olyan függvényt definiáltam, amely nem tartalmaz semmit, csak egy docstringet. Ez érvényes Python. Tulajdonképpen néhány programozó esküszik rá. „Ne csonkolj, dokumentálj!”)

Most a tesztesetek ténylegesen sikertelenek lesznek.

you@localhost:~/diveintopython3/examples$ python3 romantest5.py
F.F....
======================================================================
FAIL: test_from_roman_known_values (__main__.KnownValues)
a from_roman ismert eredményt kell adjon ismert bemenetre
----------------------------------------------------------------------
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 minden n-re
----------------------------------------------------------------------
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)

Most ideje megírni a from_roman() függvényt.

def from_roman(s):
    """római számok egésszé alakítása"""
    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. Itt a minta ugyanaz, mint a to_roman() függvénynél. Végiglépkedsz a római számok adatszerkezetén (tuple-k tuple-je), de a legmagasabb értékek lehető leggyakoribb illesztése helyett a „legmagasabb” értékű római számot képviselő karakterláncot illeszted olyan gyakran, amennyire csak lehetséges.

Ha nem teljesen világos, hogyan működik a from_roman(), akkor adj egy print utasítást a while ciklus végéhez:

def from_roman(s):
    """római számok egésszé alakítása"""
    result = 0
    index = 0
    for numeral, integer in roman_numeral_map:
        while s[index:index+len(numeral)] == numeral:
            result += integer
            index += len(numeral)
            print('Megtalálva:', numeral, 'hossza:', len(numeral), ', hozzáadva:', integer)
>>> import roman5
>>> roman5.from_roman('MCMLXXII')
Megtalálva: M hossza: 1, hozzáadva: 1000
Megtalálva: CM hossza: 2, hozzáadva: 900
Megtalálva: L hossza: 1, hozzáadva: 50
Megtalálva: X hossza: 1, hozzáadva: 10
Megtalálva: X hossza: 1, hozzáadva: 10
Megtalálva: I hossza: 1, hozzáadva: 1
Megtalálva: I hossza: 1, hozzáadva: 1
1972

Ideje újrafuttatni a teszteket.

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

OK

Két izgalmas hír van itt. Az első, hogy a from_roman() függvény jó bemenet esetén működik, legalábbis az összes ismert érték esetén. A második, hogy a „körbejárás” teszt is sikeres volt. Az ismert értékek teszttel kombinálva meglehetősen biztos lehetsz abban, hogy mind a to_roman(), mind a from_roman() függvény megfelelően működik az összes lehetséges jó értékre. (Erre nincs garancia, elméletileg előfordulhat, hogy a to_roman() olyan programhibát tartalmaz, amely hibás római számokat állít elő bemenetek bizonyos halmazához, és hogy a from_roman() olyan inverz programhibát tartalmaz, amely ugyanezeket a hibás egész értékeket állítja elő pontosan ugyanazon római számok halmazához, mint amelyeket a to_roman() helytelenül állított elő. Az alkalmazástól és a követelményektől függően ez a lehetőség gondot okozhat, ebben az esetben írj átfogóbb teszteseteket, amíg a probléma meg nem szűnik.

Még több rossz bemenet

Most hogy a from_roman() függvény megfelelően működik jó bemenet esetén, ideje helyére illeszteni a kirakós utolsó darabját: tegyük megfelelően működővé rossz bemenettel is. Ehhez olyan módszert kell találnunk, amellyel egy karakterláncról ránézésre megállapítható, hogy érvényes római számok-e. Ez eredendően nehezebb, mint a numerikus bemenet ellenőrzése a to_roman() függvényben, de rendelkezésedre áll egy hatékony eszköz: a reguláris kifejezések. (Ha nem ismered a reguláris kifejezéseket, akkor itt az ideje elolvasni a reguláris kifejezések fejezetet.)

Amint az Esettanulmány: római számok fejezetben láthattad, néhány egyszerű szabály vonatkozik a római számok előállítására az M, D, C, L, X, V és I betűk használatával. Tekintsük át a szabályokat:

Emiatt egy hasznos tesztnek biztosítania kell, hogy a from_roman() függvény nem fut le, ha egy túl sok ismétlődő karaktert tartalmazó karakterláncot kap. Hogy mennyi a „túl sok”, az a római számtól függ.

class FromRomanBadInput(unittest.TestCase):
    def test_too_many_repeated_numerals(self):
        '''a from_roman nem engedélyezhet túl sok ismétlődő karaktert'''
        for s in ('MMMM', 'DD', 'CCCC', 'LL', 'XXXX', 'VV', 'IIII'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Egy másik hasznos teszt lehet annak ellenőrzése, hogy bizonyos minták nem ismétlődnek. Az IX például 9, de az IXIX soha nem érvényes.

    def test_repeated_pairs(self):
        '''a from_roman nem engedélyezhet ismétlődő számpárokat'''
        for s in ('CMCM', 'CDCD', 'XCXC', 'XLXL', 'IXIX', 'IVIV'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Egy harmadik teszt ellenőrizhetné, hogy a római számjegyek a helyes sorrendben jelennek-e meg, a legmagasabbtól a legalacsonyabb értékig. A CL például 150, de az LC soha nem érvényes, mert az 50-nek megfelelő karakter soha nem állhat a 100-nak megfelelő előtt. Ez a teszt tartalmazza érvénytelen előtagok véletlenül kiválasztott halmazát: I az M előtt, V az X előtt, stb.

    def test_malformed_antecedents(self):
        '''a from_roman nem engedélyezhet rosszul formázott előtagokat'''
        for s in ('IIMXCC', 'VX', 'DCM', 'CMM', 'IXIV',
                  'MCMC', 'XCX', 'IVI', 'LM', 'LD', 'LC'):
            self.assertRaises(roman6.InvalidRomanNumeralError, roman6.from_roman, s)

Ezen tesztek mindegyike arra épül, hogy a from_roman() függvény egy új, InvalidRomanNumeralError kivételt dob, ezt azonban még nem definiáltuk.

# roman6.py
class InvalidRomanNumeralError(ValueError): pass

A három teszt egyike sem lehet sikeres, mert a from_roman() függvény még egyáltalán nem tartalmaz érvényesség-ellenőrzést. (Ha sikeresek lennének, akkor mi a fenét tesztelnének?)

you@localhost:~/diveintopython3/examples$ python3 romantest6.py
FFF.......
======================================================================
FAIL: test_malformed_antecedents (__main__.FromRomanBadInput)
a from_roman nem engedélyezhet rosszul formázott előtagokat
----------------------------------------------------------------------
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)
a from_roman nem engedélyezhet ismétlődő számpárokat
----------------------------------------------------------------------
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)
a from_roman nem engedélyezhet túl sok ismétlődő karaktert
----------------------------------------------------------------------
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)

Jókora adag. Most csak annyit kell tennünk, hogy a római számok érvényességét tesztelő reguláris kifejezést hozzáadjuk a from_roman() függvényhez.

roman_numeral_pattern = re.compile('''
    ^                   # karakterlánc eleje
    M{0,3}              # ezresek - 0 és 3 közti M
    (CM|CD|D?C{0,3})    # százasok - 900 (CM), 400 (CD), 0-300 (0 és 3 közti C),
                        #            vagy 500-800 (D, amelyet 0 és 3 közti C követ)
    (XC|XL|L?X{0,3})    # tizesek - 90 (XC), 40 (XL), 0-30 (0 és 3 közti X),
                        #        vagy 50-80 (L, amelyet 0 és 3 közti X követ)
    (IX|IV|V?I{0,3})    # egyesek - 9 (IX), 4 (IV), 0-3 (0 és 3 közti I),
                        #        vagy 5-8 (V, amelyet 0 és 3 közti I követ)
    $                   # karakterlánc vége
    '''def from_roman(s):
    '''római számok egésszé alakítása'''
    if not roman_numeral_pattern.search(s):
        raise InvalidRomanNumeralError('Érvénytelen római szám: {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

És futtasd újra a teszteket…

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

OK

Az év ellencsúcspontja díjat pedig… az „OK” szó kapja, amelyet a unittest modul ír ki, ha az összes teszt sikerül.

© 2001–11 Mark Pilgrim