22. 5. 2016

Když testy padají při určitém pořadí

Jednou ze základních vlastností dobrého testu je izolovanost. Není přípustné, aby úspěch testu závisel na předchozím volání jiného testu. V jistém smyslu test připomíná pure funkci ve funkcionálním paradigmatu - nemá vedlejší efekty.

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ý.