lukaszstaniszewski.pl

blog programistyczny

lukaszstaniszewski.pl

blog programistyczny

Co to jest atrapa obiektu (Mock Object)

Po wpisie wprowadzającym do TDD oraz testów jednostkowych (jeżeli nie czytałeś tego wpisu, zapraszam Cię do przeczytania go na moim blogu) przyszła pora na atrapy obiektu – mock object.

Jest to nierozłączny element testów jednostkowych, bez którego ich wydajność spadłaby drastycznie. Do tego generowałoby to wiele bezsensownych błędów oraz kolejnych testów. Dlaczego i jakie byłyby konsekwencje takiego działania będziecie mogli przeczytać później.

Gotowe implementacje przykładów, możesz znaleźć tutaj.

Okej, ale czym tak naprawdę jest ta atrapa obiektu?

Aby w jak najprostszy sposób, wytłumaczyć Ci czym jest atrapa obiektu, musimy wyobrazić sobie pewien przykład.

Wyobraźmy sobie sytuacje gdy testujemy system zamawiania produktu. Po zakończeniu zakupów, nasz system wysyła maila do klienta z podsumowaniem. Czy widzisz już kod, jaki musisz przetestować? Mamy więc dwie klasy, a dokładniej klasę odpowiedzialną za wysyłkę e-mail’a oraz klasę odpowiedzialną za wykonanie logiki biznesowej zakupu produktu. I teraz właśnie przechodzimy do całego sensu mockowania. Testując klasę odpowiedzialną za wysyłkę powiadomień, będziemy musieli wysłać email i oczywiście sprawdzić, czy został on poprawnie wysłany, a co z zakupem produktu? Tu z pomocą przychodzi nam mockowanie, dzięki któremu nie będziemy musieli ponownie wysyłać maila, lecz utworzymy atrapę klasy oraz metod do obsługi tej operacji.

Kończąc przykład, jestem pewien że sam wyciągniesz wnioski dzięki którym zrozumiesz czym jest atrapa obiektu.

Ale ja zrobię testy bez mockowania!

Mówią że ciekawość, to pierwszy stopień do piekła, ale w IT tak nie jest. To ciekawość budzi u nas myśli, dlaczego jest tak a nie inaczej, dzięki niej zgłębiamy dany temat dokładniej.

Wracając do tematu atrap obiektów możemy dojść do dosyć ciekawych rozterek. Na widok testów, bez atrapy obiektów, możemy zadać sobie następujące pytanie, “Ale co gdy metoda mająca za zadanie wysyłki maili załamie się (test nie przeszedł, nie wysyła maila)?”. Otóż wtedy obydwa testy świecą się na czerwono, ale zastanówmy się dlaczego? Metoda wysyłająca mail na pewno rzuci wyjątkiem, który nasz kod w metodzie odpowiedzialnej za logikę biznesową zakupu produktu, nie powinien niwelować (try, catch). Oczywistym jest że nie oczekujemy wyjątku więc wszystkie testy używające klasy Email świecą na czerwono.

Rozpatrzmy dodatkową sytuację. Pracujemy nad dużym projektem, w którym klasa Email jest często używana i nie wysyła maila, tylko rzuca wyjątkiem. Analogicznie jak wyżej, wszystkie testy, które używają klasy Email, załamują się.

Dodatkowo badamy kod

Na tym nie kończy się potencjał mockowania. Dzięki niemu, możemy badać ilość wywołań metod. W czym to pomaga? Otóż, jeżeli tworzysz skomplikowaną klasę, możesz przypadkowo wywołać podwójnie jakąś metodę. Nic wielkiego się nie stanie, jeżeli to będzie prosta metoda np. set encji, ale co gdy wywołujemy po raz drugi skomplikowane zapytanie? Twoja aplikacja, nagle traci na wydajności, ałć… boli prawda?

Dochodzimy tutaj do momentu, że TDD poza dawaniem nam pewności działania skryptu, optymalizuje go. Co za tym idzie, daje pewność developerowi, jakości jego kodu.

Pamiętaj tylko o jednym, nie dawaj na ślepo ilości wywołań danej metody. Przypominam o tym, ponieważ konsola wypluje Ci ilość faktycznych użyć a oczekiwanych. Jeżeli dochodzi do takiej sytuacji, sprawdź dokładnie, dlaczego ta metoda jest tyle razy wywoływana.

Przyszedł czas na praktykę w Javie

Tak samo jak w przypadku pierwszego wpisu (który znajdziecie na moim blogu), zaczynamy od konfiguracji Maven’a. Wcześniej korzystaliśmy tylko z jUnit, teraz do tej konfiguracji będziemy musieli dodać sobie Mockito. A więc do dzieła!

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com</groupId>
    <artifactId>lukaszstaniszewski</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.mockito</groupId>
            <artifactId>mockito-all</artifactId>
            <version>1.10.19</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.0</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Przejdźmy więc do głównego tematu wpisu, czyli mockowania.

package lukaszstaniszewski.util;

import org.junit.Before;
import org.junit.Test;
import static org.junit.Assert.assertTrue;

/**
 * The type Email test.
 */
public class EmailTest {

    private Email email;

    /**
     * Sets up.
     *
     * @throws Exception the exception
     */
    @Before
    public void setUp() throws Exception {
        this.email = new Email();
    }

    /**
     * Send true.
     *
     * @throws Exception the exception
     */
    @Test
    public void sendTrue() throws Exception {
        assertTrue(this.email.send("kontakt@lukaszstaniszewski.pl"));
    }

    /**
     * Send false.
     *
     * @throws Exception the exception
     */
    @Test(expected = Exception.class)
    public void sendFalse() throws Exception {
        this.email.send("kontaktlukaszstaniszewski.pl");
    }
}
package lukaszstaniszewski.service;

import lukaszstaniszewski.util.EmailInterface;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.runners.MockitoJUnitRunner;

@RunWith(MockitoJUnitRunner.class)
public class CreateOrderServiceTest {

    @Mock
    private EmailInterface emailInterface;

    private CreateOrderService createOrderService;

    @Before
    public void setUp() throws Exception  {
        this.createOrderService = new CreateOrderService(this.emailInterface);
    }

    @Test
    public void successCreateOrder() throws Exception {
        Mockito.when(this.emailInterface.send(Mockito.isA(String.class))).thenReturn(true);

        this.createOrderService.createOrder("kontakt@lukaszstaniszewski.pl");

        Mockito.verify(this.emailInterface).send("kontakt@lukaszstaniszewski.pl");
    }

    @Test(expected = Exception.class)
    public void notSendEmail() throws Exception {
        Mockito.when(this.emailInterface.send(Mockito.isA(String.class))).thenReturn(false);

        this.createOrderService.createOrder("kontaktlukaszstaniszewski.pl");

        Mockito.verify(this.emailInterface).send("kontaktlukaszstaniszewski.pl");
    }
}

Sprawdźmy teraz jak reagują testy. Załamują się? Bardzo dobrze, bo jeszcze nie utworzyliśmy klas. Przyszła pora więc na dwie klasy tj. Email oraz CreateProductService. Będą one bardzo uproszczone, tylko do celów demonstracyjnych.

package lukaszstaniszewski.util;

/**
 * The interface Email interface.
 */
public interface EmailInterface {
    /**
     * Send boolean.
     *
     * @param email the email
     * @return the boolean
     * @throws Exception the exception
     */
    public boolean send(String email) throws Exception;
}
package lukaszstaniszewski.util;

/**
 * The type Email.
 */
public class Email implements EmailInterface {

    @Override
    public boolean send(String email) throws Exception {
        if (!email.matches("^([a-z|A-Z|0-9]){1,24}@([a-z|A-Z|0-9]){1,64}.([a-z]){2,6}$")) {
            throw new Exception("Email is invalid");
        }

        // Send mail

        return  true;
    }
}
package lukaszstaniszewski.service;

import lukaszstaniszewski.util.EmailInterface;

/**
 * The type Create order service.
 */
public class CreateOrderService {

    private EmailInterface email;

    /**
     * Instantiates a new Create order service.
     *
     * @param email the email
     */
    public CreateOrderService(EmailInterface email) {
        this.email = email;
    }

    /**
     * Create order.
     *
     * @param emailAddress the email
     * @throws Exception the exception
     */
    public void createOrder(String emailAddress) throws Exception {
        if (!this.email.send(emailAddress)) {
            throw new Exception("Email not send!");
        }

        // Other business logic
    }
}

Został nam już tylko ostatni krok, sprawdźmy czy testy świecą na zielono. Widzisz zieleń? Brawo!

Mamy w Javie a jak w PHP?

Zacznijmy tak samo jak w Javie od menadżera pakietów. Ej, ej, wait, stop! Ale co mam wybrać? Przecież są Mockery i PHPUnit. To jest bardzo ciężkie pytanie i bez jednoznacznej odpowiedzi, musisz sam wybrać. Mockery bardzo dobrze współgrają z PHPUnitem oraz są moim zdaniem dużo czytelniejsze, niż Mockowanie w PHPUnit. W tych przykładach wybierzemy Mockery.

{
  "require": {
    "php": "7.3.*"
  },
  "require-dev": {
    "phpunit/phpunit": "8.0.*",
    "mockery/mockery": "1.2.*"
  },
  "autoload": {
    "psr-4": {
      "App\\": "./src"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "App\\Test\\": "./tests"
    }
  }
}

To do dzieła, robimy testy oraz mockowanie!

<?php
declare(strict_types=1);

namespace App\Test\Util;

use App\Util\Email;
use PHPUnit\Framework\TestCase;
use \Exception;

/**
 * Class EmailTest
 * @package App\Test\Util
 */
class EmailTest extends TestCase
{
    /**
     * @var
     */
    private $email;
    
    /**
     *
     */
    public function setUp(): void
    {
        $this->email = new Email();
    }
    
    /**
     * @test
     */
    public function send(): void
    {
        $this->assertTrue($this->email->send('kontakt@lukaszstaniszewski.pl'));
    }
    
    /**
     * @test
     */
    public function emailInvalid(): void
    {
        $this->expectException(Exception::class);
        $this->email->send('kontakt*lukaszstaniszewski.pl');
    }
}

<?php
declare(strict_types=1);

namespace App\Test\Service;

use App\Service\CreateOrderService;
use App\Util\EmailInterface;
use PHPUnit\Framework\TestCase;
use \Mockery;
use \Exception;

/**
 * Class CreateOrderServiceTest
 * @package App\Test\Service
 */
class CreateOrderServiceTest extends TestCase
{
    //Trait for Integration with PHPUnit, second method is use listener in PHPUnit configuration
    use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
    
    /**
     * @var
     */
    private $email;
    
    /**
     * @var CreateOrderService
     */
    private $createOrderService;
    
    /**
     *
     */
    public function setUp(): void
    {
        $this->email = Mockery::mock(EmailInterface::class);
        $this->createOrderService = new CreateOrderService($this->email);
    }
    
    /**
     * @throws Exception
     * @test
     */
    public function createOrder(): void
    {
        $this->email->shouldReceive('send')->once()->andReturnTrue();
        $this->createOrderService->createOrder('kontakt@lukaszstaniszewski.pl');
    }
    
    /**
     * @throws Exception
     * @test
     */
    public function notSendEmail(): void
    {
        $this->email->shouldReceive('send')->once()->andReturnFalse();
        $this->expectException(Exception::class);
        $this->createOrderService->createOrder('kontaktlukaszstaniszewski.pl');
    }
}

Pora na implementację klas, ale przed tym zobaczmy czerwony pasek w testach… i do pracy!

<?php
declare(strict_types=1);

namespace App\Util;

/**
 * Interface EmailInterface
 * @package App\Util
 */
interface EmailInterface
{
    /**
     * @param string $email
     * @return bool
     */
    public function send(string $email): bool;
}
<?php
declare(strict_types=1);

namespace App\Util;

use \Exception;

/**
 * Class Email
 * @package App\Util
 */
class Email implements EmailInterface
{
    /**
     * @param string $email
     * @return bool
     * @throws Exception
     */
    public function send(string $email): bool
    {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new Exception('Email is not valid');
        }
        
        //Send email
        
        return true;
    }
}
<?php
declare(strict_types=1);

namespace App\Service;

use App\Util\EmailInterface;
use \Exception;

/**
 * Class CreateOrderService
 * @package App\Handler
 */
class CreateOrderService
{
    /**
     * @var EmailInterface
     */
    private $email;
    
    /**
     * CreateOrderService constructor.
     * @param EmailInterface $email
     */
    public function __construct(EmailInterface $email)
    {
        $this->email = $email;
    }
    
    /**
     * @param string $email
     * @throws Exception
     */
    public function createOrder(string $email): void
    {
        if (!$this->email->send($email)) {
            throw new Exception('Email not send');
        }
        // Other business logic
    }
}

Kilka dodatkowych słów…

W obu przykładach mockowania, użyliśmy interfejsu (EmailInterface), zamiast klasy Email. Jak dobrze wiemy, kompozycja jest ponad dziedziczenie, dlatego właśnie w taki sposób mockowaliśmy obiekty. Jest to temat na przyszły wpis, dlatego nie będę tutaj się rozpisywał.

Nie będę w tym wpisie, również opisywać całego API bibliotek do mockowania, ponieważ nie miało by to najmniejszego sensu. Z tego powodu odsyłam Cię do dokumentacji technicznych bibliotek: MockeryMockitoPHPUnit, w celu powiększenia swojego zasobu wiedzy.

Podsumowując

Jeżeli przeczytałeś, mój pierwszy wpis dotyczący TDD, testów jednostkowych oraz ten, to posiadasz już wystarczające podstawy, aby efektywnie testować swój kod. Teraz czas na trochę własnej pracy i zdobycie doświadczenia, bo nic tak dobrze nie uczy jak test świecący na czerwono od tygodnia.

Do zobaczenia

Co to jest atrapa obiektu (Mock Object)
Przewiń na górę