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.
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.
Nous allons tout d'abord voir à qui ressemble d'architecture technique de notre crawler : classes en présente, dépendances, etc.
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()));
}
}
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;
}
}
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);
}
}
Nous allons à présent expliciter le contrat de service de nos composants, que nous aurons ensuite à cœur de valider et tester.
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.
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.
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()
.
Nous allons dans cette section écrire nos classes et méthodes de test à proprement parler.
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("https://www.google.fr");
emptyPage = new Page();
}
public void testLoadDom() {
// Le code de test de loadDom()...
}
public void testExtractLinks() {
// Le code de test de extractLinks()...
}
}
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);
}
testExtractLinks()
Intéressons nous maintenant à la méthode extractLinks()
. Nous allons vérifier plusieurs choses :
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("https://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();
}
}
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.
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 :
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("https://www.yahoo.fr");
this.stack.addUrl(url);
// La seule URL présente dans le stack à ce moment doit être "https://www.yahoo.fr"
assertTrue(stack.size() == 1);
assertTrue(stack.getUrl(0).equals(url));
Url url2 = new Url("https://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("https://www.google.fr"));
stack.addUrl(new Url("https://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("https://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("https://www.google.fr")));
assertTrue(stack.getUrl(1).equals(new Url("https://www.yahoo.fr")));
}
}
Même principe que précédemment : pour vérifier que nos tests passent bien, lançons les !
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 !
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 !
En relançant les test après cette correction, nous constatons que tout se passe correctement :
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.