6min.

(Re)découverte des sélecteurs XPath

This blog post is also available in 🇬🇧 English: (Re)discover XPath selectors.

Les tests, nous détestons souvent les écrire, mais nous aimons qu’ils soient présents. Ceci n’est pas un article pour vous convaincre d’en écrire.

Section intitulée rappelRappel

Les différents types de tests :

  • Tests unitaires, qui garantissent qu’un morceau de code fonctionne comme attendu ;
  • Tests d’intégrations, qui combinent plusieurs classes fonctionnant ensemble ;
  • Tests applicatifs ou tests fonctionnels, pour le fonctionnement complet de l’application. Ils recourent à des requêtes HTTP et testent des réponses retournées.

Aujourd’hui nous allons parler des tests applicatifs sur une application/site web classique (non API).

Section intitulée la-zone-de-confortLa zone de confort

Classiquement, voici comment on fait :

  • Nous créons une requête, et nous avons des supers outils pour ça ;
  • Nous interagissons avec la page (par exemple en cliquant sur un lien ou en envoyant un formulaire), nous avons aussi d’excellents outils pour ça ;
  • Finalement, nous testons la réponse, encore une fois, avec des outils parfaits pour ça, surtout le composant CssSelector de Symfony 💙

Il est certain que le composant CSS selector est agréable à utiliser. En tant que développeur web, le CSS nous parle, et il est naturel d’utiliser cette méthode pour chercher et valider la présence d’éléments HTML dans nos réponses.

Alors pourquoi s’embêter, quel est le problème ?

Section intitulée a-la-recherche-du-selecteur-perduÀ la recherche du sélecteur perdu

Aujourd’hui nous allons redécouvrir l’intérêt et la puissance des sélecteurs XPath. En effet, avec les différentes façons d’écrire du CSS moderne, via des frameworks (tailwindcss), ou en utilisant des classes utilitaires plutôt que sémantiques (OOCSS, BEM), le ciblage d’éléments particuliers devient de plus en plus difficile.

Alors effectivement l’écriture (et la lecture) de sélecteurs CSS est plus facile, mais elle est aussi moins puissante.

Même si XPath continue de faire peur, nous allons voir que c’est loin d’être justifié, et que nous pouvons même nous amuser.

Section intitulée demystifier-xpathDémystifier XPath

XPath est un langage de requête pour localiser une portion d’un document XML. Ce langage peut s’appliquer au HTML, c’est ce qui nous intéresse aujourd’hui.

Certes la courbe d’apprentissage est un peu difficile, mais en avançant petit à petit nous allons apprécier ses possibilités.

Commençons par un premier exemple, et rappelons-nous que XPath c’est avant tout un path (chemin) donc comme sur un système de fichiers.

<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <title>Symfony Demo application</title>
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
  </head>
  <body id="blog_index">
    <div class="mt-8 text-sm">
      <article class="flex mx-4">
        <p>This is my first p in Article</p>
        <p>This is a second P in Article</p>
      </article>
    </div>
  </body>
</html>

Avec le document ci-dessus, l’expression : /html/body/div/article/p
Retourne :

<p>This is my first p in Article</p>
<p>This is a second P in Article</p>

Tout comme pour un répertoire sur notre système de fichiers, le chemin doit être exact, si notre <div> était elle-même dans une autre <div> nous n’aurions eu aucun résultat.

Simple, n’est-ce pas ?

Maintenant évitons de devoir spécifier l’intégralité du chemin pour trouver notre nœud.

L’expression : //div/article/p retourne les mêmes nœuds.

On voit que // nous évite de partir du nœud root /. L’expression demande ici tous les chemins qui contiennent p dans article dans div ou, dit dans l’autre sens, div contenant article contenant p.

Tous les chemins ? Oui.

L’analogie avec les chemins de fichiers s’arrête là, en effet, en XML / HTML on peut avoir plusieurs chemins (path) égaux.

Exemple

<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <title>Symfony Demo application</title>
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
  </head>
  <body id="blog_index">
    <div class="mt-8 text-sm">
      <article class="flex mx-4 art-first">
        <p>This is my first p in first Article</p>
        <p>This is a second P in first Article</p>
      </article>
      <article class="flex mx-4 art-second">
        <p class="my-p">This is my first p in second Article</p>
        <p>This is a second P in second Article</p>
      </article>
    </div>
  </body>
</html>

L’expression : //div/article/p
Retourne : Les 4 <p>.

Tout cela est finalement assez simple, qu’en est-il de la vraie vie ?

Section intitulée filtrer-ou-selectionner-la-position-index-du-noeudFiltrer ou sélectionner la position/index du nœud

//article[2] (2ᵉ article, l’index commence à 1)
//article[last()] (dernier article)

Section intitulée filtrer-ou-selectionner-par-attributsFiltrer ou sélectionner par attributs

//body[attribute::id='blog_index']
//body[@id='blog_index'] (le @ est un sucre syntaxique)
//p[@class='my-p']

Section intitulée fonctions-xpathFonctions XPath

Continuons notre exploration par l’exemple. Ici, du fait que l’attribut class est composé d’un texte qui inclut plusieurs noms de classes, l’expression : //article[@class='art-first'] ne fonctionnera pas.
En effet, nous n’avons pas <article class="art-first"> mais <article class="flex mx-4 art-first">. Il faut donc utiliser des fonctions XPath.

Ce sont des fonctions utilitaires incluses dans le langage qui permettent d’adapter vos expressions. Dans notre exemple nous allons utiliser contains.

Notre expression devient :
//article[contains(@class, 'art-first')]

Section intitulée recuperer-le-contenu-du-noeud-et-pas-le-noeud-lui-memeRécupérer le contenu du nœud et pas le nœud lui-même ?

//article[2]/p[1]/text() donne le contenu du premier paragraphe du second article.

Voici un cas concret de sélecteur utilisé dans un projet :

$this->assertEquals(1, $crawler->filterXPath('//a[starts-with(@href, "/blog/post/")]')->count());

Section intitulée utilisation-dans-les-tests-fonctionnelsUtilisation dans les tests fonctionnels

L’intégration des filtres XPath dans le framework de test Symfony est directement prise en charge via le composant DOMCrawler.

Voici concrètement comment il peut être utilisé :

public function testHomepageHasLinkToBlog(): void
{
    $client = static::createClient();
    $crawler = $client->request('GET', '/en/homepage');
    $selector = '//a[starts-with(@href, "/en/blog")]';

    $this->assertEquals(1, $crawler->filterXPath($selector)->count());
}

Notez que DOMCrawler::filterXPath() retourne une instance de DOMCrawler, vous avez donc ensuite accès à plusieurs méthodes qui permettent de filtrer à nouveaux votre liste de nœuds. Il est alors possible d’ajouter de la logique métier sur cette liste de nœuds dans vos tests si besoin.

Exemple : vérifier que tous les enfants sélectionnés par le XPath respectent eux même une structure, etc.

$crawler->filterXPath('//a[starts-with(@href, "/post/")]')->each (function ($node) {
    // Your logic here
})

Section intitulée conclusionConclusion

Nous avons fait un tour rapide sur les bases des sélecteurs XPath, le but n’est pas d’en faire une description exhaustive, mais de rappeler qu’ils existent et qu’ils sont très puissants.

Même si leur syntaxe est inhabituelle, ou tout simplement nouvelle pour vous, il ne faut pas en avoir peur.

Au contraire, c’est souvent assez satisfaisant de trouver le bon sélecteur, et comme d’habitude nous sommes aidés par des outils de bonne qualité.

Dans les developer tools des navigateurs, il existe habituellement un menu contextuel sur un élément HTML pour récupérer directement son XPath.
Capture d'écran des outils developer tools

Et aussi le formidable xpather.com qui est un outil très puissant et vous aidera à tester et trouver le sélecteur de vos rêves (oui, oui, rien que ça).

Commentaires et discussions