Objets fantaisie

Cette page...

Remanier les tests à nouveau

Avant d'ajouter de nouvelles fonctionnalités il y a du remaniement à faire. Nous allons effectuer des tests chronométrés et la classe TimeTestCase a définitivement besoin d'un fichier propre. Appelons le tests/time_test_case.php...

<?php
    if (! defined('SIMPLE_TEST')) {
        define('SIMPLE_TEST', 'simpletest/');
    }
    require_once(SIMPLE_TEST . 'unit_tester.php');

    class TimeTestCase extends UnitTestCase {
        function TimeTestCase($test_name = '') {
            $this->UnitTestCase($test_name);
        }
        function assertSameTime($time1, $time2, $message = '') {
            if (! $message) {
                $message = "Time [$time1] should match time [$time2]";
            }
            $this->assertTrue(
                    ($time1 == $time2) || ($time1 + 1 == $time2),
                    $message);
        }
    }
?>
Nous pouvons lors utiliser require() pour incorporer ce fichier dans le script all_tests.php.

Ajouter un timestamp au Log

Je ne sais pas trop quel devrait être le format du message de log pour le test alors pour vérifier le timestamp nous pourrions juste faire la plus simple des choses possibles, c'est à dire rechercher une suite de chiffres.

<?php
    require_once('../classes/log.php');
    require_once('../classes/clock.php');

    class TestOfLogging extends TimeTestCase {
        function TestOfLogging() {
            $this->TimeTestCase('Log class test');
        }
        function setUp() {
            @unlink('../temp/test.log');
        }
        function tearDown() {
            @unlink('../temp/test.log');
        }
        function getFileLine($filename, $index) {
            $messages = file($filename);
            return $messages[$index];
        }
        function testCreatingNewFile() {
            ...
        }
        function testAppendingToFile() {
            ...
        }
        function testTimestamps() {
            $log = new Log('../temp/test.log');
            $log->message('Test line');
            $this->assertTrue(
                    preg_match('/(\d+)/', $this->getFileLine('../temp/test.log', 0), $matches),
                    'Found timestamp');
            $clock = new clock();
            $this->assertSameTime((integer)$matches[1], $clock->now(), 'Correct time');
        }
    }
?>
Ce scénario de test crée un nouvel objet Log et écrit un message. Nous recherchons une suite de chiffres et nous la comparons à l'horloge présente en utilisant notre objet Clock. Bien sûr ça ne marche pas avant d'avoir écrit le code.

All tests

Pass: log_test.php->Log class test->testappendingtofile->Expecting [/Test line 1/] in [Test line 1]
Pass: log_test.php->Log class test->testappendingtofile->Expecting [/Test line 2/] in [Test line 2]
Pass: log_test.php->Log class test->testcreatingnewfile->Created before message
Pass: log_test.php->Log class test->testcreatingnewfile->File created
Fail: log_test.php->Log class test->testtimestamps->Found timestamp

Notice: Undefined offset: 1 in /home/marcus/projects/lastcraft/tutorial_tests/tests/log_test.php on line 44
Fail: log_test.php->Log class test->testtimestamps->Correct time
Pass: clock_test.php->Clock class test->testclockadvance->Advancement
Pass: clock_test.php->Clock class test->testclocktellstime->Now is the right time
3/3 test cases complete. 6 passes and 2 fails.
Cette suite de tests montre encore les succès de notre modification précédente.

Nous pouvons faire passer les tests en ajoutant simplement un timestamp à l'écriture dans le fichier. Oui, bien sûr, tout ceci est assez trivial et d'habitude je ne le testerais pas aussi fanatiquement, mais ça va illustrer un problème plus général... Le fichier log.php devient...

<?php
    require_once('../classes/clock.php');
    
    class Log {
        var $_file_path;
        
        function Log($file_path) {
            $this->_file_path = $file_path;
        }
        
        function message($message) {
            $clock = new Clock();
            $file = fopen($this->_file_path, 'a');
            fwrite($file, "[" . $clock->now() . "] $message\n");
            fclose($file);
        }
    }
?>
Les tests devraient passer.

Par contre notre nouveau test est plein de problèmes. Qu'est-ce qui se passe si notre format de temps change ? Les choses vont devenir largement plus compliquées si ça venait à se produire. Cela veut aussi dire que n'importe quel changement du format de notre classe horloge causera aussi un échec dans les tests de log. Bilan : nos tests de log sont tout mélangés avec les test d'horloge et par la même très fragiles. Tester à la fois des facettes de l'horloge et d'autres du log manque de cohésion, ou de focalisation étanche si vous préférez. Nos problèmes sont causés en partie parce que le résultat de l'horloge est imprévisible alors que l'unique chose à tester est la présence du résultat de Clock::now(). Peu importe le contenu de l'appel de cette méthode.

Pouvons-nous rendre cet appel prévisible ? Oui si nous pouvons forcer le loggueur à utiliser une version factice de l'horloge lors du test. Cette classe d'horloge factice devrait se comporter exactement comme la classe Clock à part une sortie fixée dans la méthode now(). Et au passage, ça nous affranchirait même de la classe TimeTestCase !

Nous pourrions écrire une telle classe assez facilement même s'il s'agit d'un boulot plutôt fastidieux. Nous devons juste créer une autre classe d'horloge avec la même interface sauf que la méthode now() retourne une valeur modifiable via une autre méthode d'initialisation. C'est plutôt pas mal de travail pour un test plutôt mineur.

Sauf que ça se fait sans aucun effort.

Une horloge fantaisie

Pour atteindre le nirvana de l'horloge instantané pour test nous n'avons besoin que de trois lignes de code supplémentaires...

require_once('simpletest/mock_objects.php');
Cette instruction inclut le code de générateur d'objet fantaisie. Le plus simple reste de le mettre dans le script all_tests.php étant donné qu'il est utilisé assez fréquemment.
Mock::generate('Clock');
C'est la ligne qui fait le travail. Le générateur de code scanne la classe, en extrait toutes ses méthodes, crée le code pour générer une classe avec une interface identique, mais en ajoutant le nom "Mock" et ensuite eval() le nouveau code pour créer la nouvelle classe.
$clock = &new MockClock($this);
Cette ligne peut être ajoutée dans n'importe quelle méthode de test qui nous intéresserait. Elle crée l'horloge fantaisie prête à recevoir nos instructions.

Notre scénario de test en est à ses premiers pas vers un nettoyage radical...

<?php
    require_once('../classes/log.php');
    require_once('../classes/clock.php');
    Mock::generate('Clock');

    class TestOfLogging extends UnitTestCase {
        function TestOfLogging() {
            $this->UnitTestCase('Log class test');
        }
        function setUp() {
            @unlink('../temp/test.log');
        }
        function tearDown() {
            @unlink('../temp/test.log');
        }
        function getFileLine($filename, $index) {
            $messages = file($filename);
            return $messages[$index];
        }
        function testCreatingNewFile() {
            ...
        }
        function testAppendingToFile() {
            ...
        }
        function testTimestamps() {
            $clock = &new MockClock($this);
            $clock->setReturnValue('now', 'Timestamp');
            $log = new Log('../temp/test.log');
            $log->message('Test line', &$clock);
            $this->assertWantedPattern(
                    '/Timestamp/',
                    $this->getFileLine('../temp/test.log', 0),
                    'Found timestamp');
        }
    }
?>
Cette méthode de test crée un objet MockClock puis définit la valeur retourné par la méthode now() par la chaîne "Timestamp". A chaque fois que nous appelons $clock->now(), elle retournera cette même chaîne. Ça devrait être quelque chose de facilement repérable.

Ensuite nous créons notre loggueur et envoyons un message. Nous incluons dans l'appel message() l'horloge que nous souhaitons utiliser. Ça veut dire que nous aurons à ajouter un paramètre optionnel à la classe de log pour rendre ce test possible...

class Log {
    var $_file_path;
    
    function Log($file_path) {
        $this->_file_path = $file_path;
    }
    
    function message($message, $clock = false) {
        if (!is_object($clock)) {
            $clock = new Clock();
        }
        $file = fopen($this->_file_path, 'a');
        fwrite($file, "[" . $clock->now() . "] $message\n");
        fclose($file);
    }
}
Maintenant tous les tests passent et ils ne testent que le code du loggueur. Nous pouvons à nouveau respirer.

Est-ce que ce paramètre supplémentaire dans la classe Log vous gêne ? Nous n'avons changé l'interface que pour faciliter les tests après tout. Les interfaces ne sont-elles pas la chose la plus importante ? Avons nous souillé notre classe avec du code de test ?

Peut-être, mais réfléchissez à ce qui suit. A la prochaine occasion, regardez une carte avec des circuits imprimés, peut-être la carte mère de l'ordinateur que vous regardez actuellement. Sur la plupart d'entre elles vous trouverez un trou bizarre et vide ou alors un point de soudure sans rien de fixé ou même une épingle ou une prise sans aucune fonction évidente. Peut-être certains sont là en prévision d'une expansion ou d'une variation future, mais la plupart n'y sont que pour les tests.

Pensez-y. Les usines qui fabriquent ces cartes imprimées par centaine de milliers gaspillent des matières premières sur des pièces qui n'ajoutent rien à la fonction finale. Si les ingénieurs matériel peuvent faire quelques sacrifices à l'élégance, je suis sûr que nous pouvons aussi le faire. Notre sacrifice ne gaspille pas de matériel après tout.

Ça vous gêne encore ? En fait moi aussi, mais pas tellement ici. La priorité numéro 1 reste du code qui marche, pas un prix pour minimalisme. Si ça vous gêne vraiment alors déplacez la création de l'horloge dans une autre méthode mère protégée. Ensuite sous classez l'horloge pour le test et écrasez la méthode mère avec une qui renvoie le leurre. Vos tests sont bancals mais votre interface est intacte.

Une nouvelle fois je vous laisse la décision finale.

Pour aller plus loin...