Technologie de tests

Dans le passé, j'ai travaillé avec webtest, selenium en 2007/2008, mais ils ne me suffisaient pas. J'avais donc une stack classique depuis 5 ans déjà, améliorée de temps en temps par l'ajout d'une librairie ou une organisation de code différente.

  • Scenario : Cucumber
  • Code : Ruby
  • Navigateur : Watir-webdriver
  • Framework : page-object
  • Mobile : appium, avec vrais android et xcode simulator pour iOS
  • Serveur : Jenkins
  • Autres technologies : applytools, http-server, ...

A plusieurs reprises, j'ai hésité à changer de langage, mais cela voulait aussi dire de changer l'ensemble du code actuel pour changer de langage, avoir accès à des choses qui n'existent pas, ou pour faciliter les recrutements. Trouver des développeurs ruby n'est pas toujours facile, en passant à java, ils sont plus nombreux par exemple. Ayant même des développeurs java dans les équipes de développement, on aurait pu avoir des appuis en cas de charge momentanée de travail.

Je n'ai jamais franchi le cap de changer cette stack, qu'on arrivait à adapter et exploiter de mieux en mieux avec le temps. Former mes nouveaux collègues me paraissait simple, et était "facile" parce que tout était logique pour moi. Ils comprenaient vite, posaient des questions parfois pertinentes.

Il m'est arrivé de dire qu'on n'avait pas fait comme il pensait, parce qu'on avait du passif, qui imposait tel ou tel gestion particulière dans un cas précis, qui avait disparu depuis. Ou comment lever une refacto pour simplifier du code en parlant "à bâtons rompus" autour de la stack actuelle.

Nouvelle stack

Fin 2015, nous étions une équipe qualité de 6 personnes, codant des tests automatiques. En janvier 2016, j'ai changé de société. Dans la nouvelle, j'ai le même rôle : j'automatise des tests pour assurer la qualité, éviter les bugs, supprimer les régressions à chaque mise en production, etc.

Cette société utilise beaucoup de langage, et j'ai décidé de me mettre un challenge en plus : m'adapter à un des langages utilisés en interne, pour faciliter les interactions en internes : si un développeur veut me filer un coup de main, il peut plus facilement. Etant seul à l'automatisation, si je suis en vacances et qu'un problème survient, ils auront des gens capables de lire les erreurs de code et de débugguer / tenir à jour si c'est urgent.

  • Scenario : behave
  • Code : Python
  • Navigateur : Selenium-webdriver2
  • Framework : page-object
  • Mobile : pour le moment, simulateur dans chrome via les user-agents
  • Serveur : Jenkins
  • Autres technologies : aucune pour le moment

On voit que c'est très proche :

  • Cucumber et behave sont pareils, avec quelques subtilités. Par exemple, je n'ai pas trouvé de solution pour tagguer différement des exemples de scenarios outlines.
  • Watir et selenium se ressemblent beaucoup. Je préfère parfois la syntaxe et la facilité de watir/ruby, qui me permet d'écrire browser.div(class: 'maClasse').span(index: 1) au lieu de selenium/python qui donne browser.find_element_by_class_name('maClasse').find_elements_by_tag_name('span')[1]
  • Le page object de python me parait moins abouti (et moins utile) que celui de ruby. Je n'utilise au final qu'une partie de ses possibilités, le reste étant déjà gérées par l'organisation de code de behave.

Il m'a fallu plusieurs semaines pour arriver à coder du python aussi propre que je le souhaitais, quelques jours pour coder en selenium sans penser à watir.

C'est surtout un changement de mentalité, comme quand on change de langue entre français et anglais : on ne traduit pas littéralement, il faut changer sa façon de penser.

Cela étant, garder quelques points figés, comme les scenarios en BDD (behaviour driven development) m'a permis de gagner beaucoup de temps, de savoir où je voulais aller, ce dont j'avais besoin, pour facilirer la transition.

Le paradigme reste le même : avoir des cas de tests simples, découpés, qui permettent de tester point par point que l'interface utilisateur réagit comme prévu.

Contexte

Je travaille souvent à tester des comportements liés à la vidéo. Vérifier qu'elle se déroule bien, qu'elle se finit au moment voulu, que les interactions sont possibles. Une des problématiques est de pouvoir vérifier que les évenements de tracking (statistiques, etc.) sont bien envoyés au bon moment.

Dans mon cas, chaque événement est un appel à un pixel précis, avec un tas de paramètres. L'intérêt pour l'utilisateur est de pouvoir vérifier le fonctionnement, et par exemple déterminer si les utilisateurs qui mettent en pause à un moment relancent la vidéo pour la regarder jusqu'au bout.

Lorsqu'on teste ce genre de chose manuellement, il suffit d'ouvrir la console réseau du navigateur, et de vérifier que les urls sont bien appelées au bon moment.

Industrialisation

Dans mon cas, j'ai besoin d'effectuer une centaine de contrôles de ce type, sur différents navigateurs, tous les jours. L'ensemble des tests étant écrit en ruby / watir-webdriver, j'ai longtemps chercher une solution permettant via webdriver de vérifier ce qui passait au niveau réseau.

J'ai envisagé un temps de démarrer mes navigateurs en leur ajoutant un plugin : webdriver permet de le faire à la volée avec le fichier xpi. Mais cela ne marche pas pour Internet Explorer, que je ne peux pas exclure des tests.

Après des semaines de réflexions, par une nuit d'insomnie, j'ai trouvé une solution:

  • Installer un proxy sur le serveur de tests
  • Analyser les logs de ce proxy pour mes besoins

Le choix du proxy était "limité" avec de nombreuses contraintes :

  • Besoin d'avoir un fichier de logs
  • Besoin de pouvoir le contrôler en ruby (ou dans un navigateur)
  • Besoin de fonctionner sur windows et mac

Mon choix s'est porté vers Charles proxy.

Le fichier de log de charles étant horodaté, nous avons pu comparer les événements les uns par rapport aux autres. Pour vérifier, par exemple, que l'evenement "50%" se produit à la moitié de la durée de la vidéo.

Il a suffit d'ajouter des urls bien reconnaissables pour toutes nos vidéos de tests et de se retrousser les manches.

Problèmes

Durant la mise en place de cette solution (et sa maintenance), nous avons rencontrés plusieurs problèmes :

  • Nécessité, pour chaque scénario, de redémarrer le proxy. Il est gourmand en ressources (mémoire principalement), et nos centaines de tests journaliers le mettent à rude épreuve.
  • Lors d'un test où nous en avons besoin, il faut pour l'analyser ouvrir un navigateur, télécharger le fichier de log (format CSV), filter les lignes utiles, etc. Cela est couteux en temps. Le fichier de log pèse environ 1mo à chaque fois. Cela est couteux en temps.
  • Les tests de vidéo sur mobile rencontrent parfois des lenteurs, ce qui compliquent l'analyse des écarts de temps.
  • Le format du fichier de log (en particulier les dates) est différent enttre windows et mac.
  • Cela empêche la paralélisation sur une même serveur.

Bénéfices

Malgré ces défauts, ce système nous convenait parfaitement.

Il nous a permis d'industrialiser des tests sur un point clé de certains projets, sur tous les navigateurs, y compris sur mobile.

Lorsqu'un nouveau collègue est arrivé dans l'équipe en juillet, et que je lui ai présenté cette brique bien pratique, il a trouvé ça intéressant, et trouvait l'idée originale.

On prend les mêmes et on recommence, mais pas tout à fait

Quelques mois plus tard, lors d'une discussion avec un collègue sans aucun rapport, j'ai eu une meilleure idée. Objectifs:

  • Diminuer le temps d'initialisation
  • Simplifier le code (et donc faciliter la maintenance)

Plutôt que d'analyser le départ de tous les pixels, on inverse la pensée. Comme dans la vraie vie, il faut que nous ayons notre propre serveur web, qui récupère les appels à des pixels, et il nous faut "juste" analyser les logs de ce serveur. C'est là qu'entre en scène node.js, et et plus précisement son http-server.

Dans un dossier proxy, on créer un fichier html. On édite les urls de pixels de toutes nos vidéos de tests, pour appeler le serveur local http://127..0.0.1:8080/track.htm?action=pause&id=123456

Ensuite, la ligne de commande http-server proxy/ --cors -a 127.0.0.1 >> proxy/log.txt permet de lancer le serveur localement, et d'avoir le log dans un fichier lui même accessible sur le serveur.

Pour l'analyse du log, il est disponible via une véritable url, donc on gagne du temps : pas besoin d'ouvrir un navigateur comme avec le log de charles.

url = "http://127.0.0.1:8080/log.txt"
content = Net::HTTP.get(URI.parse(url))

Bénéfices

Quand j'ai commencé cette modification, j'espérais gagner un peu de temps. Nous avions à ce moment là environ 100h de tests, et pour certains projets, je n'attendais pas de gros gains. Et puis, j'ai fait les calculs.

  • Quelques centaines de scenarios n'ont plus besoin du tout d'initialisation. 10 à 15 secondes à chaque fois sur windows.
  • Le fichier de log (1 Mo / scenario auparavant) a fortement diminué. Puisqu'on ne log plus que ce qui est utile, on gagne plus de 99% de taille de fichier de log.
  • La non ouverture du navigateur pour télécharger le fichier de log fait également gagner beaucoup de temps.

Première estimation : environ 4h de gagnées parmi les 100h de tests.

Lors des premiers tests, sur mon mac, j'ai eu l'impression que mes calculs sous estimaient les gains. On a en fait avoisiné les 6h30 de gain.

Après analyses et contrôles, quelques pistes supplémentaires pour expliquer ces gains en plus:

  • Sans proxy, les pages s'affichent un tout petit peu plus vite. Non perceptible à l'oeil humain, mais vu la quantité de pages de nos batteries de tests, c'est sensible.
  • La mémoire vive libérée par ce changement sur les serveurs windows peut être utilisée par le serveur à autre chose de "plus utile" pour les tests.

C'est finalement une clef primordiale quand on met en place des tests sur de gros projets: il faut tester brique par brique, et améliorer la solidité et la couvertue de façon empirique.

Comme je le disais il y a peu, sur les gros projets, il est intéressant de surveiller la simplicité du code. J'utilise pour cela flog qui calcule la complexité des méthodes.

Zone grise

Sur notre projet actuel, le score total flog est de plus de 6300 points de complexité d'après flog. Avec une moyenne de 12.7 et un maximum de 206.5, le champ est large.

Répartition des plus hauts scores:

  • > 200 : 1 fonction
  • > 100 : 3 fonctions
  • > 50 : 16 fonctions
  • > 20 : 41 fonctions
  • < 20 : un peu plus de 400.

Evidemment, quand je cherche à simplifier, je commence à regarder les fonctions les plus complexes. Il est souvent plus facile de simplifier une chose compliquée qu'une chose déjà simple.

Il est pourtant très difficile de savoir quelle est la limite acceptable. Selon les gens, et les projets, cela varie entre 25 et 60. On définit par conséquent une zone grise, contenant les fonctions "à vérifier" et à simplifier si possible, au cas par cas.

Au dessus, il faut bien évidemment faire quelque chose. En dessous, c'est peu utile. J'ai donc encore quelques fonctions bien tordues à simplifier sur le projet, pour respecter cette règle à la lettre.

Complexe ou compliqué ?

J'ai enquêté sur une fonction particulière, assez longue, qui vérifie la présence d'un élément particulier. Cet élément varie en fonction du type de page que l'on teste.

def format_is_present(format)
    case (format.downcase)
    when 'bt'
        expect(@browser.divs(class: 'wtr').length).to eq(1)
        expect(@browser.divs(class: 'wtl').length).to eq(1)
    when 'bc'
        @browser.send_keys :space
        expect(@browser.iframe(id: 'ei0').divs(id: 'ep0').length).to eq(1)
    when 'ib'
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(id: 'ebib').length).to eq(1)
    when 'ic'
        expect(@browser.divs(id: 'bc').length).to eq(1)
    when 'ifw'
        expect(@browser.divs(id: 'bc').length).to eq(1)
    when 'ift'
        expect(@browser.divs(class: 'wf').length).to eq(1)
    when 'ir'
        @browser.send_keys :space
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(id: 'en').length).to eq(1)
    when 'ise'
        expect(@browser.divs(class: 'bm').length).to eq(1)
    when 'iso'
        @browser.send_keys :space
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(id: 'ep0').length).to eq(1)
    when 'ise'
        sleep(SHORT_TIMEOUT)
        if (@browser.div(class: 'ebt').exists?)
            expect(@browser.divs(class: 'ebt').length).to eq(1)
        else
            expect(@browser.divs(class: 'et').length).to eq(1)
        end
    when 'it'
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(class: 'wtr').length).to eq(1)
        expect(@browser.divs(class: 'wtl').length).to eq(0)
    else
        puts "Format #{format} Not yet implemented"
        return "pending"
    end
end

Score flog : 135.8.

Vraiment pas terrible, mais le nombre de cas différents ne simplifie pas la tâche.

Premier essai : Yaml et algorithme

J'ai commencé par créer un fichier yaml contenant toutes les informations des tests, pour n'avoir ensuite qu'un algorithme commun à tous les cas.

Voici le code ruby de la fonction ainsi modifié:

def format_is_present_with_yaml(format)
    which = format.gsub(' ', '_').downcase
    # Get checkers from the yaml
    CONTROLS = YAML.load_file('support/checkers.yml')['controls']
    checks = CONTROLS[which]
    if !checks.nil?
        checks.each do | _key, check |
            # extra command
            if !check['extra'].nil?
                if check['extra'].to_s == 'sleep'
                    sleep(SHORT_TIMEOUT)
                elsif check['extra'].to_s == 'scroll'
                    @browser.send_keys :space
                elsif check['extra'].to_s == 'both'
                    sleep(SHORT_TIMEOUT)
                    @browser.send_keys :space
                end
            end

            # Check in a frame is slightly different
            if (check['in_frame'].nil?)
                b = @browser
            else
                b = @browser.iframe(id: check['in_frame'])
            end

            # Make the control
            if check['type'] == "class"
                expect(b.divs(class: check['value']).length).to eq(check['result'])
            else
                expect(b.divs(id: check['value']).length).to eq(check['result'])
            end
        end
    else
        puts "Format #{format} Not yet implemented"
        return "pending"
    end
end

Score flog : 75.3. Score presque divisé par deux : impressionnant.

Par contre, contrairement à la première fonction :

  • Une partie du raisonnement est déporté dans un fichier externe. Si je ne le poste pas, il est impossible de savoir ce que fait l'algo.
  • Si de nouveaux cas apparaissent, il faudra peut être modifié ce nouvel algorithme, ce qui peut être risqué et ajouter des effets de bords.
  • Franchement, lisez le code : c'est beaucoup moins clair que la fonction précédente. La gestion des cas particuliers représente la plus grande part du code, noyant complétement la compréhension.

Un compromis

Autre solution, regrouper les différents cas par lots similaires.

def format_is_present_new(format)
    ids = {
        'ib'    => 'ebib',
        'ic'    => 'bc',
        'ifw'   => 'bc',
        'ir'    => 'en',
        'is'    => 'ep0'
    }
    classes = {
        'ift'   => 'wf',
        'is'    => 'bm',
        'ise'   => /e(|b)t/
    }

    case (format.downcase)
    # generic cases with element by id
    when 'ib', 'ic', 'ifw', 'ir', 'is'
        if %w(ir is).include? format.downcase
            # generic cases but with scroll
            @browser.send_keys :space
        end
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(id: ids[format]).length).to eq(1)
    # generic cases with element by class
    when 'is', 'ift', 'ise'
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(class: classes[format]).length).to eq(1)

    # particular cases
    when 'bt'
        expect(@browser.divs(class: 'wtr').length).to eq(1)
        expect(@browser.divs(class: 'wtl').length).to eq(1)
    when 'bc'
        @browser.send_keys :space
        expect(@browser.iframe(id: 'ei0').divs(id: 'ep0').length).to eq(1)
    when 'intab'
        sleep(SHORT_TIMEOUT)
        expect(@browser.divs(class: 'wtr').length).to eq(1)
        expect(@browser.divs(class: 'wtl').length).to eq(0)
    else
        puts "Format #{format} Not yet implemented"
        return "pending"
    end
end 

Quelques détails :

  • J'ai regroupé en fonction du type d'element à vérifier : les div filtrés par id, ceux filtrés par class, puis tous les autres cas particuliers.
  • En écrivant cet article, j'ai trouvé une petite amélioration qui baisse la note flog de quelques points supplémentaires.
  • Ajouter des nouveux cas sera trivial. Le code reste parfaitement lisible, même pour quelqu'un découvrant le projet.
  • Le score est toujours au dessus de la zone grise, mais je suis à cours d'idée. Pour le moment.

Score flog final : 76.4. Il était de 85 quand j'ai commencé à écrire cet article : merci à vous d'avoir été mon canard en plastique