8.7. Utiliser des expressions de correspondance souples

Quand on écrit des tests de recette, on est souvent amené à exprimer des attentes relatives à des objets du domaine ou à des ensembles d’objets du domaine. Par exemple, si vous testez une fonctionnalité de recherche multi-critères, vous voulez savoir si l’application trouve les enregistrements que vous attendez. Vous pourriez être capable de faire cela d’une manière très précise (par exemple en sachant exactement les valeurs des champs que vous attendez) ou bien vous pourriez vouloir rendre vos tests davantage flexibles en exprimant les plages de valeurs qui seraient acceptables. Thucydides fournit quelques fonctionnalités qui facilitent l'écriture des tests de recette dans ce type de cas.

Dans le reste de cette section, nous étudierons quelques exemples basés sur des tests du site de recherche Maven Central (voir Figure 8.1, “La page des résultats de la page de recherche de Maven Central”). Ce site vous permet de rechercher des artefacts Maven dans le dépôt Maven et de consulter les détails d’un artefact donné.

figs/maven-search-report.png

Figure 8.1. La page des résultats de la page de recherche de Maven Central


Nous allons utiliser quelques tests imaginaires de non régression pour ce site afin d’illustrer comment les comparateurs (matchers) de Thucydides peuvent être utilisés pour écrire des tests plus expressifs. Le premier scénario que nous allons envisager consiste à simplement chercher un artefact par son nom et à s’assurer que seuls les artefacts correspondant à ce nom apparaissent dans la liste des résultats. Nous pourrions énoncer informellement ce critère de validation de la manière suivante:

Avec JUnit, un test Thucydides correspondant à ce scénario pourrait ressembler à celui-ci:

...
import static net.thucydides.core.matchers.BeanMatchers.the_count;
import static net.thucydides.core.matchers.BeanMatchers.each;
import static net.thucydides.core.matchers.BeanMatchers.the;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.startsWith;

@RunWith(ThucydidesRunner.class)
public class WhenSearchingForArtifacts {

    @Managed
    WebDriver driver;

    @ManagedPages(defaultUrl = "http://search.maven.org")
    public Pages pages;

    @Steps
    public DeveloperSteps developer;

    @Test
    public void should_find_the_right_number_of_artifacts() {
        developer.opens_the_search_page();
        developer.searches_for("Thucydides");
        developer.should_see_artifacts_where(the("GroupId", startsWith("net.thucydides")),
                                             each("ArtifactId").isDifferent(),
                                             the_count(is(greaterThanOrEqualTo(16))));

    }
}

Voyons comment le test est implémenté dans cette classe. Le test should_find_the_right_number_of_artifacts() peut être explicité comme suit:

  1. Quand nous ouvrons la page de recherche
  2. Et que nous cherchons l’artefact contenant le mot Thucydides
  3. Alors nous devrions voir une liste d’artefacts pour lesquels chaque Group ID commence par "net.thucydides", chaque Artifact ID est unique et qu’il y a au moins 16 entrées de ce type d’affichées.

L’implémentation de ces étapes est illustrée ici:

...
import static net.thucydides.core.matchers.BeanMatcherAsserts.shouldMatch;

public class DeveloperSteps extends ScenarioSteps {

    public DeveloperSteps(Pages pages) {
        super(pages);
    }

    @Step
    public void opens_the_search_page() {
        onSearchPage().open();
    }

    @Step
    public void searches_for(String search_terms) {
        onSearchPage().enter_search_terms(search_terms);
        onSearchPage().starts_search();
    }

    @Step
    public void should_see_artifacts_where(BeanMatcher... matchers) {
        shouldMatch(onSearchResultsPage().getSearchResults(), matchers);
    }

    private SearchPage onSearchPage() {
        return getPages().get(SearchPage.class);
    }

    private SearchResultsPage onSearchResultsPage() {
        return getPages().get(SearchResultsPage.class);
    }
}

Les deux premières étapes sont implémentées par des méthodes relativement simples. Cependant, la troisième étape est plus intéressante. Regardons-la de plus près:

    @Step
    public void should_see_artifacts_where(BeanMatcher... matchers) {
        shouldMatch(onSearchResultsPage().getSearchResults(), matchers);
    }

Ici, nous passons un nombre arbitraire d’expression à la méthode. Ces expressions sont en fait des matchers, des instances de la classe BeanMatcher. Vous n’avez normalement pas à vous soucier de ce niveau de détail - vous créez ces expressions de correspondance en utilisant un ensemble de méthodes statiques fournies par la classe BeanMatcher. Aussi vous ne devriez typiquement passer que des expressions relativement lisibles telles que the("GroupId", startsWith("net.thucydides")) ou each("ArtifactId").isDifferent().

La méthode shouldMatch() de la classe BeanMatcherAsserts attend soit un unique objet Java, soit un ensemble d’objets Java et vérifie qu’au moins certains de ces objets correspondent aux contraintes indiquées par les matchers. Dans le cas du test web, ces objets sont typiquement des POJOs fournis par l’objet page pour représenter des objets du domaine ou des objets affichés à l'écran.

Il existe un certain nombre d’expressions différentes de matchers parmi lesquelles choisir. Le matcher le plus communément utilisé vérifie simplement la valeur d’un champ dans un objet. Par exemple, supposons que vous utilisez l’objet domaine montré ici:

     public class Person {
        private final String firstName;
        private final String lastName;

        Person(String firstName, String lastName) {
            this.firstName = firstName;
            this.lastName = lastName;
        }

        public String getFirstName() {...}

        public String getLastName() {...}
    }

Vous pourriez écrire un test pour vous assurer que la liste des personnes contienne au moins une personne appelée "Bill" en utilisant la méthode statique "the", comme montré ici:

    List<Person> persons = Arrays.asList(new Person("Bill", "Oddie"), new Person("Tim", "Brooke-Taylor"));

    shouldMatch(persons, the("firstName", is("Bill"))

Le second paramètre de la méthode the() est un matcher Hamcrest qui vous donne une grande marge de flexibilité dans vos expressions. Par exemple, vous pourriez également écrire ce qui suit:

    List<Person> persons = Arrays.asList(new Person("Bill", "Oddie"), new Person("Tim", "Brooke-Taylor"));

    shouldMatch(persons, the("firstName", is(not("Tim"))));
    shouldMatch(persons, the("firstName", startsWith("B")));

Vous pouvez également passer des conditions multiples:

    List<Person> persons = Arrays.asList(new Person("Bill", "Oddie"), new Person("Tim", "Brooke-Taylor"));

    shouldMatch(persons, the("firstName", is("Bill"), the("lastName", is("Oddie"));

Thucydides fournit également la classe DateMatchers qui vous permet d’appliquer les matchers Hamcrest aux objets Java standard Dates et aux Datetimes JodaTime. Les exemples de code suivant illustrent la façon dont cela peut être utilisé:

    DateTime january1st2010 = new DateTime(2010,01,01,12,0).toDate();
    DateTime may31st2010 = new DateTime(2010,05,31,12,0).toDate();

    the("purchaseDate", isBefore(january1st2010))
    the("purchaseDate", isAfter(january1st2010))
    the("purchaseDate", isSameAs(january1st2010))
    the("purchaseDate", isBetween(january1st2010, may31st2010))

Vous avez également parfois besoin de vérifier des contraintes qui s’appliquent à tous les éléments considérés. Le plus simple de ces cas de figure consiste à vérifier que toutes les valeurs prises par un champ particulier sont uniques. Vous pouvez faire cela en utilisant la méthode each():

    shouldMatch(persons, each("lastName").isDifferent())

Vous pouvez également vérifier que le nombre d'éléments qui correspondent est conforme à ce que vous attendiez. Par exemple, pour vérifier qu’il n’y a qu’une seule personne dont le prénom est Bill, vous pourriez faire cela:

     shouldMatch(persons, the("firstName", is("Bill"), the_count(is(1)));

Vous pouvez également vérifier les valeurs minimum et maximum en utilisant les méthodes min() et max(). Par exemple, si la classe Person possède une méthode getAge(), nous pourrions nous assurer que chaque personne a plus de 21 ans et moins de 65 en faisant ce qui suit:

     shouldMatch(persons, min("age", greaterThanOrEqualTo(21)),
                          max("age", lessThanOrEqualTo(65)));

Ces méthodes fonctionnent avec les objets Java normaux mais aussi avec Maps. C’est pourquoi le code suivant fonctionne également:

    Map<String, String> person = new HashMap<String, String>();
    person.put("firstName", "Bill");
    person.put("lastName", "Oddie");

    List<Map<String,String>> persons = Arrays.asList(person);
    shouldMatch(persons, the("firstName", is("Bill"))

L’autre chose sympathique avec cette approche est que les matchers s’intègrent harmonieusement avec les rapports Thucydides. Ainsi, quand vous utilisez la classe BeanMatcher comme paramètre de vos étapes de test, les conditions exprimées dans l'étape seront affichées dans le rapport du test, comme montré dans Figure 8.2, “Les expressions de condition sont affichées dans les rapports de test”.

figs/maven-search-report.png

Figure 8.2. Les expressions de condition sont affichées dans les rapports de test


Il existe deux canevas utilisés habituellement lors de la construction d’objets pages et d'étapes qui utilisent ce type de matcher. Le premier consiste à écrire une une méthode d’objet de page qui retourne la liste des objets du domaine (par exemple, les personnes) affichées dans la table. Par exemple, la méthode getSearchResults() utilisée dans l'étape should_see_artifacts_where() pourrait être implémentée comme suit:

    public List<Artifact> getSearchResults() {
        List<WebElement> rows = resultTable.findElements(By.xpath(".//tr[td]"));
        List<Artifact> artifacts = new ArrayList<Artifact>();
        for (WebElement row : rows) {
            List<WebElement> cells = row.findElements(By.tagName("td"));
            artifacts.add(new Artifact(cells.get(0).getText(),
                                       cells.get(1).getText(),
                                       cells.get(2).getText()));

        }
        return artifacts;
    }

Le second consiste à accéder directement au contenu de la table HTML sans explicitement modéliser les données qui y sont contenues. Cette approche est plus rapide et plus efficace si vous ne prévoyez pas de réutiliser l’objet du domaine dans d’autres pages. Nous verrons comment faire ceci après.

8.7.1. Travailler avec les tables HTML

Puisque les tables HTML restent largement utilisées pour représenter des séries de données dans les applications web, Thucydides possède une classe HtmlTable qui fournit nombre de méthodes utiles qui facilitent l'écriture des objets page qui contiennent des tables. Par exemple, la méthode rowsFrom renvoie le contenu d’une table HTML sous forme d’une liste de Maps dans laquelle chaque map contient les valeurs des cellules pour une ligne, indexées par l’en-tête correspondant, comme montré ici:

...
import static net.thucydides.core.pages.components.HtmlTable.rowsFrom;

public class SearchResultsPage extends PageObject {

    WebElement resultTable;

    public SearchResultsPage(WebDriver driver) {
        super(driver);
    }

    public List<Map<String, String>> getSearchResults() {
        return rowsFrom(resultTable);
    }

}

Ceci économise beaucoup de saisie - notre méthode getSearchResults() ressemble maintenant à ceci:

    public List<Map<String, String>> getSearchResults() {
        return rowsFrom(resultTable);
    }

Et puisque les matchers Thucydides fonctionnent à la fois avec les objets Java et les Maps, les expressions des matchers seront très semblables. La seule différence est que les Maps renvoyés sont indexés par les valeurs textuelles contenues dans les en-têtes de la table au lieu que ce soit dans les noms de propriété compatibles Java.

Vous pouvez également lire des tables sans en-tête (i.e, éléments <th>) en indiquant vos propres en-têtes en utilisant la méthode withColumns. Par exemple :

    List<Map<Object, String>> tableRows =
                    HtmlTable.withColumns("First Name","Last Name", "Favorite Colour")
                             .readRowsFrom(page.table_with_no_headings);

Vous pouvez également utiliser la classe HtmlTable pour choisir des lignes particulières dans une table pour travailler avec. Par exemple, un autre scénario de test pour la page de recherche Maven implique de cliquer sur un artefact et d’en afficher les détails. Le test pour ceux-ci ressemble à quelque chose comme ça:

    @Test
    public void clicking_on_artifact_should_display_details_page() {
        developer.opens_the_search_page();
        developer.searches_for("Thucydides");
        developer.open_artifact_where(the("ArtifactId", is("thucydides")),
                                      the("GroupId", is("net.thucydides")));

        developer.should_see_artifact_details_where(the("artifactId", is("thucydides")),
                                                    the("groupId", is("net.thucydides")));
    }

Maintenant la méthode open_artifact_where() nécessite de cliquer sur une ligne particulière de la table. Cette étape ressemble à quelque chose comme ça:

    @Step
    public void open_artifact_where(BeanMatcher... matchers) {
        onSearchResultsPage().clickOnFirstRowMatching(matchers);
    }

De cette façon, nous déléguons effectivement à l’objet Page qui effectue le vrai travail. La méthode correspondante de l’objet Page ressemble à ceci:

import static net.thucydides.core.pages.components.HtmlTable.filterRows;
...
    public void clickOnFirstRowMatching(BeanMatcher... matchers) {
        List<WebElement> matchingRows = filterRows(resultTable, matchers);
        WebElement targetRow = matchingRows.get(0);
        WebElement detailsLink = targetRow.findElement(By.xpath(".//a[contains(@href,'artifactdetails')]"));
        detailsLink.click();
    }

La partie intéressante ici est la première ligne de la méthode où nous utilisons la méthode filterRows(). Cette méthode va renvoyer une liste de WebElements qui correspondent au matcher que vous avez passé. Cette méthode rend vraiment facile la sélection de lignes qui vous intéressent pour un traitement particulier.