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
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
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 :
- Je crée une commande « vide » ;
- La commande génère un fichier statique ;
- Je rends ce fichier dynamique ;
- Je créer quelques validation pour éviter les fautes de saisis ;
- Je crée les tests ;
- 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
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
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
{
$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 :
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
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
$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
.
{
// 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.
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.