Tester unitairement un crawler simple en Java

Sur un exemple simple de crawler de moteur de recherche codé en Java, nous allons sur cette page implémenter quelques tests unitaires en Java.

Le système à tester : un crawler simple en Java

Considérons un crawler de moteur de recherche très simple : la fonction d'un tel composant est de parser des pages HTML, en extraire le contenu (dont les liens sortants) et alimenter une pile de liens vers des pages qui seront elles-mêmes crawlées par la suite.

Architecture du crawler
Diagramme de classes simplifié de notre crawler

Le parseur est un élément fondamental pour les moteurs de recherche : c'est ce composant qui assure l'alimentation de la pile d'URLs à crawler, en extrayant les liens présents sur les pages.

D'autre part, la pile de liens doit être alimentée correctement, sans doublons, de manière à ce que le crawler ait toujours du « grain à moudre »…

Nous allons donc être vigilants quand à ces composants, et s'assurer qu'ils correctement testés unitairement.

Présentation du code de l'application

Nous allons tout d'abord voir à qui ressemble d'architecture technique de notre crawler : classes en présente, dépendances, etc.

Les URLs et les liens

Deux concepts fondamentaux qui doivent être manipulés par notre système sont les URLs et les liens :

Url.java
package com.parser;

public class Url {
    String url;
    
    public Url(String url) {
        this.url = url;
    }

    public String getUrl() {
        return this.url;
    }

    public void setUrl(String url) {
        this.url = url;
    }
    
    @Override
    public boolean equals(Object object) {
        return (this.url.equals(((Url) object).getUrl()));
    }
}
Link.java
package com.parser;

public class Link {
    Url url;
    String anchor;

    public Link(Url url, String anchor) {
        this.url = url;
        this.anchor = anchor;
    }

    public Url getUrl() {
        return url;
    }

    public void setUrl(Url url) {
        this.url = url;
    }

    public String getAnchor() {
        return anchor;
    }

    public void setAnchor(String anchor) {
        this.anchor = anchor;
    }
    
    public boolean equals(Link l){
        return (this.anchor.equals(l.getAnchor()));
    }
}

Les pages

Une fois les URLs et les liens implémentés, nous pouvons créer des pages. Notre classe Page implémente notamment deux méthodes importantes :

  • loadDom(), qui permet de charger en mémoire le DOM de la page ;
  • extractLinks(), qui permet d'extraire l'ensemble des liens présents dans le DOM de la page, et de les récupérer sous la forme d'une liste.
Page.java
package com.parser;

import java.util.ArrayList;
import java.util.List;
import java.io.IOException;

import org.jsoup.*;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

public class Page {
    String url;
    Document dom;

    public Page() {}
    
    public Page(String pUrl){
        this.url = pUrl;
    }
    
    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public Document getDom() {
        return dom;
    }

    public void setDom(Document dom) {
        this.dom = dom;
    }

    public void loadDom() throws IOException {
        this.dom = Jsoup.connect(url).get();
    }

    public void loadDomFromHtmlString(String html) {
        this.dom = Jsoup.parse(html);
    }
    
    public List extractLinks() throws IOException {
        Elements pageLinks = this.dom.select("a[href]");
        
        List links = new ArrayList();
        for (Element element : pageLinks) {
            links.add(new Link(new Url(element.attr("href")), element.text()));
        }
        
        return links;
    }
}

La pile d'URLs à crawler

Voici maintenant la classe permettant de stocker l'ensemble des URLs à crawler : UrlStack.

UrlStack.java
package com.parser;

import java.util.ArrayList;
import java.util.List;

public class UrlStack {
    List stack;
    
    
    public UrlStack() {
        stack = new ArrayList();
    }

    public List getStack() {
        return stack;
    }

    public void setStack(List stack) {
        this.stack = stack;
    }

    public void addUrl(Url url) {
        stack.add(url);
    }
    
    public Url getUrl(int index) {
        return stack.get(index);
    }
    
    public int size() {
        return stack.size();
    }
    
    public boolean equals(UrlStack u) {
        return u.getStack().equals(this.stack);
    }
}

Le contrat de service à valider

Nous allons à présent expliciter le contrat de service de nos composants, que nous aurons ensuite à cœur de valider et tester.

Contrat de loadDom()

Quand nous exécutons loadDom() sur une page, il nous faut absolument qu'une instance de la classe Dom soit stockée dans l'attribut dom de la page.

Contrat de extractLinks()

Quand on exécute la méthode extractLinks() sur une page, il faut absolument que la méthode retourne une liste de liens, qui corresponde à l'ensemble des liens présents dans la page.

Rappel : dans test unitaire il y a… unitaire !

Avant de commencer à écrire des tests, rappelons-nous que l'idée des tests unitaires est de tester les composants un à un, de manière à s'assurer que chacun d'entre-eux respecte son contrat de service.

En l’occurrence, quand je teste extractLinks(), je me fiche complètement que loadDom() fonctionne ou pas ! Ce n'est pas mon problème à ce moment là : tout ce dont j'ai besoin, c'est disposer d'un objet Dom pour pouvoir tester extractLinks().

Implémentation des tests

Nous allons dans cette section écrire nos classes et méthodes de test à proprement parler.

La classe de test TestPage

Pour tester les méthodes loadDom() et extractLink() de la classe Page, nous allons créer une classe de test pour cette classe : TestPage. En voici l'ossature : nous allons la compléter par la suite.

TestPage.java
package com.parser;

import junit.framework.TestCase;

public class PageTest extends TestCase {

    private Page pageFromUrl;
    private Page emptyPage;
    
    public void setUp() {
        pageFromUrl = new Page("http://www.google.fr");
        emptyPage = new Page();
    }
    
    public void testLoadDom() {
        // Le code de test de loadDom()...
    }
    
    public void testExtractLinks() {
        // Le code de test de extractLinks()...
    }
}

La méthode testLoadDom()

Nous allons ici commencer par tester que la méthode retourne bien un objet de type Dom. Utilisons pour cela l'assertion jUnit assertTrue() et l'opérateur Java instanceof.

public void testLoadDom() {
    try {
        pageFromUrl.loadDom();
    } catch (IOException e) {
        e.printStackTrace();
    }
    // Le dom extrait doit être une instance de la classe Document
    assertTrue(pageFromUrl.getDom() instanceof Document);
}

La méthode testExtractLinks()

Intéressons nous maintenant à la méthode extractLinks(). Nous allons vérifier plusieurs choses :

  • Si un contenu comporte x liens, la méthode extrait bien x éléments.
  • Les liens extraits sont les bons.
public void testExtractLinks() {
    Document dom = Jsoup.parse("<html><head><title>Hello parser!</title></head>"
      + "<body><p>Parsed <a href=\"http://en.wikipedia.org/wiki/HTML\">HTML</a> into"
      + "a <a href=\"#document\">doc</a>.</p></body></html>");       
    emptyPage.setDom(dom);
         
    try {
        List<Link> links = emptyPage.extractLinks();
        // Le HTML fourni comporte 2 liens : on doit retourner une liste de 2 éléments
        assertTrue(links.size() == 2);
        // Vérifions le premier lien... (<a href="...">HTML</a>)
        Link firstLink = new Link(new Url("http://en.wikipedia.org/wiki/HTML"), "HTML");            
        assertTrue(firstLink.equals(links.get(0)));
        // Puis le second... (<a href="...">doc</a>)
        Link secondLink = new Link(new Url("#document"), "doc");            
        assertTrue(secondLink.equals(links.get(1)));
    } catch (IOException e) {
        e.printStackTrace();
    }
}

Lancement des tests

Pour vérifier que nos tests passent bien, lancçons les !

Astuce Selon l'IDE que vous utilisez, vous pouvez disposer d'outils graphiques pour exécuter vos tests et consulter les rapports. C'est notamment le cas pour jUnit sous Eclipse.

IMAGE
Rapport de test jUnit de la classe Page sous Eclipse

La classe de test UrlStackTest

Nous allons maintenant tester notre pile d'URLs, gérée par la classe UrlStack : nous créons pour cela une nouvelle classe de test UrlStackTest.

Nous allons tester :

  • l'ajout d'une URL dans la pile (cas nominal) ;
  • la tentative d'ajout d'une URL déjà présente dans la pile : dans ce cas, l'URL ne doit pas être ajouté.
TestPage.java
package com.parser;

import junit.framework.*;

public class UrlStackTest extends TestCase {

    private UrlStack stack;
    
    public void setUp() {
        this.stack = new UrlStack();
    }
    
    public void testAddUrl() {
        Url url = new Url("http://www.yahoo.fr");
        this.stack.addUrl(url);        
        // La seule URL présente dans le stack à ce moment doit être "http://www.yahoo.fr"
        assertTrue(stack.size() == 1);
        assertTrue(stack.getUrl(0).equals(url));
        
        Url url2 = new Url("http://www.google.fr");
        this.stack.addUrl(url2);        
        // Il doit maintenant y avoir 2 urls dans le stack
        assertTrue(stack.size() == 2);
        // ...et les bonnes !
        assertTrue(stack.getUrl(0).equals(url));
        assertTrue(stack.getUrl(1).equals(url2));      
    }
    
    public void testAddExistingUrl() {
        stack.addUrl(new Url("http://www.google.fr"));
        stack.addUrl(new Url("http://www.yahoo.fr"));
        // Cette URL est déjà dans le stack : on ne doit pas l'ajouter pas une nouvelle fois :
        this.stack.addUrl(new Url("http://www.yahoo.fr"));
        // Donc, il doit y avoir 2 URLs dans le stack
        assertTrue(stack.size() == 2);
        // La 1re et la 2e
        assertTrue(stack.getUrl(0).equals(new Url("http://www.google.fr")));
        assertTrue(stack.getUrl(1).equals(new Url("http://www.yahoo.fr")));      
    }
}

Lancement des tests

Même principe que précédemment : pour vérifier que nos tests passent bien, lançons les !

jUnit classe UrlStack
Rapport de test de la classe UrlStack

Hum, l'affaire se corse… Tout n'est plus au vert car un des tests a échoué : testAddExistingUrl.

L'identification du problème est simple : quand on ajoute 3 URLs à la pile, dont 2 en doublon, le nombre d'URL présentes dans la pile (3) ne correspond pas au nombre attendu (2). En réalité, notre méthode addUrl() est bugguée : la gestion des doublons n'est pas fonctionnelle.

Il va donc nous falloir corriger cette méthode addUrl(), puis relancer nos tests pour voir que tout est conforme !

Correction des problèmes

Revoyons le code de la méthode addUrl() de la classe UrlStack :

UrlStack.java
public void addUrl(Url url) {
    stack.add(url);
}

Le voilà notre problème ! Avant d'ajouter une URL à la pile, aucun contrôle n'est réalisé pour vérifier si cette URL n'est pas déjà dans cette pile. Nous allons corriger ceci en ajoutant le test adéquat :

UrlStack.java
public void addUrl(Url url) {
    if (!this.stack.contains(url)) {
        stack.add(url);
    }
}

Voilà qui devrait faire en sorte qu'aucun doublon ne se retrouve dans la pile !

Re-lancement des tests

En relançant les test après cette correction, nous constatons que tout se passe correctement :

jUnit classe UrlStack debug
Rapport de test de la classe UrlStack corrigée

En conclusion…

Dans cette exemple détaillé, nous avons montré comment, de manière très simple, les test unitaires permettent de vérifier le respect du contrat de service d'un composant, et de détecter les erreurs éventuelles.

Cet exemple montre également comment les tests unitaires peuvent s'insérer dans le processus de développement : sans encore parler de TDD (test driven development), les tests unitaires peuvent être utilisés pendant les développements pour valider, tester et débuguer le code. Ils remplacent ainsi avantageusement l'écriture de code inutile simplement destiné à exécuter les méthodes écrites pour s'assurer qu'elles fonctionnent.

À retenir Faire du testing unitaire, c'est en somme une autre façon de développer, c'est une démarche qui s'intègre parfaitement dans votre workflow de développement !