Introducción a PHPUnit – Drupal Day Spain - Bilbao, 2014 – Asserts



Introducción a PHPUnit – Drupal Day Spain - Bilbao, 2014 – Asserts

0 0


charla-phpunit

Charla sobre PHPUnit realizada el 8 de noviembre en la Drupal Day 2014

On Github carlosreig / charla-phpunit

Introducción a PHPUnit

Drupal Day Spain - Bilbao, 2014

Carlos Reig Matut / @unstatu

¿Quién soy?

  • Carlos Reig Matut
  • Ingeniero informático por la UV
  • Freelance web developer
  • @unstatu

¿Qué es PHPUnit?

PHPUnit es un framework orientado a objetos para realizar testing en PHP.

Entonces... ¿Qué es testing?

Testing, en este caso, es automatizar el proceso de comprobar que las cosas funcionan como hemos pensado que lo hagan.

También hay testing manual...

¿Por qué es bueno tener tests?

  • Facilita refactorizar el código
  • Ayuda a tener un buen diseño
  • Sirve para documentar el código
  • ...
Permite el TDD. También da una sensación de seguridad, aunque tampoco está bien del todo... En un proyecto como Drupal tener tests es imprescindible.

Los números de Drupal 8*

  • 6498 tests
  • 35929 asserts
Drupal 8 también utiliza SimpleTest que es otro Framework the tests unitarios. Tests con ejemplo de cuenta bancaria.

¿Y cómo los ejecuto?

  • El código de los tests está en
    ./core/tests/Drupal/Tests/
    ./core/modules/%MODULE%/tests/src/
  • Normalmente se ejecutan por línea de comandos:
    php ./core/vendor/bin/phpunit --configuration ./core
  • O puedes usar el módulo Testing (no está activado por defecto)
  • O puedes usar Drush
                                drush test-run
                            
admin/config/development/testing/

Estructura de una suite

  • Se compone de test cases
  • Un test case es una clase que hereda de \PHPUnit_Framework_TestCase
  • En Drupal 8 los test cases heredan de \Drupal\Tests\UnitTestCase

Estructura de un test case

  • Es una clase: métodos y atributos
  • Los tests son los métodos públicos que empiezan por "test":
    public function testSiEstaClaro() { $this->assertTrue( true ); }
  • Si un test tiene algún assert que no se cumpla, el test falla.
  • Dos métodos especiales:
    • protected function setUp()
    • protected function tearDown()

Básicamente, un test se divide en tres fases...

Si el método es público pero no empieza por test, se puede indicar a PHPUnit que debe ejecutarlo añadiendo la anotación @test al PHPDoc Normalmente el nombre del test case (el de la clase) es el nombre de la clase que está testeando + Test

1. Preparar el test

2. Actuar

3. Comprobar si el resultado es el esperado (con asserts)

Acción y efecto de afirmar o dar por cierto algo. Un test falla si una aserción no se cumple. Comprobar si el resultado es el esperado no significa que siempre tengamos que comprobar el valor devuelto del método que testeamos. Puede ser comprobar que se ha pasado un argumento concreto a otro método, que un archivo se ha creado, etc.
$this->config = array(
    'one' => '1',
    'two' => '2',
);
$this->settings = new Settings($this->config);
...
$this->assertEquals($this->config['one'], Settings::get('one'));
$this->assertEquals($this->config['two'], Settings::get('two'));
                    Drupal\Tests\Core\Site\SettingsTest::testGet
//Preparar
$this->config = array(
    'one' => '1',
    'two' => '2',
);

//Actuar
$this->settings = new Settings($this->config);
$settingOne = Settings::get('one');
$settingTwo = Settings::get('two');
...
//Comprobar
$this->assertEquals($this->config['one'], $settingOne);
$this->assertEquals($this->config['two'], $settingTwo);
                    Drupal\Tests\Core\Site\SettingsTest::testGet

Fixtures

Cuando escribes un test necesitas empezar en un estado conocido. Este estado es la fixture del test.

Se suele hacer en el setUp del test case

Ejemplo de la pila

Asserts

Hacen fallar el test si no se cumplen.

Hay 36 diferentes: assertArrayHasKey, assertClassHasAttribute, assertClassHasStaticAttribute, assertContains, assertContainsOnly, assertContainsOnlyInstancesOf, assertCount, assertEmpty, assertEqualXMLStructure, assertEquals, assertFalse, assertFileEquals, assertFileExists, assertGreaterThan, assertGreaterThanOrEqual, assertInstanceOf, assertInternalType, assertJsonFileEqualsJsonFile, assertJsonStringEqualsJsonFile, assertJsonStringEqualsJsonString, assertLessThan, assertLessThanOrEqual, assertNull, assertObjectHasAttribute, assertRegExp, assertStringMatchesFormat, assertStringMatchesFormatFile, assertSame, assertStringEndsWith, assertStringEqualsFile, assertStringStartsWith, assertThat, assertTrue, assertXmlFileEqualsXmlFile, assertXmlStringEqualsXmlFile, assertXmlStringEqualsXmlString.

class TimerTest extends UnitTestCase {

    /**
    * Tests Timer::read() time accumulation accuracy across multiple restarts.
    *
    * @see \Drupal\Component\Utility\Timer::read()
    */
    public function testTimer() {
        Timer::start('test');
        usleep(5000);
        $value = Timer::read('test');
        usleep(5000);
        $value2 = Timer::read('test');
        ...
        // Although we sleep for 5 milliseconds, we should test that at least 4 ms
        // have past because usleep() is not reliable on Windows. See
        // http://php.net/manual/en/function.usleep.php for more information. The
        // purpose of the test to validate that the Timer class can measure elapsed
        // time not the granularity of usleep() on a particular OS.
        $this->assertGreaterThanOrEqual(4, $value, 'Timer failed to measure at least 4 milliseconds of sleeping while running.');

        $this->assertGreaterThanOrEqual($value + 4, $value2, 'Timer failed to measure at least 8 milliseconds of sleeping while running.');

        // Stop the timer.
        $value5 = Timer::stop('test');
        $this->assertGreaterThanOrEqual($value4, $value5['time'], 'Timer measured after stopping was not greater than last measurement.');
        ...
    }

}Drupal\Tests\Component\Utility\TimerTest
class JsonTest extends UnitTestCase {

    /**
    * A test string with the full ASCII table.
    *
    * @var string
    */
    protected $string;

    /**
    * An array of unsafe html characters which has to be encoded.
    *
    * @var array
    */
    protected $htmlUnsafe;

    /**
    * An array of unsafe html characters which are already escaped.
    *
    * @var array
    */
    protected $htmlUnsafeEscaped;


    /**
    * {@inheritdoc}
    */
    protected function setUp() {
        parent::setUp();

        // Setup a string with the full ASCII table.
        // @todo: Add tests for non-ASCII characters and Unicode.
        $this->string = '';
        for ($i = 1; $i < 128; $i++) {
            $this->string .= chr($i);
        }

        // Characters that must be escaped.
        // We check for unescaped " separately.
        $this->htmlUnsafe = array('<', '>', '\'', '&');
        // The following are the encoded forms of: < > ' & "
        $this->htmlUnsafeEscaped = array('\u003C', '\u003E', '\u0027', '\u0026', '\u0022');
    }

    /**
    * Tests encoding length.
    */
    public function testEncodingLength() {
        // Verify that JSON encoding produces a string with all of the characters.
        $json = Json::encode($this->string);
        $this->assertTrue(strlen($json) > strlen($this->string), 'A JSON encoded string is larger than the source string.');
    }

    /**
    * Tests end and start of the encoded string.
    */
    public function testEncodingStartEnd() {
        $json = Json::encode($this->string);
        // The first and last characters should be ", and no others.
        $this->assertTrue($json[0] == '"', 'A JSON encoded string begins with ".');
        $this->assertTrue($json[strlen($json) - 1] == '"', 'A JSON encoded string ends with ".');
        $this->assertTrue(substr_count($json, '"') == 2, 'A JSON encoded string contains exactly two ".');
    }

    /**
    * Tests converting PHP variables to JSON strings and back.
    */
    public function testReversibility() {
        $json = Json::encode($this->string);
        // Verify that encoding/decoding is reversible.
        $json_decoded = Json::decode($json);
        $this->assertSame($this->string, $json_decoded, 'Encoding a string to JSON and decoding back results in the original string.');
    }

    /**
    * Test the reversibility of structured data
    */
    public function testStructuredReversibility() {
        // Verify reversibility for structured data. Also verify that necessary
        // characters are escaped.
        $source = array(TRUE, FALSE, 0, 1, '0', '1', $this->string, array('key1' => $this->string, 'key2' => array('nested' => TRUE)));
        $json = Json::encode($source);
        foreach ($this->htmlUnsafe as $char) {
            $this->assertTrue(strpos($json, $char) === FALSE, sprintf('A JSON encoded string does not contain %s.', $char));
        }
        // Verify that JSON encoding escapes the HTML unsafe characters
        foreach ($this->htmlUnsafeEscaped as $char) {
            $this->assertTrue(strpos($json, $char) > 0, sprintf('A JSON encoded string contains %s.', $char));
        }
        $json_decoded = Json::decode($json);
        $this->assertNotSame($source, $json, 'An array encoded in JSON is identical to the source.');
        $this->assertSame($source, $json_decoded, 'Encoding structured data to JSON and decoding back not results in the original data.');
    }Drupal\Tests\Component\Serialization\JsonTest

Sobre los tests

@depends

class ConfigEntityBaseUnitTest extends UnitTestCase {
...
    /**
    * @covers ::setStatus
    * @covers ::status
    */
    public function testSetStatus() {
        $this->assertTrue($this->entity->status());
        $this->assertSame($this->entity, $this->entity->setStatus(FALSE));
        $this->assertFalse($this->entity->status());
        $this->entity->setStatus(TRUE);
        $this->assertTrue($this->entity->status());
    }

    /**
    * @covers ::enable
    * @depends testSetStatus
    */
    public function testEnable() {
        $this->entity->setStatus(FALSE);
        $this->assertSame($this->entity, $this->entity->enable());
        $this->assertTrue($this->entity->status());
    }
...
}
                        \Drupal\Tests\Core\Config\Entity\ConfigEntityBaseUnitTest
Los @depends se suelen usar para proveer de argumentos a otros tests. Si falla el test del cual dependen los demás, estos no se ejecutan.

@dataProvider

class UuidTest extends UnitTestCase {

    /**
    * Dataprovider for UUID instance tests.
    *
    * @return array
    */
    public function providerUuidInstances() {

        $instances = array();
        $instances[][] = new Php();

        // If valid PECL extensions exists add to list.
        if (function_exists('uuid_create') && !function_exists('uuid_make')) {
            $instances[][] = new Pecl();
        }

        // If we are on Windows add the com implementation as well.
        if (function_exists('com_create_guid')) {
            $instances[][] = new Com();
        }

        return $instances;
    }

    /**
    * Tests generating valid UUIDs.
    *
    * @dataProvider providerUuidInstances
    */
    public function testGenerateUuid(UuidInterface $instance) {
        $this->assertTrue(Uuid::isValid($instance->generate()), sprintf('UUID generation for %s works.', get_class($instance)));
    }

    /**
    * Tests that generated UUIDs are unique.
    *
    * @dataProvider providerUuidInstances
    */
    public function testUuidIsUnique(UuidInterface $instance) {
        $this->assertNotEquals($instance->generate(), $instance->generate(), sprintf('Same UUID was not generated twice with %s.', get_class($instance)));
    }


    /**
    * Tests UUID validation.
    *
    * @param string $uuid
    *   The uuid to check against.
    * @param bool $is_valid
    *   Whether the uuid is valid or not.
    * @param string $message
    *   The message to display on failure.
    *
    * @dataProvider providerTestValidation
    */
    public function testValidation($uuid, $is_valid, $message) {
        $this->assertSame($is_valid, Uuid::isValid($uuid), $message);
    }

    /**
    * Dataprovider for UUID instance tests.
    *
    * @return array
    *  An array of arrays containing
    *   - The Uuid to check against.
    *   - (bool) Whether or not the Uuid is valid.
    *   - Failure message.
    */
    public function providerTestValidation() {
        return array(
            // These valid UUIDs.
            array('6ba7b810-9dad-11d1-80b4-00c04fd430c8', TRUE, 'Basic FQDN UUID did not validate'),
            array('00000000-0000-0000-0000-000000000000', TRUE, 'Minimum UUID did not validate'),
            array('ffffffff-ffff-ffff-ffff-ffffffffffff', TRUE, 'Maximum UUID did not validate'),
            // These are invalid UUIDs.
            array('0ab26e6b-f074-4e44-9da-601205fa0e976', FALSE, 'Invalid format was validated'),
            array('0ab26e6b-f074-4e44-9daf-1205fa0e9761f', FALSE, 'Invalid length was validated'),
        );
    }
}
                        \Drupal\Tests\Component\Uuid\UuidTest
Con los @dataProviders puedes ejecutar varias veces un test con distintos argumentos.

@expectedException

class DateTimePlusTest extends UnitTestCase {
    ...
    public function providerTestInvalidDates() {
        return array(
            // Test for invalid month names when we are using a short version
            // of the month.
            array('23 abc 2012', NULL, 'd M Y', "23 abc 2012 contains an invalid month name and did not produce errors."),
            // Test for invalid hour.
            array('0000-00-00T45:30:00', NULL, 'Y-m-d\TH:i:s', "0000-00-00T45:30:00 contains an invalid hour and did not produce errors."),
            // Test for invalid day.
            array('0000-00-99T05:30:00', NULL, 'Y-m-d\TH:i:s', "0000-00-99T05:30:00 contains an invalid day and did not produce errors."),
            // Test for invalid month.
            array('0000-75-00T15:30:00', NULL, 'Y-m-d\TH:i:s', "0000-75-00T15:30:00 contains an invalid month and did not produce errors."),
            // Test for invalid year.
            array('11-08-01T15:30:00', NULL, 'Y-m-d\TH:i:s', "11-08-01T15:30:00 contains an invalid year and did not produce errors."),
        );
    }

    /**
    * @dataProvider providerTestInvalidDates
    * @expectedException \Exception
    */
    public function testInvalidDates($input, $timezone, $format, $message) {
        DateTimePlus::createFromFormat($format, $input, $timezone);
    }
    ...
\Drupal\Tests\Component\Datetime\DateTimePlusTest
También se puede comprobar la salida del script o los errores de PHP.

Mocks/Stub

Un mock es un objeto de mentira.

Con un mock puedes:

  • Comprobar si un método se ha llamado
  • Comprobar con qué argumentos se ha llamado
  • Modificar el comportamiento del método (sino se modifica, devuelve null por defecto)
  • Testear clases que tienen dependencias o interacciones con otras: INYECCIÓN DE DEPENDENCIAS
  • ...

Cómo usar un mock

//Creamos el mock
$oMock = $this->getMock('Clase_a_la_que_falsear');

//Cambiamos su comportamiento o hacemos comprobaciones
$oMock->expects( número_de_llamadas_esperadas )1
->method( 'metodo_que_vigilar' )
->will( valor_a_devolver );
                        
class AliasManagerTest extends UnitTestCase {

    protected $aliasManager;

    protected $aliasStorage;

    protected $aliasWhitelist;

    protected $languageManager;

    protected $cache;

    protected $cacheKey = 'preload-paths:key';

    protected $path = 'key';

    /**
    * {@inheritdoc}
    */
    protected function setUp() {
        parent::setUp();

        $this->aliasStorage = $this->getMock('Drupal\Core\Path\AliasStorageInterface');
        $this->aliasWhitelist = $this->getMock('Drupal\Core\Path\AliasWhitelistInterface');
        $this->languageManager = $this->getMock('Drupal\Core\Language\LanguageManagerInterface');
        $this->cache = $this->getMock('Drupal\Core\Cache\CacheBackendInterface');

        $this->aliasManager = new AliasManager($this->aliasStorage, $this->aliasWhitelist, $this->languageManager, $this->cache);

    }

    /**
    * Tests the getPathByAlias method for an alias that have no matching path.
    *
    * @covers ::getPathByAlias()
    */
    public function testGetPathByAliasNoMatch() {
        $alias = $this->randomMachineName();

        $language = new Language(array('id' => 'en'));

        $this->languageManager->expects($this->any())
            ->method('getCurrentLanguage')
            ->with(LanguageInterface::TYPE_URL)
            ->will($this->returnValue($language));

        $this->aliasStorage->expects($this->once())
            ->method('lookupPathSource')
            ->with($alias, $language->getId())
            ->will($this->returnValue(NULL));

        $this->assertEquals($alias, $this->aliasManager->getPathByAlias($alias));
        // Call it twice to test the static cache.
        $this->assertEquals($alias, $this->aliasManager->getPathByAlias($alias));
    }

    /**
    * Tests the getPathByAlias method for an alias that have a matching path.
    *
    * @covers ::getPathByAlias()
    */
    public function testGetPathByAliasNatch() {
        $alias = $this->randomMachineName();
        $path = $this->randomMachineName();

        $language = $this->setUpCurrentLanguage();

        $this->aliasStorage->expects($this->once())
            ->method('lookupPathSource')
            ->with($alias, $language->getId())
            ->will($this->returnValue($path));

        $this->assertEquals($path, $this->aliasManager->getPathByAlias($alias));
        // Call it twice to test the static cache.
        $this->assertEquals($path, $this->aliasManager->getPathByAlias($alias));
    }
...\Drupal\Tests\Core\Path\AliasManagerTest
class PasswordHashingTest extends UnitTestCase {

    protected $user;
    protected $password;
    protected $md5Password;
...
    protected function setUp() {
        parent::setUp();
        $this->user = $this->getMockBuilder('Drupal\user\Entity\User')
            ->disableOriginalConstructor()
            ->getMock();
        $this->passwordHasher = new PhpassHashedPassword(1);
    }
...
    public function testPasswordNeedsUpdate() {
        $this->user->expects($this->any())
            ->method('getPassword')
            ->will($this->returnValue($this->md5Password));
        // The md5 password should be flagged as needing an update.
        $this->assertTrue($this->passwordHasher->userNeedsNewHash($this->user), 'User with md5 password needs a new hash.');
    }
...

                        \Drupal\Tests\Core\Password\PasswordHashingTest

PHPUNIT.xml

  • Indica de cuáles son las suites y en qué directorios están los tests
  • Permite cambiar algunos valores de la configuración de PHP
  • Permite indicar un archivo de bootstrap
<!--?xml version="1.0" encoding="UTF-8"?-->

<phpunit bootstrap="tests/bootstrap.php" colors="true">
    <php>
        <!-- Set error reporting to E_ALL. -->
        <ini name="error_reporting" value="32767">
        <!-- Do not limit the amount of memory tests take to run. -->
        <ini name="memory_limit" value="-1">
    </ini></ini></php>
    <testsuites>
        <testsuite name="Drupal Unit Test Suite">
            <directory>./tests</directory>
            <directory>./modules/*/tests</directory>
            <directory>../modules</directory>
            <directory>../sites/*/modules</directory>
            <!-- Exclude Composer's vendor directory so we don't run tests there. -->
            <exclude>./vendor</exclude>
            <!-- Exclude Drush tests. -->
            <exclude>./drush/tests</exclude>
            <!-- Exclude special-case files from config's test modules. -->
            <exclude>./modules/config/tests/config_test/src</exclude>
        </testsuite>
    </testsuites>
    <!-- Filter for coverage reports. -->
    <filter>
        <whitelist>
            <directory>./includes</directory>
            <directory>./lib</directory>
            <directory>./modules</directory>
            <directory>../modules</directory>
            <directory>../sites</directory>
        </whitelist>
    </filter>
</phpunit>./core/phpunit.xml.dist