Írjunk professzionális unit teszteket Pythonban
() translation by (you can also view the original English article)
Tesztelés az alapja a SOLID szoftver fejlesztésnek. Többféle tesztelési típus létezik, de a legfontosabb típusa a unit tesztelés. Unit tesztekkel eléggé biztos lehetsz abban, hogy jól tesztelt programrészeket használsz mint primitívek, és függhetsz tőlük, amikor összekapcsolod őket, hogy elkészítsd a programodat. A programozási nyelved beépített kódjain és alap könyvtárán túl, ezek a tesztek bővítik a megbízható kódjaid tárházát. Ezen felül a Python nyelv pedig kiváló támogatást nyújt a unit tesztek írásához.
Működő példa
Mielőtt nekilátunk az alapelveknek és az iránymutatásoknak, nézzünk meg egy reprezentatív unit tesztet működés közben. A SelfDrivingCar
osztály egy önvezető autó vezetési logikájának részleges implementációja. Főként az autó sebességének szabályozásával foglalkozik. Figyelembe veszi az előtte lévő objektumokat, a sebesség korlátot, és hogy megérkezett-e a cél állomásra, vagy sem.
1 |
class SelfDrivingCar(object): |
2 |
|
3 |
def __init__(self): |
4 |
|
5 |
self.speed = 0 |
6 |
|
7 |
self.destination = None |
8 |
|
9 |
|
10 |
|
11 |
def _accelerate(self): |
12 |
|
13 |
self.speed += 1 |
14 |
|
15 |
|
16 |
|
17 |
def _decelerate(self): |
18 |
|
19 |
if self.speed > 0: |
20 |
|
21 |
self.speed -= 1 |
22 |
|
23 |
|
24 |
|
25 |
def _advance_to_destination(self): |
26 |
|
27 |
distance = self._calculate_distance_to_object_in_front() |
28 |
|
29 |
if distance < 10: |
30 |
|
31 |
self.stop() |
32 |
|
33 |
|
34 |
|
35 |
elif distance < self.speed / 2: |
36 |
|
37 |
self._decelerate() |
38 |
|
39 |
elif self.speed < self._get_speed_limit(): |
40 |
|
41 |
self._accelerate() |
42 |
|
43 |
|
44 |
|
45 |
def _has_arrived(self): |
46 |
|
47 |
pass
|
48 |
|
49 |
|
50 |
|
51 |
def _calculate_distance_to_object_in_front(self): |
52 |
|
53 |
pass
|
54 |
|
55 |
|
56 |
|
57 |
def _get_speed_limit(self): |
58 |
|
59 |
pass
|
60 |
|
61 |
|
62 |
|
63 |
def stop(self): |
64 |
|
65 |
self.speed = 0 |
66 |
|
67 |
|
68 |
|
69 |
def drive(self, destination): |
70 |
|
71 |
self.destination = destination |
72 |
|
73 |
while not self._has_arrived(): |
74 |
|
75 |
self._advance_to_destination() |
76 |
|
77 |
|
78 |
self.stop() |
79 |
|
80 |
def __init__(self): |
81 |
|
82 |
self.speed = 0 |
83 |
|
84 |
self.destination = None |
85 |
|
86 |
|
87 |
|
88 |
def _accelerate(self): |
89 |
|
90 |
self.speed += 1 |
91 |
|
92 |
|
93 |
|
94 |
def _decelerate(self): |
95 |
|
96 |
if self.speed > 0: |
97 |
|
98 |
self.speed -= 1 |
99 |
|
100 |
|
101 |
|
102 |
def _advance_to_destination(self): |
103 |
|
104 |
distance = self._calculate_distance_to_object_in_front() |
105 |
|
106 |
if distance < 10: |
107 |
|
108 |
self.stop() |
109 |
|
110 |
|
111 |
|
112 |
elif distance < self.speed / 2: |
113 |
|
114 |
self._decelerate() |
115 |
|
116 |
elif self.speed < self._get_speed_limit(): |
117 |
|
118 |
self._accelerate() |
119 |
|
120 |
|
121 |
|
122 |
def _has_arrived(self): |
123 |
|
124 |
pass
|
125 |
|
126 |
|
127 |
|
128 |
def _calculate_distance_to_object_in_front(self): |
129 |
|
130 |
pass
|
131 |
|
132 |
|
133 |
|
134 |
def _get_speed_limit(self): |
135 |
|
136 |
pass
|
137 |
|
138 |
|
139 |
|
140 |
def stop(self): |
141 |
|
142 |
self.speed = 0 |
143 |
|
144 |
|
145 |
|
146 |
def drive(self, destination): |
147 |
|
148 |
self.destination = destination |
149 |
|
150 |
while not self._has_arrived(): |
151 |
|
152 |
self._advance_to_destination() |
153 |
|
154 |
self.stop() |
155 |
Itt van egy unit teszt a stop() metódushoz, étvágygerjesztőnek. A részleteket később nézzük meg.
1 |
from unittest import TestCase |
2 |
|
3 |
|
4 |
|
5 |
class SelfDrivingCarTest(TestCase): |
6 |
|
7 |
def setUp(self): |
8 |
|
9 |
self.car = SelfDrivingCar() |
10 |
|
11 |
|
12 |
|
13 |
def test_stop(self): |
14 |
|
15 |
self.car.speed = 5 |
16 |
|
17 |
self.car.stop() |
18 |
|
19 |
# Verify the speed is 0 after stopping
|
20 |
|
21 |
self.assertEqual(0, self.car.speed) |
22 |
|
23 |
|
24 |
|
25 |
# Verify it is Ok to stop again if the car is already stopped
|
26 |
|
27 |
self.car.stop() |
28 |
|
29 |
self.assertEqual(0, self.car.speed) |
Unit tesztelés irányvonalak
Elköteleződés
Jó unit teszteket írni nehéz munka. Unit teszteket írni időbe telik. Amikor módosítasz a kódodon, általában módosítanod kell a tesztjeidet is. Néha bugos lesz a teszt kódod. Ez azt jelenti, hogy nagyon elkötelezettnek kell lenned. Az előnyök hatalmasak, még kis projektek esetén is, de ezek nem ingyenesek.
Légy fegyelmezett
Fegyelmezettnek kell lenned. Légy következetes. Legyél biztos benne, hogy a tesztek mindig zöldek (passed) legyenek. Ne hagyd, hogy a tesztek pirosak (failed) legyenek, mert te úgyis "tudod", hogy a kód OK.
Automatizálás
Ahhoz hogy fegyelmezett legyél, automatizálnod kell a unit tesztjeidet. A teszteknek automatikusan futniuk kell meghatározott események hatására, mint pl. pre-commit vagy pre-deployment. Ideálisan, a forráskód menedzselő rendszerednek vissza kell utasítania azt a kódot, ami nem ment át minden teszten.
Definíció: A teszteletlen kód hibás
Ha nem teszteled a kódod, nem mondhatod, hogy működik. Ez azt jelenti, hogy hibásnak kell tekintened. Ha ez egy kritikus kód, ne deployold éles környezetbe.
Alapok
Mi is az egység (unit)?
A unit tesztelés szempontjából az egység (unit) az egy file/modul, ami bizonyos függvények halmazát vagy egy osztályt tartalmaz. Ha olyan fájlod van, ami több osztályt tartalmaz, akkor mindegyikhez külön unit tesztet kell írnod.
TDD-zni vagy nem TDD-zni
A teszt vezérelt fejlesztés (TDD) egy olyan gyakorlat, amikor a tesztjeidet még a kódolás előtt megírod. Ennek a megközelítésnek jónéhány előnye van, de azt javaslom, hogy most még ne menjünk bele, amíg nem tudunk biztosan helyes teszteket írni.
Ennek az oka az, hogy kódolás közben tervezek. Kódolok, megnézem, újraírom, megnézem újra, és gyorsan újra átírom. Ha először írok tesztet, az korlátoz engem és lelassít.
Amikor kész vagyok a kezdeti tervezéssel, azonnal megírom a teszteket, mielőtt integrálnám a rendszerem többi részéhez. Ez egy nagyszerű módja annak, hogy bevezesd magad a unit tesztek írásába, és így minden kódodhoz lesz teszt is.
A unittest Modul
A Python standard könyvtára tartalmazza a unittest modult. Ez egy TestCase
osztályt biztosít, amiből származtathatod a saját osztályodat. Ezután felüldefiniálhatod a setUp()
metódust, amivel előkészítjük minden egyes teszt előtt a fix tesztkörnyezetet (fixture) és/vagy a classSetUp()
osztály metódust definiáljuk felül, amivel a legelején, az összes teszt előtt egyszer készítünk elő egy fix tesztkörnyezetet (fixture). Utóbbi esetben a különböző tesztek között ekkor nem lesz előkészítve (resetelve) a környezet. A fenti függvények párjai a tearDown()
és a classTearDown()
metódusok, amiket ugyancsak felüldefiniálhatsz.
Lássuk a releváns részeket a SelfDrivingCarTest
osztályunkból. Én most csak a setUp()
metódust használom. Készítek egy friss ropogós SelfDrivingCar
példányt és eltárolom a self.car
-ban, így minden tesztben elérhető lesz.
1 |
from unittest import TestCase |
2 |
|
3 |
|
4 |
|
5 |
class SelfDrivingCarTest(TestCase): |
6 |
|
7 |
def setUp(self): |
8 |
|
9 |
self.car = SelfDrivingCar() |
A következő lépésben megírjuk a specifikus teszt metódusokat, amikkel ellenőrizzük a tesztelés során, hogy a kód - jelen esetben a SelfDrivingCar
osztály - azt csinálja-e, amit kell. A teszt metódus struktúrája eléggé általános:
- Előkészítjük a környezetet (opcionális).
- Előkészítjük az elvárt értéket.
- Meghívjuk a kódot a tesztelés során.
- Ellenőrizzük, hogy a jelenlegi érték megegyezik az elvárt értékkel.
Megjegyzem, hogy a vizsgálandó érték nem szükséges, hogy a metódus kimenete legyen. Lehet egy osztály állapot változása, vagy egy másodlagos folyamat, mint pl. egy új sor beszúrása az adatbázisba, vagy fájlba írás, vagy egy email küldése.
Például, a SelfDrivingCar
osztály stop()
metódusa nem tér vissza semmivel, de megváltoztatja a belső állapotot azzal, hogy a sebességet 0-ra állítja. Az assertEqual()
metódust, amit a TestCase
alaposztály biztosít, arra használjuk itt, hogy ellenőrizzük, hogy a stop()
hívása úgy működött, ahogy elvártuk.
1 |
def test_stop(self): |
2 |
|
3 |
self.car.speed = 5 |
4 |
|
5 |
self.car.stop() |
6 |
|
7 |
# Verify the speed is 0 after stopping
|
8 |
|
9 |
self.assertEqual(0, self.car.speed) |
10 |
|
11 |
|
12 |
|
13 |
# Verify it is Ok to stop again if the car is already stopped
|
14 |
|
15 |
self.car.stop() |
16 |
|
17 |
self.assertEqual(0, self.car.speed) |
Jelenleg két teszt van most. Az első teszttel megbizonyosodunk arról, ha az autó sebessége 5, és a stop()
függvényt meghívtuk, akkor a sebesség 0-ra változik. Aztán, a másik teszttel megnézzük, hogy nem lesz probléma akkor, amikor újra meghívjuk a stop()
függvényt, amikor az autó már megállt.
Végül, bemutatok még néhány tesztet, hogy lássunk még funkciókat.
A doctest modul
A doctest module egy érdekes dolog. Segítségével interaktív kód példákat használhatsz a docstringedben (3 idézőjel közötti kód dokumentáció) és ellenőrizheted az eredményeket, a dobott kivételeket (exception) is beleértve.
Én nem használom és nem is javaslom a doctest-et nagyméretű rendszerekben. A helyes unit tesztelés sok munkával jár. A teszt kód tipikusan sokkal nagyobb, mint az a kód, amit tesztelünk. A docstring-ek nem a legmegfelőbb eszközök a részletes tesztekhez. Egyébként menők. Itt van, ahogy a factorial függvény néz ki doc tesztekkel:
1 |
import math |
2 |
|
3 |
|
4 |
|
5 |
def factorial(n): |
6 |
|
7 |
"""Return the factorial of n, an exact integer >= 0.
|
8 |
|
9 |
|
10 |
|
11 |
If the result is small enough to fit in an int, return an int.
|
12 |
|
13 |
Else return a long.
|
14 |
|
15 |
|
16 |
|
17 |
>>> [factorial(n) for n in range(6)]
|
18 |
|
19 |
[1, 1, 2, 6, 24, 120]
|
20 |
|
21 |
>>> [factorial(long(n)) for n in range(6)]
|
22 |
|
23 |
[1, 1, 2, 6, 24, 120]
|
24 |
|
25 |
>>> factorial(30)
|
26 |
|
27 |
265252859812191058636308480000000L
|
28 |
|
29 |
>>> factorial(30L)
|
30 |
|
31 |
265252859812191058636308480000000L
|
32 |
|
33 |
>>> factorial(-1)
|
34 |
|
35 |
Traceback (most recent call last):
|
36 |
|
37 |
...
|
38 |
|
39 |
ValueError: n must be >= 0
|
40 |
|
41 |
|
42 |
|
43 |
Factorials of floats are OK, but the float must be an exact integer:
|
44 |
|
45 |
>>> factorial(30.1)
|
46 |
|
47 |
Traceback (most recent call last):
|
48 |
|
49 |
...
|
50 |
|
51 |
ValueError: n must be exact integer
|
52 |
|
53 |
>>> factorial(30.0)
|
54 |
|
55 |
265252859812191058636308480000000L
|
56 |
|
57 |
|
58 |
|
59 |
It must also not be ridiculously large:
|
60 |
|
61 |
>>> factorial(1e100)
|
62 |
|
63 |
Traceback (most recent call last):
|
64 |
|
65 |
...
|
66 |
|
67 |
OverflowError: n too large
|
68 |
|
69 |
"""
|
70 |
|
71 |
if not n >= 0: |
72 |
|
73 |
raise ValueError("n must be >= 0") |
74 |
|
75 |
if math.floor(n) != n: |
76 |
|
77 |
raise ValueError("n must be exact integer") |
78 |
|
79 |
if n+1 == n: # catch a value like 1e300 |
80 |
|
81 |
raise OverflowError("n too large") |
82 |
|
83 |
result = 1 |
84 |
|
85 |
factor = 2 |
86 |
|
87 |
while factor <= n: |
88 |
|
89 |
result *= factor |
90 |
|
91 |
factor += 1 |
92 |
|
93 |
return result |
94 |
|
95 |
|
96 |
|
97 |
|
98 |
|
99 |
if __name__ == "__main__": |
100 |
|
101 |
import doctest |
102 |
|
103 |
doctest.testmod() |
Amint láthatod, a docstring sokkal nagyobb mint a függvény kódja. Nem az olvashatóságot reklámozza.
Tesztek futtatása
OK. Megírtad a unit tesztjeidet. Nagy rendszer esetén, tíz, száz, ezer modulod és osztályod lesz, valószínű több könyvtárban. Hogy fogod futtatni ezeket a teszteket?
A unittest modul különféle eszközöket biztosít a tesztek csoportosítására és programozott futtatására. Nézz utána a Tesztek betöltésének és futtatásának. De a legkönnyebb mód a tesztek feltérképezése (discovery). Ezt az opciót csak Python 2.7-ben vezették be. 2.7 előtt a nose modult használhatod a feltérképezéshez és a tesztek futtatásához. A nose modulnak néhány más előnye is van, mint pl. teszt függvények futtatása, a tesztesetekhez szükséges osztály létrehozása nélkül. De hogy a cikk témájánál maradjunk, térjünk vissza a unittest modulhoz.
Unit teszt alapú tesztjeid feltérképezéséhez és futtatásához csak írd be a parancssorba:
python -m unittest discover
A unittest parancs végignézi az összes file-t és alkönyvtárt, lefuttatja az összes tesztet, amit talál, és egy szép riportot ad vissza a futása során. Ha szeretnéd látni, melyik teszteket futtatja, add hozzá a -v kapcsolót:
python -m unittest discover -v
Van még néhány kapcsoló, amit használhatunk:
1 |
python -m unittest -h |
2 |
|
3 |
Usage: python -m unittest [options] [tests] |
4 |
|
5 |
|
6 |
|
7 |
Options: |
8 |
|
9 |
-h, --help Show this message |
10 |
|
11 |
-v, --verbose Verbose output |
12 |
|
13 |
-q, --quiet Minimal output |
14 |
|
15 |
-f, --failfast Stop on first failure |
16 |
|
17 |
-c, --catch Catch control-C and display results |
18 |
|
19 |
-b, --buffer Buffer stdout and stderr during test runs |
20 |
|
21 |
|
22 |
|
23 |
Examples: |
24 |
|
25 |
python -m unittest test_module - run tests from test_module |
26 |
|
27 |
python -m unittest module.TestClass - run tests from module.TestClass |
28 |
|
29 |
python -m unittest module.Class.test_method - run specified test method |
30 |
|
31 |
|
32 |
|
33 |
[tests] can be a list of any number of test modules, classes and test |
34 |
|
35 |
methods. |
36 |
|
37 |
|
38 |
|
39 |
Alternative Usage: python -m unittest discover [options] |
40 |
|
41 |
|
42 |
|
43 |
Options: |
44 |
|
45 |
-v, --verbose Verbose output |
46 |
|
47 |
-f, --failfast Stop on first failure |
48 |
|
49 |
-c, --catch Catch control-C and display results |
50 |
|
51 |
-b, --buffer Buffer stdout and stderr during test runs |
52 |
|
53 |
-s directory Directory to start discovery ('.' default) |
54 |
|
55 |
-p pattern Pattern to match test files ('test*.py' default) |
56 |
|
57 |
-t directory Top level directory of project (default to |
58 |
|
59 |
start directory) |
60 |
|
61 |
|
62 |
|
63 |
For test discovery all test modules must be importable from the top |
64 |
|
65 |
level directory of the project. |
Teszt lefedettség
A teszt lefedettség egy gyakran hanyagolt terület. A lefedettség azt jelenti, hogy a kódodnak mekkora része van valójában tesztelve. Például, ha van egy függvényed egy if-else
utasítással, és csak az if
ágat teszteled, akkor nem tudod, hogy az else
ág működik-e vagy sem. A következő példakódban, az add()
függvény ellenőrzi a bementi paramétereinek a típusát. Ha mindkettő egész, akkor csak összeadja őket.
Ha mindkettő sztring, megpróbálja egésszé konvertálni és összeadni. Egyébként kivételt dob. A test_add() függvény teszteli az add() függvényt úgy, hogy mindkét argumentum egész, vagy float, és ellenőrzi a helyes viselkedést mindkét esetben. De a teszt lefedettség nem teljes. A sztring paramétereket nem teszteltük még. A teszt sikeresen lefutott, de az elírást az ágban, ahol mindkét paraméter sztring, nem találta meg (látod az 'intg'-t?).
1 |
import unittest |
2 |
|
3 |
|
4 |
|
5 |
def add(a, b): |
6 |
|
7 |
"""This function adds two numbers a, b and returns their sum
|
8 |
|
9 |
|
10 |
|
11 |
a and b may integers
|
12 |
|
13 |
"""
|
14 |
|
15 |
if isinstance(a, int) and isinstance(b, int): |
16 |
|
17 |
return a + b |
18 |
|
19 |
elseif isinstance(a, str) and isinstance(b, str): |
20 |
|
21 |
return int(a) + intg(b) |
22 |
|
23 |
else: |
24 |
|
25 |
raise Exception('Invalid arguments') |
26 |
|
27 |
|
28 |
|
29 |
class Test(unittest.TestCase): |
30 |
|
31 |
def test_add(self): |
32 |
|
33 |
self.assertEqual(5, add(2, 3)) |
34 |
|
35 |
self.assertEqual(15, add(-6, 21)) |
36 |
|
37 |
self.assertRaises(Exception, add, 4.0, 5.0) |
38 |
|
39 |
|
40 |
|
41 |
unittest.main() |
Ez a kimenet:
1 |
---------------------------------------------------------------------- |
2 |
|
3 |
Ran 1 test in 0.000s |
4 |
|
5 |
|
6 |
|
7 |
OK |
8 |
|
9 |
|
10 |
|
11 |
Process finished with exit code 0 |
Saját unit tesztek
Ipari szintű unit teszteket írni nem egyszerű és nem könnyű. Van néhány dolog, amit figyelembe kell venni és amiben kompromisszumot kell kötni.
Tesztelhetőség tervezés
Ha a te kódod olyan, amit formálisan spagetti kódnak hívnak, vagy olyan, mint egy nagy sárlabda, ahol a különböző szintű absztrakciók össze vannak keverve, és minden egyes kód részlet függ az összes többi kód részlettől, akkor nagyon nehéz lesz tesztelned. Ráadásul, ha megváltoztatsz valamit, akkor egy csomó tesztedet is frissítened kell.
A jó hír az, hogy a tesztelhetőség érdekében éppen az általános célú pontos szoftver tervezésére van szükséged. Valójában jól strukturált, moduláris kódra van szükség, ahol minden komponensnek egyértelmű felelősségi köre van, és jól definiált interfészeken keresztül van kapcsolatban más komponensekkel. Így játszva lehet jó unit teszteket készíteni.
Például, a SelfDrivingCar
osztályunk felelős az autó magas szintű feladataiért: go, stop, navigate. Van egy calculate_distance_to_object_in_front()
metódusa, ami még nem lett implementálva. Ezt a funkciót valószínű egy teljesen más rész rendszerként kell implementálni. Tartalmazhat olyan feladatokat, mint pl. különféle szenzorok olvasása, kommunikáció más önjáró autókkal, a teljes látható tartomány feltérképezése, amivel képeket elemez több különböző kamerából.
Lássuk, hogy működik ez a gyakorlatban. A SelfDrivingCar
egyik paramétere az object_detector
, aminek van egy calculate_distance_to_object_in_front()
nevű metódusa, és ez delegálni fogja ezt a funkcionalitást ennek az objektumnak. Most nincs szükség unit tesztelésre, mert az object_detector
felelős ezért, amit külön kellene tesztelni. Azt a tényt szeretnénk unit tesztelni, hogy megfelelően használjuk az object_detector
-t.
1 |
class SelfDrivingCar(object): |
2 |
|
3 |
def __init__(self, object_detector): |
4 |
|
5 |
self.object_detector |
6 |
|
7 |
self.speed = 0 |
8 |
|
9 |
self.destination = None |
10 |
|
11 |
|
12 |
|
13 |
def _calculate_distance_to_object_in_front(self): |
14 |
|
15 |
return self.object_detector.calculate_distance_to_object_in_front() |
Költség / Haszon
A tesztelésbe fektetett erőfeszítésednek korrelációban kell lennie a hiba költségével, mennyire stabil a kód, és hogy mennyire könnyű javítani, ha problémák jönnek elő éles környezetben.
Például az önjáró autó osztályunk biztonság kritikus. Ha a stop()
metódus nem működik megfelelően, az önvezető autónk akár embereket ölhet, dolgokat tehet tönkre és alááshatja az önvezető autó piacot is. Ha egy önvezető autót fejlesztesz, gyanítom a stop()
metódushoz készült unit tesztjeid sokkal komolyabbak, mint az enyémek.
Másrészről, ha a web alkalmazásodban egy oldalon egy egyszerű gombot veszünk, ami három szintű mélységbe temettél a főoldalon, és arrébb megy egy kicsit, amikor rákattintasz, kijavíthatod, de valószínű nem fogsz egy dedikált unit tesztet készíteni erre az esetre. A gazdaságosság nem engedné.
Hozzáállás tesztelése
A hozzáállás tesztelése is fontos. Egy alapvető dolgot minden kódomnál használok, mégpedig azt, hogy legalább két használója van, egy másik kód, ami használja, és a teszt ami teszteli. Ez az egyszerű szabály sokat segít a tervezésben és a függőségek kialakításában. Ha tudod, hogy tesztet kell írnod a kódodhoz, nem fogsz sok függőséget hozzáadni, amit nehéz a tesztelés során rekonstruálni.
Például, tegyük fel, hogy a kódodnak ki kell számolnia valamit. Ennek érdekében be kell töltenie valamilyen adatot egy adatbázisból, be kell olvasnia egy konfigurációs fájlt, és dinamikusan lekérni valamilyen REST API-tól a friss információkat. Ezekre különböző okok miatt lehet szükség, de mindezt egyetlen függvénybe téve eléggé nehézzé tenné a unit tesztelést. Meg lehetne csinálni mockolással, de sokkal jobb a kódot megfelelően struktruálni.
Tiszta függvények
A legkönnyebb kód, amit tesztelni lehet, azok a tiszta függvények (pure function). A tiszta függvények olyanok, amelyek csak a paramétereik értékeihez férnek hozzá, nincs mellékhatásuk, és ugyanazt a visszatérési értéket adják vissza, amikor ugyanazok a bemeneti paraméterek. Nem változtatják meg a programod állapotát, nem férnek hozzá a fájlrendszerhez vagy a hálózathoz. Annyi előnyük van, hogy nem tudjuk itt összeszedni.
Miért könnyű ezeket tesztelni? Azért, mert nincs szükség a teszteléséhez speciális környezet létrehozására. Csak átadod a paramétert és teszteled az eredményt. És tudhatod azt, hogy amíg a kód nem változik, addig a tesztet sem kell módosítani.
Hasonlítsuk össze egy olyan függvénnyel, ami egy XML konfigurációs fájlt olvas be. A tesztben létre kell hozni egy XML fájlt, és a fájlnevét át kell adni a tesztelendő kódnak. Nem nagy ügy. De aztán valaki úgy döntött, hogy az XML már nem kívánatos, és minden konfigurációs fájlnak JSON-ban kell lennie. Elintézik a munkát, és minden konfigurációs fájlt JSON-ba konvertálnak. Lefuttatják az összes tesztet, beleértve a tieidet is, és mind zöld lesz!
Miért? Mert a kód nem változott. Még mindig XML konfigurációt vár, és a tesztjeid XML fájlt készítenek. De az éles környezetben a kódod JSON fájlt fog kapni, amit nem fog tudni feldolgozni.
Hibakezelés tesztelése
A hibakezelés egy másik kritikus dolog, amit tesztelni kell. Ez is a tervezés része. Ki felelős a bemenet helyességéért? Minden függvény és metódusnak tisztában kell lennie ezzel. Ha a függvény felelőssége, akkor annak ellenőriznie kell a bemeneteit, de ha a hívó felelőssége, akkor a függvény csak elvégzi a dolgát és azt feltételezi, hogy a bemenet helyes. A rendszer teljes helyes működését azok a tesztek biztosítják, amelyek ellenőrzik, hogy a hívó csak helyes bemenetet adnak a függvényednek.
Általában le akarjuk tesztelni a kódunk publikus felületére érkező bemeneteket, mivel szükségszerűen nem tudjuk, ki fogja meghívni a kódunkat. Nézzük meg az önjáró autónk drive()
metódusát. Egy 'úticél' paramétert vár. Az 'úticél' paraméter később a navigációnál lesz használva, de a drive metódus nem ellenőrzi egyáltalán.
Feltételezzük, hogy az úticél egy (szélesség, hosszúság) számpárból áll. Sokféle teszt létezik, amivel eldönthető, hogy ez az adat érvényes (pl. az úticél a tenger közepén van-e?) A mi célunknak most megfelel, ha biztosítjuk, hogy ez a float-okból álló számpár, és a szélesség 0.0, 90.0 között van, és a hosszúság -180.0 és 180.0 között.
Itt van a frissített SelfDrivingCar
osztályunk. Implementáltam néhány hiányzó metódust, mivel a drive() metódus hívja ezeket közvetve vagy közvetlenül.
1 |
class SelfDrivingCar(object): |
2 |
|
3 |
def __init__(self, object_detector): |
4 |
|
5 |
self.object_detector = object_detector |
6 |
|
7 |
self.speed = 0 |
8 |
|
9 |
self.destination = None |
10 |
|
11 |
|
12 |
|
13 |
def _accelerate(self): |
14 |
|
15 |
self.speed += 1 |
16 |
|
17 |
|
18 |
|
19 |
def _decelerate(self): |
20 |
|
21 |
if self.speed > 0: |
22 |
|
23 |
self.speed -= 1 |
24 |
|
25 |
|
26 |
|
27 |
def _advance_to_destination(self): |
28 |
|
29 |
distance = self._calculate_distance_to_object_in_front() |
30 |
|
31 |
if distance < 10: |
32 |
|
33 |
self.stop() |
34 |
|
35 |
|
36 |
|
37 |
elif distance < self.speed / 2: |
38 |
|
39 |
self._decelerate() |
40 |
|
41 |
elif self.speed < self._get_speed_limit(): |
42 |
|
43 |
self._accelerate() |
44 |
|
45 |
|
46 |
|
47 |
def _has_arrived(self): |
48 |
|
49 |
return True |
50 |
|
51 |
|
52 |
|
53 |
def _calculate_distance_to_object_in_front(self): |
54 |
|
55 |
return self.object_detector.calculate_distance_to_object_in_front() |
56 |
|
57 |
|
58 |
|
59 |
def _get_speed_limit(self): |
60 |
|
61 |
return 65 |
62 |
|
63 |
|
64 |
|
65 |
def stop(self): |
66 |
|
67 |
self.speed = 0 |
68 |
|
69 |
|
70 |
|
71 |
def drive(self, destination): |
72 |
|
73 |
self.destination = destination |
74 |
|
75 |
while not self._has_arrived(): |
76 |
|
77 |
self._advance_to_destination() |
78 |
|
79 |
self.stop() |
A hibakezelés teszteléséhez érvénytelen paramétereket adok át, és ellenörzöm, hogy helyesen vissza lesznek utasítva. Ezt a self.assertRaises()
metódussal lehet megcsinálni. ami a unittest.TestCase metódusa. Ez a metódus sikeres, ha a tesztelt kód valóban egy kivételt dob.
Lássuk ezt a gyakorlatban. A test_drive()
metódus az érvényes tartományon kívüli szélesség és hosszúság értékeket ad át, és azt várja, hogy a drive()
metódus kivételt dobjon.
1 |
from unittest import TestCase |
2 |
|
3 |
from self_driving_car import SelfDrivingCar |
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
class MockObjectDetector(object): |
10 |
|
11 |
def calculate_distance_to_object_in_front(self): |
12 |
|
13 |
return 20 |
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
class SelfDrivingCarTest(TestCase): |
20 |
|
21 |
def setUp(self): |
22 |
|
23 |
self.car = SelfDrivingCar(MockObjectDetector()) |
24 |
|
25 |
|
26 |
|
27 |
def test_stop(self): |
28 |
|
29 |
self.car.speed = 5 |
30 |
|
31 |
self.car.stop() |
32 |
|
33 |
# Verify the speed is 0 after stopping
|
34 |
|
35 |
self.assertEqual(0, self.car.speed) |
36 |
|
37 |
|
38 |
|
39 |
# Verify it is Ok to stop again if the car is already stopped
|
40 |
|
41 |
self.car.stop() |
42 |
|
43 |
self.assertEqual(0, self.car.speed) |
44 |
|
45 |
|
46 |
|
47 |
def test_drive(self): |
48 |
|
49 |
# Valid destination
|
50 |
|
51 |
self.car.drive((55.0, 66.0)) |
52 |
|
53 |
|
54 |
|
55 |
# Invalid destination wrong range
|
56 |
|
57 |
self.assertRaises(Exception, self.car.drive, (-55.0, 200.0)) |
A teszt piros lesz, mivel a drive()
metódus nem ellenőrzi a paramétereinek az érvényességét, és nem dob kivételt. Egy szép riportot kapunk részletes információval arról, hogy mi lett piros (failed), hol és miért.
1 |
python -m unittest discover -v |
2 |
|
3 |
test_drive (untitled.test_self_driving_car.SelfDrivingCarTest) ... FAIL |
4 |
|
5 |
test_stop (untitled.test_self_driving_car.SelfDrivingCarTest) ... ok |
6 |
|
7 |
|
8 |
|
9 |
====================================================================== |
10 |
|
11 |
FAIL: test_drive (untitled.test_self_driving_car.SelfDrivingCarTest) |
12 |
|
13 |
---------------------------------------------------------------------- |
14 |
|
15 |
Traceback (most recent call last): |
16 |
|
17 |
File "/Users/gigi/PycharmProjects/untitled/test_self_driving_car.py", line 29, in test_drive |
18 |
|
19 |
self.assertRaises(Exception, self.car.drive, (-55.0, 200.0)) |
20 |
|
21 |
AssertionError: Exception not raised |
22 |
|
23 |
|
24 |
|
25 |
---------------------------------------------------------------------- |
26 |
|
27 |
Ran 2 tests in 0.000s |
28 |
|
29 |
|
30 |
|
31 |
FAILED (failures=1) |
Javítsuk ki, és frissítsük a drive()
metódust, hogy ellenőrizze a paramétereinek a tartományát:
1 |
def drive(self, destination): |
2 |
|
3 |
lat, lon = destination |
4 |
|
5 |
if not (0.0 <= lat <= 90.0): |
6 |
|
7 |
raise Exception('Latitude out of range') |
8 |
|
9 |
if not (-180.0 <= lon <= 180.0): |
10 |
|
11 |
raise Exception('Latitude out of range') |
12 |
|
13 |
|
14 |
|
15 |
self.destination = destination |
16 |
|
17 |
while not self._has_arrived(): |
18 |
|
19 |
self._advance_to_destination() |
20 |
|
21 |
self.stop() |
Most minden teszt zöld (pass).
1 |
python -m unittest discover -v |
2 |
|
3 |
test_drive (untitled.test_self_driving_car.SelfDrivingCarTest) ... ok |
4 |
|
5 |
test_stop (untitled.test_self_driving_car.SelfDrivingCarTest) ... ok |
6 |
|
7 |
|
8 |
|
9 |
---------------------------------------------------------------------- |
10 |
|
11 |
Ran 2 tests in 0.000s |
12 |
|
13 |
|
14 |
|
15 |
OK |
16 |
Privát metódusok tesztelése
Vajon minden függvényt és metódust tesztelni kell? Pontosabban, privát metódusokat is kell tesztelni, amiket csak a saját kódunk hív meg? A tipikus, nem kielégítő válasz az, hogy "attól függ".
Megpróbálok értelmesen válaszolni erre, és elmondani, hogy mitől függ. Pontosan tudod, hogy ki hívja a privát metódusodat - a saját kódod. Ha kimerítőek és részletesek azok a tesztjeid, amelyek a publikus metódusaidat tesztelik, ami a privát metódusodat hívja, akkor már kimerítően teszteled a privát metódusodat is. De ha a privát metódusod nagyon összetett, akkor lehet hogy külön is tesztelnéd. Neked kell meghozni a döntést.
Hogyan szervezd a unit tesztjeid
Egy nagy rendszerben nem mindig tiszta, hogyan szervezzük a tesztjeinket. Egy nagy fájlba tegyük az összes tesztet a csomaghoz, vagy egy fájl legyen minden osztályhoz? Azonos könyvtárban legyenek a tesztek a kóddal, vagy külön?
Én ezt a rendszert használom. A teszteknek teljesen szeparálva kell lenniük attól a kódtól, amit tesztelnek (ezért nem használok doctest-et). Ideálisan a kódodnak package-ben kell lennie. A teszteknek minden egyes package-hez egy testvér könyvtárban kell lennie a package-dzsel. A teszt könyvtárban, minden modulhoz külön fájlra van szükség, amit így nevezhetünk: test_<module name>
.
Például, ha van három modul a package-ben: module_1.py
, module_2.py
és module_3.py
, akkor három teszt fájlnak kell lennie: test_module_1.py
, test_module_2.py
és test_module_3.py
a teszt könyvtárban.
Ennek a konvenciónak több előnye is van. Csak a könyvtárak böngészéséből egyértelművé válik, ha elfelejtettünk-e egy modult tesztelni. Abban is segít, hogy a tesztjeinket vállalható méretű darabokba rendezzük. Ha feltesszük, hogy a moduljaink megfelelő méretűek, akkor a modulokhoz tartozó tesztkód a saját fájlában lesz, ami talán kicsit nagyobb lesz, mint a modul, amit tesztel, de még mindig akkora, ami kényelmesen elfér egy fájlban.
Összegzés
A SOLID kód alapja a unit tesztelés. Ebben a tutorialban bemutattam néhány alapelvet és irányvonalat a unit teszteléssel kapcsolatban, és elmagyaráztam az okokat a legjobb technikákkal kapcsolatban. Minél nagyobb rendszert építesz, annál fontosabbak lesznek a unit tesztek. De a unit tesztek nem elegendőek. Más féle tesztekre is szükség van a nagyméretű rendszerek esetén, mint pl. integrációs tesztek, performancia tesztek, terhelés tesztek, penetrációs tesztek, elfogadási tesztek.