sobota, 27 marca 2010

TDD interfejsu użytkownika

29.03.2010 Zmieniłem trochę kod zgodnie z podpowiedzią Jakuba Nabrdalika, żeby zapisać to mockitową składnią BDD, oraz Michała Margiela, który znalazł duplikację w kodzie :-)
Pamiętam czas, kiedy miałem 19-20 lat. W ciągu dnia studia, wieczorami dla zabawy tłukłem oldskoolowe dos'owe wirusy i animacje fraktalne w asemblerze, a nad ranem i w weekendy zarabiałem, żeby było w tygodniu na obiady w 'Złotej Kurce' koło polibudy. Zarabiałem pisząc aplikacje w Delphi i C++ Builderze. To były narzędzia! Wystarczyło poukładać okienkowe komponenty na panelu i na każdym wykonać dwuklik. Od razu IDE przenosiło Cię do odpowiedniej metody gdzie wystarczyło napisać kod, który miał być wykonany gdy użytkownik kliknie w ten komponent. Ciągle się zastanawiam dlaczego mimo upływu 12 lat, środowiska Javowe nie pozwalają na to samo...
Ale wracając do tematu - za pomocą tych narzędzi: świetnego środowiska i tzw. Event-Driven Development stworzyłem parę... potworków. Dlaczego potworków? No więc pewnie dlatego, że moje doświadczenie programistyczne było małe i nie wiedziałem, że kod UI nie powinien być wymieszany z logiką. A już na pewno nie z kodem obsługi bazy danych...
W międzyczasie parę lat minęło i zdążyłem się zorientować, że są sposoby na pisanie kodu tak, by móc go zmienić bezpiecznie jakiś czas po napisaniu. Najpierw okazało się, że są wzorce projektowe, które dość skutecznie pomagają zaprojektować kod przed napisaniem. Dzięki temu dało się potem taki kod zmienić i poprawić. Niestety zwykle tylko raz, czasem dwa. Potem ten kod i tak stawał się do ... niczego nie podobny. Potem okazało się, że są testy jednostkowe, które pozwalają na utrzymywanie kodu w nieskończoność (o ile utrzymuje się też testy...) I tak doszedłem w końcu do mementu kiedy logikę miałem ujarzmioną testami, ale pozostawał interfejs użytkownika jako najsłabsze ogniwo. Tak mniej-więcej koło 2006 Martin Fowler napisał parę artykułów opisujących wzorce tworzenia UI (w szczególności chodzi mi tu o Passive View) i... sprawa stała się jasna :-)


W Pragmatists tworzymy oprogramowanie wyłącznie w TDD. Również interfejs użytkownika. Używamy więc wzorców przedstawionych przez Fowlera, ponieważ podstawowa ich zaleta to uniezależnienie logiki UI od widoku, więc idealnie nadają się do testowania. Nie zawsze jest to dokładnie to, co proponował Fowler, ale co do zasady jest to jakaś wersja wzorca Model-View-Presenter. Passive View polega na tym, że widok potrafi jedynie przekazać prezenterowi komunikat o tym, że zaistniało jakieś zdarzenie, oraz potrafi się odświeżyć na podstawie posiadanych przez siebie danych. Prezenter, po otrzymaniu komunikatu, że coś się w widoku zdarzyło, wykonuje odpowiednią operację na modelu i pobiera z niego dane. Następnie ustawia na widoku uaktualnione dane,a ten po zakończeniu działania prezentera, odświeża się. Widok jest zupełnie 'głupi' - bez jakiejkolwiek logiki poza wywoływaniem prezentera i własnym odświeżaniem się. Cała logika widoku jest w prezenterze, a model jest tak naprawdę Business Delegate'em do reszty aplikacji. Dzięki temu możemy odpuścić sobie testowanie view, bo ryzyko, że pomylimy się w jakimś getterze jest znikome ;-) Możemy dokładnie przetestować prezenter, włącznie z ew. przepływem między ekranami/panelami. A testy modelu to tak naprawdę testy funkcjonalne niższych warstw aplikacji.


Weźmy konkretny przykład. Załóżmy, że piszemy aplikację a'la kalendarz w Outlook'u i mamy ekran tworzenia nowego zdarzenia w kalendarzu. Takie zdarzenie ma zwykle nazwę, datę z godziną i listę zaproszonych osób. Chcielibyśmy móc takie zdarzenie zapisać, wybrać z książki adresowej osoby zaproszone, i wysłać zaproszenia do osób zainteresowanych. W zależności czy aplikację piszemy top-down (zaczynamy od UI idąc 'w głąb') czy mamy już gotową logikę (baza danych + wysyłanie zaproszeń), możemy zacząć albo od prezentera albo od modelu. Załóżmy więc, że zaczynamy od zera, więc jakiś grafik dłubie dżejpegi z ekranem nowego zdarzenia, a my w tym czasie robimy coś ciekawego :-)

Wiemy, że prezenter trzyma sobie model i widok (od razu je mockujemy - przecież ich nie ma, bo zaczynamy od prezentera), i potrafi zapisać zdarzenie (na podstawie danych z widoku, za pośrednictwem modelu):
@RunWith(MockitoJUnitRunner.class)
public class NewEventPresenterTest {
 @Mock
 private NewEventView view;
 
 @Mock
 private NewEventModel model;

 private NewEventPresenter presenter;

 
 @Before
 public void setUpPresenter() {
  presenter = new NewEventPresenter(model, view);
 }
 
 @Test
 public void shouldSaveEvent() throws Exception {
  //when
  presenter.saveEvent();
  
  //then
  verify(model).saveEvent(presenter.event);  
 }
}

No i taki test pozwala nam sprawdzić, że prezenter faktycznie zapisze reprezentowane przez view zdarzenie w modelu.

No to teraz użytkownik zmienia nazwę zdarzenia:
@Test
 public void shouldSetEventTitle() throws Exception {
  //given
  given(view.getTitle()).willReturn("test title");
  
  //when
  presenter.onTitleChanged();
  
  //then
  verify(view).getTitle();    
  assertEquals("test title", presenter.event.getTitle());
 }

Ustawia datę:
@Test
 public void shouldSetEventDateTime() throws Exception {
  //given
  given(view.getDateTime()).willReturn("2010-01-01 12:00");
  
  //when
  presenter.onDateTimeChanged();
  
  //then
  verify(view).getDateTime();    
  assertEquals(new LocalDateTime(2010,1,1,12,0), presenter.event.getDateTime());
 }

Pokazuje użytkownikowi listę osób, z których może wybrać kogoś do zaproszenia:
@Test
 public void shouldFillPotentialInvitees() throws Exception {
  //when
  presenter.onShowPotentialInvitees();
  
  //then
  verify(model).listPotentialInvitees();
  verify(view).setPotentialInvitees((Collection)anyObject());
 }

No i w końcu zaprasza wybrane osoby:
@Test
 public void shouldSetSelectedInvitees() throws Exception {
  //given
  given(view.getSelectedInvitees()).willReturn(Arrays.asList("friend1", "friend2"));
  
  //when
  presenter.onSelectedInvitees();
  
   //then
  verify(model).invitePeople(view.getSelectedInvitees());
  assertEquals(view.getSelectedInvitees(), presenter.event.getInvitedPeople()); 
 }


Do tego mamy oczywiście model, który wykonuje te wszystkie operacje. Najpierw tworzymy sobie zdarzenie (tu mockujemy dalszą warstwę, bo testujemy model):
@RunWith(MockitoJUnitRunner.class)
public class NewEventModelTest { 
 @Mock
 private EventService eventService;
 
 private Event sampleEvent;

 private NewEventModel eventModel;
 
 @Before
 public void initSampleEvent() {
  Collection friends = Arrays.asList("friend1","friend2");
  sampleEvent = new Event()
   .title("Test Event")
   .dateTime(new LocalDateTime()
    .withDate(2010, 10, 23)
    .withTime(0, 0, 0, 0))
   .invited(friends);
  when(eventService.fetchEvent((LocalDateTime)anyObject())).thenReturn(sampleEvent);
  when(eventService.fetchPotentialInvitees()).thenReturn(Arrays.asList("friend1", "friend2", "friend3"));
  
  eventModel = new NewEventModel(); 
  eventModel.setEventService(eventService);
 }

 @Test
 public void shouldCreateNewEvent() throws Exception {  
  assertNotNull(sampleEvent);
 }
}

Weryfikujemy warunki brzegowe:
@Test
 public void shouldNotSendInvitationsWhenNoEvent() throws Exception {
  //given  
  sampleEvent = null;
  
  //when
  eventModel.sendInvitations(sampleEvent);
  
  //then
  verifyZeroInteractions(eventService);
 }

 @Test
 public void shouldNotSendInvitationsWhenInviteesNull() throws Exception {
  //given  
  given(eventService.fetchEvent((LocalDateTime)anyObject())).willReturn(sampleEvent.invited(null));
  
  //when
  eventModel.sendInvitations(sampleEvent);
  
  //then
  verifyZeroInteractions(eventService);
 }
 
 @Test
 public void shouldNotSendInvitationsWhenInviteesEmpty() throws Exception {
  //given  
  given(eventService.fetchEvent((LocalDateTime)anyObject())).willReturn(sampleEvent.invited(new ArrayList()));
  
  //when
  eventModel.sendInvitations(sampleEvent);
  
  //then
  verifyZeroInteractions(eventService);
 }

Wyciągamy listę potencjalnych zaproszonych:
@Test
 public void shouldRetrievePossibleInvitees() throws Exception {
  //given  
  NewEventModel eventModel = new NewEventModel();  
  eventModel.setEventService(eventService);  
  
  //when
  Collection possibleInvitees = eventModel.listPotentialInvitees();
  
  //then
  assertNotNull(possibleInvitees);
  assertFalse(possibleInvitees.isEmpty());  
 }

W końcu wysyłamy zaproszenia:
@Test
 public void shouldSendInvitationsWhenInviteesNotEmpty() throws Exception {
  //when
  eventModel.sendInvitations(sampleEvent);
  
  //then
  verify(eventService).sendInvitations(sampleEvent.getInvitedPeople());
 }

I zapisujemy zdarzenie:
@Test
 public void shouldSaveEvent() throws Exception {
  //when
  eventModel.saveEvent(sampleEvent);
  
  //then
  assertSame(sampleEvent, eventService.fetchEvent(sampleEvent.getDateTime()));
 }

W ten sposób nie tylko mamy dobrze przetestowany zarówno prezenter jak i model, ale również mamy ładny w nich kod (czyż fluent interface klasy Event nie jest śliczny?) - a to dzięki temu, że zaczęliśmy pisanie tego kodu od jego użycia (testu = przykładu). Teraz tylko czas popoganiać grafika, żeby szybciej te pikselki układał i wybrać jakąś wygodną technologię do napisania widoku...

Ach, zapomniałem wkleić tu właściwy kod.... Ech, z resztą, ten to przecież teraz już każdy potrafi napisać, w końcu eclipse wygenerował sam przynajmniej z 30%...


*Autor posta dziękuje autorowi Mockito za to, że dzięki niemu w powyższych przykładach mockowanie jest tak proste, że prawie go nie widać :)

4 komentarze:

  1. Dzięki Paweł za bardzo fajnego posta. Będę pokazywał wszystkim frontendowym niedowiarkom :)

    Jedna uwaga: dla czytelności osób niezaznajomionych z BDD/Mockito mógłbyś zamiast 'org.mockito.Mockito.when' użyć 'org.mockito.BDDMockito.given'. Trochę głupio wygląda metoda 'when' pod komentarzem 'given'.

    OdpowiedzUsuń
  2. Z drugiej strony lepiej mi się czyta when...thenReturn niż given...thenReturn. Ale pewnie masz rację. Bartek Bańkowski opowiadał mi, że spotkał gostka, który mockował w sekcji 'when' żeby mu słówka pasowały...

    A co do Passive View i testowania, to to jest fajne rozwiązanie, bo można tak kanonicznie jak jest w tym poście, a można (w prostszych przypadkach) też trochę 'na skróty' i np. wołać z view prezenter z odpowiednim parametrem, zmniejszając 'gadatliwość' wzorca. My też czasem pomijamy model i wołamy serwis bezpośrednio z prezentera, tam gdzie byłaby to tylko delegacja.

    OdpowiedzUsuń
  3. Kuba, poprawiłem kod na BDD. Może faktycznie to when trochę w oczy kłuło...

    OdpowiedzUsuń
  4. Super post. Dla mnie przydatny ze względu na Mockito i przyznam, że po raz pierwszy porządnie z niego skorzystałem i świetnie mi się pisze testy. Bardzo dziękuję.

    OdpowiedzUsuń