Testovanie v PHP pre začiatočníkov

Akým spôsobom testujete urobené zmeny?

Testujete celú aplikáciu alebo len zmenené časti?

Má manažment dôveru vo váš kód?

Sadol by si váš manažér do lietadla, ktorého systémy ste programovali vy?

Sadli by ste si vy do takého lietadla?

Automatizované testy

Napísaný test je jediný dôkaz, že ste si svoj kód vyskúšali

Napísaný test ochráni váš kód pred chybou v budúcnosti

Ako vyzerá typický test?

  • pripraví si prostredie
  • urobí nejakú akciu
  • skontroluje výsledok akcie (urobí aserciu)

Čo by mal každý test spĺňať?

  • je opakovateľný
  • nezávislý na ostatných testoch
  • neovplyvňuje iné testy

Druhy testov

* zjednodušene

Akceptačný test

  • aplikácia sa testuje v celku
  • navštívi stránku aplikácie
  • urobí nejakú akciu
  • a skontroluje, či stránka vyzerá tak, ako by mala

Druhy testov

Integračný test

  • testuje spoluprácu viacerých jednotiek kódu
  • typicky spolupráca viacerých tried
  • spolupráca kódu a DB, prepojenia na API a pod.

Druhy testov

Unit test

  • testuje jednu jednotku kódu (trieda, metóda)
  • v absolútnej izolácii

Druhy testov

Príklady

Stiahnite si zdrojáky pre príklad:

goo.gl/gDNHa9

PHPUnit

Najrozšírenejší testovací framework pre PHP.

Dajú sa s ním robiť všetky druhy testov.

Testy sa píšu ako metódy triedy odvodenej od

 

PHPUnit\Framework\TestCase

PHPUnit - Inštalácia

 

composer require --dev phpunit/phpunit

PHPUnit - Spustenie

 

vendor/bin/phpunit
    --bootstrap=vendor/autoload.php
    --color
    tests/

Akceptačný test

v PHPUnit

Testovací scenár:

Akceptačný test v PHPUnit

  • otvoríme stránku
  • nájdeme formulár
  • vyplníme text nového todo
  • odošleme
  • skontrolujeme, že položka sa objavila na stránke

Pridanie novej položky do zoznamu

Čo potrebujeme?

Akceptačný test v PHPUnit

"Prehliadač" ovládateľný z PHP

composer require --dev fabpot/goutte

Napr. Goutte: postavený nad BrowserKit a DomCrawler zo Symfony

Napíšeme test

Akceptačný test v PHPUnit

namespace tests\Acceptance;

use Goutte\Client;
use PHPUnit\Framework\TestCase;

class AddTodoTest extends TestCase
{
    public function testAddTodo()
    {
        $myTodoText = 'Môj prvý akceptačný test';

        $client = new Client();

        $crawler = $client->request('GET', 'http://localhost:8080/');

        $form = $crawler->selectButton('Pridaj úlohu')->form();
        $form->setValues([
            'text' => $myTodoText,
        ]);
        $crawler = $client->submit($form);

        $this->assertEquals(200, $client->getResponse()->getStatus());

        $nodes = $crawler->filter('li.list-group-item:contains("' . $myTodoText . '")');

        $this->assertEquals(1, $nodes->count());
    }
}

Spustíme test

Akceptačný test v PHPUnit

vendor/bin/phpunit --bootstrap=vendor/autoload.php --color tests/

Výsledok:

Tento test nespĺňa požiadavky na dobrý test

  • nie je opakovateľný (už druhé spustenie vyvolá chybu)
  • keďže zapisuje do spoločnej databázy môže ovplyvniť iné testy

Akceptačný test v PHPUnit

Oprava: pred spustením si pripravíme prostredie do vhodného stavu

Akceptačný test v PHPUnit

Testovacia fixtúra

Akceptačný test v PHPUnit

Fixný a známy stav objektov a dát, nad ktorým vieme spustiť test.

Pripravíme si databázovú fixtúru

Akceptačný test v PHPUnit

[]
tests/Acceptance/fixtures/addTodo.json

Pri spustení testu ju načítame

Akceptačný test v PHPUnit

// tests/Acceptance/AddTodoTest.php

protected function loadDbFixtures(string $fixturePath)
{
    copy($fixturePath, __DIR__ . '/../../db/data.json');
}

public function testAddTodo()
{
    $this->loadDbFixtures(__DIR__ . '/fixtures/addTodo.json');

    $myTodoText = 'Môj prvý akceptačný test';

    // ...
}

Zabezpečíme, že testy nebudú bežať nad vývojou databázou

Akceptačný test v PHPUnit

#!/bin/bash

cp db/data.json db/data.bak.json

vendor/bin/phpunit --bootstrap=vendor/autoload.php --color $@

mv db/data.bak.json db/data.json

Vytvoríme spustiteľný súbor bin/run-tests

Vyskúšame spustiť test

Akceptačný test v PHPUnit

bin/run-tests tests/

Výsledok:

Ako na fixtúry pre relačnú databázu?

Akceptačný/Integračný test v PHPUnit

  • Oddelená db pre testy
  • Transakcie: begin na začiatku a rollback na konci testu
  • Bez transakcií: resetovanie obsahu stavu db

Integračný test v PHPUnit

Integračný test v PHPUnit

Testovací scenár:

  • vytvoríme inštanciu TodoDataProvider
  • zavoláme metódu findAll
  • assertami overíme, že sa nám vrátili očakávané dáta

Načítanie všetkých todo z databázy

Integračný test v PHPUnit

Db fixtúra

[
    {
        "id": 11,
        "text": "prve todo",
        "createdAt": "2018-01-01 01:01:01",
        "isDone": false
    },
    {
        "id": 22,
        "text": "druhe todo",
        "createdAt": "2018-02-02 02:02:02",
        "isDone": true
    }
]

Súbor tests/Integration/DataProvider/fixtures/findAll.json

Integračný test v PHPUnit

Test

namespace tests\Integration\DataProvider;

class TodoDataProviderTest extends TestCase
{
    public function testFindAll()
    {
        $pathToFile = __DIR__ . '/fixtures/findAll.json';
        $converter = new TodoItemConverter();

        $provider = new TodoDataProvider($pathToFile, $converter);

        $items = $provider->findAll();

        $this->assertCount(2, $items);

        $firstTodo = $items[0];

        $this->assertInstanceOf(TodoItem::class, $firstTodo);
        $this->assertEquals(11, $firstTodo->getId());
        $this->assertEquals('prve todo', $firstTodo->getText());
        $this->assertEquals(
            '2018-01-01 01:01:01',
            $firstTodo->getCreatedAt()->format('Y-m-d H:i:s')
        );
        $this->assertEquals(false, $firstTodo->isDone());
    }
}

Integračný test v PHPUnit

Napíšte test, ktorý otestuje fungovanie findAll nad prázdnou db

Testami by sme mali pokryť pokiaľ možno všetky situácie, ktoré vieme predvídať.

Unit test v PHPUnit

Testuje sa iba jedna jednotka kódu

Unit test v PHPUnit

Všetko, čo nepatrí tejto jednotke, musí byť nahradené dablérmi plne pod kontrolou testu.

Testovací scenár

Unit test v PHPUnit

  • vytvoríme inštanciu TodoItemConverter
  • zavoláme metódu convertToEntity
  • assertami overíme, že sa nám vrátili očakávané dáta

Konverzia dát na entity

Test

namespace tests\Unit\DataConverter;

class TodoItemConverterTest extends TestCase
{
    public function testConvertToEntity()
    {
        $converter = new TodoItemConverter();

        $data = [
            'id'        => 111,
            'text'      => 'lorem ipsum',
            'createdAt' => '2018-01-01 01:01:01',
            'isDone'    => false,
        ];

        $entity = $converter->convertToEntity($data);

        $this->assertInstanceOf(TodoItem::class, $entity);

        $this->assertEquals($data['id'], $entity->getId());
        $this->assertEquals($data['text'], $entity->getText());
        $this->assertEquals($data['createdAt'], $entity->getCreatedAt()->format('Y-m-d H:i:s'));
        $this->assertEquals($data['isDone'], $entity->isDone());
    }
}

Unit test v PHPUnit

Napíšte test pre convertToArray

namespace tests\Unit\DataConverter;

class TodoItemConverterTest extends TestCase
{
    public function testConvertToArray()
    {
        $converter = new TodoItemConverter();

        $todo = new TodoItem();
        $todo->setId(222)
            ->setText('Star Trek Ipsum')
            ->setCreatedAt(new \DateTime('2018-02-02 02:02:02'))
            ->setAsNotDone();

        $data = $converter->convertToArray($todo);

        $this->assertEquals(222, $data['id']);
        $this->assertEquals('Star Trek Ipsum', $data['text']);
        $this->assertEquals('2018-02-02 02:02:02', $data['createdAt']);
        $this->assertFalse($data['isDone']);
    }
}

Unit test v PHPUnit

Ide o unit test?

testConvertToArray pracuje s dvoma triedami:

  • TodoItemConverter
  • TodoItem

TodoItem je však entita (DTO, Value Object), takže to až tak nevadí

Striktne vzaté sa jedná o integračný test.

Unit test v PHPUnit

Testovací scenár

Unit test v PHPUnit

  • vytvoríme inštanciu TodoRepository
  • zavoláme metódu findAll
  • assertami overíme, že sa nám vrátili očakávané dáta

Získavanie entít z repozitáru

Závislosť na TodoDataProvider nahradíme dablérom

Unit test v PHPUnit

Test

namespace tests\Unit\Repository;

class TodoRepositoryTest extends TestCase
{
    public function testFindAll()
    {
        $dataProvider = /* Ako vytvoriť dataProvider? */;

        $repository = new TodoRepository($dataProvider);

        $items = $repository->findAll();

        $expectedItems = /* ... */;
        $this->assertEquals($expectedItems, $items);
    }
}

Unit test v PHPUnit

Test

// ...
        
$dataProvider = /* Ako vytvoriť dataProvider? */;

// ...

Nesmie to byť inštancia triedy TodoDataProvider, pretože táto trieda nie je predmetom testu a nemáme ju v teste pod kontrolou.

Unit test v PHPUnit

Riešenie

class TestFindAllDataProvider extends TodoDataProvider
{
    public function findAll()
    {
        return 'hodnota len pre účel testu';
    }
}

Vytvoríme triedu, ktorá bude potomkom pôvodného provideru

a bude sa používať len pre tento test.

Unit test v PHPUnit

Mock

Takejto "náhradnej" triede sa hovorí "mock".

Našťastie ich nemusíme vytvárať ručne, ale existujú k tomu knižnice.

Môžeme použiť priamo PHPUnit.

Ale prečo by sme tancovali vo zvieracej kazajke, keď máme alternatívu?

Unit test v PHPUnit

Nainštalujeme Mockery

composer require --dev mockery/mockery

Unit test v PHPUnit

Dokončíme test

class TodoRepositoryTest extends TestCase
{
    protected function tearDown()
    {
        Mockery::close();
    }

    public function testFindAll()
    {
        $itemA = Mockery::mock(TodoItem::class);
        $itemB = Mockery::mock(TodoItem::class);

        $dataProvider = Mockery::mock(TodoDataProvider::class);
        $dataProvider->shouldReceive('findAll')->once()->andReturn([$itemA, $itemB]);

        $repository = new TodoRepository($dataProvider);

        $items = $repository->findAll();

        $this->assertEquals([$itemA, $itemB], $items);
    }
}

Unit test v PHPUnit

Test pre pridanie todo

   public function testAddTodo()
   {
        $newId = 23;

        $newItem = Mockery::mock(TodoItem::class);
        $newItem->shouldReceive('getId')->andReturn($newId);

        $itemA = Mockery::mock(TodoItem::class);
        $itemA->shouldReceive('getId')->andReturn(11);

        $itemB = Mockery::mock(TodoItem::class);
        $itemB->shouldReceive('getId')->andReturn(22);

        $dataProvider = Mockery::mock(TodoDataProvider::class);
        $dataProvider->shouldReceive('findAll')->once()->andReturn([$itemA, $itemB]);

        $newItem->shouldReceive('setId')->once()->with($newId)->andReturnSelf();

        $dataProvider->shouldReceive('saveItems')->once()->with([
            $itemA,
            $itemB,
            $newItem
        ]);

        $repository = new TodoRepository($dataProvider);
        $id = $repository->addTodo($newItem);

        $this->assertEquals($newId, $id);
    }

Unit test v PHPUnit

Volania statických metód?

Tieto sa najlepšie mockujú pomocou knižnice Patchwork

Záver

Kedy spúšťať testy?

Pri vývoji spúšťame priebežne testy aspoň pre nový kód.

Po dokončení tasku spustíme všetky testy.

Najlepšie je mať testy spúšťané aj v CI pre každý Pull Request a po každom merge-i.

Aké druhy testov by sme mali robiť?

Všetky!

Nespomaľuje písanie testov čas vývoja?

Keď sa s testami začína a všetci sa ich učia, tak áno

Ale

  • funkctionalitu treba aj tak otestovať
  • počas vývoja sa testuje niekoľkokrát to isté - prečo si to neautomatizovať?
  • ako dlho trvá fixovanie bugov, ktoré sa našli v produkcii?
  • koľko peňazí stojí chyba nájdená v produkcii?

Písanie testov vedie k tvorbe kvalitnejšieho kódu

Existujúce testy znižujú množstvo chýb v kóde

Vývoj ide rýchlejšie, keď je existujúci kód kvalitnejší a bez chýb

Ďakujem za pozornosť

Otázky?