Testy jednostkowe z PHPUnit oraz Mockery
|
Jedną z głównych zasad dobrych testów jednostkowych jest izolacja. Jak w takim razie poradzić sobie z różnymi zależnościami? Z pomocą przychodzi nam Mockery - narzędzie do imitacji obiektów.
Głównym celem izolacji jest testowanie tylko jednej klasy na raz (a w zasadzie funkcjonalności). Stąd też pochodzi nazwa samych testów: testy jednostkowe. Bardzo często zdarza się jednak, że testowana klasa czy metoda korzysta z metod innych klas. Jednym z rozwiązań tego problemu (moim ulubionym) jest zastosowanie frameworka który imituje zachowanie wskazanych obiektów: Mockery.
Zakładam, że znasz PHPUnit, umiesz go zainstalować (najlepiej przez Composera) oraz wiesz jak z niego skorzystać i napisać podstawowy test. Jeżeli nie, to zapraszam na mojego bloga, zamierzam wkrótce popełnić serię artykułów na temat TDD gdzie na pewno przerobimy temat PHPUnit od podstaw (taka publiczna deklaracja zmotywuje mnie do pisania).
Geneza
Na potrzeby tekstu załóżmy, że posiadamy klasę (przykład będzie mega uproszczony, ale w praktyce wygląda to podobnie) która odpowiada za obsługę jakiegoś eventu naszej aplikacji. W czasie jego obsługi wysyła ona wiadomość e-mail. Postaram się posłużyć praktycznym przykładem: rejestracja klienta i wysłanie maila aktywacyjnego. Rozpatrzmy poniższą klasę:
class UserEventHandler {
protected $mailer;
public function __construct(Mailer $mailer)
{
$this->mailer = $mailer;
}
public function onUserRegister()
{
return $this->mailer->sendActivationMessage();
}
}
Do przeprowadzenia testu potrzebna będzie również klasa Mailera (a raczej jej kiepska imitacja :), tak tego nigdy nie róbcie:
class Mailer {
public function sendActivationMessage()
{
return mail('test@test.pl', 'Wiadomość aktywacyjna', 'Lorem lipsum', 'From: test@test.pl');
}
}
Napiszmy teraz klasę testu która przetestuje całość:
class UserEventTest extends PHPUnit_Framework_TestCase {
public function testUserRegister() {
$event = new UserEventHandler(new Mailer());
$this->assertEquals(true, $event->onUserRegister());
}
}
Uruchamiamy phpunit i okazuje się, że wszystko działa jak należy:
Time: 2.71 seconds, Memory: 2.25Mb
OK (1 test, 1 assertion)
Wstępnie wydawałoby się, że możemy przejść dalej. Testy są zielone, wszystko działa. Czy oby na pewno? Okazuje się, że za każdym razem gdy uruchamiamy test wysyłany jest e-mail. Wyobraźmy sobie sytuację w której mamy 100 takich testów. Czy mamy czas na wysyłania 100 e-maili? Sam proces testowania na pewno też będzie trwał. Jeżeli to cię nie przekonuje, to zastanów się co tak naprawdę chcemy tutaj przetestować. W końcu klasa Mailer’a powinna posiadać swoje niezależne testy jednostkowe.
Rozwiązanie
Całkowita imitacja obiektu
Zanim przejdziemy do instalacji nowego narzędzia informuję, że możliwe jest przeprowadzenie podobnej „imitacji” obiektu przy pomocy samego PHPUnit. Na samym końcu przedstawię odpowiedni kod który robi to samo bez potrzeby instalacji Mockery. Sami ocenicie które z narzędzi jest bardziej czytelne (dosłownie).
Najpierw musimy w jakiś sposób „zaciągnąć” Mockery. Jeżeli korzystasz z Composera nie ma z tym najmniejszych problemów, jeden wpis załatwia sprawę:
"require": {
"phpunit/phpunit": "~4.3",
"mockery/mockery": "~0.9"
}
Teraz pozostaje wpisać w konsoli composer install
i możemy przejść do napisania tzw. „mocka” czyli obiektu który będzie imitował naszą klasę. Dodatkowo na zakończenie każdego testu musimy wywołać Mockery::close
, który sprawdzi czy wszystkie makiety zostały użyte zgodnie z założeniami. Efekt ten uzyskamy dodając metodę tearDown()
do klasy naszego testu. Kompletna nowa klasa testu będzie wyglądać następująco:
class UserEventTest extends PHPUnit_Framework_TestCase {
public function tearDown()
{
Mockery::close();
}
public function testUserRegister()
{
$mock = Mockery::mock('Mailer');
$mock->shouldReceive('sendActivationMessage')
->once()
->andReturn(TRUE);
$event = new UserEventHandler($mock);
$this->assertEquals(TRUE, $event->onUserRegister());
}
}
Najpierw tworzymy nową atrapę - w metodze mock
podajmy nazwę klasy, którą ma imitować. Następnie na stworzonym obiekcie tworzymy szereg instrukcji, który przedstawia sposób imitowania obiektu. Stosowane metody utworzonej atrapy wykorzystują tzw. „fluent interface”, przez co możemy wpisywać je kolejno po sobie. Więc w powyższym przykładzie: na naszej atrapie ($mock
) powinna zostać wywołana metoda sendActivationMessage
(uzyskujemy to wywołując shouldReceive('sendActivationMessage')
), jeden raz (once()
) i powinna zwrócić logiczne true
(andReturn(true)
).
Przyznacie sami, że składania jest samoopisująca się (o ile znamy język angielski).
Ok. Teraz uruchamiamy ponownie nasz test:
Time: 65 ms, Memory: 2.75Mb
OK (1 test, 1 assertion)
Jak widać różnica jest znacząca. Test pokrywa to co ma pokrywać oraz żaden e-mail nie jest wysyłany. W pokazany sposób możemy tworzyć atrapy dowolnych klas.
Częściowa imitacja obiektu
W celu zaprezentowania częściowej imitacji obiektu, musimy trochę „urozmaicić” nasz przykład. Załóżmy, że nasz event potrzebuje jeszcze klasy użytkownika aby pobrać jego e-mail. Dodatkowo dodamy metody do klasy Mailer
(set i get):
class UserEventHandler {
protected $mailer;
protected $user;
public function __construct(User $user, Mailer $mailer)
{
$this->mailer = $mailer;
$this->user = $user;
}
public function onUserRegister()
{
$this->mailer->setEmail($this->user->getEmail());
return $this->mailer->sendActivationMessage();
}
}
Klasa Mailer
po modyfikacjach:
class Mailer {
protected $email;
public function setEmail($email)
{
$this->email = $email;
}
public function getEmail()
{
return $this->email;
}
public function sendActivationMessage()
{
return mail($this->email, 'Wiadomość aktywacyjna', 'Lorem lipsum', 'From: test@test.pl');
}
}
Teraz możemy napisać test, który imituje wyłączanie metody sendActivationMessage
, reszta metod będzie korzystać z pierwotnego obiektu:
class UserEventTest extends PHPUnit_Framework_TestCase {
public function tearDown()
{
Mockery::close();
}
public function testUserRegister()
{
$user = Mockery::mock('User');
$user->shouldReceive('getEmail')
->once()
->andReturn('test@test.pl');
$mailer = Mockery::mock('Mailer[sendActivationMessage]');
$mailer->shouldReceive('sendActivationMessage')
->once()
->andReturn(TRUE);
$event = new UserEventHandler($user, $mailer);
$this->assertEquals(TRUE, $event->onUserRegister());
$this->assertEquals('test@test.pl', $mailer->getEmail());
}
}
W kwadratowym nawiasie wpisana została metodą którą będziemy emulować. Można wpisać tam więcej funkcji oddzielając je przecinkiem. Każda wybrana w ten sposób metodą powinna być obsłużona przez shouldReceive
. Na końcu dodałem dodatkowe sprawdzenie (asercję), aby upewnić się, że działanie pozostałych metod (setEmail
i getEmail
) jest prawidłowe. W ten sposób przekonamy się, czy faktycznie emulowana jest tylko wybrana przez nas metoda. Nie pozostaje nam nic innego jak uruchomić test:
Time: 98 ms, Memory: 2.75Mb
OK (1 test, 2 assertions)
Jak widać na potrzeby tego testu klasa User
nie musi nawet istnieć. Kod testu i opisanych klas dostępny pod adresem: https://github.com/itcraftsmanpl/PHPUnitAndMockeryTest
Poniżej przedstawiam dodatkowe metody jakie oferuje Mockery (są one na tyle dobrze nazwane, że nie trzeba ich nawet komentować):
$mock = Mockery::mock('User');
$mock->shouldReceive('getId')->once();
$mock->shouldReceive('getId')->times(2);
$mock->shouldReceive('getId')->atLeast()->times(3);
$mock->shouldReceive('getId')->withAnyArgs()->once();
$mock->shouldReceive('getParam')->with('paramName')->once();
$mock->shouldReceive('getParam')->with(Mockery::type('string'))->once();
$mock->shouldReceive('getParams')->with('param1', Mockery::any())->once();
Na zakończenie prezentuję jeszcze w jaki sposób można imitować obiekt natywnie przy użyciu PHPUnit:
public function testUserRegisterNativeMock()
{
$mock = $this->getMock('Mailer');
$mock->expects($this->once())
->method('sendActivationMessage')
->will($this->returnValue(TRUE));
$event = new UserEventHandler($mock);
$this->assertEquals(TRUE, $event->onUserRegister());
}
Jak widać robi się to bardzo podobnie, ale otrzymany kod jest mniej czytelny. Na dzisiaj to wszystko. Zachęcam do komentowania i udostępniania - może się przydać :).