piątek, 20 lutego 2009

Mix SpringTestContext Framework i EasyMock

W ramach aktualnego projektu zastanawiałem się nad zagadnieniem jego testowania. Staram się aby projekt był prowadzony zgodnie z TDD i do tego celu korzystamy intensywnie z EasyMock oraz Spring TestContext Framework. Wszystko działa jak należy, ale pojawił się problem z testowaniem integracyjnym (przy wykorzystaniu Spring TestContext Framework), gdy ścieżka wykonania testu obejmuję komponenty, które to są zależne od innych (dostarczonych z zewnątrz) elementów.

Zgodnie z best-practices, w ramach projektu context Springowy zostaly podzielony na oddzielne pliki nie tylko ze względu na warstwę systemu(web,service,DAO, adapter), ale także ze względu na infrastrukturę (Junit, Tomcat, Websphere).

Początkowo wydawało się, że wystarczy stworzyć dodatkowy plik contextu - tylko na potrzeby testów- który mógłby defniować beany odpowiadające systemom zewnętrznym. W takim przypadku należy stworzyć klasy tych beanów, które pełniłyby rolę stubów. Podejście takie ma 1 zasadniczą wadę: w przypadku gdy nasza logika związana z interakcją z systemem zewnętrznym jest bardziej skomplikowana niż "fire 'n forget"to możliwość definiowania zachowania stuba jest ograniczona. Logika działania stuba jest zdefiniowana "a priori" w jego klasie i ewentualna zmiana zachowania wiąże się z utworzeniem nowej klasy/rozbudową istniejącej. Osobiście nie jestem zwolennikiem tworzenia dużej ilości dodatkowego kodu wyłącznie na potrzeby testów, tzn. czasami nie da się od tego uciec, ale jeżeli istnieje alternatywne rozwiązanie...
Trywialny przykład interfejsu do systemu zewnętrznego:
public interface UserAuthenticationManager {

int logUser(String name, String pass);

}

Wartość funkcji logUser jest ściśle określona, ale dla uproszczenia przyjmijmy, ze interesują nas 4 przypadki:
nie ma usera o danej nazwie, name/pass nie pasują, user zablokowany, wymagana zmiana hasła usera. Każda z takich sytuacji jest mapowana na inną wartość liczbową i na tej podstawie następuję specyficzne przetwarzanie.
Stosując podejście przedstawione powyżej, możemy albo utworzyć 4 klasy stubów, umieścić je w osobnych plikach contextu i odpowienio do scenariusza ładować jeden z nich, albo utworzyć 1 klasę stuba a w niej umieścić odpowiednie "if" aby pokryć wszystkie 4 przypadki.
Oba podejścia są słabe: albo rozdrabniamy konfiguracje, co powoduję niesamowity przyrost plików konfiguracyjnych, albo zajmiemy się pisaniem logiki, która na podstawie odpowiednich wartości (wartości parametrów metody, albo wartości umieszczonej w zmiennej ThreadLocal) zwróci nam wartość odpowiednią do scenariusza jaki w tym momencie testujemy.

Idealne w sytuacji wydaje się połączenie Spring TestContext Framework oraz EasyMock. Scenariusze testów integracyjnych byłyby sterowane przez Spring, a w przypadku odwoływania się do systemów zewnętrznych do akcji wchodziłyby mocki, dla których zachowanie zostałoby określone(nagrane) bezpośrednio w metodzie testowej. Z technicznego punktu widzenia chodzi o to, aby móc podmieniać w czasie wykonywania testu wybrane beany Springowe (w moim przypadku singletony), na utworzone mocki.
Pierwszą przeszkodą było agresywne inicjowanie beanów Springowych, które są singletonami. Problem polegał na tym, ze zanim metody testowe zostaną odpalone (a w nich miałaby się odbyć podmiana) context Springowy zostanie zaczytany, a beany będące singletonami utworzone. Pewnym obejściem, może być ustawienie atrybut default-lazy-init na true w elemencie <beans> w plikach contextu.
Pomimo, że rozwiązuje to problem, jest to sprzeczne z filozofią związaną z agresywnym tworzeniem beanów, dzięki której pomimo dłuższego czasu startu aplikacji błędy związane z tworzeniem się contextu bardzo szybko "wylatują". Jest to szczególnie ważne przy uruchamianiu aplikacji w środowiskach integracyjnym i produkcyjnym. Dlatego zdecydowałem się znaleźc alternatywne rozwiązanie, dzięki któremu nie musiałbym "naginać"konfiguracji Springowej do testowania.
Szczęśliwie twórcy Spring TestContext Framework umozliwiają bardzo prosto określenie klasy, która to będzie odpowiedzialna za odczytywanie i ładowanie ApplicationContext a sprowadza się to do podania odpowiedniej klasy w annotacji ContextConfiguration
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations={"/context.xml"}, loader=LazyContextLoader.class)
public class Test{
...
}

Klasa LazyContextLoader jest odpowiedzialna za wczytywanie kontekstu i jej implementacja jest niemal identyczna do klasy GenericXmlContextLoader:
public class LazyContextLoader extends AbstractGenericContextLoader {

@Override
protected BeanDefinitionReader createBeanDefinitionReader( final GenericApplicationContext context ) {
XmlBeanDefinitionReader result = new XmlBeanDefinitionReader( context );
result.setDocumentReaderClass( LazyInitByDefaultBeanDefinitionDocumentReader.class );
return result;
}

@Override
public String getResourceSuffix() {
return "-context.xml";
}
}

Zmieniona została tylko implementacja interfejsu BeanDefinitionDocumentReader, który to odczytuję pliki XML z defincjami beanów.
Zadaniem LazyInitByDefaultBeanDefinitionDocumentReader jest ustawienie w locie na root każdego z zaczytanych plików XML atrybutu default-lazy-init na wartość true
public class LazyInitByDefaultBeanDefinitionDocumentReader extends DefaultBeanDefinitionDocumentReader {

@Override
protected BeanDefinitionParserDelegate createHelper( XmlReaderContext readerContext, Element root ) {
root.setAttribute( BeanDefinitionParserDelegate.DEFAULT_LAZY_INIT_ATTRIBUTE,"true" );
return super.createHelper( readerContext,root );
}
}

W ten sposób udało się "czysto" zaczytać konfiguracje Springowa bez inicjalizacji beanów.
Następnym krokiem pozostała podmiana istniejących beanów, która okazała się dość prosta:

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations={"/context.xml"}, loader=LazyContextLoader.class)
public class Test{

@Autowired
private GenericApplicationContext context;

@Test
@DirtiesContext
public void testMethod(){
context.removeBeanDefinition( "userAuthenticationManager" );
UserAuthenticationManager mock = EasyMock.createMockUserAuthenticationManager.class );
EasyMock.expect( mock.logUser( (String)EasyMock.notNull(),(String)EasyMock.notNull() ) ).andReturn( 12 );
EasyMock.replay( mock );
context.getBeanFactory().registerSingleton( "userAuthenticationManager", mock );
...
}

Należy pamiętać jednak o kilku sprawach:
  • beany, które chcemy podmienić nie mogą być bezpośrednio injectowane w klasie testów, ani też nie moga być składowymi innych beanów, które mają byc injectowane (poprzez wkorzystywanie @Autowired)
  • metody testowe, które zmieniają context powinny byc oznaczone annotacją @DirtiesContext
  • Przedstawiony powyżej kod, wrzuca bean do contextu jako w pełni zainicjalizowany komponent, tzn. nie są na nim odpalane jakiekolwiek metody związane z cyklem życia beana, nie podlega konfiguracji AOP zawartej w plikach contextu.
  • Dla mojego projektu wystarczyło podmieniać singletony, nie potrzebowałem podmieniać beanów o innym cyklu życia
  • "leniwa" inicjalizacja beanów pozwala zaczytać niepełny context Springowy, czyli przykładowo taki, który nie musi zawierać definicji wszystkich beanów. Dopóki nie odwołamy się, za pomocą context.getBean() lub @Autowired, do beana, który nie został zdefiniowany, lub nie zostały zdefiniowane dla niego wszystkie zależne beany, możemy dowolnie "mieszać" w konfiguracji
  • Spring cachuje zaczytany context/contexty aplikacji na podstawie wartości locations
    annotacji @ContextConfiguration. Ze względów wydajnościowych sensownie jest wydzielić testy integracyjne, które w swoim działaniu mogą wywoływać systemy zewnętrzne od pozostałych testów integracyjnych systemu. Wydzielenie te będzie jedynie poprzez określenie dodatkowego (sztucznego) pliku contextu aby pozostałe testy integracyjne (te, które nie dotykają systemów zewnętrznych) mogły swobodnie korzystać z cachowanego contextu.

Brak komentarzy:

Prześlij komentarz