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:
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:
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().
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.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:
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
Ostatnie odpowiedzi