Na wstępie: fajnie by było, abyś wiedział mniej więcej co to jest Mock, Stub i Fake - nie będę tego jakoś szczególnie objaśniał bo idea tego wpisu jest inna niż wstęp do “zaślepek”. Tutaj możesz poczytać o różnych zaślepkach na przykładzie PHPUnit.
Dla przypomnienia, zaślepka (z ang. Test Double) to obiekt, który jest przekazywany do obiektu testowanego zamiast obiektu będącego rzeczywistą zależnością w kodzie produkcyjnym. Przykładem może być wstrzyknięcie zaślepki obiektu ProductRepository
(abstrakcji na persystentną kolekcję produktów) do jakieś usługi, np. ProductService
.
Aby utworzyć obiekt ProductService
potrzeba ProductRepository
, aby utworzyć ProductRepository
musimy utworzyć EntityManagera
(zakładam implementację opartą na Doctrine), aby utworzyć EntityManagera
trzeba utworzyć konfigurację oraz połączenie z bazą danych, aby połaczyć się z bazą danych musimy mieć z czym się połaczyć, więc serwer bazy danych musi być zainstalowany, schema bazy powinno być utworzone itp. Aby utworzyć obiekt konfiguracji, trzeba utworzyć obiekt cache… Na litość Boską, ja tylko chcę przetestować czy ProductService::createProduct($name, $price)
tworzy obiekt produktu, zapisuje go w repozytorium i triggeruje przy tym odpowiedni event…
Powyższa historyjka pokazuje jedną z przyczyn stosowania zaślepek. Jest ich więcej, np:
DoctrineProductRepository
- ta klasa ma osobne testy!Oczywiście są sytuacje, w których nie należy stosować zaślepek. Należy pamiętać, że nie należy zamieniać części obiektu/podsystemu który testujemy. Np. nie powinno się zastępować EntityManagera
implementacją “in memory” w testach DoctrineProductRepository
, gdyż testy te mają testować, czy poprawnie szukamy / zapisujemy dane w bazie.
W idealnym świecie, zgodnie z zasadą Command-Query separation, każda metoda powinna być typu Command lub Query.
W złym guście jest tworzenie metod które zarówno są Command i Query, bo taka funkcja robi de facto dwie rzeczy - zmienia stan i go zwraca. Nie można wywołać 2x taką metodę aby pobrać stan, gdyż za każdym wywołaniem ten stan jest zmieniany.
Związek typu metod z zaślepkami:
Krótkie wyjaśnienie aby nie było zamętu:
Można utworzyć Stuba za pomocą frameworka do tworzenia Mocków. To brzmi dziwnie, ale jest to jak najbardziej stosowana i poprawna praktyka. Tutaj są przykłady tworzenia różnych zaślepek za pomocą frameworku do mockowania, który jest wykorzystany w PHPUnit. To czy obiekt jest mockiem, nie jest definiowane przez fakt, że został utworzony przez metodę
getMock
(w PHPUnit), ale przez to, że nałożono na ten obiekt dodatkowe oczekiwania, które są później jawnie bądź niejawnie weryfikowane.
Przykład kodu do którego chcemy napisać test:
//interfejs translatora
interface Translator {
function trans($id, array $params = [], $locale = null);
}
class UserService {
private $translator;
//...
//trywialna metoda którą chcemy przetestować
function createInvitation(User $user) {
return new Invitation(
$user,
$this->translator->trans(
'Hello %user%',
['%user%' => (string) $user]
),
...
);
}
}
Translator ma metodą trans
, która jest typu Query - tak więc nie powinniśmy stosować Mocka, ale dla celów naukowych spróbujmy…
Uwaga, bardzo ZŁY test! Nie rób tego w domu (w pracy też)!
//given
$user = ...;
$translator = $this->getMock('Translator');
$service = new UserService(..., $translator);
$translator->expects($this->once())
->method('trans')
->with('Hello %user%', ['%user%' => (string) $user ])
->willReturn('Hello Peter');
//when
$invitation = $service->createInvitation($user);
//then
//... asercje
Co zyskaliśmy:
Problemy:
with
)Lepsze rozwiązanie od poprzedniego, wg magicznej listy Stuba można wykorzystać do zaślepienia metod typu Query.
//given
$user = ...;
$translator = $this->getMock('Translator');
$service = new UserService(..., $translator);
//usuneliśmy $this->once() i wywołanie "with" - to były oczekiwania,
//a chcemy stworzyć Stuba - $translator mimo że powstał za pomocą
//metody "getMock" nie jest Mockiem
$translator->expects($this->any())
->method('trans')
->willReturn('Hello Peter');
//when
$invitation = $service->createInvitation($user);
//then
//... asercje
Co zyskaliśmy:
Problemy:
trans
- słabsze pokrycie kodu testami (nie w sensie Code Coverage, który tak naprawdę nie jest dobrą metryką oceny jakości testów, ale w sensie testów mutacyjnych)//kod Fake
class FakeTranslator implements Translator {
function trans($id, array $params = [], $locale = null) {
return strtr($id, $params);
}
}
//...
//given
$user = ...;
$service = new UserService(..., new FakeTranslator());
//when
$invitation = $service->createInvitation($user);
//then
//... asercje
Co zyskaliśmy:
trans
FakeTranslator
w wielu różnych testach oraz w różnych klasach testowych.Problemy:
Translator
, trzeba zaktualizować implementację FakeTranslator
Przy małej liczbie metod do zaślepienia, można zbudować Stuba za pomocą frameworka do Mockowania. Gdy liczba metod jest większa i w kilku klasach testowych wykorzystywany jest podobny stub, powinno się zastanowić czy wprowadzenie “fałszywej” implementacji nie będzie korzystne. Zastosowanie Mocka w tym przypadku jest bardzo słabiutkie, aczkolwiek istnieją przypadki w których zastosowanie Mocka dla metod typu Query jest wskazane - o tym w końcowych przemyśleniach.
Przykład kodu do którego chcemy napisać test:
interface ProductRepository {
function save(Product $product);
function findOneById($id);
}
interface EventDispatcher {
function dispatch($eventName, $event = null);
}
class ProductService {
private $repository;
private $eventDispatcher;
//trywialna metoda, którą chcemy przetestować
function saveProduct(Product $product) {
$this->repository->save($product);
$this->eventDispatcher->dispatch(
'product.saved',
new Event([ 'product' => $product ]
);
}
}
Metody ProductRepository::save
i EventDispatcher::dispatch
są typu Command (nic nie zwracają, zmieniają stan), więc powinniśmy zastosować Mocki lub ewentualnie Fake. Nie powinno się stosować Stuba, ale dla cełów naukowych to rozwiązanie idzie na pierwszy ogień…
Uwaga, okropnie ZŁY test!
//given
$product = ...;
$repository = $this->getMock('ProductRepository');
$eventDispatcher = $this->getMock('EventDispatcher');
$service = new ProductService($repository, $eventDispatcher);
$repository->expects($this->any())
->method('save');
$eventDispatcher->expects($this->any())
->method('dispatch');
//when
$service->saveProduct($product);
//then
//??? jak zweryfikować taki test? Nie da się ;)
Co zyskaliśmy:
Problemy:
//given
$product = ...;
$repository = $this->getMock('ProductRepository');
$eventDispatcher = $this->getMock('EventDispatcher');
$service = new ProductService($repository, $eventDispatcher);
$repository->expects($this->once())
->method('save')
->with($product);
$eventDispatcher->expects($this->once())
->method('dispatch')
->with('product.saved', new Event([ 'product' => $product ]);
//when
$service->saveProduct($product);
//then
//PHPUnit na końcu odpala weryfikacje mocków
Co zyskaliśmy:
Problemy:
//implementacja Fakeów
class FakeProductRepository implements ProductRepository {
private $products = [];
function save(Product $product) {
if($product->getId() === null) {
$product->setId(rand(0, 9999999));
}
$this->products[$product->getId()] = $product;
}
function findOneById($id) {
if(!isset($this->products[$id])) throw new Exception();
return $this->products[$id];
}
}
class FakeEventDispatcher implements EventDispatcher {
private $dispatchedEvents = [];
function dispatch($name, $event = null) {
$this->dispatchedEvents[] = [$name, $event];
}
function getDispatchedEvents() {
return $this->dispatchedEvents;
}
}
//... i testy
//given
$product = ...;
$repository = new FakeProductRepository();
$eventDispatcher = new FakeEventDispatcher();
$service = new ProductService($repository, $eventDispatcher);
//when
$service->saveProduct($product);
//then
$this->assertEquals(
$product,
$repository->findOneById($product->getId()
);
$this->assertEquals(
[[ 'product.saved', new Event(['product' => $product]) ]],
$eventDispatcher->getDispatchedEvents()
);
Co zyskaliśmy:
ProductService
- np. nie ma wzmianki o metodzie ProductRepository::save
Problemy:
FakeEventDispatcher::getDispatchedEvents
aby dało się przetestować, czy event miał miejsceW tym konkretnym przypadku, dobrym rozwiązaniem byłoby wykorzystanie FakeProductRepository
oraz mocka EventDispatcher
, czyli rozwiązanie hybrydowe. Dzięki temu zniwelowane byłyby problemy związane z zastosowaniem tylko Mocków lub tylko Fakeów. O ile zastosowanie Mocków do zaślepiania metod typu Query ma jeszcze jakikolwiek sens (w niektórych przypadkach), to zastosowanie Stubów do zaślepienia metod typu Command już nie.
Mocki mają zastosowanie głównie dla zaślepiania metod typu Command. Jednakże nie każda metoda Command powinna być z urzędu zaślepiana Mockiem - niekiedy lepszym rozwiązaniem jest Fake. Kiedy lepszy będzie Fake? Gdy w wielu klasach testowych są wykorzystywane podobne Mocki (Fake pozwala zachować zasadę DRY). Fake pozwala również uprościć kod testów i sprawić, że test będzie w mniejszym stopniu powiązany z implementacją testowanej klasy.
Istnieją sytuacje, w których wskazane jest użycie Mocka nawet dla metod typu Query, np:
Cache::test
zwróci false, ma zostać wywołana metoda Cache::save
, ale nie powinna być wywołana metoda Cache::load
- tutaj dobrym wyborem jest Mock, bo inaczej ciężko to przetestować - mimo iż test
i load
są typu QueryGdy doprowadziło się do sytuacji, w której jeden Mock zwraca drugiego, to jest to zapach złego designu (prawdopodobnie złamanie prawa Demeter) lub ewentualnie źle dobranych rodzajów zaślepek.
Testy powinny w miarę możliwości traktować obiekt testowany jak czarną skrzynkę:
Test powinien sprawdzać, czy obiekt zrobił to co chcemy, a nie jak to zrobił. Test powinien pozostać nienaruszony i działający, jeśli zmienimy implementacje metody testowanej, zmienimy algorytm, zrefaktoryzujemy kod itp.
Stosowanie Mocków skutecznie uniemożliwia takie podejście, gdyż samo użycie Mocka wiąże test z implementacją obiektu. Dlatego trzeba w pełni świadomie korzystać z Mocków, bo w przeciwnym wypadku testy będą kruche i każda zmiana w kodzie produkcyjnym będzie ciągnąć za sobą zmianę testów. Może to doprowadzić do kuriozalnej sytuacji, w której nie będziemy refaktoryzować, bo wymagałoby to zmiany w testach. Polecam tego talka - porusza on m. in. problem “zabetonowania” kodu za pomocą złych testów.
Written on January 7th, 2015 by psliwa