Dublery w testach (Test Doubles)

checklista

Test Doubles to termin określający obiekty będące fałszywymi reprezentacjami klas. Przygotowane tylko na potrzeby testów. Potocznie na wszystkie mówi się Mock, ale tak naprawdę dzielą się one na dwie grupy: Stubs i Mocks. Istnieje pięć typów takich dublerów, z czego trzy określiłbym jako Stuby, a dwa jako Mocki – choć to oczywiście podział umowny.

Do czego przydadzą się Test Doubles? Testowane klasy często zależą od innych i nie wszystkie z nich da się dostarczyć w środowisku testowym, a już na pewno nie jest to łatwe. Nie ma jednak takiej potrzeby, bo w testach jednostkowych sprawdzane są małe jednostki. Dzięki temu testy są szybkie oraz nie utrudniają utrzymywania i rozwijania aplikacji, a wręcz przeciwnie. Dodatkowo dzięki temu testy są odpowiednio izolowane.

Każda zależność wykorzystywana w testowanej klasie prawdopodobnie posiada swój własny test. Skoro tak, to na potrzeby testu wystarczy symulacja zachowań klas zależnych. Oczywiście, dla tych obiektów, które byłyby trudne do stworzenia w środowisku testowym albo mogłyby powodować skutki uboczne – na przykład zapis do bazy danych.

Dzięki takiemu podejściu, nie trzeba trudzić się przygotowaniem jednostki do przetestowania. Nie jest oczywiście tak, że tworzenie dublerów zawsze jest proste i przyjemne. Im więcej operacji w danej metodzie i zależności w klasie, tym więcej ustawiania danych wejściowych (Arrange z podejścia Arange-Act-Assert lub Given z podejścia Given-When-Then). Jednakże testowanie aplikacji znakomicie uzupełnia się z pisaniem kodu zgodnego z dobrymi praktykami. Odpowiednio podzielony kod będzie łatwo testowalny, a dostarczenie imitacji oryginalnych klas nie będzie tak problematyczne jak mogłoby to być w przypadku realnych reprezentacji.

Test Doubles – implementacja w PHP

Atrapa (Dummy)

Obiekt w najprostszej postaci, który tylko spełnia dany interfejs. Przeważnie dostarczony wyłącznie na potrzeby zależności klasy i nie używany w samym teście. Ewentualnie wywołanie jego zachowań nie powoduje żadnych efektów i powinno być pomijalne.

// Dummy own implementation

final class DummyEntityRepository implements RepositoryInterface
{
    public function save(Entity $entity): void
    {
    }
}
// Dummy implementation using Mockery Framework

$entityRepositoryDummy = Mockery::mock(RepositoryInterface::class);
// Dummy implementation using Prophecy Framework

$entityRepositoryDummy = $this->prophesize(RepositoryInterface::class)->reveal();

Podróbka (Fake)

Obiekt, który nie tylko spełnia konkretny interfejs, ale również dostarcza zachowania będące imitacją tych produkcyjnych. Nie są to jednak prawdziwe metody, a przygotowane w całości na potrzeby testów.

// Fake own implementation

final class FakeEntityRepository implements RepositoryInterface
{
    /** @var Entity[] */
    private array $entities;

    public function __construct()
    {
        $this->entities = [];
    }

    public function save(Entity $entity): void
    {
        $this->entities[$entity->getId()] = $entity;
    }

    public function findOneByEmail(string $email): ?Entity
    {
        foreach ($this->entities as $entity) {
            if ($entity->getEmail() === $email) {
                return $entity;
            }
        }

        return null;
    }
}

Zaślepka (Stub)

Obiekt, który nie tylko spełnia konkretny interfejs, ale również dostarcza jasno zadeklarowane zachowania. Posiada sztywno zdefiniowane metody i tylko te, które są potrzebne do przeprowadzenia testu.

// Stub own implementation

final class StubEntityRepository implements RepositoryInterface
{
    public function findOneByEmail(string $email): ?Entity
    {
        return new Entity($email);
    }
}
// Stub implementation using Mockery Framework

$entityRepositoryStub = Mockery::mock(RepositoryInterface::class);
$entityRepositoryStub
    ->shouldReceive('findOneByEmail')
    ->withArgs([$email])
    ->andReturn(new Entity($email));
// Stub implementation using Prophecy Framework

/** @var RepositoryInterface|ProphecyInterface $entityRepositoryStub */
$entityRepositoryStub = $this->prophesize(RepositoryInterface::class);
$entityRepositoryStub
    ->findOneByEmail($email)
    ->willReturn(new Entity($email));
$entityRepositoryStub->reveal();

Szpieg (Spy)

Obiekt, który nie tylko spełnia konkretny interfejs, ale również sprawdza w jaki sposób realizowane są zachowania. Pozwala więc przetestować, czy i ile razy dane zachowanie zostało użyte.

// Spy own implementation

final class SpyEntityRepository implements RepositoryInterface
{
    private int $amount = 0;

    public function findOneByEmail(string $email): ?Entity
    {
        ++$this->amount;

        return new Entity($email);
    }
    
    public function countExecutions(): int
    {
        return $this->amount;
    }
}
// Spy implementation using Mockery Framework

$entityRepositorySpy = Mockery::spy(RepositoryInterface::class);
$entityRepositorySpy
    ->shouldHaveReceived('findOneByEmail')
    ->withAnyArgs()
    ->times(3);
// Spy implementation using Prophecy Framework

/** @var RepositoryInterface|ProphecyInterface $entityRepositorySpy */
$entityRepositorySpy = $this->prophesize(RepositoryInterface::class)->reveal();
$entityRepositorySpy
    ->findOneByEmail(Argument::type('string'))
    ->shouldHaveBeenCalledTimes(3);

Imitacja (Mock)

Obiekt, który nie tylko spełnia konkretny interfejs, ale również definiuje w jaki sposób realizowane mają być zachowania. Pozwala więc przetestować, czy obiekt zachował się tak jak zostało to przewidziane, ale też nakreślić jak powinien się zachować.

// Mock implementation using Mockery Framework

$entityRepositoryMock = \Mockery::mock(RepositoryInterface::class);
$entityRepositoryMock
    ->shouldReceive('findOneByEmail')
    ->withAnyArgs()
    ->times(3);
// Mock implementation using Prophecy Framework

/** @var RepositoryInterface|ProphecyInterface $entityRepositoryMock */
$entityRepositoryMock = $this->prophesize(RepositoryInterface::class);
$entityRepositoryMock
    ->findOneByEmail(Argument::type('string'))
    ->shouldBeCalledTimes(3);
$entityRepositoryMock->reveal();

Dublery – używać, czy nie?

Mam wrażenie, że w praktyce najwięcej korzysta się ze Stubów. Mocki bardziej ingerują w detale implementacyjne. Nie oznacza to jednak, że nie są pomocne. Jak wszystko – mają swoje miejsce i właśnie w nich sprawdzą się najlepiej. Tak, czy inaczej celem tego materiału nie jest wybieranie najczęściej używanego, ale omówienie wszystkich dostępnych. Tak by korzystać z nich we właściwy sposób.

Korzystając z popularnych bibliotek do mockowania (jak użyte powyżej: Prophecy i Mockery), nie trzeba się zastanawiać, którego z naśladowców użyć. Samo API pozwala na konfigurację ich wszystkich, ale nie wymaga znajomości ich klasyfikacji. Mimo wszystko, uważam że warto je rozróżniać i być świadomym ich przeznaczenia. Bez mockowania bardzo trudno byłoby pisać testy jednostkowe. Z racji tego, zdecydowanie warto ich używać. Jak zwykle – rozsądnie.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.