Egy egységtesztelés tipikusan fejlesztői tesztelés: a tesztelendő programot fejlesztő programozó gyakorlatilag a programozási munkája szerves részeként készíti és futtatja az egységteszteket. Erre azért van szükség, hogy ő maga is meggyőződhessen arról, hogy amit leprogramozott, az valóban az elvárások szerint működik. Az egység tesztelésére létrehozott tesztesetek darabszáma önmagában nem minőségi kritérium: nem állíthatjuk bizonyossággal, hogy attól, mert több tesztesetünk van, nagyobb eséllyel találjuk meg az esetleges hibákat. Ennek oka, hogy a teszteseteinket gondosan meg kell tervezni. Pusztán véletlenszerű tesztadatok alapján nem biztos, hogy jobb eséllyel fedezzük fel a rejtett hibákat, azonban egy olyan tesztesettervezési módszerrel, amely például a bemenetek jellegzetességeit figyelembe véve alakítja ki a teszteseteket, nagyobb eséllyel vezet jobb teszteredményekhez. Egy fontos mérőszám a tesztlefedettség, amely azon kód százalékos aránya, amelyet az egységteszt tesztel. Ez minél magasabb, annál jobb, bár nem éri meg meg minden határon túl növelni.
A JUnit egységtesztelő keretrendszert Kent Beck és Erich Gamma fejlesztette ki, e jegyzet írásakor a legfrissebb verziója a 4.11-es. A 3.x változatról a 4.0-ra váltás egy igen jelentős lépés volt a JUnit életében, mert ekkor jelentős változások történtek, köszönhetően elsősorban a Java 5 által bevezetett olyan újdonságoknak, mint az annotációk megjelenése. Még ma is sokan vannak, akik a 3.x verziójú JUnit programkönyvtár használatával készítik tesztjeiket, de jelen jegyzetben csak a 4.x változatok használatát ismertetjük.
A JUnit 4.x egy automatizált tesztelési keretrendszer, ami annyit tesz, hogy a tesztjeinket is programokként megfogalmazva írjuk meg. Ha már egyszer elkészítettük őket, utána viszonylag kis költséggel tudjuk őket újra és újra, automatizált módon végrehajtani. A JUnit keretrendszer a tesztként lefuttatandó metódusokat annotációk segítségével ismeri fel, tehát tulajdonképpen egy beépített annotációfeldolgozót is tartalmaz. Jellemző helyzet, hogy ezek a metódusok egy olyan osztályban helyezkednek el, amelyet csak a tesztelés céljaira hoztunk létre. Ezt az osztályt tesztosztálynak nevezzük.
Az alábbi kódrészlet egy JUnit tesztmetódust tartalmaz. Az Eclipse-ben egy tesztosztály létrehozását a
→ → → menüpontban végezhetjük el.@Test public void testMultiply() { // MyClass a tesztelendő osztály MyClass tester = new MyClass(); // leellenőrizzük, hogy a multiply(10,5) 50-nel tér-e vissza assertEquals("10 x 5 must be 50", 50, tester.multiply( 10, 5)); }
A JUnit tesztfuttatója az összes,
@Test
annotációval ellátott metódust
lefuttatja, azonban, ha töb ilyen is van, közöttük a sorrendet nem
definiálja. Épp ezért tesztjeinket úgy célszerű kialakítani, hogy
függetlenek legyenek egymástól, vagyis egyetlen tesztesetmetódusunkban
se támaszkodjunk például olyan állapotra, amelyet egy másik teszteset
állít be. Egy teszteset általában úgy épül fel, hogy a tesztmetódust az
@org.junit.Test
annotációval ellátjuk, a
törzsében pedig meghívjuk a tesztelendő metódust, és a végrehajtás
eredményeként kapott tényleges eredményt az elvárt eredménnyel össze
kell vetni. A JUnit keretrendszer alapvetően csak egy parancssoros
tesztfuttatót biztosít, de ezen felül nyújt egy API-t az integrált
fejlesztőeszközök számára, amelynek segítségével azok grafikus
tesztfuttatókat is implementálhatnak. Az Eclipse grafikus
tesztfuttatóját a → → menüpontból
érhetjük el. Egy tesztmetódust kijelölve lehetőség nyílik csupán ennek a
tesztesetnek a lefuttatására is.
A JUnit a @Test
annotáció mellett
további annotációtípusokat is definiál, amelyekkel a tesztjeink
futtatását tudjuk szabályozni. Az alábbi táblázat röviden összefoglalja
ezen annotációkat.
3.2. táblázat - JUnit annotációk
Annotáció | Leírás |
---|---|
@Test public void method()
| A @Test annotáció egy
metódust tesztmetódusként jelöl meg. |
@Test(expected = Exception.class) public void
method()
| A teszteset elbukik, ha a metódus nem dobja el az adott kivételt |
@Test(timeout=100) public void method()
| A teszt elbukik, ha a végrehajtási idő 100 ms-nál hosszabb |
@Before public void method()
| A teszteseteket inicializáló metódus, amely minden teszteset előtt le fog futni. Feladata a tesztkörnyezet előkészítése (bemenetei adatok beolvasása, tesztelendő osztály objektumának inicializálása, stb.) |
@After public void method()
| Ez a metódus minden egyes teszteset végrehajtása után lefut, fő feladata az ideiglenes adatok törlése, alapértelmezések visszaállítása. |
@BeforeClass public static void method()
| Ez a metódus pontosan egyszer fut le, még az összes
teszteset és a hozzájuk kapcsolódó
@Before -ok végrehajtása előtt.
Itt tudunk olyan egyszeri inicializálós lépéseket elvégezni,
mint amilyen akár egy adatbázis-kapcsolat kiépítése. Az ezen
annotációval ellátott metódusnak mindenképpen statikusnak kell
lennie! |
@AfterClass public static void method()
| Pontosan egyszer fut le, miután az összes tesztmetódus,
és a hozzájuk tartozó @After
metódusok végrehajtása befejeződött. Általában olyan egyszeri
tevékenységet helyezünk ide, amely a
@BeforeClass metódusban
lefoglalt erőforrások felszabadítását végzi el. Az ezzel az
annotációval ellátott metódusnak statikusnak kell
lennie! |
@Ignore
| Figyelmen kívül hagyja a tesztmetódust, illetve tesztosztályt. Ezt egyrészt olyankor használjuk, ha megváltozott a tesztelendő kód, de a tesztesetet még nem frissítettük, másrészt akkor, ha a teszt végrehajtása túl hosszú ideig tartana ahhoz, hogy lefuttassuk. Ha nem metódus szinten, hanem osztály szinten adjuk meg, akkor az osztály összes tesztmetódusát figyelmen kívül hagyja. |
A következő példa egy mesterséges példa, jelen esetben a Java
Collections Framework egy kollekciójának tesztelése történik meg,
azonban a JUnit alapvető eszközrendszere könnyen bemutatható általa. Itt
tehát a @Test
annotációval eljelölt
metódusok azok, amelyek tesztesetként lefuttatatandók
import org.junit.*; import static org.junit.Assert.*; import java.util.* ; public class JunitTestFirstExample { private Collection<String> collection; @BeforeClass public static void oneTimeSetUp() { System.out.println("@BeforeClass - oneTimeSetUp"); } @AfterClass public static void oneTimeTearDown() { System.out.println("@AfterClass - oneTimeTearDown"); } @Before public void setUp() { collection = new ArrayList(); System.out.println("@Before - setUp"); } @After public void tearDown() { collection.clear(); System.out.println("@After - tearDown"); } @Test public void testEmptyCollection() { assertTrue(collection.isEmpty()); System.out.println("@Test - testEmptyCollection"); } @Test public void testOneItemCollection() { collection.add("itemA"); assertEquals(1, collection.size()); System.out.println("@Test - testOneItemCollection"); } }
A végehajtás az alábbi eredménnyel zárul:
@BeforeClass - oneTimeSetUp @Before - setUp @Test - testEmptyCollection @After - tearDown @Before - setUp @Test - testOneItemCollection @After - tearDown @AfterClass - oneTimeTearDown
Ez csak egy lehetséges végrehajtási sorrend, ugyanis a JUnit
tesztfuttatója nem definiál sorrendet az egyes tesztmetódusok
végrehajtása között, ezért a
testEmptyCollection
és
testOneItemCollection
végrehajtása a
fordított sorrendben is végbemehetett volna. Ami viszont biztos: a
@Before
mindig a teszteset futtatása
előtt, az @After
utána fut le, minden
egyes tesztesetre. A @BeforeClass
egyszer, az első teszt, illetve hozzá tartozó
@Before
előtt, az
@AfterClass
ennek tükörképeként, a
legvégén, pontosan egyszer.
A végrehajtás tényleges eredménye és az elvárt eredmény közötti
összehasonlítás során állításokat fogalmazunk meg.
Az állítások nagyon hasonlóak az „Állítások (assertions)”
alfejezetben már megismertekhez, azonban itt nem az assert
utasítást, hanem az org.junit.Assert
osztály statikus metódusait használjuk ennek megfogalmazására. Ezen
metódusok nevei az assert részsztringgel kezdődnek, és lehetővé teszik,
hogy megadjunk egy hibaüzenetet, valamint az elvárt és téényleges
eredményt. Egy ilyen metódus elvégzi az értékek összevetését, és egy
AssertionError
kivételt dob, ha az
összehasonlítás elbukik. (Ez a hiba ugyanaz, amelyet az
assert
utasítás is kivált, ha a feltétele hamis.) A
következő táblázat összefoglalja a legfontosabb ilyen metódusokat. a
szögletes zárójelek ([]
) közötti paraméterek
opcionálisak.
3.3. táblázat - Az Assert
osztály metódusai
Állítás | Leírás |
---|---|
fail([String])
| Feltétel nélkül elbuktatja a metódust. Annak ellenőrzésére használhatjuk, hogy a kód egy adott pontjára nem jut el a vezérlés, de arra is jó, hogy legyen egy elbukott tesztünk, mielőtt a tesztkódot megírnánk. |
assertTrue([String], boolean)
| Ellenőrzi, hogy a logikai feltétel igaz-e. |
assertFalse([String], boolean)
| Ellenőrzi, hogy a logikai feltétel hamis-e. |
assertEquals([String], expected, actual)
| Az equals metódus alapján
megvizsgálja, hogy az elvárt és a tényleges eredmény
megegyezik-e. |
assertEquals([String], expected, actual,
tolerance)
| Valós típusú elvárt és aktuális értékek egyezőségét vizsgálja, hogy belül van-e tűréshatáron. |
assertArrayEquals([String], expected[],
actual[])
| Ellenőrzi, hogy a két tömb megegyezik-e |
assertNull([message], object)
| Ellenőrzi, hogy az ojektum null -e |
assertNotNull([message], object)
| Ellenőrzi, hogy az objektum nem
null -e |
assertSame([String], expected, actual)
| Ellenőrzi, hogy az elvárt és a tényleges objektumok referencia szerint megegyeznek-e |
assertNotSame([String], expected, actual)
| Ellenőrzi, hogy az elvárt és a tényleges objektumok referencia szerint nem egyeznek-e meg |
A JUnit 4-es verziójában jelent meg a parametrizált tesztek készítésének lehetősége. Ezek célja, hogy lehetővé tegyék ugyanazon tesztesetek többszöri lefuttatását, persze rendre különböző értékekkel.
Leegyszerűsített példaként tekintsünk egy olyan osztályt,
amelynek az add
metódusa összeadja a
paramétereként kapott két számot:
public class Addition { public int add(int x, int y) { return x + y; } }
Parametrizált tesztek készítéséhez az alábbi öt tevékenységet kell elvégeznünk:
El kell látni a tesztosztályt a
@RunWith(Parameterized.class)
annotációval.
@RunWith(Parameterized.class) public class AdditionTest { private int expected; private int first; private int second; ... }
Készíteni kell egy olyan konstruktort, amely egy sornyi tesztadat információit képes befogadni.
public AdditionTest(int expected, int first, int second) { this.expected = expected; this.first = first; this.second = second; }
Létre kell hozni egy, a
@Parameters
annotációval ellátott
olyan publikus statikus metódust, amely egydimenziós tömbök
kollekcióját adja vissza. Egy-egy tömb ebben a kollekcióban az
egyes tesztvégrehajtások során használt adatokat tartalmazza. A
kollekció mérete azt mondja meg a tesztfuttatónak, hogy hányszor
kell majd az egyes teszteseteket lefuttatni. Az egyes tömböknek
azonos elemszámúaknak kell lennie, ráadásul ez pont annyi, mint
a konstruktor paramétereinek a száma, hiszen a tesztfuttató majd
a tömb elemei alapján fogja létrehozni a paraméterezett teszt
végrehajtásához szükséges objektumot.
@Parameters public static Collection<Integer[]> addedNumbers() { return Arrays.asList(new Integer[][] {{3, 1, 2}, {5, 2, 3}, {7, 3, 4}, {9, 4, 5}}); }
A példában látható tömbök (például a 3, 1, 2 elemekt
tartalmazó) felhasználásával hozza majd a futtató rendszer létre
a tesztosztály egy-egy objektumát (az
AdditionTest
konstruktornak átadva a tömb
elemeit).
Létre kell hozni a tesztmetódus(oka)t. Ezeket szokás
szerint a @Test
annotáció jelöli,
és a tesztosztály példányváltozóin operálnak.
@Test public void sum() { Addition add = new Addition(); assertEquals(expected, add.addNumbers(first, second)); }
Néha előfordul, hogy az elvárt működéshez az tartozik, hogy a
tesztelt program egy adott ponton kivételt dobjon. Például a
kivételkezeléssel foglalkozó alfejezetben így működött a
push
művelet,ha már tele volt a fix méretű
verem: FullStackException
kivételt vált
ki, ha már elfogytak a helyek a veremben. Elvárásunkat, mely szerint
kivételnek kellene bekövetkeznie, a teszteset
@Test
annotációjának expected
paraméterének megadásával fogalmazhatjuk meg. Ilyenkor a tesztfuttató
majd akkor tartja sikeresnek a tesztet, ha valóban a megjelölt kivétel
hajtódik végre, és sikertelennek számít minden más esetben. Példa:
@Test(expected=FullStackException.class) public void testPush() { FixedLengthStack<Integer> s = new FixedLengthStack<Integer>(3); for (int i = 0; i < 4; i++) s.push(i); }
A példában egy háromelemű verembe 4 beszúrást kísérlünk meg. Arra számítunk, hogy ekkor a megjelölt kivétel kerül kiváltásra.
Ha nem váltódik ki kivétel, vagy nem a várt kivétel váltódik ki, a teszt elbukik. Azaz ha kivétel nélkül jutunk el a metódus végére, a teszteset megbukik.
Ha a kivétel üzenetének tartalmát akarjuk tesztelni, vagy a kivétel várt kiváltódásának helyét akarjuk szűkíteni (egy hosszabb tesztmetóduson belül), arra ez a módszer nem jó. Ilyenkor tegyük a következőt:
kapjuk el a kivételt mi magunk,
használjuk a fail-t, ha egy adott pontra nem volna szabad eljutni,
a kivételkezelőben pedig nyerjük ki a kivétel szövegét, és hasonlítsuk az elvárt szöveghez.
public void testException() { try { exceptionCausingMethod(); // Ha eljutunk erre a pontra, a várt kivétel nem váltódott ki, ezért megbuktatjuk a tesztesetet. fail("Kivételnek kellett volna kiváltódnia"); } catch(ExceptedTypeOfException exc) { String expected = "Megfelelő hibaüzenet"; String actual = exc.getMessage(); Assert.assertEquals(expected, actual); } }
Egy tesztkészlet (test suite) alatt összetartozó és együttesen
végrehajtandó teszteseteket értünk. Ez akkor igazán hasznos, ha egy
összetettebb funkció teszteléséhez számos, a részfunkciókat tesztelő
teszteset tartozik, amelyeket ilyenkor sokszor különálló osztályokba
szervezünk a könnyebb áttekinthetőség érdekében. A különböző
tesztosztályokban elhelyezett teszteket azonban mégis szeretnénk
együttesen (is) lefuttatni, amelyhez egy olyan tesztosztályra van
szükségünk, amelyet a
@RunWith(Suite.class)
és a
@Suite.SuiteClasses
annotációkkal is el
kell látnunk. Az előbbi a JUnit tesztfuttatónak mondja meg, hogy
tesztkészlet végrehajtásáról lesz szó, míg a második paraméteréül a
tesztkészletet alkotó tesztosztályok osztályliteráljait adjuk.
import org.junit.runner.RunWith; import org.junit.runners.Suite; @RunWith(Suite.class) @Suite.SuiteClasses({ Test1.class, Test2.class, JunitTestFirstExample.class }) public class TestSuite { }
A példában a Test1
, a
Test2
és a
JunitTestFirstExample
tesztosztályok által
tartalmazott tesztesetek végrehajtását végző tesztkészlet létrehozását
láthatjuk.
A JUnit bemutatott eszközeinek segítségével viszonylag alacsony költséggel tudunk inkrementális módon olyan tesztkészletet fejleszteni, amellyel mérhetjük az előrehaladást, kiszúrhatjuk a nem várt mellékhatásokat, és jobban koncentrálhatjuk a fejlesztési erőfeszítéseinket. Az a többletkódolás, amit a tesztesetek kialakítása érdekében kell megtennünk, valójában általában gyorsan behozza az árát és hatalmas előnyöket biztosít fejlesztési projektjeink számára. Mindez persze csak akkor lesz, lehet így, amennyiben az egységteszteink jól vannak megírva – éppen ezért érdemes megvizsgálni, hogy melyek azok a tevékenységek, amelyek a leggyakoribb hibákat jelentik az egységtesztelés során. Ha ezekkel tisztában vagyunk, remélhetőleg már nem követjük el őket mi magunk is.
A JUnit tesztek alapvető építőelemei az állítások (assertion-ök), amelyek olyan logikai kifejezések, amik ha hamisak, az valamilyen hibát jelez. Az egyik legnagyobb hiba velük kapcsolatban az, ha kézi ellenőrzést végzünk. Ez általában úgy jelenik meg (innen ismerhetünk rá), hogy a tesztmetódus viszonylag sok utasítást tartalmaz, azonban állítást egyet sem. Ilyenkor a fejlesztő a tesztet elsősorban arra használja, hogy ha valamilyen hiba (például egy kivétel) a teszt végrehajtása során bekövetkezik, akkor kézzel elkezdhesse debugolni. Ez a megközelítés azonban pontosan a tesztautomatizálás lényegét és egyben legnagyobb előnyét veszi el, nevezetesen, hogy tesztjeinket a háttérben, minde külső beavatkozás nélkül kvázi folyamatosan futtassuk. A kézi ellenőrzések másik tünete, ha a tesztek viszonylag nagy mennyiségű adatot írnak a szabványos kimenetre vagy egy naplóba, majd ezeket kézzel ellenőrzik, hogy minden rendben zajlott-e. Ehhez nagyon hasonló ellenminta a hiányzó állítások esete, amikor egy tesztmetódus nem tartalmaz egyetlen utasítást sem. Ezek a helyzetek kerülendőek.
Nem csak a túl kevés, de a túl sok állítás is problémás lehet: ha egy tesztmetódusban több állítás is van, az általában azt jelenti, hogy a tesztmetódus túl sokat próbál tesztelni. Ezt orvosolhatjuk, ha szétvágjuk a tesztmetódust több tesztmetódusra.
// KERÜLENDŐ!
public class MyTestCase {
@Test
public void testSomething () {
// Teszteset inicializálása, lokális változók manipulálása
assertTrue (condition1);
assertTrue (condition2);
assertTrue (condition3);
}
}
// JAVASOLT!
public class MyTestCase {
// A lokális változókból példányváltozók lesznek
@Before
protected void setUp() {
// Teszteset inicializálása, példányváltozók manipulálása
}
@Test
public void testCondition1() {
assertTrue(condition1);
}
@Test
public void testCondition2() {
assertTrue(condition2);
}
@Test
public void testCondition3() {
assertTrue(condition3);
}
}
Ez nem feltétlenül jelenti azt, hogy tesztenként pontosan egy állítás kerüljön megfogalmazásra! Tapasztalt tesztelők is készítenek néha olyat, hogy egy tesztmetódus több (de csak néhány) állítást tartalmaz. Általában azzal van a probléma, hogy összekeveredik a funkcionalitást tesztelő kód és az elvárt eredmények ellenőrzését végző kód, mert ilyenkor a hiba okát elég nehéz meglelni.
A redundáns feltételek szintén kerülendőek. Egy redundáns
állítás egy olyan assert
metódus, amelyben
a feltétel beleégetett módon true
. Általában
ezzel a helyes működési mód demonstrálását szeretnék elvégezni,
azonban a szükségtelen bőbeszédűség csak zsúfolttá teszi a metódust.
Amennyiben egyéb állítások nincsenek is, akkor ez tulajdonképpen a
kézzel ellenőrzés antimintával egyenértékű. Ha ilyennel találkozunk,
egyszerűen csak szüntessük meg azon állításokat, amelyek a beégetett
feltételt tartalmazzák.
//KERÜLENDŐ! @Test public void testSomething() { ... assertTrue("...", true); }
A rossz állítás alkalmazása szintén problémát okozhat. Az
Assert
osztálynak elég sok metódusa kezdődik
assert
-tel, ráadásul sokszor csak kicsit eltérő
közöttük a paraméterek száma és a szementikájuk. Sokan talán épp
emiatt csupán egyetlen assert
metódust használnak,
mégpedig az assertTrue
-t, és annak a
logikai kifejezés részébe szuszakolják bele, hogy mit is szeretnének
levizsgálni. Példák a rossz használatra:
assertTrue("Objects must be the same", expected == actual); assertTrue("Objects must be equal", expected.equals(actual)); assertTrue("Object must be null", actual == null); assertTrue("Object must not be null", actual != null);
Ezek helyett használjuk rendre az alábbiakat:
assertSame("Objects must be the same", expected, actual); assertEquals("Objects must be equal", expected, actual); assertNull("Object must be null", actual); assertNotNull("Object must not be null", actual);
A kezdő egységtesztelők gyakorta csak valamilyen alapvető tesztkódot írnak, és nem vizsgálják meg teljesen a tesztelendő kódot. Ennek többféle megjelenési formája is van:
Csak az alapvető lefutás tesztelése: csak a rendszer elvárt viselkedése kerül tesztelésre. Érvényes adatokat megadva az elképzelt helyes eredmény ellenében történik az ellenőrzés, hiányoznak azonban a kivételes esetek vizsgálatai. Ilyen például, hogy mi történik hibás bemeneti adatok esetén, az elvárt kivételek eldobásra kerültek-e, melyek az érvényes és érvénytelen adatok ekvivalenciaosztályainak határai, stb.
Csak a könnyű tesztek: az előzőhez némiképpen hasonlóan, csak arra koncentrálunk, amit egyszerű ellenőrizni, és így a tesztelendő rendszer igazi logikája figyelmen kívül marad. Ez tipikusan a tapasztalatlan fejlesztő komplex kódot tesztelni célzó próbálkozásainak a tünete.
Ezek ellen valamilyen tesztlefedettség-mérő eszköz alkalmazásával védekezhetünk, amely segít meghatározni, hogy a kód melyik része nincs kielégítő módon tesztelve.
Az egységtesztek kódjának az éles rendszer kódjához hasonlóan könnyen érthetőnek kell lennie. Általánosságban azt mondhatjuk, hogy egy programozónak a lehető leggyorsabban meg kell értenie egy teszt célját. Ha egy teszt olyan bonyolult, hogy nem tudjuk azonnal megmondani róla, jó-e vagy sem, akkor nehéz megállapítani, hogy egy sikertelen tesztvégrehajtás a tesztelendő vagy a tesztelő kód rossz mivolta miatt következett-e be. Vagy ami még ennél is rosszabb, fennáll a lehetősége annak, hogy egy kód úgy megy át egy teszten, hogy nem volna neki szabad.
A túlbonyolított tesztek egyszerűsítését ugyanúgy végezzük, mint bármilyen más túlbonyolított kód egyszerűsítését: kódújraszervezést (refaktorálást) hajtunk végre a minél könnyebben érthető kód érdekében. Általában ezt a lépéssorozatot mindaddig végezzük, mígnem könnyen felismerhető módon a következő szerkezettel fog rendelkezni:
Inicializálás (set up)
Az elvárt eredmények deklarálása
A tesztelendő egység meghívása
A tevékenység eredményeinek beszerzése
Állítás megfogalmazása az elvárt és a tényleges eredményről.
Annak érdekében, hogy a kód helyesen működjön, számos külső függőségre kell támaszkodnia, például függhet:
egy bizonyos dátumtól vagy időtől,
egy harmadik fél által készített (úgynevezett third-party) jar formátumú programkönyvtártól,
egy állománytól,
egy adatbázistól,
a hálózati kapcsolattól,
egy webszervertől,
egy alkalmazásszervertől,
a véletlentől,
stb.
Az egységtesztek a tesztelési hierarchia legalsó szintjén helyezkednek el, a céljuk az, hogy kis mennyiségű kóddal izoláltan próbára tegyék az éles kód egy kis részét, vagyis az egységet. A magasabb szintű teszteléssel szemben az egységtesztelés célja tehát csakis önálló egységek ellenőrzése. Minél több függőségre van egy egységnek szüksége a futtatásához, annál nehezebb igazolni a megfelelő működést. Ha adatbázis-kapcsolatot kell konfigurálni, el kell indítani egy távoli szervert, stb., akkor az egységteszt futtatásáért nagy erőfeszítéseket kell tenni.
Az egységtesztek hatékonyságának jó mérőszáma, hogy egy kezdő fejlesztő mennyi idő alatt jut el a tesztek lefuttatásához onnantól kezdve, hogy a verziókezelő rendszerből beszerezte a kódokat. A legegyszerűbb megoldás a verziókezelőből (például cvs, svn vagy git) történő checkout után a build-elést végző eszköz (például ant vagy maven) futtatása, amely csak akkor megy ilyen egyszerűen, ha nincsenek külső függőségek. Ökölszabály, hogy a külső függőségeket el kell kerülni.
Ennek érdekében az alábbiakat tehetjük:
a harmadik fél által készített könyvtáraktól való függés elkerüléséhez használjunk tesztduplázókat (test doubles), például mock objektumokat,
biztosítsuk, hogy a tesztadatok a tesztkóddal együtt kerülnek csomagolásra,
kerüljük el az adatbázishívásokat egységtesztjeinkben,
ha mindenképpen adatbázisra van szükségünk, használjunk memóriában tárol adatbázist (például HSQLDB-t).
Míg az éles kód írásakor a fejlesztők általában tudatában vannak az el nem kapott kivételek problémáinak, ezért elég szorgalmasan elkapogatják és naplózzák a problémákat, azonban egységtesztelés esetén ez a minta teljességgel rossz!
Tekintsük az alábbi tesztmetódust::
// KERÜLENDŐ!
@Test
public void testCalculation () {
try {
deepThought.calculate();
assertEquals("Calculation wrong", 42, deepThought.getResult());
} catch (CalculationException ex) {
Log.error("Calculation caused exception", ex);
}
}
Ez teljesen rossz, hiszen a teszt átmegy akkor is, ha kiváltódott egy kivétel! Persze a napló bejegyzéseinek a vizsgálatával a problémára fény derülhet, de egy automatizált tesztelési környezetben gyakran senki nem olvassa a naplókat.
Még ennél is agyafúrtabb példát láthatunk itt:
// KERÜLENDŐ!
@Test
public void testCalculation() {
try {
deepThought.calculate();
assertEquals("Calculation wrong", 42, deepThought.getResult());
}
catch(CalculationException ex) {
fail("Calculation caused exception");
}
}
Habár ez a példa annak rendje és módja szerint elbukik, és így jelzi a JUnit futtatónak, hogy valamivel hiba történt, a hiba helyét jelző aktuális veremtartalom elvész.
A megoldás az lesz, hogy ne kapjuk el a nem várt kivételeket! Hacsaknem direkt azért írunk kivételkezelőt, hogy ellenőrizzük, hogy egy eldobandó kivétel dobása tényleg megtörténik, nincs okunk elkapni a kivételeket. Sokkal inkább tovább kellene adni a hívási láncot a JUnit-nak, hogy kezelje ő. Az átalakított kód valahogy így nézhet ki:
@Test public void testCalculation() throws CalculationException { deepThought.calculate(); assertEquals("Calculation wrong", 42, deepThought.getResult()); }
Mint látható, eltűnt a try
-blokk és megjelent egy
throws
utasításrész, a kód pedig könnybben olvashatóvá
vált.
Amennyiben azt kellene igazolnunk, hogy egy adott kivétel bekövetkezik, akkor azt megtehetnénk többféleképpen is. Az első példában lévő teszteset csak akkor nem bukik el, ha a kivétel bekövetkezik.
@Test public void testIndexOutOfBoundsException() { try { ArrayList emptyList = new ArrayList(); Object o = emptyList.get(0); fail("Exception was not thrown"); } catch(IndexOutOfBoundsException ex) { // Siker! } }
A második példában ugyanerre a
Test
annotáció
expected
paraméterét
használjuk:
@Test(expected = IndexOutOfBoundsException.class) public void testIndexOutOfBoundsException() { ArrayList emptyList = new ArrayList(); Object o = emptyList.get(0); fail("Exception was not thrown"); }
A rossz helyre szervezett tesztkódok zavart okozhatnak. Tekintsük az alábbi elhelyezést, ahol a tesztosztály ugyanabban a könyvtárban helyezkedik el, mint a tesztelendő osztály:
src/ com/ xyz/ SomeClass.java SomeClassTest.java
Ekkor nehéz megkülönböztetni a tesztkódot az alkalmazás kódjától. Ezen persze egy elnevezési konvencióval valamennyire lehet segíteni, azonban a tesztkódnak sokszor olyan segédosztályok is részét képezik, amelyek nem közvetlenül kerülnek tesztként futtatásra, csak felhasználják őket a tesztek.
Egy másik rossz elhelyezés:, ha a tesztjeinket egy alkönyvtárba helyezzük a tesztelendő kód alá.
src/ com/ xyz/ SomeClass.java test/ SomeClassTest.java
Ezzel egyszerűbb kitalálni, hogy melyik osztályra van a
szükség a teszteléshez, és melyik az alkalmazás része, azonban a
protected
és csomag szintű láthatósággal rendelkező
tagok tesztelésének búcsút inthetünk. Hacsaknem a teszt kedvéért
kinyitjuk a hozzáférést, ami viszont megöli a bezárást.
Ráadásul mindkét megoldás további pluszmunkát ró ránk a szoftver kiadásának elkészítésekor, hiszen az egységteszt kódját nem telepítjük az éles rendszerre, épp ezért a csomagolás során valahogyan ki kell zárnunk a tesztelésre használatos kódunkat, ami az alkalmazott elnevezési konvenciótól függően akár elég byonyolult dolog is lehet.
A megoldás az, hogy a tesztkódokat ugyanabba a csomagba, de
mégis eltérő (párhuzamos) hierarchiába tegyük. Ekkor könnyen
szétválaszthatóak az éles kódok a tesztkódoktól és a bezárás
megsértésének problémája sem lép fel, hiszen a tesztkód ugyanabban a
csomagban helyezkedik el, mint a tesztelendő, ezért annak osztály
szintű és protected
tagjait is tesztelni
tudja.
src/ com/ xyz/ SomeClass.java test/ com/ xyz/ SomeClassTest.java
Ez esetben nem magukkal a tesztekkel, hanem azok hiányával van a baj. Minden programozó tudja, hogy teszteket kellene írnia a kódjához, mégis kevesen teszik. Ha megkérdezik tőlük, miért nem írnak teszteket, ráfogják a sietségre. Ez azonban ördögi körhöz vezet: minél nagyobb nyomást érzünk, annál kevesebb tesztet írunk. Minél kevesebb tesztet írunk, annál kevésbé leszünk produktívak és a kódunk is annál kevésbé lesz stabil. Minél kevésbé vagyunk produktívak és precízek, annál nagyobb nyomást érzünk magunkon. Ezt a problémát elhárítani csak úgy tudjuk, ha teszteket írunk. Komolyan. Annak ellenőrzése, hogy valami jól működik, nem szabad, hogy a végfelhasználóra maradjon. Az egységtesztelés egy hosszú folyamat első lépéseként tekintendő.
Azon túlmenően, hogy az egységtesztek a tesztvezérelt fejlesztés alapkövei, gyakorlatilag minden fejlesztési módszertan profitálhat a tesztek meglétéből, hiszen segítenek megmutatni, hogy a kódújraszervezési lépések nem változtattak a funkcionalitáson, és bizonyíthatják, hogy az API használható. Tanulmányok igazolták, hogy az egységtesztek használata drasztikusan növelni tudja a szoftverminőséget.