Testez vos SPA avec Cypress

J’ai découvert Cypress pendant que je faisais mes recherches pour l’article d’introduction aux tests de bout en bout. Disons que ça a été une révélation pour moi. Je pense que Cypress est le genre d’outil qu’on prend du plaisir à utiliser, et à explorer les possibilités.

J’ai donc décidé de consacrer ce premier cas pratique à Cypress.

Cypress? C’est quoi ça ?

Comme les auteurs le définissent, Cypress est un framework JavaScript pour les tests de bout en bout. Moi, je dirai un peu plus ; Cypress, c’est un agréable framework pour tester les applications JavaScript.

La philosophie

La philosophie derrière Cypress, c’est d’améliorer l’expérience des tests (automatisés) pour les développeurs. Et cette philosophie se traduit très bien dans l’outil, avec la mise à disposition de fonctionnalités qui vont dans ce sens.

Page de fonctionnalités Cypress
Page de fonctionnalités Cypress

Depuis l’écriture des tests à leur exécution en passant par le débogage, tout est optimisé pour rendre l’expérience la plus agréable possible.

Un exemple rapide, c’est l’interface d’exécution des tests de Cypress. C’est à ce jour l’interface la plus élaborée, mais aussi la plus accessible que j’ai utilisée. On y retrouve pleins de petites fonctionnalités intéressantes, qui bien combinées ou utilisées vont permettre de rendre l’exécution et le débogage des tests beaucoup plus aisés.

Interface d'exécution de tests de Cypress
Interface d’exécution de tests de Cypress

Nous verrons cela en détail plus loin.

Comment ça marche ?

Cypress, comment ça marche ?
Cypress, comment ça marche

Cette image résume assez bien comment marche Cypress. L’idée, c’est qu’il intègre à la fois :

  • Un exécutant de test (test runner)
  • Un outil de contrôle du navigateur
  • Des librairies d’assertions

Plus concrètement, Cypress utilise Mocha comme framework de BDD. Cela lui permet d’utiliser les syntaxes comme describe(), it(), before(), skip() pour décrire les tests et leurs comportements. Ensuite, la puissante librairie Chai est utilisée pour effectuer des assertions sur les tests.

💡

Lisez ici pour en savoir plus sur les autres librairies que Cypress utilise nativement.

En termes de fonctionnement, Cypress s’exécute ensemble avec l’application qui est testée. Celui lui permet entre autres d’être très rapide, mais aussi d’avoir un accès direct à l’application, et donc de se passer de certains niveaux d’abstraction en écrivant des tests.

Autre chose intéressante, Cypress est capable d’intercepter, de modifier, de rediriger tout le trafic qui entre et sort de l’application. C’est cette capacité notamment qui permet au framework de par exemple de se connecter programmatiquement à une application ; et donc d’éliminer ce temps de connexion dans tous les tests exécutés.

Les auteurs de Cypress ont mis un point d’honneur à avoir une documentation très fournie. Je vous invite donc à lire celle-ci sur le fonctionnement de Cypress.

Les cas d’usage et les limitations

Comme tout outil ou technologie, il y a des contextes dans lesquels Cypress va très bien performer ; et dans d’autres, ce ne sera pas du tout l’outil adapté.

Cypress est très adapté pour créer et automatiser les tests de bout en bout pour les applications JavaScript. C’est un peu son corps de métier.

Par contre, ce ne sera pas le meilleur outil pour faire du scrapping sur un site par exemple, ou encore pour des tests de performance.

En plus de cela, il y a des limitations qui sont inhérentes à l’outil, et dont il faut prendre compte avant de l’adopter ou non :

  • Cypress s’exécute seulement dans le navigateur, en mode headless ou headed
  • Il n’est pas possible d’exécuter des tests dans plusieurs onglets du navigateur à la fois
  • Cypress ne peut pas contrôler plus d’un navigateur à la fois
  • Dans un même test, il n’est pas possible de visiter deux URLs avec des same-origin policy différents

Certaines de ces limitations sont permanentes, c’est-à-dire que la nature et le fonctionnement même de Cypress ne lui permettent pas de les outrepasser. D’autres peuvent être contournées.

Mais si vous vous retrouvez à chercher des workaround pour toutes les limitations de Cypress, alors il y a des chances que l’outil ne soit pas adapté à vos besoins.

Un petit cas pratique

Maintenant qu’on a fait un tour d’horizon sur Cypress et son fonctionnement, essayons de voir comment il se comporte en situation réelle.

L’application que nous allons tester

L’application qu’on va utiliser pour nos tests, c’est TodoMVC. Comme vous le devinez, c’est une application de to-do liste. Elle a la particularité d’être écrite dans plusieurs langages/technologies JavaScript. Le but des auteurs, c’est de permettre de permettre aux développeur de voir l’implémentation d’une application JavaScript complète dans plusieurs cas d’usages.

Page d'accueil Todo-MVC
Page d’accueil TodoMVC

En termes de fonctionnement, l’application permet de créer des éléments de to-do, de les marquer comme achevés, terminés, etc.

Fonctionnement de l’application Todo-MVC

Pour ce petit cas pratique, nous allons utiliser la version de l’application en JavaScript natif (vanilla JS). Dans notre contexte, ça ne change pas grand-chose d’utiliser une version plutôt qu’une autre, parce que nous n’allons tester que le frontend de l’application, directement dans le navigateur.

Débuter avec Cypress

Je ne vais pas m’attarder sur l’installation de Cypress, parce que la documentation officielle couvre très bien cet aspect (et beaucoup d’autres d’ailleurs).

Ce qu’il faut retenir, c’est que Cypress est une application desktop, avec des supports pour macOS, Linux et Windows. C’est également possible de l’utiliser comme package npm. Tous les détails sur l’installation et les prérequis sont ici.

Les fonctionnements de l’application que nous allons tester

Ici, nous allons tester les fonctionnalités de base de Todo-MVC. Comme l’ajout, la suppression de tâches, le classement correct des tâches selon qu’elles sont terminées ou actives, et les informations relatives au nombre de tâches dans les différentes vues.

Autre détail, je ne vais pas cloner le dépôt de l’application dans mes tests, parce que je ne ferai pas d’interaction directe avec le code, et que l’application est directement accessible en ligne.

Poser les bases des tests

Écrire les scénarios des tests

Avant de se mettre même à écrire un test, l’une des premières étapes est d’écrire les scénarios des tests. C’est très important de le faire, parce que cela permet d’avoir une vue d’ensemble sur tous les tests à écrits. Aussi, en écrivant les scénarios des tests, on a l’occasion de parcourir le fonctionnement de l’application, et on peut commencer à se faire une idée du code à écrire.

Selon les contextes, les technologies utilisées, etc. la manière d’écrire les scénarios peut varier. En règle générale, l’important est de les rendre le plus lisible possible.

Pour les tests que je vais écrire, voilà à quoi ressemble mes scénarios, qui sont juste dans un simple fichier .txt.

scenarios.txt
-----------------------------------------------------------------------------------

--- Adding a new task ---
Given that the Todo-MVC app is running
When I add a new task
Then the new task should appear in the task list
And the task number should be 1

--- Showing a new task as active ---
Given that the Todo-MVC app is running
When I add a new task
And I click the active tasks link
Then the new task should appear in the active tasks list

--- Deleting a task ---
Given that the Todo-MVC app is running
When I add a new task
And I click the delete button for that task
Then the task should be removed from the task list
And the to-do list <ul> tag should be removed

--- Marking a task as complete ---
Given that the Todo-MVC app is running
When I add a new task
And I click the complete toggle button for that task
Then the task label should be crossed out
And the complete tasks list should contain the task

--- Marking all tasks as complete ---
Given that the Todo-MVC app is running
When I add two new tasks
And I click the toggle all button
Then the task labels should be crossed out
And the complete tasks list should contain both tasks

--- Removing completed tasks ---
Given that the Todo-MVC app is running
When I add two new tasks
And I click the toggle all button
And I click the "Clear completed" button
Then the completed tasks should be removed from the task list
And the to-do list <ul> tag should be removed

Ils sont en anglais juste parce que trouve cela plus confortable.

À quoi ressemble Cypress quand on l’installe ?

Architecture de fichiers Cypress
Architecture de fichiers Cypress

Quand Cypress est installé dans un projet, voilà à quoi ressemble l’architecture des fichiers.

  • Le fichier cypress.json contient toutes les configurations des tests, comme celles relatives à l’environnement, l’exécution des tests, etc.
  • Le dossier fixtures contient les données provenant de sources externes et qui peuvent être utilisées dans les tests. Dans notre cas, il contient juste un fichier task.json avec les titres des tâches qui vont être créées durant les tests.
  • Le dossier integration contient le code même des tests. Ce dossier peut être bien évidemment structuré pour plus de clarté dans le code. Il peut aussi être changé ou configuré pour que par exemple, les fichiers avec une extension en particulier soient reconnus comme des tests par Cypress.
  • Le dossier plugins contient toutes les extensions qui viendront modifier les comportements internes de Cypress
  • Le dossier support va contenir tout le code custom qu’on peut écrire, et qui va être réutilisé dans les tests.

Écrire les premiers tests

À cette étape, on peut commencer à écrire nos premiers tests. D’après nos scénarios, on va donc commencer par tester la création d’une tâche et la vérification qu’une nouvelle tâche est bien libellée comme active.

!

Je ne vais pas m’étendre sur la syntaxe pour les tests avec Cypress (qui reste très très lisible), parce que je pense qu’on la comprend mieux en faisant ; et la documentation officielle présente de manière très claire comment démarrer.

Cela dit, voilà à quoi ressemble mon code initial pour les deux premiers tests.

integration/0-lurking/sample_specs.js
-----------------------------------------------------------------------------------

describe('Test todomvc.com frontend output', () => {
    it('correctly adds a new task to the task list', function() {
        cy.visit('https://todomvc.com/examples/vanillajs/');
        cy.get('.new-todo').type(`Test task 1{enter}`);
        cy.get('ul.todo-list label').should('contain', 'Test task 1');
        cy.get('span.todo-count').should('contain', '1 item left');
    })

    it('correctly shows new task as active', function() {
        cy.visit('https://todomvc.com/examples/vanillajs/');
        cy.get('.new-todo').type(`Test task 1{enter}`);
        cy.get('a').contains('Active').click();
        cy.get('ul.todo-list label').should('contain', 'Test task 1');
    })
})

Et voilà le résultat des tests dans l’exécutant de tests.

Exécutant de tests Cypress

💡

Pour lancer l’exécution des tests, j’ai juste à faire npx cypress open dans la console.

Comme on peut le voir, les deux premiers tests passent bien (et très vite). Le test runner de Cypress fournit pleins d’informations utiles sur l’exécution des tests, et ça, c’est vraiment génial comme expérience.

Mais si on regarde mon code initial plus attentivement, on remarque qu’il y a des étapes qui sont répétées dans les deux tests :

  • Visiter l’URL de l’application
  • Créer une nouvelle tâche

Donc ces deux lignes :

integration/0-lurking/sample_specs.js
-----------------------------------------------------------------------------------

cy.visit('https://todomvc.com/examples/vanillajs/');
cy.get('.new-todo').type(`Test task 1{enter}`);

On peut aisément anticiper que ces deux actions vont se répéter dans tous les tests, donc c’est une bonne idée d’en faire une abstraction qu’on va réutiliser partout.

Pour cela, on va utiliser le hook beforeEach() de Mocha, qui rappelé vous, est embarqué dans Cypress. Et on va aussi mettre à profit les données que nous avons dans le dossier fixtures.

fixtures/tasks.json
-----------------------------------------------------------------------------------

{
  "task1": "Test task 1",
  "task2": "Test task 2"
}

On va donc dans notre beforeEach() importer une tâche, visiter la page de l’application et créer la tâche. Notre code va donc ressembler à quelque chose dans le style.

integration/0-lurking/sample_specs.js
-----------------------------------------------------------------------------------

describe('Test todomvc.com frontend output', () => {
    beforeEach(function() {
        cy.fixture('task').then(task => {
            this.task = task;
            cy.visit('https://todomvc.com/examples/vanillajs/');
            cy.get('.new-todo').type(`${task["task1"]}{enter}`);
        });
    });

    it('correctly adds a new task to the task list', function() {
        cy.get('ul.todo-list label').should('contain', this.task["task1"]);;
        cy.get('span.todo-count').should('contain', '1 item left');
    })

    it('correctly shows new task as active', function() {
        cy.get('a').contains('Active').click();
        cy.get('ul.todo-list label').should('contain', this.task["task1"]);;
    })
})

Cela va grandement nous faciliter les choses pour les tests qui vont suivre. Dans ce code, j’utilise la commande cy.fixture() de Cypress pour charger le fichier .json dont j’utilise le contenu pour créer la tâche.

🤔

J’imagine que ça va être pertinent plus tard de ne charger ce fichier qu’une seule fois. Le code précédent s’en sort bien avec deux lignes de JSON et une dizaine de tests, mais, avec plus de données, c’est définitivement un no-go.

Itérer et implémenter les scénarios restants

Dès qu’on a une base solide avec les premiers tests, on peut se mettre à itérer, améliorer le code et écrire le reste des tests.

Voilà à quoi mon code ressemble en rajoutant rapidement les tests qui suivent.

integration/0-lurking/sample_specs.js
-----------------------------------------------------------------------------------

describe('Test todomvc.com frontend output', () => {
    beforeEach(function() {
        cy.fixture('task').then(task => {
            this.task = task;
            cy.visit('https://todomvc.com/examples/vanillajs/');
            cy.get('.new-todo').type(`${task["task1"]}{enter}`);
        });
    });

    it('correctly adds a new task to the task list', function() {
        cy.get('ul.todo-list label').should('contain', this.task["task1"]);;
        cy.get('span.todo-count').should('contain', '1 item left');
    })

    it('correctly shows new task as active', function() {
        cy.get('a').contains('Active').click();
        cy.get('ul.todo-list label').should('contain', this.task["task1"]);;
    })

    it('correctly delete from the view a task ' +
        'on a click on the delete button',
        function() {
            cy.get('button.destroy')
                .invoke('show')
                .click();
            cy.get('ul.todo-list').should('not.be.visible');
        })

    it('correctly mark a task as completed ' +
        'on a click on the completion checkbox',
        function() {
            cy.get('input.toggle').check();
            cy.get('li.completed label')
                .should('have.css', 'text-decoration', 'line-through solid rgb(217, 217, 217)');
            cy.get('span.todo-count').should('contain', '0 items left');
            cy.get('a').contains('Completed').click();
            cy.get('ul.todo-list label').should('contain', this.task["task1"]);
        })

    it('correctly mark all task as completed on a click on all completion toggle', function() {
        cy.get('.new-todo').type(`${this.task["task2"]}{enter}`);
        cy.get('label[for="toggle-all"]').click();
        cy.get('li.completed label')
            .should('have.css', 'text-decoration', 'line-through solid rgb(217, 217, 217)');
    });

    it('correctly removes completed tasks on a click on "Clear completed"', function() {
        cy.get('label[for="toggle-all"]').click();
        cy.get('button').contains('Clear completed').click();
        cy.get('ul.todo-list').should('not.be.visible');
    })
})

Et voilà les résultats du test runner.

Exécutant de tests Cypress

J’ai téléversé tout le code des tests dans ce dépôt GitHub pour un accès plus facile.

Que retenir ?

  • Cypress peut s’avérer être un outil très intéressant quand il est utilisé dans le bon contexte
  • Il est important d’analyser correctement les limitations de l’outil avant de l’adopter
  • IL EST IMPORTANT DE LIRE LES DOCUMENTATIONS
  • Les scénarios de tests sont primordiaux quand on veut implémenter des tests de bout en bout (e2e tests)
  • Il est plus facile d’implémenter une suite de tests stable et efficace quand on commence par les bases, et qu’on itère régulièrement