Testowanie poleceń w Hibernate

I’m back, big as life and twice as ugly! (gen. Rancor)

Reaktywacja. W czasie ostatnich kilku miesięcy  zajmowałem się moją pracą dyplomową, a że temat był trudny oraz bardzo ciekawy to nie miałem czasu na prowadzenie tego dziennika. Dopiero teraz, kiedy doprowadziłem sprawę prawie do końca,  w miarę krótkim czasie postaram się omówić kilka ciekawych tematów. Możliwe że po obronieniu pracy (w czerwcu) napiszę o niej coś więcej.

Pierwszy raz wrzucam tekst artykułu na stronę. Niestety edytor wordpressa strasznie miesza formatowanie, więc nie jest tak jak w OpenOffice, w którym dokument powstał. Postaram się znaleźć jakieś rozwiązanie, ale na razie jest jak jest. Tak jak w poprzednich wpisach – artykuł można ściągnąć w formacie pdf (Testowanie poleceń bazodanowych).

W bieżącym wpisie chciałbym poruszyć problem testowania zapytań realizowanych w oparciu o Hibernate. Wszystkie przykłady wykorzystują encje wykorzystywane w poprzednich artykułach – dla przypomnienia są to:

@Entity
@Table(name="customer")
public class User
{
  @Id
  @GeneratedValue
  private Integer id;

  @OneToMany(cascade={javax.persistence.CascadeType.PERSIST}, mappedBy="author")
  private Set entries = new HashSet();

  @OneToMany(cascade={javax.persistence.CascadeType.PERSIST}, mappedBy="author")
  private Set comments = new HashSet();

  private String name;

  User(){ /* required by hiberante */ }

  public User( String name )
  {
    this.name = name;
  }

  public void addEntry( Entry entry )
  {
    entries.add( entry );
    entry.setAuthor(this);
  }

  public Set getEntries()
  {
    return entries;
  }

  public void addComment( Comment comment )
  {
    comments.add( comment );
    comment.setAuthor(this);
  }

  public Integer getId()
  {
    return id;
  }
}

@Entity
public class Entry
{
  @Id
  @GeneratedValue
  private Integer id;

  @ManyToOne(optional=false, cascade={javax.persistence.CascadeType.PERSIST})
  private User author;

  @OneToMany(cascade={javax.persistence.CascadeType.PERSIST}, mappedBy="entry")
  private Set comments = new HashSet();

  private String content;

  @Temporal(TemporalType.TIMESTAMP)
  private Date creationDate;

  Entry() { /* required by hiberante */ }

  public Entry( String content )
  {
    this.content = content;
    creationDate = new Date();
  }

  public Entry( User author, String content )
  {
    this.author = author;
    this.content = content;
    creationDate = new Date();
  }

  public void addComment( Comment comment )
  {
    comments.add( comment );
    comment.setEntry(this);
  }

  public Set getComments()
  {
    return this.comments;
  }

  public void setAuthor(User author)
  {
    this.author = author;
  }
}

@Entity
public class Comment
{
  @Id
  @GeneratedValue
  private Integer id;

  @ManyToOne(cascade={javax.persistence.CascadeType.PERSIST},optional=false)
  private Entry entry;

  @ManyToOne(cascade={javax.persistence.CascadeType.PERSIST},optional=false)
  private User author;

  private String content;

  Comment() { /* required by hibernate */ }

  public Comment( String content )
  {
    this.content = content;
  }

  public Comment( Entry entry, User author, String content )
  {
    entry.addComment(this);
    author.addComment(this);
    this.content = content;
  }

  public void setAuthor(User author)
  {
    this.author = author;
  }

  public User getAuthor()
  {
    return this.author;
  }

  public void setEntry(Entry entry)
  {
    this.entry = entry;
  }
}

Tylko i wyłącznie uproszczenia operacji zapisu wewnątrz testów, wszystkie związki między encjami propagują operację „persist”.
Testy zapytań, których dotyczy artykuł, mają charakter integracyjny, gdyż wykorzystujemy w nich bazę danych, potencjalnie znajdującą się na innym komputerze. Są one potrzebne, by sprawdzić czy zapytanie działa w pożądany sposób, jak zachowa się w skrajnych sytuacjach itd. Problemem charakterystycznym testów tego rodzaju jest: „skąd wziąć dane ?”. Jeśli dysponujemy lokalną maszyną „integracyjną” na której działa baza z danymi skopiowanymi z systemu produkcyjnego, bądź wygenerowanymi (tak by ich rozkłady, wartości, częstości itp. były zbliżone), to możemy spróbować wyszukać w niej dane, które nadają się do naszego testu i je wykorzystać. Ewentualnie możemy takie dane wpisać samemu w jakimś kliencie bazy danych. Wszystko może działać świetnie do chwili, gdy ktoś owych danych nie zmodyfikuje – przypadkiem, lub celowo – by pasowały do testów jego zapytań. A do tego prędzej czy później dojdzie na pewno. Poza tą niewątpliwą „kruchością” testu, musimy liczyć się z tym, że będzie prawdopodobnie mało czytelny a przez to trudniejszy w utrzymaniu i zrozumieniu w przypadku gdy zakończy się niepowodzeniem. Trudność w odczytaniu testu wynika z tego, że ma on często taką postać:

@org.junit.Test
public void testQuery()
{
  List results = query( 101010, "alamakota" );

  Assert.assertEquals( 5, results.size() );
  //...
}

Aby zrozumieć co tak naprawdę jest testowane musimy zajrzeć do bazy i sprawdzić dane użytkownika o identyfikatorze 101010 i powiązanych z nim rekordów – a to może zająć dużo czasu, jeśli zapytanie które testujemy jest rozbudowane i sprawdza rekordy przechodząc przez wiele tabel. I teraz wyobraźmy sobie, że nagle okazuje się, że test nie działa, a jego twórca jest na urlopie/ zmienił pracę itp. No dobrze – nawet jeśli to ostatnie nie jest prawdą to i tak jest bardzo wątpliwe by autor kodu pamiętał co w tej chwili natchnienia miał na myśli, a nas czeka mozolne przeglądanie w kliencie bazy danych wyników różnych zapytań nierzadko zawierających setki kolumn i wierszy – po to tylko by się przekonać, że np. ktoś dodał jedną literę w jakimś polu. Jeśli korzystamy z narzędzia do ciągłej integracji aplikacji ( np. Hudsona ), to może to cały proces może potrwać jeszcze dłużej, bo będziemy podejrzewać, że to ostatnio zatwierdzona zmiana kodu jest winna całej sytuacji, i dopiero po weryfikacji nowego kodu przejdziemy do szperania w bazie danych. W skrócie – dużo mozolnej pracy i straconego czasu.
Przejdźmy zatem do drugiego rozwiązania, w którym w każdym teście tworzone są dane, na których testujemy zapytanie. Postępując w ten sposób nie musimy już się bać, że jakaś zmiana w bazie zepsuje nasz test, jest on dobrze izolowany od tych zmian. Niestety wiąże się to ze wzrostem złożoności samego testu, który teraz może prezentować się następująco:

@org.junit.Test
public void test()
{
  User user = new User( "user" );

  User other1 = new User( "other" );
  Entry entry = new Entry();
  other1.addEntry(entry);

  Comment comment1 = new Comment( "tralala" );
  comment1.setEntry( entry );
  user.addComment(comment1);

  Entry userEntry = new Entry( "bumcykcyk" );
  user.addEntry(userEntry);

  User other2 = new User( "someUser" );
  Comment comment2 = new Comment( "alamakota" );
  comment2.setEntry( userEntry );
  other2.addComment( comment2 );
  //...

  //save all entities
  session.persist(user);
  //...

  List readers = getReadersOf( user, session );

  Assert.assertEquals( 5, results.size() ); ) );
  //...
}

Już na powyższym, prostym przykładzie widzimy, że napisanie takiego testu wymaga dużo klepania, a rezultat jest mało czytelny. W rzeczywistej aplikacji utworzenie potrzebnych danych nierzadko wymaga utworzenia kilkudziesięciu obiektów, połączonych w skomplikowany sposób i których pola muszą spełniać różnego rodzaju ograniczenia bazodanowe. To ostatnie potrafi nieźle skomplikować sprawę, w szczególności ograniczenia UNIQUE oraz wyzwalacze – w zamyśle test powinien działać niezależnie od zastanej zawartości bazy danych. Gdy już utworzymy niezbędne obiekty, przychodzi kolej na ich utrwalenie. W przedstawionych testach załatwia to ustawienie propagacji operacji „persist” w każdym związku encji – w rzeczywistej aplikacji tak wygodnie z reguły nie jest, więc musimy sprawdzić reguły propagacji w mapowaniu i utrwalić obiekty w odpowiedniej kolejności. Gdy mamy wiele encji proces ten może zająć trochę czasu – co prawda nie tak dużo jak kreowanie danych, ale wcale nie jest to takie hop-siup. W rezultacie powstaje test liczący kilkadziesiąt lub kilkaset linii kodu, mało czytelny i trudny w diagnozie, utrzymaniu. Cechy te powodują, że wielu programistów uważa pisanie takiego testu za stratę czasu i zwyczajnie tego nie robi.
Wymienione wyżej wady testów tego rodzaju można jednak usunąć: znacznie zmniejszyć objętość oraz czas potrzebny na pisanie testu oraz zwiększyć jego czytelność. Aby to zrobić musimy wpierw zaobserwować dwie charakterystyczne cechy testów tego rodzaju. Po pierwsze – często w całej aplikacji lub pewnym jej module istnieje swego rodzaju „centralna” encja – węzeł początkowy grafu obiektów, dla której reszta encji stanowi zbiór właściwości, rozszerzeń. Po drugie – najczęściej zapisujemy cały graf obiektów – czyli wszystkie obiekty, które możemy osiągnąć przechodząc od owej encji do jej „właściwości”. W oparciu o te spostrzeżenia uprościmy proces tworzenia testu zapytań w Hibernate.
Gdy w aplikacji nad którą pracujemy istnieje „centralna” encja, to z reguły w testach zapytań najpierw musimy stworzyć właśnie obiekt tego tego typu, a dopiero potem owe właściwości, dodatkowe informacje, a skoro tak to tworzenie grafu obiektów możemy napisać (w możliwie konfigurowalny sposób) raz i opakować we wzorzec projektowy Budowniczy (ang. Builder) i wykorzystywać w każdym z testów. Dla opisanych wcześniej klas – encji, zakładając że to klasa User jest „najważniejsza”, rozwiązanie to może wyglądać następująco (metody createXXX tworzą obiekty odpowiedniego typu z jakimiś domyślnymi wartościami):

public class UserBuilder
{
  private boolean withComment;
  private boolean withEntry;
  private boolean withEntryComment;

  public UserBuilder(){ }

  public UserBuilder withComment()
  {
    this.withComment = true;
    return this;
  }

  public UserBuilder withEntry()
  {
    this.withEntry = true;
    return this;
  }

  public UserBuilder withEntryComments()
  {
    this.withEntryComment = true;
    return this;
  }

  public User build()
  {
    verify();

    User user = createUser( "user" );
    if ( withComment )
    {
      User other = createUser( "other" );
      Entry entry = createEntry();
      other.addEntry(entry);

      Comment comment = createComment( entry, user );
      user.addComment(comment);
    }
    if ( withEntry )
    {
      Entry entry = createEntry();
      user.addEntry(entry);

      if ( withEntryComment )
      {
        User other = createUser( "someUser" );
        createComment( entry, other );
      }
    }

    return user;
  }

  private void verify()
  {
    if ( withEntryComment && !withEntry )
    {
      throw new IllegalStateException( "Entry comment required but no entry available." );
    }
  }
  //...
}

Teraz nasz test staje się krótszy i dużo bardziej czytelny:

@org.junit.Test
public void simplifiedTest()
{
  User user = new UserBuilder()
                  .withComment()
                  .withEntry()
                  .withEntryComments()
                  .build();

  //save all entities
  session.persist(user);
  //...

  List readers = getReadersOf( user, session );

  Assert.assertEquals( 5, results.size() ); ) );
  //...
}

Oczywiście nic nie stoi na przeszkodzie, by klasa przygotowująca graf obiektów była bardziej skomplikowana – np. pozwalała na tworzenie różnej liczby komentarzy, określała jakieś ważne pola klas itp. Gdy w przyszłości asercje testu nie zostaną spełnione to dużo łatwiej będzie zrozumieć sam test i zlokalizować przyczynę błędu.
Rozwiązanie drugiego z problemów tworzenia testów zapytań, a mianowicie – utrwalania grafu obiektów osiągalnych od obiektu „centralnego” (ang. presistence by reachability) wymaga zaimplementowania odpowiedniej procedury. Jest to dużo trudniejsze niż tworzenie klasy budowniczego i może trochę spowolnić testy, ale gdy już to zrobimy nie będziemy musieli sprawdzać za każdym razem ustawień propagacji operacji w mapowaniu, a w praktyce czas narzut takiej metody jest znikomy. Treść takiej metody zostanie omówiona i możliwa do pobrania ze strony w jednym z przyszłych artykułów – kiedyś już napisałem coś takiego w trochę uproszczonym wydaniu, ale gdzieś mi się zapodziało, więc muszę to zrobić ponownie. Stosując metodę utrwalającą graf obiektów test zostaje uproszczony do postaci:

@org.junit.Test
public void simplifiedTest()
{
  User user = new UserBuilder()
                  .withComment()
                  .withEntry()
                  .withEntryComments()
                  .build();

  //save all entities
  save( user );

  List readers = getReadersOf( user, session );

  Assert.assertEquals( 5, results.size() ); ) );
  //...
}

Zamiast pogmatwanego, wielo(set)linijkowego behemota, otrzymujemy prostą i bardzo czytelną metodą, która można bardzo szybko napisać. Aby to osiągnąć musimy jednak wpierw utworzyć odpowiednią klasę budującą oraz metodę utrwalającą oraz dobrze je przetestować.
Korzystając z budowniczego musimy mieć świadomość, że od domyślnych wartości pól obiektów może potencjalnie zależeć wiele testów, więc raz ustalone wartości powinny być rzadko zmieniane. Najlepiej w każdym teście samemu ustawiać wartości kluczowych pól wykorzystywanych w poleceniu bazodanowym, przez co uniezależniamy się od domyślnych wartości i zwiększamy czytelność testu – nie musimy wtedy zaglądać do implementacji budowniczego by poznać owe wartości. Jeśli będziemy postępować w ten sposób to możemy bez problemu zmieniać domyślne wartości, co może okazać się przydatne podczas refaktoryzacji bazy danych – zmiany definicji pól, długości itd., bo wystarczy to zrobić w jednym miejscu dla wszystkich testów.
Omawiając budowniczego warto zahaczyć o zagadnienie niezależne od zaproponowanych w artykule rozwiązań, a mianowicie o to, jakimi danymi testować polecenie. Tworząc klasę–budowniczego, możemy co prawda przyjąć jakie zechcemy wartości domyślne pól – byle tylko spełniały ograniczenia zdefiniowane w bazie danych, ale jeśli postąpimy w ten sposób to będziemy wiedzieć jak polecenie zadziała na systemie produkcyjnym. Jeśli aplikacja została już zainstalowana na takowym systemie, to należy z tego skorzystać i sprawdzić charakterystyki wartości co ważniejszych kolumn w bazie i dopiero wtedy określić wartości domyślne. Przy okazji warto zbudować słownik zawierający po kilka wartości skrajnych i przeciętnych kolumn, tak by programiści mogli je z niego pobierać i ustawiać w miejsce kluczowych dla zapytania pól. Testowanie zapytania wydaje się proste, ale lepiej się upewnić co do tego, na jakie dane może ono trafić – zwłaszcza gdy ( broń Boże ) istnieją rozbieżności między lokalną i produkcyjną bazą danych. Gdy systemu produkcyjnego jeszcze nie ma, to w oparciu o schemat bazy danych musimy sami zgadywać, na jakie wartości polecenie może się w przyszłości nadziać – pamiętając o prawach Murphy’ego dla baz danych: jeśli na jakąś kolumnę lub grupę kolumn w bazie danych nie zostało nałożone ograniczenie, to może/mogą przyjąć najgorsze dla zapytania wartości.
Tak na marginesie to gdy już wystarczająco zweryfikujemy za pomocą testów poprawność polecenia a posiadamy w bazie dane możliwie zbliżone do tych z systemu produkcyjnego, to warto włączyć w Hibernate opcje show_sql, skopiować polecenie do klienta bazy danych (np. sqldeveloper firmy Oracle) i sprawdzić plan zapytania – czasami można w ten sposób szybko odkryć problemy wydajnościowe wynikające z błędów w Hibernate lub nawet w konfiguracji bazy danych (stwierdzono metodą empiryczną).
Podsumowując – pisanie testów poleceń bazodanowych w oparciu o Hibernate nie musi być pracochłonne, żmudne, monotonne, a wyniki – nieczytelne i trudne w utrzymaniu. Stosując opisane wyżej metody można pisać dobre, niezależne stanu bazy testy szybko i łatwo, a gdy w przyszłości zasygnalizują błąd – to szybko go zidentyfikujemy.

Dodaj komentarz

Wprowadź swoje dane lub kliknij jedną z tych ikon, aby się zalogować:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Zmień )

Twitter picture

You are commenting using your Twitter account. Log Out / Zmień )

Facebook photo

You are commenting using your Facebook account. Log Out / Zmień )

Connecting to %s

Follow

Otrzymuj każdy nowy wpis na swoją skrzynkę e-mail.