V případě čistých unit testů věřím, že s tím většina Java programátorů problém nemá. Máme JUnit, jasně dáno, že instance testovací třídy se vytváří pro každý test, anotacemi řízený setup a teardown a hotovo, není skoro co zkazit.
O něco horší situace je u integračních testů postavených na inicializaci testovacího Spring kontextu. V tomto případě se totiž jednou vytvořený Spring kontext recykluje pro každý test a tím překračuje hranice izolace testu. To je samozřejmě pragmatické nejen z důvodu celkového času testů, ale i proto, že graf vzájemně proinjektovaných Spring-managed bean je pro většinu testů neměnná struktura a těch pár testů, které do ní vrtají, lze pořešit dodatečně pomocí @DirtiesContext. Nicméně z pohledu teoretických pouček je to již ústupek.
Jedním z dalších takových ústupků může být použití aspektů. A že nesprávné použití není na první pohled znát, ukazuje příklad následujícího čerstvě vyřešeného problému z praxe.
Problém
Teamcity reportuje u jedné testovací třídy zfailování jedné metody. Ostatní metody i testy procházejí. Samotný test ovšem v IDE také prochází. Po reprodukci stejného pořadí jako v Teamcity a minimalizaci problému docházím k následujícímu chování:- spustí-li se po sobě testy A,B,C, test C zfailuje
- spustí-li se po sobě testy A,C, test C skončí úspěšně
- spustí-li se po sobě testy B,C, test C skončí úspěšně
- spustí-li se po sobě testy B,A,C, test C skončí úspěšně
Popis aktérů
- A je integrační test, který inicializuje Spring. Součástí Springu je cache manager s cachemi používanými z metod označených anotacemi @Cacheable a @CacheEvict.
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = {"classpath:applicationContext.xml"}) public class A { @Test public void test() {} }
- cachování je realizováno přes AspectJ způsobem "compile-time-weaving". Tj. metody s cachovacími anotacemi jsou již při překladu modifikovány tak, že veškerá práce aspektu je zapečena v bytekódu, který obaluje původní tělo metody. Součástí této práce je vyhledání cache manageru v kontextu Springu, nicméně není-li kontext k dispozici, tiše se pokračuje dál a operace se efektivně redukuje na provolání původní metody.
- B je obyčejný unit test, který ověřuje činnost servisky pro data vrácená z DAO. Metody FooService.method() i FooDao.get() jsou cachované.
public class B { @Test public void test() { Entity x = new Entity("X"); FooDao fooDao = mock(FooDao.class); when(fooDao.get()).thenReturn(x); FooService fooService = new FooService(); fooService.setFooDao(fooDao); Object result = fooService.method(); ... } }
- C je obyčejný unit test. Používá stejný setup FooService a FooDao jako test B až na to, že metody učí mock FooDao na hodnoty Y1, Y2 a u 2. testcasu se očekává výjimka. Anotace @CacheEvict zajišťuje, aby se metody v rámci testovací třídy navzájem neovlivňovaly.
public class C { @Test @CacheEvict(value = "FooCache", allEntries = true) public void test1() { Entity y1 = new Entity("Y1"); FooDao fooDao = mock(FooDao.class); when(fooDao.get()).thenReturn(y1); FooService fooService = new FooService(); FooService.setFooDao(fooDao); Object result = fooService.method(); ... } @Test(expected = SomeException.class) @CacheEvict(value = "FooCache", allEntries = true) public void test2() { Entity y2 = new Entity("Y2"); FooDao fooDao = mock(FooDao.class); when(fooDao.get()).thenReturn(y2); FooService fooService = new FooService(); fooService.setFooDao(fooDao); Object result = fooService.method(); ... } }
Poznámka: příklady jsou minimalizované, ve skutečnosti je část logiky v setupu, testy se od sebe více liší v setupu i verifikační části, test C je vysušen parametrizací, metody FooService.method() i FooDao.get()mají parametry a cachovací anotace mají příslušné klíče - tolik jen abych neodváděl pozornost puristů od tématu :-).
Příčina
Co se tedy dělo v případě "nešťastného" sledu testů?
- Na testu A je podstatná pouze inicializace springového kontextu, především beany typu CacheManager.
- Test B je obyčejný unit test, který tvoří vlastní instanci FooService. Mohlo by se tedy zdát, že nemá se Springem nic společného. Omyl – díky compile-time weavingu je třída obohacena o cachovací aspekt, který je přímo propojen se springovým cache managerem. Protože spring je již nainicializován, cache se použije a výsledkem volání testu je kromě úspěchu i naplnění cache pro metodu method hodnotou resultX.
- Test C je obyčejný unit test, který nejprve volá metodu test1. Ta se ovšem vůbec nedostane k tomu, aby použila mock DAO, protože volání metody method vytáhne cachovanou hodnotu resultX a pro ní selžou příslušné asserty.
- Protože test1 skončí výjimkou, neprovede se vyčištění cache anotací @CacheEvict.
- Zavolá se metoda test2 testu C. Vzhledem ke "špinavé" cachi se opakuje stejná situace. Zde se ovšem výjimka očekává (pozn. v našem případě se jednalo zrovna o stejnou třídu výjimky, při nestejné třídě by tato divergentní situace opět pokračovala), test proto skončí úspěšně a konečně se vyčistí cache.
- Pokud by následovaly další testy, už by proběhly korektně bez ohledu na to, zda se u nich očekává normální doběhnutí či výjimka.
Proč ostatní sledy testů proběhly dobře?
- Pokud se vynechal test A, neinicializoval se Spring a tudíž se nepoužívala cache. Všechna volání jinak cachované servisní metody tedy reálně do metody vstoupila (a získala správná testovací data ze svého mock DAO).
- V případě kombinace AC byl test C prvním, který byl spuštěn po inicializaci Springu, takže dostal čistou cache a díky anotaci @CacheEvict ji průběžně mazal. Problém nicméně i v této kombinaci existoval a byla by to jen otázka konstrukce vhodného testu D, který by na něj upozornil.
Shrnutí
Když záleží na pořadí testů, je to nepříjemné a není to žádná zábava. Jaké zkušenosti si z této lekce odnést?
- Aspekty na způsob "compile-time weaving" dělají z POJO něco, co už POJO není. Místo obyčejné beany máme něco, co závisí na org.springframework.
- Uvedené použití anotace @CacheEvict v testech není vhodné. Defaultní hodnota beforeInvocation = false znamená, že aspekt se vyvolá po skončení testu, ale až v dokumentaci se dočteme, že je tomu tak pouze pokud test neskončil výjimkou.
- Neměli bychom vyhráno ani při uvedení beforeInvocation = true, protože by cache zůstala špinavá po posledním testu (anotace neumožňuje 2 nezávislé přepínače beforeInvocation a afterInvocation).
- Řešením je po testu smazat cache. Aktuálně sice máme na projektu třídu, která je předkem testů a v @After metodě takové mazání provádí, byla však postavena pouze na znalosti injektovaného Spring cache manageru, což umožňovalo ji použít pouze u springových integračních testů. Při použití v prostých unit testech není k dispozici springový kontext, a přesto chceme přistupovat na cache (samozřejmě pouze je-li dosud inicializována). Naštěstí se lze na cachovací aspekt dostat přes statickou metodu aspectOf:
AnnotationCacheAspect cacheAspect = AnnotationCacheAspect.aspectOf(); CacheManager cm = cacheAspect.getCacheManager(); if (cm != null) { // cache was initialized before Collection
cacheNames = cm.getCacheNames(); for (String cacheName : cacheNames) { cm.getCache(cacheName).clear(); } } - Třída není jediný způsob řešení a má své nevýhody (nedefinované pořadí @After anotací, zabetonování hierarchie k třídě, která má spíš povahu traitu). Alternativním řešením je použití JUnit rules, princip však zůstává stejný.