Laravel 4 : chapitre 25 : Un blog : les tests unitaires

Comme suite à la modification des articles précédents concernant l’exemple de blog le présent article est devenu obsolète. Je le laisse pour le moment en l’état pour ses informations concernant le principe des tests unitaires en attendant de le réécrire.

Nous allons voir dans cet article un aspect important d’une application : les tests unitaires. Comme il est humainement impossible d’écrire un code sans erreur il faut trouver un moyen efficace de les détecter. La méthode traditionnelle consiste à jouer l’utilisateur et à essayer une à une toutes les fonctionnalités. C’est long, laborieux et pas forcément complet. Heureusement il existe une autre possibilité qui consiste à automatiser ces test. C’est justement le but des tests unitaires.

C’est quoi un test unitaire ?

Un test unitaire c’est un bout de code qui va vérifier qu’une fonctionnalité de votre application correspond bien au résultat attendu. En gros du code qui vérifie un autre code. Si le résultat est correct tout va bien, sinon vous recevez un message qui vous indique le problème. Le fait de diviser vos tests en petites unités présente de nombreux avantages : les résultats sont bien organisés et faciles à lire, on a une progression séquentielle des tests, on peut rendre certains tests dépendants d’un autre test…

Que faut-il pour faire des tests ?

Laravel utilise PHPUnit pour effectuer les tests. Ce framework est devenu de fait le standard dans ce domaine. Il est à la fois puissant et simple à utiliser. D’autre part Laravel 4 a été conçu dans le souci de rendre les tests faciles à réaliser avec PHPUnit. Pour utiliser ce framework vous devez l’installer. Il y a plusieurs possibilités mais la plus simple consiste à l’utiliser sous la forme d’un fichier phar que vous pouvez récupérer ici. Il vous suffit de placer ce fichier à la racine de votre application et il est disponible ! Laravel utilise aussi des composants de symfony (HttpKernel, DomCrawler, et BrowserKit). D’autre part vous pouvez utiliser également Mockery. Dans cet article je vais me contenter de choses simples sans faire trop d’appels à ces composants. Ce choix n’est pas innocent et a de fortes implications, en particulier concernant Mockery, ce dernier permet de simuler le fonctionnement de classes et d’ainsi pouvoir réellement isoler les tests. Il est possible que je complète cet article avec un autre répondant mieux au critère d’isolation…

Principes de base de PHPUnit

Je vous conseille la lecture de la documentation en Français et d’un style très agréable et précis. Les éléments importants sont :

  • un test pour un classe MaClasse va dans une classe MaClasseTest
  • un test est une méthode publique dont le nom commence par test
  • dans la méthode du test on utilise des méthodes d’assertion (par exemple assertTrue()) pour affirmer que le test est correct
  • les tests sont à placer dans une architecture de dossiers (dans le répertoire app/tests pour Laravel) qui correspond à celle des classes à tester (notez qu’il y a à la racine de l’application un fichier phpunit.xml qui renseigne entre autres sur le dossier des tests)
  • chaque test se fait dans un environnement nouveau avec un état connu, ce qui se nomme fixture du test. Lors de l’utilisation de tests Laravel se met automatiquement en mode testing. Il y a un dossier app/config/testing destiné à recevoir la configuration pour les tests (cache, base…)

Configurer la base de données pour les tests

Il n’est pas vraiment conseillé de faire les tests sur la base qui vous sert en production. Vous avez la possibilité de créer un fichier de configuration app/config/testing/database.php destiné à recevoir la configuration de la base pour la situation de test, et uniquement pour elle. La façon la plus efficace de procéder est d’utiliser sqlite en mode memory. Autrement dit la base est créée en mémoire vive et disparait à l’issue du test. On y gagne en rapidité d’exécution. Mais il faut évidemment avoir les bonnes migrations et seeds pour générer la base et la remplir avec des données de test. Voici le contenu de ce fichier pour nos tests :

return array(

    'default' => 'sqlite',

    'connections' => array(
        'sqlite' => array(
            'driver'   => 'sqlite',
            'database' => ':memory:',
            'prefix'   => ''
        ),
    )
);

En mode testing la base MySQL est ignorée et c’est une base sqlite qui sera créée en mémoire vive.

Les fichier TestCase

Le fichier app/test/TestCase contient l’initialisation de vos tests, si vous l’ouvrez vous trouvez cela :

class TestCase extends Illuminate\Foundation\Testing\TestCase {

	/**
	 * Creates the application.
	 *
	 * @return Symfony\Component\HttpKernel\HttpKernelInterface
	 */
	public function createApplication()
	{
		$unitTesting = true;

		$testEnvironment = 'testing';

		return require __DIR__.'/../../bootstrap/start.php';
	}

}

On voit qu’une application est créée et que l’environnement se met en mode testing ici. On va évidemment conserver ce code indispensable mais il nous faut créer la base, la remplir et prendre une dernière précaution : éviter d’envoyer des mails et plutôt générer un log. Voici le petit ajout :

class TestCase extends Illuminate\Foundation\Testing\TestCase {

    /**
     * Préparation avant les tests
     */
    public function setUp()
    {
        parent::setUp();

        Artisan::call('migrate');
        $this->seed();
        Mail::pretend(true);
    }

	/**
	 * Creates the application.
	 *
	 * @return Symfony\Component\HttpKernel\HttpKernelInterface
	 */
	public function createApplication()
	{
		$unitTesting = true;

		$testEnvironment = 'testing';

		return require __DIR__.'/../../bootstrap/start.php';
	}

}

La méthode Setup est appelée avant chaque test unitaire. Maintenant nous sommes sûrs d’avoir une base de données fonctionnelle pour nos tests sans interférer sur la base de production Laughing.

Vous avez également un fichier app/test/ExampleTest.php que vous devez supprimer.

Architecture des dossiers

Voici l’architecture avec nos contrôleurs :

img39

Nous aurons donc la même architecture pour les tests :

img40

Et voici le contenu initial pour ces fichiers :

class HomeControllerTest extends TestCase {}
class AuthControllerTest extends TestCase {}
class GuestControllerTest extends TestCase {}

Remarquez que ces classes dérivent de TestCase et qu’elles respectent la convention de nommage en finissant par Test. Pour le moment créez seulement le premier pour éviter de recevoir un message de la part de PHPUnit dans la suite de notre démarche.

Notre premier test

Il faut bien comprendre la philosophie des tests. Vous faites une action et vous attendez un certain résultat. Prenons cette action du contrôleur HomeController :

public function accueil()
{
	$articles = Article::select('id', 'title', 'intro_text')
	                    ->orderBy('created_at', 'desc')
	                    ->paginate(4);
	return $this->gen_accueil($articles);
}

Cette action est activée par cette route :

Route::get('/', array('uses' => 'HomeController@accueil', 'as' => 'accueil'));

Autrement dit avec l’URL  http://localhost/blog/public on s’attend à voir apparaître la page d’accueil du blog. Nous allons créer un test pour vérifier que ça fonctionne bien :

class HomeControllerTest extends TestCase {

    public function testAccueil()
    {
        $response = $this->call('GET', '/'); 
        $this->assertResponseOk();
    }
}

Le test s’appelle testAccueil (il commence forcément par test). la première instruction génère la requête et récupère la réponse. La seconde instruction est une assertion qui vérifie qu’on reçoit une réponse correcte. Lançons maintenant PHPUnit :

img41

On trouve logiquement 1 test et 1 assertion, avec un résultat positif. Que nous apprend ce test ? Que le chemin est bon, qu’on renvoie une réponse correcte, mais on a aucune idée sur le contenu de la réponse. C’est ici qu’il faut savoir ce qu’on désire réellement tester et jusqu’où aller. Soyons un peu curieux et voyons ce que nous offre cette réponse :

echo var_dump($response->getContent());

img42

On se retrouve avec le contenu de la page HTML retournée. Ce qui peut être utile.

Nous savons aussi que nous transmettons 3 paramètres à la vue, nous pouvons tester cette transmission ainsi :

    public function testAccueil()
    {
        $response = $this->call('GET', '/'); 
        $this->assertViewHas(array('categories', 'articles', 'actif'));
        $this->assertResponseOk();
    }

img43

Cette fois nous avons 4 assertions, une pour chaque paramètre et une pour la réponse.

Vous pouvez aussi utiliser le crawler pour explorer le DOM, filtrer des informations :

    public function testAccueil()
    {
        $crawler = $this->client->request('GET', '/');
        $this->assertTrue($this->client->getResponse()->isOk());
        $this->assertCount(1, $crawler->filter('h1:contains("Mon joli blog !")'));
    }

Vous avez la documentation du crawler ici.

Les tests pour HomeController

Actions « accueil », « categorie » et « article »

Maintenant que nous nous sommes échauffés passons en revue les actions du contrôleur HomeController pour écrire les tests correspondants. Il y a 3 actions de même nature pour lesquelles nous allons nous contenter de vérifier la réponse correcte :

    public function testAccueil()
    {
        $response = $this->call('GET', '/'); 
        $this->assertResponseOk();
    }

    public function testCategorie()
    {
        $response = $this->call('GET', 'cat/1'); 
        $this->assertResponseOk();
    }

    public function testArticle()
    {
        $response = $this->call('GET', 'art/1/1'); 
        $this->assertResponseOk();
    }

Action « find »

Maintenant il nous faut réfléchir à une action un peu particulière, celle de la recherche à partir du formulaire prévu à cet effet. Il nous faut définir deux tests : un avec le résultat de recherche avec des articles trouvés ou pas et l’autre négatif en cas de champ de recherche vide. Voyons pour le positif. Nous devons trouver un terme de recherche, simuler l’envoi du formulaire et voir si la réponse est correcte :

    public function testFindOk()
    {
        $article = Article::find(1);
        $find = $article->intro_text;
        // Envoi du formulaire 
        $response = $this->call('POST', 'find', array('find' => $find));
        // Assertion
        $this->assertContains('<p>'.$find.'</p>', $response->getContent());
    }

Voyons un peu où nous en sommes :

img44

Voyons à présent le cas du champ vide :

    public function testFindNok()
    {
        // Envoi du formulaire 
        $response = $this->call('POST', 'find');
        // Assertions
        $this->assertRedirectedTo('/'); 
        $this->assertSessionHas('flash_error', 'Il faudrait entrer un terme pour la recherche !');       
    }

Cette fois on envoie un formulaire sans le champ. Vous voyez l’utilisation de la méthode assertRedirectedTo pour tester la redirection et assertSessionHas pour vérifier la présence d’une information de session.

 Action « comment »

Il ne nous manque plus qu’à tester l’action « comment » pour en finir avec ce contrôleur :

    public function testComment()
    {
        // Authentification
        $this->be(User::find(1));
        // Envoi du formulaire 
        $response = $this->call('POST', 'comment', 
            array(
                'title' => 'titre à tester',
                'comment' => 'commentaire',
                'id_art' => 1,
                'id_cat' => 1
            )
        );
        // Assertions
        $this->assertEquals(1, Comment::where('title', 'titre à tester')->count());
        $this->assertRedirectedTo('art/1/1'); 
    }

Notez qu’on doit procéder dans un premier temps à l’authentification avec la méthode be parce que seuls les utilisateurs connectés peuvent laisser un commentaire. On voit aussi apparaître la méthode assertEquals qui permet de tester une égalité, ça nous sert pour vérifier que l’information a bien été mémorisée dans la base.

Les tests pour AuthController

Ici ça ira vite étant donné qu’il n’y a que l’action « logout » :

class AuthControllerTest extends TestCase {

    public function testLogout()
    {
    	// Authentification
        $this->be(User::find(1));
        $this->assertTrue(Auth::check());
        // Requête
        $response = $this->call('GET', 'auth/logout'); 
        // Assertions
        $this->assertTrue(Auth::guest());
        $this->assertRedirectedToRoute('accueil');
    }

}

On commence par authentifier l’utilisateur avec la méthode be. On vérifie que c’est bien fait. Ensuite on génère la requête et on vérifie que l’utilisateur n’est plus authentifié. Il ne nous reste plus qu’à vérifier la redirection. Voyons où nous en sommes :

img45

Les tests pour GuestController

Ici nous avons un peu plus de travail. Mais vous avez compris le principe, je vous livre le code sans le commenter :

class GuestControllerTest extends TestCase {

    public function testGetLogin()
    {
        $response = $this->call('GET', 'guest/login'); 
        $this->assertResponseOk();
    }

    public function testGetReset()
    {
        $response = $this->call('GET', 'guest/reset/token'); 
        $this->assertResponseOk();
    }

    public function testGetOubli()
    {
        $response = $this->call('GET', 'guest/oubli'); 
        $this->assertResponseOk();
    }

    public function testGetInscription()
    {
        $response = $this->call('GET', 'guest/inscription'); 
        $this->assertResponseOk();
    }

    public function testPostLoginOk()
    {
        $this->assertTrue(Auth::guest());
        // Envoi du formulaire 
        $response = $this->call('POST', 'guest/login', 
            array(
                'username' => 'admin',
                'password' => 'admin'
            )
        ); 
        // Assertions
        $this->assertTrue(Auth::check()); 
        $this->assertRedirectedToRoute('accueil');      
        $this->assertSessionHas('flash_notice', 'Vous avez été correctement connecté avec le pseudo ' . Auth::user()->username);
    }

    public function testPostLoginNok()
    {
        // Envoi du formulaire 
        $response = $this->call('POST', 'guest/login'); 
        // Assertions
        $this->assertTrue(Auth::guest()); 
        $this->assertRedirectedTo('guest/login');      
        $this->assertSessionHas('flash_error', 'Pseudo ou mot de passe non correct !');
    }

    public function testPostOubliOk()
    {
        $user = User::find(1);
        // Envoi du formulaire 
        $response = $this->call('POST', 'guest/oubli', array('email' => $user->email));
        // Assertions
        $this->assertRedirectedTo('guest/oubli');
        $this->assertSessionHas('flash_notice', 'Un mail vous a été envoyé, veuillez suivre ses indications pour renouveler votre mot de passe.');
    }

    public function testPostOubliNok()
    {
        // Envoi du formulaire 
        $response = $this->call('POST', 'guest/oubli');
        $this->assertRedirectedTo('guest/oubli'); 
    }

    public function testPostInscriptionOk()
    {
        // Envoi du formulaire 
        $response = $this->call('POST', 'guest/inscription', 
            array(
                'username' => 'Dupontel',
                'password' => 'jolipasse',
                'email' => 'mail@moi.com',
                'password_confirmation' => 'jolipasse'
            )
        ); 
        // Assertions 
        $this->assertEquals(1, User::where('username', 'Dupontel')->count());
        $this->assertRedirectedToRoute('accueil'); 
        $this->assertSessionHas('flash_notice', 'Votre compte a été créé.');      
    }

    public function testPostInscriptionNok()
    {
        // Envoi du formulaire 
        $response = $this->call('POST', 'guest/inscription'); 
        // Assertion
        $this->assertRedirectedTo('guest/inscription'); 
    }

    public function testPostResetOk()
    {
        $token = sha1('token');
        $user = User::find(1);
        // Mot de passe initial
        $passe1 = $user->password;
        // Création du token dans la base
        DB::table('password_reminders')->insert(
            array(
                'email' => $user->email, 
                'token' => $token,
                'created_at' => new DateTime)
        );  
        // Envoi du formulaire      
        $response = $this->call('POST', 'guest/reset/'.$token, 
            array(
                'token' => $token,
                'mail' => $user->email,
                'password' => 'jolipasse',
                'password_confirmation' => 'jolipasse'
            )
        ); 
        // Nouveau mot de passe
        $user = User::find(1);
        $passe2 = $user->password;
        // Assertions   
        $this->assertNotEquals($passe1, $passe2);
        $this->assertRedirectedToRoute('accueil'); 
    }

    public function testPostResetNok()
    {
        // Envoi du formulaire      
        $response = $this->call('POST', 'guest/reset/test'); 
        // Assertions   
        $this->assertRedirectedTo('guest/reset/test'); 
    }
}

L’action la plus délicate à tester concerne le reset du mot de passe. Il faut en effet renseigner la base avec un token pour que tout se déroule bien. Lançons une dernière fois PHPUnit :

img46

Remarquez l’information sur l’envoi d’un mail.

On pourrait évidemment imaginer ces tests d’une façon différente. Le but de cet article est juste de vous montrer comment écrire des tests pour Laravel 4. Ces tests sont importants et ne les négligez pas. Il existe même une démarche de développement dirigé par les tests et même de développement dirigé par le comportement. On peut même aller plus loin et générer le squelette des classes à partir des tests…

2 réflexions sur “Laravel 4 : chapitre 25 : Un blog : les tests unitaires

Laisser un commentaire