Jesteś tutaj

Mock objects i EasyMock w praktyce

Karty podstawowe

Dobrze napisane, szczegółowe testy wymuszają niejako powstanie poprawnego programu. Testowana powinna być każda klasa zawierająca elementy logiki biznesowej. Testowanie pojedynczych klas w oderwaniu od pozostałych części systemu może jednak okazać się trudne. Sprawa się komplikuje, jeżeli testowany obiekt żyje np. wewnątrz kontenera, a my chcielibyśmy go przetestować poza kontenerem. W niniejszym artykule pokażę, jak radzić sobie w takich sytuacjach wykorzystując atrapy obiektów, czyli tak zwane mock objects.

Tradycyjne podejście do testów

Najbardziej popularnym narzędziem służącym do testowania kodu w Javie jest JUnit. Przykładowy szkielet kodu klasy testującej może wyglądać następująco:


package pl.jdn.test;

import junit.framework.TestCase;

public class SimpleTest extends TestCase {

protected void setUp() throws Exception {}

public void testBusinessLogic() throws Exception {}

public void testBusinessLogic2() throws Exception {}

protected void tearDown() throws Exception {}
}

Metoda setUp() uruchamiana jest przed każdym testem, metoda tearDown() - po wykonaniu każdego testu. Pozostałe metody z przedrostkiem test zawierają właściwe testy logiki biznesowej opatrzone asercjami.
W tradycyjnym podejściu w metodzie setUp() następuje uruchomienie aplikacji, albo, o ile to możliwe, przynajmniej tych jej składników, które są wykorzystywane przez testowaną klasę. Dla prostych projektów jest to podejście wystarczające. Ma jednak sporo wad:

  1. Uruchamiana jest cała aplikacja, co w przypadku nawet średniej wielkości programów może trwać dość długo. Co więcej, czasochłonne czynności startowe są powtarzane dla każdej metody testowej!
  2. Problematyczne staje się testowanie kodu korzystającego z zasobów zewnętrznych, np. baz danych, czy serwerów pocztowych. W skrajnym przypadku może nawet dojść do przekłamania wyników takich testów, jeżeli powstanie problem np. z operacjami czytania/zapisu niezwiązanymi bezpośrednio z testowaną logiką.
  3. Przetestowanie aplikacji w oderwaniu od reszty kodu jest trudne lub niemożliwe.

Już te trzy wady skłoniły deweloperów do opracowania alternatywnego podejścia do kwestii testów.

Pomocna atrapa

Wiemy już czego szukamy: narzędzia, które umożliwi przynajmniej trzy rzeczy:

  • pozwoli pisać testy, które będą się bardzo szybko uruchamiały,
  • metody testowe będą sprawdzać logikę, ale nie będą korzystały z "prawdziwych" zasobów zewnętrzych,
  • umożliwi testowanie pojedynczych klas w oderwaniu od pozostałych.

Intuicyjnie wyczuwamy już, że wszystkie powyższe postulaty da się spełnić, jeżeli tylko podmienimy niewygodne dla nas elementy, z których korzysta testowana klasa, na inne, bardziej nam odpowiadające. Aby nasze dalsze rozważania nie były zbyt teoretyczne rozważmy prosty przykład klasy, która wysyła życzenia do wskazanego przez użytkownika odbiorcy.


package pl.jdn.test;

import java.util.Map;

public class SendWishes {

private IMailSender mailSender;
private ITemplateProvider templateProvider;

public void setMailSender(IMailSender mailSender) {
this.mailSender = mailSender;
}

public void setTemplateProvider(ITemplateProvider tp) {
this.templateProvider = tp;
}

public void sendWishes(Map userSettings) {
String template = templateProvider.fillTemplate(
(String) userSettings.get("prefered.template"),
userSettings);
mailSender.sendMail(
(String) userSettings.get("rcpt"),
(String) userSettings.get("subject"),
template);
}
}

Klasa ta korzysta z dwóch zewnętrzych komponentów, które można ustawić np. przy pomocy kontenera IoC podczas startu aplikacji. Oto one:

package pl.jdn.test;

public interface IMailSender {
void sendMail(String rcpt, String subject, String body);
}


oraz

package pl.jdn.test;

import java.util.Map;

public interface ITemplateProvider {
String fillTemplate(String templateName, Map values);
}

Załóżmy, że domyślna implementacja ITemplateProvider pobiera szablony z bazy danych przy pomocy biblioteki Hibernate i wypełnia szablon używając biblioteki FreeMarker. Implementacja IMailSender wysyła e-mail używając API JavaMail.

Łatwo zauważyć, że przetestowanie metody sentWishes() w takich warunkach staje się bardzo trudne. Na szczęście, możemy podmienić na czas testów implementacje ITemplateProvider i IMailSender na dużo prostsze, np. pobierać szablony z plików, wypełniać je wyrażeniami regularnymi i nie wysyłać maila, tylko poprzestać na jego sprawdzeniu. Po wykonaniu tej operacji klasa testowa może wyglądać tak:

package pl.jdn.test;

import java.util.Map;

import junit.framework.TestCase;

public class SendWishesStubTest extends TestCase {

private SendWishes classToTest;

class MailSenderStub implements IMailSender {
public void sendMail(String rcpt, String subject, String body) {}
}

class TemplateProviderStub implements ITemplateProvider {
public String fillTemplate(String arg0, Map arg1) {
return null; // TODO
}
}

protected void setUp() throws Exception {
classToTest = new SendWishes();
classToTest.setMailSender(new MailSenderStub());
classToTest.setTemplateProvider(new TemplateProviderStub());
}

public void testSendWishes() {
// właściwy test
}
}

Już jest lepiej, testy nie korzystają z zasobów zewnętrznych, ale pojawił się nowy problem. Trzeba napisać bardzo dużo dodatkowego kodu, żeby osiągnąć zamierzony cel. Co więcej, nie mamy pewności, czy nasz kod poprawnie korzysta ze zmienionej klasy, czy wywołuje wszystkie metody, które powinien, czy w dobrej kolejności, itp.?

Mock objects - coś więcej niż zwykła atrapa

W ten sposób dotarliśmy do pojęcia mock object, czyli szczególnego rodzaju atrapy, która oprócz tego, że potrafi wykonać to, czego od niej wymagamy, to jeszcze potrafi sprawdzić, czy wywołaliśmy na niej wszystkie metody i czy w dobrej kolejności. Jeżeli obiekt "wyczuje", że nasz kod nie robi do końca tego, czego atrapa by oczekiwała otrzymamy stosowny komunikat o błędzie.

Samodzielne tworzenie mock'ów byłoby zadaniem dość uciążliwym. Na szczęście mamy do dyspozycji szereg projektów Open Source, które nam w tym pomogą. Prawie kompletną listę można znaleźć na głównej stronie poświęconej tematyce testowania z wykorzystaniem atrap. W dajszej części artykułu skoncentruję się na omówieniu jednego z nich - EasyMock.

EasyMock, czyli prościej się nie da

EasyMock pozwala na korzystanie ze wszelkich dobrodziejstw atrap obiektów bez konieczności ich pisania. Biblioteka ta generuje atrapy obiektów w locie za pomocą mechanizmu pośrednika (ang. proxy) dostępnego w Javie od wersji 1.3.

Zasada działania easymock jest dość przewrotna i wymaga nieco innego niż tradycyjne podejścia do atrap obiektów. EasyMock możnaby porównać do magnetowidu, który nagrywa sekwencję operacji, a następnie ją odtwarza sprawdzając, czy odtworzenie sekwencji jest zgodne z nagraniem. Brzmi to nieco enigmatycznie, dlatego lepiej od razu spojrzeć na przykład.

package pl.jdn.test;

import java.util.HashMap;
import java.util.Map;

import org.easymock.MockControl;

import junit.framework.TestCase;

public class SendWishesMockTest extends TestCase {

private SendWishes classToTest;

private MockControl mailSenderMockControl;
private MockControl templateProviderMockControl;

private IMailSender mailSenderMock;
private ITemplateProvider templateProviderMock;

protected void setUp() throws Exception {
mailSenderMockControl =
MockControl.createControl(IMailSender.class);
templateProviderMockControl =
MockControl.createControl(ITemplateProvider.class);

mailSenderMock =
(IMailSender) mailSenderMockControl.getMock();
templateProviderMock =
(ITemplateProvider) templateProviderMockControl.getMock();

classToTest = new SendWishes();
classToTest.setMailSender(mailSenderMock);
classToTest.setTemplateProvider(templateProviderMock);
}

public void testSendWishes() throws Exception {

Map userSettings = new HashMap();
userSettings.put("prefered.template", "tpl.name");
userSettings.put("rcpt", "adresat@email");
userSettings.put("subject", "Wishes for you!");

templateProviderMock.fillTemplate("tpl.name", userSettings);
templateProviderMockControl.setReturnValue("template body");

mailSenderMock.sendMail(
"adresat@email", "Wishes for you!", "template body");

templateProviderMockControl.replay();
mailSenderMockControl.replay();

classToTest.sendWishes(userSettings);

mailSenderMockControl.verify();
templateProviderMockControl.verify();
}
}

Analizę powyższego kodu rozpoczniemy od metody setUp().

  1. Pierwszą rzeczą jest utworzenie specjalnego obiektu typu MockControl, który będzie sprawował pieczę nad wygenerowanym mock'iem. Metoda MockControl.createControl(Class), przyjmuje jeden argument - interfejs (nie klasę), dla którego chcemy utworzyć atrapę. W dalszej części artykułu utworzony obiekt będę określał mianem obiektu sterującego. Dla każdego obiektu, który chcemy podmienić na atrapę należy utworzyć oddzielny obiekt sterujący.
  2. Następnie za pomocą metody getMock() pobieramy z obiektu sterującego wygenerowaną atrapę. Możemy jej od tej pory używać w taki sam sposób, jak każdą inną klasę implementującą podany interfejs. W szczególności możemy przekazać referencję do tego obiektu testowanej przez nas klasie.

Pora na kwintesencję EasyMock, czyli nagrywanie sekwencji wywołań metod i sprawdzanie, czy wszystko zostało wykonane tak, jak zaplanowano. Utworzona atrapa nie jest zwykłym obiektem w tym sensie, że wykonywanie metod na nim nie zwraca żadnych sensownych wartości. Wszystkie wywołania są jednak skrupulatnie nagrywane z dokładnością do każdego parametru. W naszym przykładzie nagraliśmy następujące zachowanie się atrapy dostawcy szablonów:


templateProviderMock.fillTemplate("tpl.name", userSettings);
templateProviderMockControl.setReturnValue("template body");

Należy to interpretować w następujący sposób: oczekuję, że na obiekcie templateProviderMock zostanie wywołana metoda fillTemplate() z podanymi parametrami. Zwróci ona łańcuch tekstowy "template body".

Operacje te należy powtórzyć dla każdej atrapy. Jeżeli uwzględniliśmy już wszystkie wywołania metod należy wykonać na każdym obiekcie sterującym metodę replay(). Powoduje ona przejście z trybu nagrywania do trybu właściwej pracy. Od tej pory nasz mock zacznie już swoim zachowaniem przypominać zwykły obiekt, tzn. zacznie zwracać ustawione wcześniej wartości. Jest to dobry moment na przeprowadzenie właściwych testów klasy. W naszym przykładzie jest to wywołanie sentWishes() na testowanym obiekcie. Ostatnim etapem jest wywołanie na każdym obiekcie sterującym metody verify(), której zadaniem jest sprawdzenie, czy wszystkie zadeklarowane metody zostały wykonane, czy parametry wywołania się zgadzają z nagranymi.

Więcej możliwości

Dodatkowe, przydatne w różnych sytuacjach funkcje EasyMock to:

  • restykcyjna weryfikacja - obiekt sterujący utworzony za pomocą MockControl.createControl() jest dość pobłażliwy, tj. nie zwraca uwagi na kolejność wykonywania metod na atrapie, a jedynie odnotowuje fakt wystąpienie takiego wywołania. Jeżeli jednak chcemy wymusić określoną kolejność wywołań powinniśmy utworzyć obiekt sterujący za pomocą MockControl.createStrictControl(),
  • rzucanie wyjątków - używając EasyMock można zasymulować sytuację, w której atrapa rzuca wyjątek. Wystarczy po nagraniu wywołania metody zgłosić taki zamiar obiektowi sterującemu poprzez MockControl.setThrowable(),
  • tworzenie atrap dla klas - istnieje rozszerzenie EasyMock umożliwiające tworzenie dynamicznych pośredników na podstawie klas, a nie interfejsów. Może to być przydatne do testowania kodu klas, które nie korzystają z interfejsów. Choć to jest możliwe, nie jest zalecane - operowanie na interfejsach ma znacznie więcej zalet.

Kiedy nie da się zastosować mock'ów?

Obiekty atrapy są bardzo potężnym narzędziem ułatwiającym pisanie testów skomplikowanych klas. Niestety, źle zaprojektowanej klasy nie da się w ten sposób testować. Kłopoty sprawiają zwłaszcza klasy zawierające statyczne wywołania, jak np. przeszukiwanie kontekstu JDNI. Zaleca się zamknięcie wszystkich statycznych wywołań w osobnej klasie i wstrzykiwanej jej do klasy testowanej. Na czas testów będzie można taką klasę w prosty sposób podmienić.

Podsumowanie

Atrapy obiektów to bardzo potężne narzędzie, które pozwala testować praktycznie dowolne klasy w oderwaniu od reszty systemu. Nie należy przez to rozumieć, że pisząc testy jednostkowe można zrezygnować ze testów funkcjonalnych aplikacji jako całości. Wręcz przeciwnie, testy jednostkowe powinny stanowić tylko uzupełnienie. W typowym scenariuszu testy jednostkowe, z racji względnie krótkiego czasu ich wykonywania, mogą być uruchamiane bardzo często (np. przy każdym zapisie pliku testowanej klasy), zaś testy funkcjonalne, całościowe raz na jakiś czas, np. raz na dobę.

Zachęcam do samodzielnych eksperymentów z mock objects i dokładnego przestudiowania API EasyMock!

Linki:
http://www.mockobjects.com
http://www.martinfowler.com/articles/mocksArentStubs.html

Artykuły: