Créer un générateur de code pour Symfony2

Le but ici, est de créer un petit bundle pour symfony2 qui génèrera une classe pour les tests fonctionnels et unitaires, un petit bout de code donc sans grande prétention. Peut être pas des plus utiles car il y a bien des moyens d’y arriver autrement, et plus simplement, mais c’est instructif pour ajouter une commandes à la console de Symfony2. Plutôt que d’apporter une solution toute faite, je vais essayer de remettre le parallèle avec le code source de Symfony2 qui m’a aidé, et donc reproduire ma démarche.
Le code généré sera des plus simple, un namespace et une classe héritant de WebTestCase. Pour les plus pressés, le code source est disponible sur github, il suffit de suivre les instructions du README pour installer le bundle facilement.

Commençons sans tarder, en créant un Bundle « from scratch ». Le répertoire de base ici sera tout au long du billet : src/Sweet/ScaffoldBundle/
On crée donc le minimum pour que le Bundle soit opérationnel.

src/Sweet/ScaffoldBundle/SweetScaffoldBundle.php

<?php

namespace Sweet\ScaffoldBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class SweetScaffoldBundle extends Bundle
{
}

On peut maintenant le déclarer et le charger.

app/AppKernel.php

...
new Sweet\ScaffoldBundle\SweetScaffoldBundle(),

Et dans l’autoload :

app/autoload.php

'Sweet'      => __DIR__.'/../src',

Rien de bien intéressant jusque là, si tout va bien il ne devrait pas se plaindre de trop si on charge une page dans le navigateur, sinon, il vous le fait savoir.

Il y a certainement plein de façon d’arriver à nos fins, je n’avais pas l’assurance de commencer par les tests, ce qui explique pourquoi on verra ça à la fin, pour ma part, j’ai suivis la façon suivante :

  1. Je crée une commande « vide » ;
  2. La commande génère un fichier statique ;
  3. Je rends ce fichier dynamique ;
  4. Je créer quelques validation pour éviter les fautes de saisis ;
  5. Je crée les tests ;
  6. Je peux me servir des tests pour implémenter d’autres commandes.

Voilà en gros le plan qui va être suivis ici.

Créer une commande vide

En sois, rendre une commande disponible n’est pas très compliqué, on trouve des exemples un peu partout que ce soit dans le code de Symfony, ou dans des bundles tel que FOS/UserBundle (FriendsOfSymfony) par exemple. On utilise les inputs, outputs, arguments fournis par la console de symfony, En ce qui concerne Mustache, il n’est pas utile ici, mais pour plus tard. Deux méthodes nécessaire ici, configure et execute.

Le nom de la commande sera scaffold:webtest, et demandera au maximum trois arguments,

  • Le bundle pour lequel le test est attribué, sous la forme de « vendor\MyBundle ».
  • Un nom de fichier à générer, finissant par Test, exemple : indexControllerTest.
  • Un path, optionnel afin de hierarchiser les tests correctement, sans arguments, il est rangé dans …/Tests/ et avec l’argument Command (par exemple) il sera rangé dans …/Tests/Command/

Maintenant qu’on sait ce que l’on veut, il ne reste plus qu’a écrire cette commande pour qu’elle soit prise en compte par la console de Symfony2.

src/Sweet/ScaffoldBundle/Command/GenerateWebTestCommand.php

<?php

namespace Sweet\ScaffoldBundle\Command;

use Symfony\Bundle\FrameworkBundle\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Output\Output;
use Symfony\Bundle\FrameworkBundle\Util\Mustache;

class GenerateWebTestCommand extends Command
{
    protected function configure ()
    {
        $this
            ->setName('scaffold:webtest')
            ->setDescription('generate a WebTestCase file')
            ->setDefinition(array(
                new InputArgument('bundle', InputArgument::REQUIRED, 'The bundle'),
                new InputArgument('filename', InputArgument::REQUIRED, 'The name of the file'),
                new InputArgument('path', InputArgument::OPTIONAL, 'The path inside the Tests directory'),
            ))
            ->setHelp(<<<EOT
The <info>scaffold:webtest</info> command create a WebTestCase file.

    <info>scaffold:webtest "vendor\MyBundle" DemoControlleTest Controller</info>
    It will create a webtest file in src/vendor/MyBundle/Tests/Controller/DemoControllerTest.php
EOT

        );
    }

    protected function execute (InputInterface $input, OutputInterface $output)
    {
    }
}

Pour les composant utilisé (use), un héritage à Command, et les utilitaires pour traiter les entrées/sorties en console. Seul Mustache peut intriguer, et sera utilisé plus bas dans ce billet.

Pour le reste, l’implémentation est assez instinctive, et on peut vérifier que tout fonctionne en faisant un app/console sans arguments, et on voit que notre commande est effectivement prise en compte, mais ne faisant rien du tout pour le moment.

C’est presque une implémentation descriptive, on s’assure de donner le nom, la description et les arguments, et le tour est joué. Bien entendu c’est un cas simple, et on peut voir des implémentations plus avancés tout au long du code de Symfony.

Générer un fichier statique

On veut que le fichier contenant quelques mots soit généré, mais surtout à l’endroit voulu, Pour faire ça, je me suis aidé d’une commande qui existe déjà, et qui génère des fichiers (tout un répertoire en faite) également, le tout suffisamment simple pour comprendre et reproduire avec mes besoin le code. Regarder le code pour la commande init:bundle est instructif pour ce qui est voulu ici, même s’il y a quelques changements à faire, on le trouve dans le fichier suivant :
vendor/symfony/src/Symfony/Bundle/FrameworkBundle/Command/InitBundleCommand.php

On crée notre fichier statique dans le bundle, qu’on prends soin de ranger dans un répertoire Resources/skeleton (pour garder les conventions utilisé déjà dans le code).

src/Sweet/ScaffoldBundle/Resources/skeleton/WebTestCase.php

<?php
echo 'coin';

Il ne nous reste plus qu’à implémenter la fonction execute() pour qu’elle copie simplement ce fichier skeleton dans le répertoire voulu avec la commande. Pour le moment, on ne s’occupe pas vraiment du contenu du fichier donc.

src/Sweet/ScaffoldBundle/Command/GenerateWebTestCommand.php

protected function execute (InputInterface $input, OutputInterface $output)
{
    $bundle   = $input->getArgument('bundle');
    $filename = $input->getArgument('filename');
    $filename = $input->getArgument('path');

    $targetFile = 'src/'.strtr($bundle, '\', '/').'/Tests/'.$path.'/'.$filename.'.php';

    $filesystem = $this->container->get('
filesystem');
    $filesystem->copy(__DIR__.'
/../Resources/skeleton/WebTestCase.php', $targetFile);

    $output->writeln('
<info>[File+]</info> '.$targetFile);
}

Le code ressemble en effet à ce qu’on a dans le init:bundle, sauf qu’on ne copie qu’un seul fichier au lieu d’un répertoire, et les nombreux tests en moins, mais on verra ça par la suite. Même si ce n’est pas très satisfaisant, regardons si le fichier est correctement crée :

app/console scaffold:webtest "Sweet\ScaffoldBundle" indexControllerTest Controller

Si tout ce passe bien, on devrait avoir un fichier dans src/Sweet/ScaffoldBundle/Tests/Controller/indexControllerTest.php contenant echo 'coin'; attendu.

Rendre le fichier dynamique

Nous avons notre fichier, c’est un premier pas, mais c’est toujours le même fichier, il nous faut au moins, un namespace et un nom de classe généré en fonction de la commande. C’est là que « Mustache » intervient. Il va nous permettre de remplacer quelques variables dans le fichier skeleton. Pour la commande init:bundle on peut regarder les skeletons dans le répertoire vendors/symfony/src/Symfony/Bundle/FrameworkBundle/Resources/skeleton/bundle/generic/

src/Sweet/ScaffoldBundle/Resources/skeleton/WebTestCase.php

<?php

namespace {{ namespace }};

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class {{ className }} extends WebTestCase
{
    public function testIndex ()
    {

    }
}

On va donc remplacer {{ namespace }} et {{ className }} par le contenu de variable. Le fichier sert surtout d’exemple, il pourrait être arrangé selon les besoin, avec plus de méthodes défini (setUp, tearDown…). On va donc créer les variable dans la méthode execute().

src/Sweet/ScaffoldBundle/Command/GenerateWebTestCommand.php

$namespace  = $bundle.'\\Tests';
$path = $input->getArgument('path');
if (isset($path)) {
    $namespace .= '\'.$path;
}

Mustache::renderFile($targetFile, array(
    '
namespace' => $namespace,
    '
className' => $filename,
));

On peut donc maintenant vérifier que le fichier générer contient bien les variables désiré.

Créer quelques validation

On y rajoute quelques vérifications surtout pour éviter les erreurs de saisis, je place donc ici le contenu de la fonction execute au complet, la lectures des exceptions jeté aide à comprendre, on remarque qu’elle sont reprise pour beaucoup du code même de init:bundle.

    protected function execute (InputInterface $input, OutputInterface $output)
    {
        // validate bundle
        if (!preg_match('/Bundle$/', $bundle = $input->getArgument('bundle'))) {
            throw new \InvalidArgumentException('The bundle must end with Bundle');
        }

        // validate that the namespace is at least one level deep
        if (false === strpos($bundle, '\')) {
            throw new \InvalidArgumentException(
                '
The bundle must contain the vendor with quotes, exemple "vendors\MyBundle"');
        }

        if (!is_dir($bundleDir = '
src/'.strtr($bundle, '\', '/'))) {
            throw new \RuntimeException(sprintf(
                '
The directory %s doesn\'t exists, are you sure that it is the correct bundle ?', $bundleDir
            ));
        }

        // validate namespace
        $namespace  = $bundle.'\\Tests';
        $path = $input->getArgument('path');
        if (isset($path)) {
            $namespace .= '\'.$path;
        }

        $namespace = strtr($namespace, '
/', '\');
        if (preg_match('
/[^A-Za-z0-9_\\\-]/', $namespace)) {
            throw new \InvalidArgumentException('
The namespace contains invalid characters.');
        }

        if (!preg_match('
/Test$/', $filename = $input->getArgument('filename'))) {
            throw new \InvalidArgumentException('
The filename musts end with Test');
        }

        $targetFile = '
src/'.strtr($bundle, '\', '/').'/Tests/'.$path.'/'.$filename.'.php';

        if (file_exists($targetFile)) {
            throw new \RuntimeException(sprintf('
The "%s" test file already exists.', $targetFile));
        }

        $filesystem = $this->container->get('
filesystem');
        $filesystem->copy(__DIR__.'
/../Resources/skeleton/WebTestCase.php', $targetFile);

        Mustache::renderFile($targetFile, array(
            '
namespace' => $namespace,
            '
className' => $filename,
        ));

        $output->writeln('
<info>[File+]</info> '.$targetFile);
    }

Pour plus d’information sur les fonctions de filesystem, regarder le fichier vendors/symfony/src/Symfony/Component/HttpKernel/Util/Filesystem.php

Les Tests

Le test suivant crée le fichier, et vérifie de sa présence, ainsi que le retour en console, il vérifie également que le contenu du fichier généré contient bien le namespace et le nom de la classe attendu.

<?php

namespace Sweet\ScaffoldBundle\Tests\Command;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Sweet\ScaffoldBundle\Command\GenerateWebTestCommand;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Output\Output;
use Symfony\Component\Console\Tester\ApplicationTester;

class GenerateWebTestCommandTest extends WebTestCase
{
    public function testGenerateWebTestFullCommand ()
    {
        $kernel = $this->createKernel();
        $command = new GenerateWebTestCommand();
        $application = new Application($kernel);
        $application->setAutoExit(false);
        $tester = new ApplicationTester($application);

        $bundle     = 'Sweet\\ScaffoldBundle';
        $filename   = 'indexControllerTest';
        $path       = 'Controller';

        $tester->run(array(
            'command'   => $command->getFullName(),
            'bundle'    => $bundle,
            'filename'  => $filename,
            'path'      => $path,
        ));

        $this->assertRegExp('/\[File\+\]/', $tester->getDisplay());
        $fullpath = 'src/Sweet/ScaffoldBundle/Tests/Controller/indexControllerTest.php';
        $this->assertFileExists($fullpath);
       
        $content = fread(fopen($fullpath, 'r'), filesize($fullpath));
        $this->assertContains('namespace Sweet\\ScaffoldBundle\\Tests\\Controller;', $content);
        $this->assertContains('class indexControllerTest extends WebTestCase', $content);

        unlink('src/Sweet/ScaffoldBundle/Tests/Controller/indexControllerTest.php');
        rmdir('src/Sweet/ScaffoldBundle/Tests/Controller');
    }

    public function testGenerateWebTestCommand ()
    {
        $kernel = $this->createKernel();
        $command = new GenerateWebTestCommand();
        $application = new Application($kernel);
        $application->setAutoExit(false);
        $tester = new ApplicationTester($application);

        $bundle     = 'Sweet\\ScaffoldBundle';
        $filename   = 'indexControllerTest';

        $tester->run(array(
            'command'   => $command->getFullName(),
            'bundle'    => $bundle,
            'filename'  => $filename,
        ));

        $this->assertRegExp('/\[File\+\]/', $tester->getDisplay());
        $fullpath = 'src/Sweet/ScaffoldBundle/Tests/indexControllerTest.php';
        $this->assertFileExists($fullpath);
       
        $content = fread(fopen($fullpath, 'r'), filesize($fullpath));
        $this->assertContains('namespace Sweet\\ScaffoldBundle\\Tests;', $content);
        $this->assertContains('class indexControllerTest extends WebTestCase', $content);
       
        unlink('src/Sweet/ScaffoldBundle/Tests/indexControllerTest.php');
    }
}

La seconde partie et là surtout pour s’assurer que si on ne donne pas de répertoire, et qu’on range le test directement à la racine de Tests/. J’avais eu un petit soucis avec le namespace, et surtout pour le corrigé que j’ai mis ça en fait. Donc, dans l’ordre, une fois avec la commande complète, et une autre fois sans le path :

  • Vérifie le retour en console ;
  • Vérifie l’existence du fichier ;
  • Vérifie le namespace ;
  • Vérifie la classe et l’héritage.

Ce test peut avec quelques changement servir a créer une nouvelle commande similaire de façon plus « TDD ». Ce que j’ai fais pour le test unitaire, mais qui n’as pas d’intérêt à être dans ce billet. Je sais qu’il y a pas mal de choses qui ne sont pas très satisfaisante dans le code, mais ça fait ce qu’on lui demande. Je vais certainement faire évoluer un peu ce petit Bundle qui est de toute façon un bon exercice, vous pouvez trouver la dernière version sur github. Je suis preneur de toute remarques, surtout sur les points négatif, et les évolutions possibles.

Vus : 1942
Publié par Nicolas Paris : 149