Tesztvezérelt fejlesztés

A tesztvezérelt fejlesztés áttekintése.


A tesztvezérelt fejlesztés (TDD) a programozásnak az a stílusa, amelyben teszteket írunk, hogy ezek irányítsák a programterv kódban való megvalósítását.

A kódolás előtt egy vagy több tesztet (különösen egységtesztet) írunk meg. A tesztek célja, hogy lefedjék a program viselkedésének egy-egy aspektusát, amely egyetlen függvényre vagy metódusra összpontosulhat. A tesztek megírása arra szolgál, hogy a program követelményeit és általános architektúráját egy adott megvalósításhoz szabott tervvé alakítsa. Lefuttatjuk a teszteket, és azoknak meg kell bukniuk, mert a kód még nincs megvalósítva. Ezután megvalósítjuk a kódot, és újra lefuttatjuk a teszteket. Ha a tesztek sikeresek, akkor vagy elkészültünk a viselkedés megvalósításával, vagy esetleg további szükséges teszteket kell még írnunk. Ha a tesztek nem sikeresek, akkor hibakeresést végzünk a kódon, és újra lefuttatjuk a teszteket. A tesztelés és a kódolás ciklusát addig ismételjük, amíg minden szükséges teszt sikeres nem lesz; ekkor a program viselkedésének erre az aspektusára vonatkozó megvalósítás elkészült... egyelőre.

Refaktorálás

A refaktorálás azt jelenti, hogy átírjuk a kódot, hogy javítsunk a tervezésén. Ez nem egyszerűen a kód átírása a bugok kijavítása céljából. Néha „refaktorálásnak” nevezik, amikor úgy módosítunk kódot, hogy az átmenjen a teszteken. Bár a kód módosítása tartalmazhatja a tervezés javítását is mint a tesztek teljesítésének eszközét, a puszta hibakeresés nem feltétlenül jelenti a kód tervezésének javítását, és így nem feltétlenül refaktorálás.

Az alábbi egy példa a refaktorálás nélküli hibakeresésre:

# A function intended to return x added to y.
# x and y are bad parameter names, but we ignore that for now.
def add(x, y):
    # used multiply operator by mistake. It fails the tests.
    return x * y

# Function corrected. It passes the tests. It has been debugged, but not refactored.
def add(x, y):
    return x + y

Az alábbi egy példa a refaktorálásra, majd az azt követő hibakeresésre:


# Function name and parameter names are modified to something more meaningful. This is refactoring.
def lot_inventory(old_cars, new_cars):
    # Introduced multiply operator by mistake. It fails the tests. This is why we test.
    return old_cars * new_cars

# Function corrected. It passes the tests. This is debugging.
def lot_inventory(old_cars, new_cars):
    return old_cars + new_cars

A TDD és a Python az Exercism-ön

Az Exercism Python-kurzusa a TDD módszertanát alkalmazza a feladataiban. Az egységtesztek már meg vannak írva. A tanuló megnézheti a teszteket, hogy részletesebben megértse, mi szükséges ahhoz, hogy egy megoldás sikeres legyen. A tanuló kaphat egy megoldásvázat is.

Sikertelen teszt hibaelhárítása az Exercism-ön az online szerkesztőben

Amikor egy Python-megoldásnál egy vagy több teszt sikertelen, a megfelelő részfeladat(ok) háttere nem lesz zöld. Az első sikertelen részfeladat területe ki lesz nyitva, és a fejléce valahogy így néz ki:

Task 1 Extract coordinates -

A mínuszjelre kattintva összecsukódik a részfeladat, így megnézhetjük a többi részfeladatot is, de egyelőre maradjunk ennél.

Alatta egy kinyitott Test terület lesz, amely valahogy így néz ki:

       Test 1                               ⌄
FAILED TisburyTreasure > get coordinate

ahol a Tisbury Treasure a feladatot jelöli, a get_coordinate pedig a sikertelen függvényt vagy metódust.

A Test 1 általában egyfajta sablon lesz, amelyben egy kódrészlet állítja be a teszteket. Nem tartalmaz információt arról, hogy pontosan melyik teszt(ek) buktak meg. Alul valami ilyesmit ír majd:

One or more variations of this test failed. Details can be found under each [variant#].

A -ra kattintva összecsukódik a teszt.

Alatta egy összecsukott teszt lesz, amely valahogy így néz ki:

       Test 2                                                    >
FAILED TisburyTreasure > get coordinate [variation #1] (item=
       ("Scrimshaw Whale's Tooth", '2A'), result='2A')

Az, hogy hogyan néz ki, a jobb oldali panel beállított szélességétől függ. A >-ra kattintva kinyílik a teszt. A bemeneti adatok és a várt eredmény adatai valószínűleg egy kódrészletben jelennek meg. Az adatok az ehhez a részfeladathoz tartozó összes tesztre vonatkozhatnak. Alul, a Test Failure szakaszban található a konkrét ok, amiért ez a teszt megbukott. Valahogy így nézhet ki:

AssertionError: ['2A'] != '2A'

Ebben az esetben azt jelzi, hogy a visszaadott ['2A'] érték nem egyezett a várt '2A' értékkel.

Ha megnézzük a get_coordinate kódját, azt látjuk, hogy így van megvalósítva:

def get_coordinate(record):
    return [record[1]]

Ha eltávolítjuk a szögletes zárójeleket a lista körül (pl. return record[1]), és újra lefuttatjuk a teszteket, az 1. részfeladat tesztjei sikeresek lesznek.

Ha egy vagy több részfeladat továbbra is sikertelen, akkor a fenti folyamatot mindegyikkel megismételjük, amíg minden teszt sikeres nem lesz.

Néha a várt és a visszaadott adatok túl nagyok ahhoz, hogy mind elférjenek a Test Failure szakaszban. Valahogy így nézhet ki:

AssertionError: '("Sc[67 chars]\')\n\n(\'Brass Spyglass\', \'Abandoned Lighth[952 chars]')\n' != '("Sc[67 chars]\')\n(\'Brass Spyglass\', \'Abandoned Lighthou[928 chars]')\n'
Diff is 970 characters long. Set self.maxDiff to None to see it.

Lehet, hogy még így is elég adat áll rendelkezésre ahhoz, hogy lássuk, mi a probléma. A fenti esetben két soremelés szerepel a visszaadott értékben (pl. \n\n(\'Brass Spyglass), holott csak egyre számítunk (pl. \n(\'Brass Spyglass).

Miután minden teszt sikeres lett

Gratulálunk! Minden teszt sikeres lett. Mi a következő lépés? A megoldást rögtön közzé is teheted. Vagy, most hogy a kód működik, ha bármilyen okból refaktorálni szeretnéd, módosíthatod a kódot, és beküldhetsz egy újabb iterációt. Ha úgy gondolod, hogy a kód jobb lehetne, de nem tudod, hogyan, mentorálást kérhetsz a megoldásodhoz. Ha van elérhető mentor, az felkereshet a megoldás más megközelítéseire vonatkozó ötletekkel. Amikor közzéteszed a megoldásodat, engedélyezheted a hozzászólásokat, és más tanulók is élhetnek az alkalommal, hogy hozzászóljanak vagy kérdéseket tegyenek fel.

Teljesítményoptimalizálás

Bár „Az idő előtti optimalizálás minden gonosz gyökere” (Tony Hoare-nak és Donald Knuthnak is tulajdonított mondás), eljön az idő, amikor bár a megoldás működik, mégis szeretnénk javítani a megoldás teljesítményén. Ilyen lehet az, amikor a megoldás bizonyos teszteken átmegy, másokon viszont időtúllépés miatt elbukik. Hasznos lehet pontosan tudni, mennyi időt vesz igénybe egy kódrészlet. A timeit modul segítségével nagyon kis időtartamokig mérhetjük a kód futási idejét. A timeit függvény legfeljebb öt argumentumot fogadhat: timeit.timeit(stmt='pass', setup='pass', timer=<default timer>, number=1000000, globals=None). A stmt paraméter határozza meg azt a kódot, amelyet lefuttatunk és időzítünk. A number paraméter határozza meg, hogy a stmt kód hányszor fusson le. A setup paraméter határozza meg azt a kódot, amelyet csak egyszer futtatunk le, hogy előkészítsük a stmt kód futtatását. A setup kód futási ideje beleszámít a teljes időbe. Minél többször fut le a stmt kód, annál kevésbé számít a setup ideje egy-egy iterációra vetítve. A timer paraméterrel az alapértelmezettől eltérő Timer adható át. A timer paraméter alapértelmezett argumentuma a perf_counter, amely a legtöbb esetben elegendő. A number paraméter alapértelmezett argumentuma 1_000_000. A globals paraméter megadja azt a névteret, amelyben a kód végrehajtódik. A globals paraméter alapértelmezett argumentuma None.

Az alábbi példa arra mutatja, hogyan használhatjuk a timeit modult annak mérésére, mennyi ideig tart megállapítani, hogy egy mondat tartalmazza-e az összes angol magánhangzót:


import timeit

# run one million times
loops = 1_000_000

# first positional argument is for stmt
# second positional argument is for setup
# third (named) argument is for number
print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    return all(letter in sentence.casefold() for letter in VOWELS)
""", number=loops) / loops)

A kód egymilliószori lefuttatása átlagosan 4.965089999896008e-07 másodpercet vett igénybe hívásonként (nagyjából 497 nanoszekundum hívásonként).

A következő példa azt vizsgálja, hogy megtakarít-e bármennyi időt, ha kivesszük a casefold hívást a listaértelmezésből:


import timeit

loops = 1_000_000

print(timeit.timeit("""has_all_vowels('Another piggy digs up the truffles.')""",
                    """

VOWELS = "AEIOU"

def has_all_vowels(sentence):
    sentence = sentence.casefold()
    return all(letter in sentence for letter in VOWELS)
""", number=loops) / loops)

A kód egymilliószori lefuttatása átlagosan 4.923898000270128e-07 másodpercet vett igénybe hívásonként (nagyjából 492 nanoszekundum hívásonként.) Így a casefold kivétele a listaértelmezésből hívásonként nagyjából 5 nanoszekundumot takarított meg, ami egymillió hívás esetén összesen mintegy 5 ezredmásodperc.

A cProfile szintén használható kód profilozására; azonban nem ennyire finom felbontású, mivel csak ezredmásodperces időtartamokig megy le.