FluentLenium Et Cucumber JVM... Complément Et Precision

left-small

Dans mon article précédent, j’avais tenté d’expliquer comment il était possible d’intégrer les frameworks Cucumber JVM et Selenium au travers de FluentLenium.

En effet, pour rappel, FluentLenium permettait d’abstraire Selenium en lui offrant une API plus fluent mais également en lui apportant nativement ce qu’il préconise, à savoir le Page Object Design Pattern.

Pour ce faire, j’avais proposé d’utiliser la délégation de l’initialisation de FluentLenium à une classe tierce injectée via le mécanisme d’injection de Cucumber JVM.

Cependant, suite à discussion avec la créatrice de FluentLenium (à savoir Mathilde), on s’est rendu compte que l’axe utilisé était légèrement biaisé (même s’il fonctionnait…).

Cet article revient donc sur ce point en proposant une solution plus simple mais présentera également comment il est possible de tester le scénario Cucumber avec différents navigateurs et il y aura un petit mot sur l’utilisation de navigateurs déportés (via les RemoteWebDriver de Selenium 2).

Pour ce faire, il sera découpé en 3 parties qui couvriront des usecases différents se traduisant donc par des implémentations différentes :

  • cas de tests pour un site simple,
  • cas de tests pour un site complet,
  • cas de tests multi-navigateurs pour un site complet.

A noter que je ne reviendrai pas sur les principes des différents frameworks/concepts mais juste sur comment il est possible d’implémenter ces différents usecases.

A noter également que l’article précédent aurait pu être modifié mais qu’en raison du nombre important de changements, il était plus simple d’en initier un autre…

Cas de tests pour un site simple

Présentation et proposition d’implémentation

Ce premier cas d’usage couvre le cas : “j’ai un site que je veux tester avec Cucumber JVM et l’ensemble des steps peut être réuni dans une seule et même classe.”

Bon je vois déjà la levée de bouclier : pourquoi réunir toutes les steps dans une seule et même classe. En fait, la raison du pourquoi sera expliquée un peu plus tard dans le paragraphe Limites de ce chapitre donc patience… ;–)

Contrairement à la façon que j’avais présentée dans mon article précédent, il n’est pas obligatoire de déléguer la déclaration des pages FluentPage à une autre classe étendant FluentTest. En fait, il suffit juste de faire étendre la classe contenant les steps cucumber de FluentAdapter, d’y déclarer les pages et d’appeler dans la méthode annotée par @Before (celui de Cucumber JVM bien sûr) les méthode d’initialisation du contexte de FluentLenium.

Pour rappel, cette initialisation instancie le WebDriver utilisé par Selenium 2 mais également les pages (annotées par l’annotation @Page) présentes dans la classe courante (ou ses parentes) qui doit, au minimum, étendre de FluentAdapter. Cela se fait au travers des méthodes initFluent() et initTest().

Le code est donc extrêmement simple puisqu’il suffit de faire quelque chose du style :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import cucumber.api.java.After;
import cucumber.api.java.Before;
import cucumber.api.java.en.Given;
import cucumber.api.java.en.Then;
import cucumber.api.java.en.When;
import org.fluentlenium.core.FluentAdapter;
import org.fluentlenium.core.annotation.Page;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;

import static org.fest.assertions.api.Assertions.assertThat;

public class SimpleStep extends FluentAdapter {

 @Page
 BingPage page;

 @Before
 public void before() {
  this.initFluent(new HtmlUnitDriver());
  this.initTest();
 }

 @Given(value = "j accede a bing")
 public void step1() {
  goTo(page);
 }

 @When(value = "je recherche ([^ ]*) ")
 public void step2(String keyword) {
  fill("#sb_form_q").with(keyword);
  submit("#sb_form_go");
 }

 @Then(value = "le titre est ([^ ]*) ")
 public void step3(String keyword) {
  assertThat(title()).contains(keyword);
 }

 @After
 public void after(){
  this.quit();
 }
}

où BingPage est :

1
2
3
4
5
6
7
8
9
import org.fluentlenium.core.FluentPage;

public class BingPage extends FluentPage {

 @Override
 public String getUrl() {
  return "http://www.bing.com";
 }
}

et la feature, la suivante :

1
2
3
4
5
6
Feature: basic

  Scenario: scenar1
    Given j accede a bing
    When je recherche toto
    Then le titre est toto

Il est intéressant de remarquer la simplicité de la chose (et rien à voir avec l’implémentation que j’avais proposé précédemment!).

A part cela, peu de choses à ajouter : le code parle de lui même…

Limites

On vient de voir comme il était simple de faire cohabiter FluentLenium et Cucumber JVM.

Bien sûr, il y a un mais… (ça serait trop simple sinon) : comme on peut le constater, actuellement, toutes les steps se trouvent être dans la même classe. Cependant, dans le cas d’un site web un peu plus complexe, il est courant et même encouragé de séparer les steps dans différentes classes.

Dans l’implémentation précédente, l’annotation @Before a été utilisée pour initialiser le contexte (et plus particulièrement le webDriver et les pages pour ensuite injecter le webDriver dans ces dernières).

Cependant, dans le cas où les steps se trouvent être dans plusieurs classes, cela pose potentiellement un problème.

En effet, Cucumber JVM instancie la classe qui contient la définition de la step dès qu’il en a besoin et appelle la méthode annotée par @Before à l’instanciation de cette classe. Ainsi, dans notre cas, si les steps s’étaient trouvées dans deux classes, chacune étendant FluentAdapter et appelant initFluent() et initTest() dans la méthode annotée par @Before, alors cette instanciation aurait été faite deux fois et non une seule fois comme on aurait pu s’y attendre pour un même scénario donné…

Pire, les pages déclarées dans les classes n’auraient pas eu la même instance du webDriver et elles ne se seraient pas vu l’une l’autre…

Pas glop tout ça… :‘(

Ainsi, l’implémentation précédente fonctionne pour des cas “simples” mais si la partie test d’acceptante/intégration avait été plus complexe, alors cela aurait empêché la réutilisation et le découplage.

Cas de tests pour un site complet

Présentation et proposition d’implémentation

Il a été vu dans le paragraphe précédent qu’il pouvait être utile de disposer de plusieurs classes disposant des implémentations des fixtures. Cependant, la question principale est de trouver comment il est possible de n’instancier qu’une seule fois par scénario le webDriver et de l’injecter dans des instances de pages propres au scénario.

La proposition présentée dans l’article précédent (modulo qu’il ne faut pas étendre de FluentTest mais de FluentAdapter) reste viable, mais il y a plus simple.

En effet, dans la proposition faite précédemment, la classe FluentTestDelegator avait à sa charge, à la fois la déclaration des pages, et l’instanciation et l’initialisation du contexte de FluentLenium. Pour rappel, cette instanciation/initialisation était réalisée par Pico Container lors de l’injection de l’instance de cette classe dans la classe contenant les fixtures.

En fait, il est plus propre, d’un point de vue séparation des concepts, de laisser à cette classe le soin de proposer les fixtures d’initialisation du webDriver tout en séparant la déclaration des pages.

Cela peut être réalisé en créant une classe (FluentPageInjector) étendant de FluentAdapter qui définit les pages et de la faire étendre d’une classe (FluentLeniumStepInitilizer) qui définit les fixtures d’instanciation du webDriver.

Cela offre deux avantages : les classes qui définissent les steps de navigation n’ont qu’à étendre de FluentPageInjector pour avoir une visibilité sur les pages (tout en continuant d’injecter via pico l’instance de FluentLeniumStepInitilizer) et il devient alors possible de variabiliser le webDriver à utiliser.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Feature: browser

  Scenario: navigation version firefox
    Given I connect on url http://localhost:8080 with firefox
    Given j accede a la homePage
    And je suis sur homePage
    When je submit
    Then je suis sur la page result
    And driver is closed


  Scenario: navigation version chrome
    Given I connect on url http://localhost:8080 with chrome with parameters webdriver.chrome.driver:/opt/chromedriver/chromedriver
    Given j accede a la homePage
    And je suis sur homePage
    When je submit
    Then je suis sur la page result
    And driver is closed


  Scenario: navigation version phantomjs
    Given I connect on url http://localhost:8080 with phantomjs with parameters phantomjs.binary.path:/opt/phantomjs-1.9.0-linux-x86_64/bin/phantomjs
    Given j accede a la homePage
    And je suis sur homePage
    When je submit
    Then je suis sur la page result
    And driver is closed

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package step;

import org.fluentlenium.core.FluentAdapter;
import org.fluentlenium.core.annotation.Page;
import page.HomePage;
import page.ResultPage;

public class FluentPageInjector extends FluentAdapter {
    @Page
    protected HomePage homePage;

    @Page
    protected ResultPage resultPage;
}

On remarque, comme dit plus haut, que cette classe n’a qu’un seul rôle qui est de déclarer les pages tout en étendant de FluentAdapter.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class HomePageStep extends FluentPageInjector {

     public HomePageStep(FluentLeniumStepInitilizer delegator) {
      this.homePage = delegator.homePage;
      this.resultPage = delegator.resultPage;
     }


     @Given("^j accede a la homePage$")
     public void j_accede_a_homePage() {
      goTo(homePage);
     }

     @Given("^je suis sur homePage$")
     public void je_suis_sur_homePage() {
      homePage.isAt();
     }

     @When("^je submit$")
     public void je_submit() throws Throwable {
      homePage.submit();
     }
}

1
2
3
4
5
6
7
8
9
10
11
12
public class ResultPageStep extends FluentPageInjector {

    public ResultPageStep(FluentLeniumStepInitilizer delegator) {
        this.resultPage = delegator.resultPage;
    }

    @When("^je suis sur la page result$")
    public void je_suis_sur_la_page_result() throws Throwable {
        resultPage.isAt();
    }

}

Ces classes correspondent aux classes qui définissent les fixtures. Elles étendent de FluentPageInjector de façon à pouvoir bénéficier de la visibilité sur les pages. Par contre, il est intéressant de constater que, dans son constructeur, la classe FluentLeniumStepInitializer est injecté via Pico Container. Cela permet d’affecter la valeur des pages.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class FluentLeniumStepInitilizer extends FluentPageInjector {

    @Given("^I connect on url ([^ ]*) with ([^ ]*) with parameters ([^ ]*)$")
    public void browser_connect(String host, String browser, String parameters) {
        init(host, browser, parameters);
    }

    @Then("^drivers are closed")
    public void close() {
        this.quit();
    }

    @After
    public void afterClose() {
        this.quit();
    }

    private void init(String host, String browserName, String parametersLine) {
        Browser browser = null;

        DesiredCapabilities capabilities = new DesiredCapabilities();

        String[] parameters = parametersLine.slip(";");
        for (String parameter : parameters) {
            if (!parameter.isEmpty()) {
                String[] key_value = parameter.split(":");
                capabilities.setCapability(key_value[0], key_value[1]);
            }
        }

        browser = Browser.getBrowser(browserLine.get(0));
        capabilities.setBrowserName(browser.getName());

        initWebDriver(host, browserHost, browser, capabilities);
    }

    private void initWebDriver(String host, String browserHost, Browser browser, DesiredCapabilities capabilities) {
        Fluent fluent = null;
        WebDriver driver = null;
        if (browser != null) {
            driver = BrowserMapper.getDriver(browser, capabilities);
            fluent = this.initFluent(driver);
        }

        fluent.withDefaultUrl(host);
        this.initTest();
    }
}

Cette classe étend par transitivité FluentAdapter et dispose donc de la visibilité sur les méthodes d’initialisation de FluentLenium. En outre, en étendant FluentPageInjector (qui étend de FluentAdapter), cela lui permet, à l’appel de initTest(), d’initialiser les pages. Concernant l’initialisation des webDriver, cela est fait au niveau de la classe BrowserMapper.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public enum Browser {
    HTMLUNIT("default"),
    FIREFOX("firefox"),
    CHROME("chrome"),
    PHANTOMJS("phantomjs");

    private String name;

    Browser(String name) {
        this.name = name;
    }

    public String getName() {
        return this.name;
    }

    public static Browser getBrowser(String name) {
        for (Browser browser : values()) {
            if (browser.getName().equalsIgnoreCase(name)) {
                return browser;
            }
        }
        return HTMLUNIT;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.firefox.FirefoxDriver;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
import org.openqa.selenium.phantomjs.PhantomJSDriver;

import java.util.HashMap;
import java.util.Map;

public class BrowserMapper {
 private String name;

 private static Map<Browser, WebDriverLazyLoader> browserMapper = new HashMap<Browser, WebDriverLazyLoader>();

 static {
  browserMapper.put(Browser.CHROME, new WebDriverLazyLoader(ChromeDriver.class));
  browserMapper.put(Browser.FIREFOX, new WebDriverLazyLoader(FirefoxDriver.class));
  browserMapper.put(Browser.HTMLUNIT, new WebDriverLazyLoader(HtmlUnitDriver.class));
  browserMapper.put(Browser.PHANTOMJS, new WebDriverLazyLoader(PhantomJSDriver.class));
 }

 public static WebDriver getDriver(Browser browser, Capabilities capabilities) {
  WebDriverLazyLoader webDriverLazyLoader = browserMapper.get(browser);
  if (webDriverLazyLoader != null) {
            if (browser == Browser.PHANTOMJS) {
                return webDriverLazyLoader.getWebDriverClass(capabilities);

            } else if (browser == Browser.CHROME) {
                System.setProperty("webdriver.chrome.driver", (String) capabilities.getCapability("webdriver.chrome.driver"));
                return webDriverLazyLoader.getWebDriverClass(capabilities);

            } else {
                return webDriverLazyLoader.getWebDriverClass();
            }
  }
  return browserMapper.get(Browser.HTMLUNIT).getWebDriverClass();
 }
}

Cette classe permet de faire le pont avec les webDriver qu’il est possible d’utiliser. Cependant, la petite astuce consiste à instancier de manière “Lazy” ces derniers.

En effet, appeler le constructeur d’un webDriver l’instancie mais le démarre également (ie. que la fenêtre du navigateur s’ouvre réellement). Du coup, la petite classe présentée ci-dessous a été utilisée.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.openqa.selenium.Capabilities;
import org.openqa.selenium.WebDriver;

class WebDriverLazyLoader {
    private Class webDriverClass;


    public WebDriverLazyLoader(Class webDriverClass) {
        this.webDriverClass = webDriverClass;
    }

    public WebDriver getWebDriverClass() {
        try {
            return (WebDriver)this.webDriverClass.newInstance();
        } catch (ReflectiveOperationException e) {
            e.printStackTrace();
        }
        return null;
    }

    public WebDriver getWebDriverClass(Capabilities capabilities) {
        try {
            return (WebDriver)this.webDriverClass.getConstructor(Capabilities.class).newInstance(capabilities);
        } catch (ReflectiveOperationException e) {
            e.printStackTrace();
        }
        return null;
    }
}

Limites

Comme on a pu le voir dans ce chapitre, il est aisé de partager les fixtures Cucumber JVM dans des classes différentes tout en bénéficiant de FluentLenium.

Cependant, pour certains besoins, il peut être utile de vouloir lancer les tests d’acceptance/intégration sur différents navigateurs.

Bien sûr, chaque step pourrait boucler sur l’ensemble des navigateurs sur lesquels les tests doivent être exécutés, mais cela induirait des problématiques d’entrelacement des actions et donc soulèverait des problématiques comme la gestion d’un contexte par webDriver, l’accès à un rapport “illisible” ou un manque de contrôle sur les préconditions du test qui sont, généralement, lié au scénario et non à une Step.

Le chapitre suivant tentera de répondre à cette problématique en proposant un moyen de “boucler” sur le scénario avec différents navigateurs.

Cas de tests multi-navigateurs pour un site complet

Présentation et proposition d’implémentation

Il a été vu dans le chapitre précédent comment il était possible d’exécuter des tests d’acceptances/intégration sur un navigateur donné.

Ce chapitre présentera, pour sa part, une façon de les lancer sur différents navigateurs sans avoir à faire de copier/coller ;–).

En fait, Cucumber JVM permet nativement de boucler sur un scénario en utilisant différents paramètres. Cela se fait par le mécanisme de scenario outline.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Feature: multibrowser

  Scenario Outline: multi browser navigation version 1:
    Given I connect on url http://localhost:8080 with <browser> with parameters <parameters>
    Given j accede a la homePage
    And je suis sur homePage
    When je submit
    Then je suis sur la page result
    And driver is closed
  Examples:
    | browser | parameters                                             |
    | firefox |                                                        |
    | default |                                                        |
    | chrome  | webdriver.chrome.driver:/opt/chromedriver/chromedriver |

Et… c’est tout!

Le code n’a pas à être modifié : Cucumber JVM s’occupe de tout! ;–)

Limites

On a vu dans le paragraphe précédent comment il était possible d’exécuter facilement des tests d’acceptance/intégration en s’appuyant sur la notion de scénario outline offerte nativement par Cucumber JVM.

Coté limitation, je n’en vois pas trop…

Peut être le fait de ne pas instancier le webDriver pour chaque scénario (opération assez coûteuse en temps) mais cela est aisément résolvable en utilisant une sorte de cache de webDriver fonctionnant sur le principe de singleton qui serait réinialisé lors de l’appel à la step drivers are closed qui serait isolée dans son propre scenario outline :

1
2
3
4
5
6
7
8
  Scenario Outline: browsers are closed:
    Then driver is closed
  Examples:
    | browser   | parameters                                                            |
    | firefox   |                                                                       |
    | default   |                                                                       |
    | phantomjs | phantomjs.binary.path:/opt/phantomjs-1.9.0-linux-x86_64/bin/phantomjs |
    | chrome    | webdriver.chrome.driver:/opt/chromedriver/chromedriver                |

Conclusion

Il a été présenté dans cet article comment il était possible d’implémenter l’intégration de Cucumber JVM et de Selenium à l’aide de FluentLenium.

Cet article n’a, cependant, pas fait mention de l’exécution des tests sur des navigateurs distants via les RemoteWebDriver mais cela est tout à fait possible (même si le code montré ici ne le présente pas) et est même totalement fonctionnel : pour ce faire (code disponible ici), il suffit de fournir, entre autre, des paramètres supplémentaires comme l’url de connexion au hub Selenium et d’instancier un RemoteWebDriver plutôt que le webDriver.

De même, le code permettant d’instancier à la mode singleton les webDriver est disponible ici (voir la méthode initCachedWebDriver() de la classe FluentLeniumStepInitilizer).

Enfin, un dernier mot sur la façon dont il est possible d’exécuter tout ce beau monde (comme ça, je réponds à la remarque très pertinente de José ;–) ) parce que faire des tests, c’est bien, les jouer, c’est mieux!

Il est possible de jouer les tests d’au moins trois manières distinctes : une orienté “vraie vie” (ie. utilisable au sein d’un build maven et donc exécutable via une usine d’intégration continue) et deux autres plutôt orientés développement.

Ainsi, pour jouer les tests via maven, il suffit de le déclarer dans le pom.xml le plugin failsafe en le branchant sur la “bonne phase”, à savoir le runner Cucumber JVM :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<plugin>
    <artifactId>maven-failsafe-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
    <configuration>
        <includes>
            <include>**/BasicRunner.java</include>
        </includes>
    </configuration>
</plugin>

Coté jouabilité des tests sur un environnement de développement il est possible d’exécuter le Runner Cucumber JVM directement (comme lors de l’exécution d’une classe de test unitaire) ou d’utiliser le plugin Cucumber JVM proposé par notre IDE préféré (pour moi IntelliJ, pour les autres, je ne sais pas…).

1
2
3
4
5
6
7
import cucumber.api.junit.Cucumber;
import org.junit.runner.RunWith;

@RunWith(Cucumber.class)
@Cucumber.Options(features = "classpath:fr/jetoile/webapp/acceptance", format = {"pretty", "html:target/cucumber", "json:target/cucumber.json"})
public class RunCucumberFeatures {
}

medium


medium

Enfin, pour rappel, mon usecase étant de tester mon application web en boite noire, un prérequis était que mon application web soit démarrée au préalable.

Pour ce faire, le plugin maven Jetty (ou Tomcat au choix) a été utilisé et branché sur la phase de pré-integration.

Lors de l’exécution des tests en mode développement (ie. en les lançant comme un TU ou à l’aide du plugin Cucumber JVM via l’IDE), un profil n’exécutant pas le plugin failsafe mais uniquement le démarrage du jetty/tomcat embarqué a été utilisé.

Pour aller plus loin…

Comments

comments powered by Disqus