PHPUnit

https://phpunit.de/manual/current/en/index.html

Istoric

  • Creat de Sebastian Bergmann
  • Versiunea 1.0.0 lansata pe 8 aprilie 2001
  • Acum este la versiunea 4.6.4 (stable)

De ce PHPUnit?

  • Este intretinut in mod activ
  • Majoritatea proiectelor PHP il folosesc
  • Face mai mult decat testare unitara

De ce sa testam?

De ce sa avem codul testabil?

  • Self-explanatory PHP code
  • Separation of Concerns
  • Clean code
  • Proper Use of Access Modifiers
    (public, private, protected, final)
  • Regression

Context

  1. Agentie webdesign sau freelancer
  2. Start-up
  3. Corporatie

Teste unitare intr-o agentie de webdesign (freelancer)

Nu ai nevoie de teste unitare pentru ca lucrezi
pe baza de proiect si nu la un produs.

Timpul alocat testelor unitare
trebuie negociat cu clientul.

Verdict

Nu trebuie, dar daca stii sa le vinzi sau lucrezi la un produs trebuie luate in calcul.

Testele unitare intr-un start-up

La inceput nu ai nevoie de teste unitare.
Lanseaza MVP si mai vedem dupa.

MVP

Minimum viable product

Round A

Cateva iteratii mai tarziu ...

"Sa facem upgrade."

New feature

Legacy code

Technical debt

Refactoring

Arhitectura de sistem

Design patterns

Coding standards

Era bine daca stiai de toate acestea
de la inceput.

Te fortau sa iei niste decizii de R&D
de la inceput.

Puteai sa iti calculezi bugetul diferit
in functie de aceste etape.

Gandirea bazata pe testarea unitara te obliga sa
decuplezi codul PHP
si sa te intrebi mereu "este testabil?"

"este testabil" === "este mai usor de inteles"

"este testabil" === "este decuplat (SRP)"

"este testabil" === "este de incredere"

"este testabil" === "este usor de manipulat"

"este testabil" === "este fun"

Daca testele unitare sunt integrate in cultura R&D
de la inceput:

  • ridica standardele de dezvoltare a codului PHP
  • incurajeaza inovatia
  • incurajeaza iteratiile rapide
  • ofera o plasa de siguranta pentru orice feature
  • elimina costurile de training si consultanta
  • scad timpul intre release-uri

Verdict

Da, dupa MVP stabileste strategia de testare.

Testele unitare intr-o corporatie

Round A

Round B

Round C

Agile

"We're Cloud, Mobile, Agile, Big Data "

New disrupting business model: ŢaaS

"Sa facem upgrade."

Product ownerul vrea features noi.

Codebase urias

Meetings

Esti ca un start-up cu mai mult cod legacy insa cu mai multa experienta de piata.

Suna-l pe Seby!

Legacy code

Technical debt

Refactoring

Arhitectura de sistem

Design patterns

Coding standards

Decizii imediate

Unit tests
vs.
Integration tests

QA Owner

Trainings

Rezistenta la schimbare

HR

Daca testele unitare sunt integrate in cultura R&D
de la inceput:

  • ridica standardele de dezvoltare a codului PHP
  • incurajeaza inovatia
  • incurajeaza iteratiile rapide
  • ofera o plasa de siguranta pentru orice feature
  • elimina costurile de training si consultanta
  • scad timpul intre release-uri

Verdict

Da, trebuie investit in cultura R&D si in QA owner(s)

Take aways

"It's okay to never test code that never changes.
For everything else, you need tests."

Timothy Fitz (Coined Continuous Deployment)

"Creating a high-quality process is not trivial,
requires the support and commitment of the organization's leadership,
and can impact choice of people, systems, processes, communications, and even organizational structures."

Andi Gutmans (Zend)

"The internal quality of software is virtually imperceptible to customers and end users.
Bad internal quality shows up in the longer term, though.
It takes longer to fix even trivial bugs."

Real Worlds Solutions for Developing High Quality
PHP Frameworks and Applications Book

Costul procesului de bugfix

Evolutia codului (velocity)

features vs bugs

Teste unitare

PHPUnit

Avantaje

Multiple in functie de context

Dificultati in adoptie

  • Politica manageriala
  • Project Product owners
  • Cost de intretinere
  • Rezultatele se vad mai tarziu
  • Rezistenta programatorilor

Solutii

Unit testing cu PHPUnit

Exemplu live

|-[+] lib
|  |- FirstClass.php
|  \- SecondClass.php
|
|-[+] tests
|  |- [+] fixtures
|  |- [+] providers
|  |- [+] lib
|  |   \- [+] FirstClass
|  |       |- firstMethodTest.php
|  |       |- secondMethodTest.php
|  |       \- constructorTest.php
|  |
|  |- phpunit.xml
|  \- bootstrap.php
|
|- .gitignore
\- composer.json     <--------- Composer instructions
|-[+] lib
|  |- FirstClass.php
|  \- SecondClass.php
|
|-[+] tests
|  |- [+] fixtures
|  |- [+] providers
|  |- [+] lib
|  |   \- [+] FirstClass
|  |       |- firstMethodTest.php
|  |       |- secondMethodTest.php
|  |       \- constructorTest.php
|  |
|  |- phpunit.xml
|  \- bootstrap.php     <--------- Special startup variables & autoloader
|
|- .gitignore
\- composer.json     <--------- Composer instructions
|-[+] lib
|  |- FirstClass.php
|  \- SecondClass.php
|
|-[+] tests
|  |- [+] fixtures
|  |- [+] providers
|  |- [+] lib
|  |   \- [+] FirstClass
|  |       |- firstMethodTest.php
|  |       |- secondMethodTest.php
|  |       \- constructorTest.php
|  |
|  |- phpunit.xml     <--------- PHPUnit config
|  \- bootstrap.php     <--------- Special startup variables & autoloader
|
|- .gitignore
\- composer.json     <--------- Composer instructions

Asserts

assert*()

assertEquals()

assertSame()

assertTrue()

assertFalse()
assertNull()

etc

class MyClassTest extends \PHPUnit_Framework_TestCase
{
    function testThatSomethingWorks()
    {
        $expected = 999;
        // ...
        $actual = 999;
        $this->assertSame($expected, $actual, $message = null);
    }
}

Demo

setup proiect

Eager tests

Fragile tests

Obscure tests

Lying tests

Slow tests

Self-validating tests

Web-surfing tests

Putem testa metodele unei clase, mock-uind obiectele de care are nevoie ca sa ruleze.

De ce mock-uim obiecte?

Trebuie sa ne asiguram ca testele unitare:

  • ruleaza pe obiecte izolate
  • nu acceseaza reteaua
  • nu acceseaza db-ul
  • nu acceseaza file system-ul
  • nu deschid thread-uri
  • nu trimit email-uri, output, etc

class ProductManager
{
    public function getProductById($id)
    {
        $db = Db::getInstance();
        try {
            $query = "SELECT * from products where id = :id";
            $statement = $this->dbClient->prepare($query);
            $statement->execute(['id' => $id]);
            $productFromDb = $statement->fetch(); 
        } catch (PDOException $e) {
            // do something with it here
        }
        $productHandler = new ProductHandler(new Product($id));

        $productHandler->setId($id);
        $productHandler->setName($productFromDb->name);
        $productHandler->setImgUrl($productFromDb->imgUrl);

        return $productHandler;
    }
}

class ProductManager
{
    public function __construct(Db $db, ProductFactory $factory){}

    public function getProductById($id)
    {
        $productFromDb = $this->db->getProductById($id);
        $productObj = $this->factory->create($id);

        $productHandler = $this->map($productFromDb, $productObj);

        return $productHandler;
    } 
}

Stubs si Mock-uri

"A stub is an object that replaces the real object for testing purposes. Methods can be configured to return certain values. A mock object also allows the definition of expectations."

Cum mock-uim?

  1. PHPUnit style
  2. Fixture style
  3. Mockery

PHPUnit style

  • $this->getMockBuilder($className)
  • $this->getMock($originalClassName, $methods, $arguments, $mockClassName, ...)
$mock = $this->getMockBuilder($className)
    ->setMockClassName($name)
    ->setConstructorArgs(array())
    ->disableOriginalConstructor()
    ->disableOriginalClone()
    ->disableAutoload()
    ->setMethods(array()|NULL)
    ->getMock();
$this->getMock(
    $originalClassName,
    $methods = array(),
    array $arguments = array(),
    $mockClassName = '',
    $callOriginalConstructor = TRUE,
    $callOriginalClone = TRUE,
    $callAutoload = TRUE
);

Tipuri de mock-uri

dummy, stub, fake, spy, mock

Dummy

$logger = $this->getMock('LoggerInterface');

Stub

$logger = $this->getMock('Logger');
$logger
    ->expects($this->any())
    ->method('getLevel')
    ->will($this->returnValue(Logger::INFO));

random expectations

Fake

$urlGenerator = $this->getMock('UrlGenerator');
$urlGenerator
    ->expects($this->any())
    ->method('match')
    ->will($this->returnArgument(0));
$urlGenerator = $this->getMock('UrlGenerator');
$urlGenerator
    ->expects($this->any())
    ->method('generate')
    ->will($this->returnCallback(
        function ($routeName) use ($urlsByRoute) {
            if (isset($urlsByRoute[$routeName])) {
                return $urlsByRoute[$routeName];
            }

            throw new UnknownRouteException();
        }
    ));

Spy

$logger = $this->getMock('Logger');
$logger
    ->expects($spy = $this->any())
    ->method('debug');

$invocations = $spy->getInvocations();

$this->assertSame(1, count($invocations));

Mock

$logger = $this->getMock('Logger');
$logger
    ->expects($this->at(0))
    ->method('debug')
    ->with('A debug message');
$logger
    ->expects($this->at(1))
    ->method('info')
    ->with('An info message');

custom expectations

/**
 * if stream socket cannot be opened connect with throw an error
 * @expectedException \HttpClient\Transport\Exception\RuntimeException
 * @expectedExceptionCode \HttpClient\Transport\Socks::ERROR_OPENING_STREAM
 */
public function testIfStreamSocketCannotBeOpenedConnectWithThrowAnError()
{
    $stub = $this->getMockBuilder('\HttpClient\Transport\Socks')
        ->setMethods(
            array('getHost', 'createStreamContext', 
            'openStream', 'close')
        )
        ->getMock();
    $stub->method('getHost')->willReturn('www.test.local');
    $stub->method('createStreamContext')->willReturn(array());
    $stub->method('openStream')->willReturn(false);
    $stub->method('close')->willReturn(false);
    $stub->connect();
}

Code

public function testVerifyReturnsResponse()
{
    $method = $this->getMock('\\ReCaptcha\\RequestMethod', array('submit'));
    $method->expects($this->once())
            ->method('submit')
            ->with($this->callback(function ($params) {
                        return true;
                    }))
            ->will($this->returnValue('{"success": true}'));
    ;
    $rc = new ReCaptcha('secret', $method);
    $response = $rc->verify('response');
    $this->assertTrue($response->isSuccess());
}

Fixture style / PHP way

class LoggerDummy implements LoggerInterface
{
    public function debug()
    {
        return null;
    }

    ...
}
class ContainerLazyExtendStub {
    public static $initialized = false;
    public function init() { static::$initialized = true; }
}

Example #1 from Laravel
Example #2 from Laravel
Example #3 from Symfony

Mockery example

Mockery in loc de getMock din PHPUnit

// partial mock, se face override constructorului
$systemMock = \Mockery::mock('System')->makePartial();

// partial mock, se mock-uieste doar metoda connect
$driverMock = \Mockery::mock('Driver[connect]');
$driverMock->shouldReceive('connect')
            ->andThrow(new \Exception('Error: ...'));

// se face stub pe metoda file_exists, asteptandu-se 
// un input la care raspunde cu o anumita valoare in functie de input
$systemMock->shouldReceive('file_exists')
            ->with('/invalid-folder/')
            ->andReturn(false);
$systemMock->shouldReceive('file_exists')
            ->with('/valid-folder/')
            ->andReturn(true);

// se pot seta raspunsuri diferite in functie de indicele de apelare
$systemMock->shouldReceive('file_exists')->times(1)->andReturn(true);
$systemMock->shouldReceive('file_exists')->times(2)->andReturn(false);

// in PHPUnit, aceasta s-ar traduce in:
$phpunitMock->expects($this->at(0))
            ->method('someMethod')
            ->will($this->returnValue($firstValue));

Unit testing overkill

$pdoMock = $this->getMock('PDO');
$statement = $this->getMock('PDOStatement');

$pdoMock->expects($this->once())
        ->method('prepare')
        ->with('SELECT * from table')
        ->will($this->returnValue($statement));

Testing system functions

$handle = fopen('log.txt', 'w');
fwrite($handle, $string, $length);

Izolare in functii protected sau private

Suprascriere prin namespace #1 #2

Coverage-ul afectat de functiile interne de PHP.
Le-am apelat indirect printr-un wrapper:

class System
{
    /**
     * Helper for calling PHP's internal methods
     * @var \Mockery
     */
    protected static $mock;

    /**
     * Calls the internal method if the TESTING 
     * const is not defined or the mock is not set.
     * @param $methodName
     * @param array $args
     * @return mixed
     */
    public static function __callStatic($methodName, $args = array())
    {
        if (!is_null(self::$mock)) {
            return call_user_func_array(
                        array(self::$mock, $methodName), 
                        $args
                );
        }
        return call_user_func_array($methodName, $args);
    }
}
/**
 * when mocking the internal method then return mocked value
 */
public function testWhenMockingTheInternalMethodThenReturnMockedValue()
{
    $systemMock = \Mockery::mock('\\MySQLExtractor\\Common\\SystemMock')
                    ->makePartial();
    $systemMock->shouldReceive('file_exists')
                ->with('/invalid-folder/')
                ->andReturn(true);

    $system = new System();

    $refObject = new \ReflectionObject($system);
    $refProperty = $refObject->getProperty('mock');
    $refProperty->setAccessible(true);
    $refProperty->setValue($system, $systemMock);

    $this->assertTrue($system::file_exists('/invalid-folder/'));
}
/**
 * when not mocking then use the internal method
 */
public function testWhenNotMockingThenUseTheInternalMethod()
{
    $system = new System();
    $this->assertFalse($system::file_exists('/invalid-folder/'));
    $this->assertEquals($system::time(), time());
}

Cum testam proprietati
protected sau private

class ReflectionObject extends ReflectionClass implements Reflector {}
/**
 * Preparing the version returns the expected string format result.
 * @dataProvider versionStringsProvider
 */
public function testPreparingTheVersionReturnsTheExpectedStringFormatResult($input, $expected)
{
    $r = new \ReflectionObject($detect = new MobileDetect());
    $m = $r->getMethod('prepareVersion');
    $m->setAccessible(true);
    $this->assertEquals($expected, $m->invoke($detect, $input));
}
$property = new \ReflectionProperty('MyClass', 'property');
$property->setAccessible(true);
$value = $property->getValue($object);

Case study

class PHPUnitProtectedHelper
{
    protected $target;
    protected $refObject;
    public function __construct($target)
    {
        $this->target = $target;
        $this->refObject = new \ReflectionObject($this->target);
    }

    public function makeCall($method, $args = array())
    {
        $class = new \ReflectionClass(get_class($this->target));
        $method = $class->getMethod($method);
        $method->setAccessible(true);
        return $method->invokeArgs($this->target, $args);
    }

    public function getValue($attribute)
    {
        return \PHPUnit_Framework_Assert::readAttribute(
                    $this->target, 
                    $attribute
                );
    }

    public function setValue($attribute, $value)
    {
        $refProperty = $this->refObject->getProperty($attribute);
        $refProperty->setAccessible(true);
        $refProperty->setValue($this->target, $value);
    }
}

Example of usage:

$application = new \MySQLExtractor\Application();
$application->processServer($this->mysqlCredentials);

$helper = new \PHPUnitProtectedHelper($application);
$extractor = $helper->getValue('extractor');

Code coverage

Case study

Technical debt

Ward Cunningham Martin Fowler

"Shipping first time code is like going into debt. A little debt speeds development so long as it is paid back promptly with a rewrite..."

Ward Cunningham

"A particular benefit of the debt metaphor is that it's very handy for
communicating to non-technical people."

Martin Fowler
Martin Fowler

PHP Testing Starter

GitHub

Multumim!

Pssst: Api client unit testing and refactor discussion