Caution: You are browsing the legacy symfony 1.x part of this website.

Jour 9 : Les tests fonctionnels

1.2 / Doctrine

Hier, nous avons vu comment faire des tests unitaires sur nos classes Jobeet en utilisant la bibliothèque intégrée lime avec symfony.

Aujourd'hui, nous allons écrire des tests fonctionnels pour les fonctionnalités que nous avons déjà mis en œuvre dans les modules job et category.

Les tests fonctionnels

Les tests fonctionnels sont un excellent outil pour tester votre application de bout en bout : de la requête faite par un navigateur jusqu'à la réponse envoyée par le serveur. Ils testent toutes les couches d'une application : le routage, le modèle, les actions et les Templates. Ils sont très similaires à ce que vous avez sans doute déjà fait manuellement : chaque fois que vous ajoutez ou modifiez une action, vous devez aller dans le navigateur et vérifier que tout fonctionne comme prévu en cliquant sur les liens et en vérifiant les éléments sur la page rendue. En d'autres termes, vous exécutez un scénario correspondant au cas d'utilisation que vous venez de mettre en œuvre.

Comme le processus est manuel, il est complexe et assujetti aux erreurs. Chaque fois que vous changer quelque chose dans votre code, vous devez faire défiler tous les scénarios pour vérifier que vous n'avez rien cassé. C'est insensé. Les tests fonctionnels dans symfony fournissent un moyen de décrire facilement des scénarios. Chaque scénario peut alors être automatiquement lu maintes et maintes fois en simulant l'expérience d'un utilisateur dans un navigateur. Comme les tests unitaires, ils vous donnent la confiance pour coder en paix.

note

Les tests fonctionnels du framework ne remplace pas les outils tels que "Selenium". Selenium s'exécute directement dans le navigateur pour automatiser les tests sur plusieurs plateformes et navigateurs. Et en tant que tel, il est en mesure de tester le JavaScript de votre application.

La classe sfBrowser

Dans Symfony, les tests fonctionnels sont gérés via un navigateur spécial, implémenté par la classe sfBrowser. Elle agit comme un navigateur taillé sur mesure pour votre application et elle est directement connectée à lui, sans la nécessité d'un serveur web. Elle vous donne accès à tous les objets de symfony avant et après chaque requête, vous donnant la possibilité de les introspecter et d'effectuer les vérifications que vous voulez par programmation.

sfBrowser fournit des méthodes qui simule la navigation effectuée dans un navigateur classique :

Méthode Description
get() Obtient une URL
post() Poste à une URL
call() Appelle une URL (utilisé pour les méthodes PUT et DELETE)
back() Revient en arrière d'une page dans l'historique
forward() Avance d'une page dans l'historique
reload() Recharge la page actuelle
click() Clique sur un lien ou sur un bouton
select() Sélectionne un radiobutton ou un checkbox
deselect() Dé-sélectionne un radiobutton ou un checkbox
restart() Redémarre le navigateur

Voici quelques exemples d'utilisation des méthodes sfBrowser :

$browser = new sfBrowser();
 
$browser->
  get('/')->
  click('Design')->
  get('/category/programming?page=2')->
  get('/category/programming', array('page' => 2))->
  post('search', array('keywords' => 'php'))
;

sfBrowser contient des méthodes supplémentaires pour configurer le comportement du navigateur :

Méthode Description
setHttpHeader() Définit une entête HTTP
setAuth() Définit les informations d'authentification de base
setCookie() Définit un cookie
removeCookie() Enlève un cookie
clearCookies() Vide tous les cookies courants
followRedirect() Suit une redirection

La classe sfTestFunctional

Nous disposons d'un navigateur, mais nous avons besoin d'un moyen pour connaître les objets de symfony pour faire le test. Cela peut être fait avec lime et certaines méthodes de sfBrowser comme getResponse() et getRequest() mais symfony fournit une meilleure façon.

Les méthodes de test sont fournis par une autre classe, sfTestFunctional qui prend une instance sfBrowser dans son constructeur. La classe sfTestFunctional délègue les tests aux objets testeur. Plusieurs testeurs sont livrés avec symfony et vous pouvez également créer les vôtres.

Comme nous l'avons vu hier, les tests fonctionnels sont stockés dans le répertoire test/functional/. Pour Jobeet, les tests se trouvent dans le sous-répertoire test/functional/frontend/ car chaque application a son propre sous-répertoire. Ce répertoire contient déjà deux fichiers: categoryActionsTest.php et jobActionsTest.php car toutes les tâches qui génèrent un module, créent automatiquement un fichier de test fonctionnel de base :

// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
 
$browser->
  get('/category/index')->
 
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'index')->
  end()->
 
  with('response')->begin()->
    isStatusCode(200)->
    checkElement('body', '!/This is a temporary page/')->
  end()
;

Au début, le script ci-dessus peut vous sembler un peu étrange. C'est parce que les méthodes de sfBrowser et de sfTestFunctional implémente une fluent interface en retournant toujours $this. Cela vous permet d'enchaîner les appels de méthode pour une meilleure lisibilité. L'extrait ci-dessus est équivalent à :

// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
 
$browser->get('/category/index');
$browser->with('request')->begin();
$browser->isParameter('module', 'category');
$browser->isParameter('action', 'index');
$browser->end();
 
$browser->with('response')->begin();
$browser->isStatusCode(200);
$browser->checkElement('body', '!/This is a temporary page/');
$browser->end();

Les tests sont exécutés dans un bloc de contexte de testeur. Un bloc de contexte de testeur commence par with('TESTER NAME')->begin() et se termine avec end() :

$browser->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'index')->
  end()
;

Le code teste que le paramètre module de la requête est égal à category et action est égal à index.

tip

Lorsque vous avez seulement besoin d'appeler une méthode de test sur un testeur, vous n'avez pas besoin de créer un bloc: with('request')->isParameter('module', 'category')

Le testeur de requête

Le testeur de requête fournit des méthodes de testeur pour introspecter et tester l'objet sfWebRequest :

Method Description
isParameter() Vérifie la valeur du paramètre de la requête
isFormat() Vérifie le format de la requête
isMethod() Vérifie la méthode
hasCookie() Vérifie si la requête a un cookie avec
le nom donné
isCookie() Vérifie la valeur d'un cookie

Le testeur de réponse

Il y a aussi une classe testeur de réponse qui fournit des méthodes de testeur pour l'objet sfWebResponse :

Method Description
checkElement() Vérifie si le sélecteur CSS d'une réponse correspond à certains critères
isHeader() Vérifie la valeur de l'entête
isStatusCode() Vérifie le code du statut de la réponse
isRedirected() Vérifie si la réponse actuelle est une redirection

note

Nous allons décrire plusieurs classes de testeurs dans les prochains jours (pour les formulaires, l'utilisateur, le cache, ...).

Exécution des tests fonctionnels

Comme pour les tests unitaires, le lancement des tests fonctionnels peut être fait en exécutant le fichier de test directement :

$ php test/functional/frontend/categoryActionsTest.php

Ou en utilisant la tâche test:functional :

$ php symfony test:functional frontend categoryActions

Tests par ligne de commande

Données de test

Comme pour les tests unitaires Doctrine, nous avons besoin de charger des données de test, chaque fois que nous lançons un test fonctionnel. On peut réutiliser le code que nous avons écrit hier :

include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfTestFunctional(new sfBrowser());
Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');

Le chargement des données dans un test fonctionnel est un peu plus facile que pour les tests unitaires car la base de données a déjà été initialisée par le script de démarrage.

Comme pour les tests unitaires, nous n'allons pas copier et coller cet extrait de code dans chaque fichier de test, mais nous allons plutôt créer notre propre classe fonctionnelle qui hérite de sfTestFunctional :

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function loadData()
  {
    Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
 
    return $this;
  }
}

Ecriture des tests fonctionnels

Ecrire des tests fonctionnels, c'est comme jouer un scénario dans un navigateur. Nous avons déjà écrit tous les scénarios que nous avons besoin de tester lors des histoires de la journée 2.

D'abord, nous allons tester la page d'accueil Jobeet en éditant le fichier de test jobActionsTest.php. Remplacez le code par ce qui suit :

Les emplois expirés ne sont pas affichés

// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The homepage')->
  get('/')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'index')->
  end()->
  with('response')->begin()->
    info('  1.1 - Expired jobs are not listed')->
    checkElement('.jobs td.position:contains("expired")', false)->
  end()
;

Comme avec lime, un message d'information peut être insérée en appelant la méthode info() pour rendre la sortie plus lisible. Pour vérifier l'exclusion des emplois expirés depuis la page d'accueil, nous vérifions que le sélecteur CSS .jobs td.position:contains("expired") ne correspond pas nulle part dans le contenu HTML de la réponse (n'oubliez pas que dans les fichiers fixtures, le seul emploi que nous avons expiré contient "expired" dans la position). Lorsque le deuxième argument de la méthode checkElement() est à 'true', la méthode teste l'existence de noeuds qui correspondent au sélecteur CSS.

tip

La méthode checkElement() est capable d'interpréter la plupart des sélecteurs CSS3 valides.

Seulement n emplois sont affichés pour une catégorie

Ajoutez le code suivant à la fin du fichier de test :

// test/functional/frontend/jobActionsTest.php
$max = sfConfig::get('app_max_jobs_on_homepage');
 
$browser->info('1 - The homepage')->
  get('/')->
  info(sprintf('  1.2 - Only %s jobs are listed for a category', $max))->
  with('response')->
    checkElement('.category_programming tr', $max)
;

La méthode checkElement() peut également vérifier que le sélecteur CSS correspond à 'n' noeuds dans le document en passant un entier comme second argument.

Une catégorie a un lien vers la page catégorie seulement si il y a plusieurs emplois

// test/functional/frontend/jobActionsTest.php
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.3 - A category has a link to the category page only if too many jobs')->
  with('response')->begin()->
    checkElement('.category_design .more_jobs', false)->
    checkElement('.category_programming .more_jobs')->
  end()
;

Dans ces tests, nous vérifions qu'il n'y a pas de lien "more jobs" pour la catégorie design (.category_design .more_jobs n'existe pas), et qu'il existe un lien "more jobs" pour la catégorie programmation (.category_programming .more_jobs existe).

Les emplois sont triés par date

$q = Doctrine_Query::create()
  ->select('j.*')
  ->from('JobeetJob j')
  ->leftJoin('j.JobeetCategory c')
  ->where('c.slug = ?', 'programming')
  ->andWhere('j.expires_at > ?', date('Y-m-d', time()))
  ->orderBy('j.created_at DESC');
 
$job = $q->fetchOne();
 
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $job->getId()))->
  end()
;

Pour tester si les emplois sont effectivement triés par date, nous devons vérifier que le premier emploi figurant sur la page d'accueil est celui que nous attendons. Cela peut être fait en vérifiant que l'URL contient la clé primaire prévue. Comme la clé primaire peut changer entre les exécutions, nous avons besoin d'obtenir l'objet Doctrine du premier de la base de données.

Même si le test fonctionne tel quel, nous avons besoin de refactoriser un peu le code, car l'obtention du premier emploi de la catégorie programmation peut être réutilisé ailleurs dans nos tests. Nous ne voulons pas déplacer le code de la couche du Modèle car le code est le test spécifique. Au lieu de cela, nous allons déplacer le code, que nous avons créé plus tôt, de la classe JobeetTestFunctional. Cette classe se comporte comme une classe de testeur fonctionnel|Testeurs d'un domaine spécifique pour Jobeet :

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function getMostRecentProgrammingJob()
  {
    $q = Doctrine_Query::create()
      ->select('j.*')
      ->from('JobeetJob j')
      ->leftJoin('j.JobeetCategory c')
      ->where('c.slug = ?', 'programming');
    $q = Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q);
 
    return $q->fetchOne();
  }
 
  // ...
}

Vous pouvez maintenant remplacer le code de test précédent par le suivant :

// test/functional/frontend/jobActionsTest.php
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]',
      $browser->getMostRecentProgrammingJob()->getId()))->
  end()
;

Chaque emploi sur la page d'accueil est cliquable

$browser->info('2 - The job page')->
  get('/')->
 
  info('  2.1 - Each job on the homepage is clickable and give detailed information')->
  click('Web Developer', array(), array('position' => 1))->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'show')->
    isParameter('company_slug', 'sensio-labs')->
    isParameter('location_slug', 'paris-france')->
    isParameter('position_slug', 'web-developer')->
    isParameter('id', $browser->getMostRecentProgrammingJob()->getId())->
  end()
;

Pour tester le lien d'un emploi sur la page d'accueil, nous simulons un clic sur le text "Web Developer". Comme il y en a plusieurs sur la page, nous avons explicitement demandé au navigateur de cliquer sur le premier (array('position' => 1)).

Chaque paramètre de la requête est ensuite testée pour s'assurer que le routage a fait son travail correctement.

Apprendre par l'exemple

Dans cette section, nous avons fourni tout le code nécessaire pour tester les pages emploi et catégorie. Lisez le code avec soin car vous apprendrez quelques nouveaux trucs :

// lib/test/JobeetTestFunctional.class.php
class JobeetTestFunctional extends sfTestFunctional
{
  public function loadData()
  {
    Doctrine::loadData(sfConfig::get('sf_test_dir').'/fixtures');
 
    return $this;
  }
 
  public function getMostRecentProgrammingJob()
  {
    $q = Doctrine_Query::create()
      ->select('j.*')
      ->from('JobeetJob j')
      ->leftJoin('j.JobeetCategory c')
      ->where('c.slug = ?', 'programming');
    $q = Doctrine::getTable('JobeetJob')->addActiveJobsQuery($q);
 
    return $q->fetchOne();
  }
 
  public function getExpiredJob()
  {
    $q = Doctrine_Query::create()
      ->from('JobeetJob j')
      ->where('j.expires_at < ?', date('Y-m-d', time()));
 
    return $q->fetchOne();
  }
}
 
// test/functional/frontend/jobActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The homepage')->
  get('/')->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'index')->
  end()->
  with('response')->begin()->
    info('  1.1 - Expired jobs are not listed')->
    checkElement('.jobs td.position:contains("expired")', false)->
  end()
;
 
$max = sfConfig::get('app_max_jobs_on_homepage');
 
$browser->info('1 - The homepage')->
  info(sprintf('  1.2 - Only %s jobs are listed for a category', $max))->
  with('response')->
    checkElement('.category_programming tr', $max)
;
 
$browser->info('1 - The homepage')->
  get('/')->
  info('  1.3 - A category has a link to the category page only if too many jobs')->
  with('response')->begin()->
    checkElement('.category_design .more_jobs', false)->
    checkElement('.category_programming .more_jobs')->
  end()
;
 
$browser->info('1 - The homepage')->
  info('  1.4 - Jobs are sorted by date')->
  with('response')->begin()->
    checkElement(sprintf('.category_programming tr:first a[href*="/%d/"]', $browser->getMostRecentProgrammingJob()->getId()))->
  end()
;
 
$browser->info('2 - The job page')->
  info('  2.1 - Each job on the homepage is clickable and give detailed information')->
  click('Web Developer', array(), array('position' => 1))->
  with('request')->begin()->
    isParameter('module', 'job')->
    isParameter('action', 'show')->
    isParameter('company_slug', 'sensio-labs')->
    isParameter('location_slug', 'paris-france')->
    isParameter('position_slug', 'web-developer')->
    isParameter('id', $browser->getMostRecentProgrammingJob()->getId())->
  end()->
 
  info('  2.2 - A non-existent job forwards the user to a 404')->
  get('/job/foo-inc/milano-italy/0/painter')->
  with('response')->isStatusCode(404)->
 
  info('  2.3 - An expired job page forwards the user to a 404')->
  get(sprintf('/job/sensio-labs/paris-france/%d/web-developer', $browser->getExpiredJob()->getId()))->
  with('response')->isStatusCode(404)
;
 
// test/functional/frontend/categoryActionsTest.php
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new JobeetTestFunctional(new sfBrowser());
$browser->loadData();
 
$browser->info('1 - The category page')->
  info('  1.1 - Categories on homepage are clickable')->
  get('/')->
  click('Programming')->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'show')->
    isParameter('slug', 'programming')->
  end()->
 
  info(sprintf('  1.2 - Categories with more than %s jobs also have a "more" link', sfConfig::get('app_max_jobs_on_homepage')))->
  get('/')->
  click('22')->
  with('request')->begin()->
    isParameter('module', 'category')->
    isParameter('action', 'show')->
    isParameter('slug', 'programming')->
  end()->
 
  info(sprintf('  1.3 - Only %s jobs are listed', sfConfig::get('app_max_jobs_on_category')))->
  with('response')->checkElement('.jobs tr', sfConfig::get('app_max_jobs_on_category'))->
 
  info('  1.4 - The job listed is paginated')->
  with('response')->begin()->
    checkElement('.pagination_desc', '/32 jobs/')->
    checkElement('.pagination_desc', '#page 1/2#')->
  end()->
 
  click('2')->
  with('request')->begin()->
    isParameter('page', 2)->
  end()->
  with('response')->checkElement('.pagination_desc', '#page 2/2#')
;

Débogage des tests fonctionnels

Parfois, un test fonctionnel échoue. Comme symfony simule un navigateur, sans aucune interface graphique, cela peut être difficile pour diagnostiquer le problème. Heureusement, symfony prévoit la méthode ~debug|Debogage~() pour afficher l'entête de la réponse et le contenu :

$browser->with('response')->debug();

La méthode debug() peut être inséré n'importe où dans un bloc de testeur de response et mettra fin à l'exécution de script.

Validation des tests fonctionnels

La tâche test:functional peut aussi être utilisée pour lancer tous les tests fonctionnels d'une application :

$ php symfony test:functional frontend

La tâche renvoie une seule ligne pour chaque fichier de test :

Validation des tests fonctionnels

Validation des tests

Comme vous pouvez vous y attendre, il existe aussi une tâche pour lancer tous les tests pour un projet (unitaires et fonctionnels) :

$ php symfony test:all

Validation des tests

À demain

Cela termine notre tour des outils de test de symfony. Vous n'avez aucune excuse pour ne plus tester vos applications ! Avec le framework lime et le framework de test fonctionnel, symfony fournit des outils puissants pour vous aider à écrire des tests avec peu d'effort.

Nous n'avons fait que gratter la surface des tests fonctionnels. A partir de maintenant, chaque fois que nous mettrons en place une fonctionnalité, nous allons aussi écrire les tests pour apprendre plus de fonctionnalités du framework de test.

Demain, nous parlerons encore d'une autre grande caractéristique de symfony: le framework de formulaire.