Tests unitaires avec AEM Mocks

Tests unitaires avec AEM Mocks

Dans cet article, je vous propose un exemple simple (les sources ici : https://github.com/jocbat/AEMUnitTestsSample) utilisant wcm.io et plus particulièrement son module Testing, permettant de faire entre autres des tests unitaires et des tests d’intégration. Ce framework se base sur les mocks des APIs Sling, JCR et OSGi accessibles ici. Il s’agit ici d’une introduction qui permet de mocker la base de données (JCR). Des exemples plus poussés viendront certainement dans un prochain article sur le mock de sling models, services OSGi etc…Il y a en effet pas mal de choses à dire déjà rien que sur cet exemple.

Soyons honnêtes : tout n’est pas parfait, la cohabitation de certaines dépendances maven posent parfois quelques problèmes et toutes les fonctionnalités ne sont pas disponibles. Manions l’euphémisme : c’est embêtant. Cependant il est possible de faire des choses très intéressantes dont cet article ne montre qu’une infime partie.

Enfin, il est également possible que le rédacteur de ce post ait ajouté les mauvaises dépendances lors de son investigation Si tel est le cas, je serai ravi que le lecteur magnanime et clément me laisse un commentaire pour m’en informer.

Pourquoi ?

Les tests unitaires en général

Note : si vous êtes « Unit Tests Friend », vous pouvez sauter cette section

Au vu du ratio de projets possédant des test unitaires ainsi que du merveilleux adage « tester, c’est douter », je me sens quelque peu obligé de répondre à cette question.

Oui, pourquoi ? Pourquoi diable se faire violence en ajoutant des dépendances maven qui, aux dires de l’auteur de cette page, peuvent être instables et cause de nombreux cris, râles et déceptions ?

La réponse est somme toute assez simple : le gain de temps :

– nous codons comme des dieux, c’est une évidence, il nous arrive tout de même parfois très humainement de laisser traîner une erreur d’implémentation dans notre code.

Même si les personnes qui testent (entre autres) notre nouvelle fonctionnalité sont également des dieux, ils leur arrivent également de ne pas incriminer notre nouvelle création, oubliant/ne considérant pas LE cas dans lequel notre faute originelle entraîne un plantage notoire. De bonnes pommes…

En outre, si l’on « découpe » le projet entier en « phases », on obtient grosso modo quelque chose comme :

1.coder en local / tester (de manière automatique ou non) notre nouvelle super feature
2.installer en intégration et tester par un tiers
3.
installer en préproduction et tester par un tiers
4.
installer en production et « testé »…par la terre entière

L’investigation de la source du bug est alors d’autant plus longue que la phase dans laquelle on se trouve est avancée : l’environnement de production peut contenir des systèmes de caches non présents dans les phases antérieures et mal vidésun web service externe peut être indisponible alors qu’il l’était en préproduction etc…Le nombre de paramètres à prendre en compte est donc bien plus grand en bout de chaîne que lors de notre ajout de ligne de code en local sur notre IDE. Et incriminer notre propre création ne fait pas partie, au début tout du moins, des premières vérifications…(nous sommes des dieux, rappelez-vous)

ensuite, coder sans test unitaire revient :

– à activer le mode debug, mettre un point d’arrêt
– attendre que celui-ci passe (et cela peut dans certains cas être assez long pour raison d’accès à un web service par exemple)
– inspecter les valeurs et faire du code « à chaud ».
– Lorsque l’on est assez « satisfait » du code produit, on déploie sur l’instance (ici AEM), déploiement qui peut prendre du temps. Parfois en local, lors de l’installation des composants OSGi, l’instance se « perd » et empêche de se connecter pendant 3-4 minutes pour cause de réactivation de bundles et ce, plusieurs fois dans la journée…

Exécuter un test unitaire prend un temps de l’ordre de la poignée de secondes. Il n’y a donc pas photo.

 

Les mocks plus spécialisés

« Ces arguments me paraissent cohérents mais du coup, Mockito ou EasyMock suffiraient » me répondrez-vous (la solitude de l’écriture m’induit à dialoguer avec vous, cher lecteur). Même si ces frameworks sont très pratiques, ils seront insuffisants dans certains cas. Prenons un exemple :

Supposons que notre super feature fasse le super job de récupérer le super titre d’une (super) page pour l’afficher dans l’IHM pour le plus grand bonheur de l’utilisateur de l’application. Notre code pourrait être de cette forme :

Page page = resourceResolver.getResource("/path/of/my/page").adaptTo(Page.class);
String title = page.getTitle();

On pourra alors mocker cela de la façon suivante dans notre classe de test :

Page mockedPage = mock(Page.class); 
when(mockedPage.getTitle()).thenReturn("Super title"); 
ResourceResolver mockedResourceResolver = mock(ResourceResolver.class); 

when(mockedResourceResolver.adaptTo(Page.class)).thenReturn(mockedPage);

Cet exemple montre un point très gênant. Si pour une raison ou une autre le code permettant de récupérer la valeur du titre était amené à changer en ceci

Node currentNode = resourceResolver.getResource("/path/of/my/page").adaptTo(Node.class); 
String title = currentNode.getProperty("jcr:title").getString();

cela entraînerait une bonne dose de rouge dans l’exécution du test (qui ne passerait donc pas). Ceci est donc un très gros problème : le test passe en « fail » alors que la feature fonctionne (dans le sens où les deux codes sont « équivalents » et valides si l’on souhaite afficher le titre de la page) ! Il est alors bon de remarquer qu’à l’instar de toute autre classe utilisant notre classe testée, la classe de test est cliente de celle-ci et ainsi ne devrait souffrir d’aucune modification correcte par le fameux principe d’encapsulation.

Ceci pointe du doigt les limites dans notre cas de l’utilisation des frameworks de test « génériques ». Pour notre précédent cas, on devra utiliser un mock de notre JCR (Java Content Repository).

On pourra noter également un autre point important que nous constaterons plus tard: la quantité de code sera beaucoup plus faible dans nos méthodes de test. A relire pour nous et nos collègues c’est beaucoup plus agréable et évitera quelques migraines.

Dernier point important : la pratique des tests entraînera une bien meilleure couverture des cas limites, cas souvent « oubliés » et/ou bottés en touche lorsqu’ils sont réalisés « à la main » pour des raisons

– de ratio « temps de test / nombre de cas avérés » grand
– de sacro-saint « il ne faut pas contribuer comme cela, le composant n’est pas fait pour »

 

Point de départ

Pour ce faire, je partirai de l’archétype compatible avec la version 6.4 fournie par Adobe ici et je modifierai le composant helloworld.

Ce composant, très simple, est composé :

– d’une partie « front » définie dans ui.apps/src/main/content/jcr_root/apps/AEMMaven13/components/content/helloworld
– d’une partie « back » définie dans /core/src/main/java/com/aem/community/core/models/HelloWorldModel.java

Les deux parties sont reliées par la balise suivante :

<pre data-sly-use.hello="com.aem.community.core.models.HelloWorldModel">

Fonctionnellement, ce composant affiche un message

HelloWorldModel says:
${hello.foldersList}

dont la valeur est fournie par la partie back via la méthode

getFoldersList()

 

Evolution du composant

Supposons que notre (super) composant fasse évoluer son service getMessage() de la façon suivante : afficher la liste des dossiers du DAM (quelle magnifique évolution !).

En termes d’implémentation la structure d’arbre de la base de données fait penser immédiatement à une fonction récursive. Il y a là matière à plusieurs oublis dans ce type de fonctions et la perspective de déployer sur l’instance AEM à chaque ajout de code n’enchante guère…

En simulant la base de données, il sera beaucoup plus facile de coder puis tester immédiatement et faire ainsi des ajustements rapidement. Nous allons utiliser Sling mock permettant lui-même d’utiliser un mock JCR décrits ici. L’option que nous choisirons sera JCR_OAK, un peu plus lente mais possédant bien plus de fonctionnalités (en fait il y a une vraie base de données sous-jacente).

En termes de dépendances maven il faudra ajouter dans le pom.xml du module core de notre projet celles-ci :

<dependency>
  <groupId>io.wcm</groupId>
  <artifactId>io.wcm.testing.aem-mock</artifactId>
  <version>2.2.10</version>
  <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.apache.sling</groupId>
    <artifactId>org.apache.sling.testing.sling-mock-oak</artifactId>
    <version>2.0.2</version>
    <scope>test</scope>
</dependency>

Attention :

– celles-ci sont à ajouter de préférence au début de la section sous peine d’avoir parfois quelques collisions inopportunes. Cela peut éviter quelques (longues) minutes de galères voir d’abandon ce qui serait bien dommage
– les numéros de version ne seront pas les mêmes si votre projet est plus « ancien », c’est là où commence la « bataille des versions » annoncées au tout début de l’article 

 

Grâce à cet ajout, nous allons pouvoir avoir une base de données à notre convenance avec différents états et ainsi vérifier que notre service répondra à nos critères. Ainsi, on serait en droit de demander à notre chère fonction :

– si le dossier du DAM de notre application (qui sera sous /content/dam/AEMMaven13) n’existe pas, renvoyer « NO_FOLDER » (un cas limite comme indiqué plus en amont)
– si le dossier du DAM de notre application existe mais ne contient aucun sous-dossier, renvoyer « No_FOLDER » (on pourrait scinder ce cas en cas : le dossier peut ne contenir absolument rien ou bien des assets mais aucun sous-dossier)
– qu’on obtienne une liste contenant folder1, subfolder11, subfolder111, subfolder12, subfolder13, folder2, subfolder21, subfolder22, folder3 pour une arborescence comme :

– folder1

– subfolder11

– subfolder111

– subfolder12
– subfolder13

– folder2

– subfolder21
– subfolder22

– folder3

Dans la classe de test que nous nommerons de manière inventive TestHelloWorldModel, nous rajouterons tout d’abord cette ligne :

@Rule 
public final AemContext context = new AemContext(ResourceResolverType.JCR_OAK);

qui permet, comme son nom l’indique de manière explicite, d’avoir accès à un « contexte« . Dans un premier temps, celui-ci va nous permettre d’importer des données dans notre mock de base de données. A ce propos, je vais dans la suite setter le resourceResolver de mon modèle « a la mano » à l’aide de :

@Before
public void setup() throws Exception {
    hello = new HelloWorldModel();
    hello.resourceResolver = context.resourceResolver();
}

Il existe cependant une manière encore plus « pro » de le faire et décrite ici. N’ayant pas encore bien exploré, je n’en parlerai donc pas et me contenterai donc d’un procédé modeste qui « fera le job ». Mais j’y reviendrai sûrement dans un prochain article :)…

Pour générer ces données, nous allons nous servir de notre instance AEM, c’est d’ailleurs la seule fois où nous l’utiliserons. Il faut tout d’abord créer l’arborescence dans le DAM pour obtenir ceci :

Une fois ceci fait grâce à l’appel à l’url http://localhost:4502/content/dam/AEMMaven13.infinity.json on obtient sous format json la définition des noeuds. Entre autres le premier dossier :

"folder1": {
  "jcr:primaryType": "sling:Folder",
  "jcr:createdBy": "admin",
  "jcr:created": "Mon Feb 04 2019 22:56:07 GMT+0100",
  "subfolder11": {
    "jcr:primaryType": "sling:Folder",
    "jcr:createdBy": "admin",
    "jcr:created": "Mon Feb 04 2019 22:56:44 GMT+0100",
    "subfolder111": {
      "jcr:primaryType": "sling:Folder",
      "jcr:createdBy": "admin",
      "jcr:created": "Mon Feb 04 2019 22:57:59 GMT+0100",
      "jcr:content": {
        "jcr:primaryType": "nt:unstructured",
        "jcr:title": "SubFolder111"
      }
    },
    "jcr:content": {
      "jcr:primaryType": "nt:unstructured",
      "jcr:title": "SubFolder11"
    }
  },
  "subfolder12": {
    "jcr:primaryType": "sling:Folder",
    "jcr:createdBy": "admin",
    "jcr:created": "Mon Feb 04 2019 22:56:56 GMT+0100",
    "jcr:content": {
      "jcr:primaryType": "nt:unstructured",
      "jcr:title": "SubFolder12"
    }
  },
  "jcr:content": {
    "jcr:primaryType": "nt:unstructured",
    "jcr:title": "Folder1"
  },
  "subfolder13": {
    "jcr:primaryType": "sling:Folder",
    "jcr:createdBy": "admin",
    "jcr:created": "Mon Feb 04 2019 22:57:02 GMT+0100",
    "jcr:content": {
      "jcr:primaryType": "nt:unstructured",
      "jcr:title": "SubFolder13"
    }
  }
}

On enregistre ce json dans un fichier, disons dam_folders.json, qui sera chargé de la manière suivante :

context.load().json("/dam_folders.json","/content/dam/AEMMaven13

/content/dam/AEMMaven13 est le chemin dans notre JCR pour accéder à cette resource.

Si, par exemple, on choisit la « stratégie TDD » et que l’on veut tester le cas évoqué ci-dessus on pourrait avoir une méthode qui ressemble à ceci :

@Test
public void given_application_dam_folder_getFolders_returns_all_folders() {
    context.load().json("/dam_folders_normal_case.json",ROOT_PATHst<String> folders = hello.getFolders();
    assertEquals(9, folders.size());
    assertTrue(folders.contains("folder1"));
    assertTrue(folders.contains("subfolder11"));
    assertTrue(folders.contains("subfolder111"));
    assertTrue(folders.contains("subfolder12"));
    assertTrue(folders.contains("subfolder13"));
    assertTrue(folders.contains("folder2"));
    assertTrue(folders.contains("subfolder21"));
    assertTrue(folders.contains("subfolder22"));
    assertTrue(folders.contains("folder3"));
}

Si on lance le test, de manière évidente, beaucoup de rouge apparaîtra.

Quelque chose comme :

junit.framework.AssertionFailedError:
Expected :9
Actual :0
<Click to see difference>

at junit.framework.Assert.fail(Assert.java:57)
at junit.framework.Assert.failNotEquals(Assert.java:329)
at junit.framework.Assert.assertEquals(Assert.java:78)
at junit.framework.Assert.assertEquals(Assert.java:234)
at junit.framework.Assert.assertEquals(Assert.java:241)
at com.aem.community.core.models.TestHelloWorldModel.given_application_dam_folder_getMessage_returns_all_folders(TestHelloWorldModel.java:61)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
...

Il suffit alors d’implémenter la méthode. Par exemple :

public List<String> getFolders() {
    ArrayList<String> returnedTitles = new ArrayList<>();
    try{

        Node node = resourceResolver.getResource(ROOT_PATH).adaptTo(Node.class);
        List<Node> allSubNodes = getAllSubFolders(node);
        for(Node currentNode : allSubNodes){
            returnedTitles.add(currentNode.getName());
        }
    }catch(Exception e){
        // My logs...
    }
    return returnedTitles;
}

private List<Node> getAllSubFolders(Node node){
    ArrayList<Node> returnedNodes = new ArrayList<>();
    try {
        if(isLeaf(node)){
            if(isFolder(node)) {
                returnedNodes.add(node);
            }
        }
        else{
            NodeIterator nodes = node.getNodes();
            if(isFolder(node)){
                returnedNodes.add(node);
            }
            while (nodes.hasNext()){
                Node currentNode = nodes.nextNode();
                returnedNodes.addAll(getAllSubFolders(currentNode));
            }
        }
    } catch (RepositoryException e) {
        // My logs
    }
    return returnedNodes;
}

private boolean isLeaf(Node node) throws RepositoryException {
    return !node.hasNodes();
}

private boolean isFolder(Node node) {
    boolean isFolder = false;
    try {
        isFolder = node.hasProperty(JCR_PRIMARY_TYPE)
                && SLING_FOLDER.equals(
                        node.getProperty(JCR_PRIMARY_TYPE).getString()
        );
    }catch (RepositoryException e) {
        // My logs...
    }
    return isFolder;
}

Le test passe alors à « vert », ô joie !

Il est également possible, en deux coups de cuillère à pot, de tester un « cas limite » , celui où le dossier parent est vide :

@Test
public void given_empty_dam_folder_getFolders_returns_empty_folders_list() {
    context.load().json("/dam_folders_empty_root.json",ROOT_PATHt<String> folders = hello.getFolders();
    assertEquals(0, folders.size());
}

Le lecteur attentif remarquera le nouveau fichier  « dam_folders_empty_root.json » qui a été ajouté : il s’agit d’un copier/coller du premier fichier json vu précédemment dont on a retiré « folder1 », « folder2 » et « folder3 ».

Par « chance » cela fonctionne du premier coup dans ce cas-ci. Mais cela ne sera pas souvent le cas : un oubli, une étourderie se cache toujours et il est ici (beaucoup) plus rapide de constater le problème dès les tests. Cet exemple ne paye pas de mine des exemples de type « manque de contribution/mauvaise contribution dans un composant » pourront être détectés et corrigés très rapidement suivant ce même procédé.

A titre d’exercice (simple), le lecteur (motivé car encore là au bout de la 14 502e ligne) pourra se pencher sur le cas « mon dossier parent n’existe pas » qui est extrêmement simple à « reproduire » 

Voilà, ce sera tout pour cette fois. En espérant que cet article vous aura plu/aidé.

N’hésitez pas à poster vos remarques 

Et n’oublions pas…les tests…c’est la vie !