Aller au contenu

d

Tests de bout en bout

Jusqu'à présent, nous avons testé le backend dans son ensemble au niveau de l'API en utilisant des tests d'intégration et testé certains composants frontend en utilisant des tests unitaires.

Ensuite, nous examinerons une manière de tester le système dans son ensemble en utilisant des tests End to End (E2E).

Nous pouvons effectuer des tests E2E d'une application web en utilisant un navigateur et une bibliothèque de tests. Il existe plusieurs bibliothèques disponibles. Un exemple est Selenium, qui peut être utilisé avec presque tous les navigateurs. Une autre option de navigateur est ce qu'on appelle les navigateurs sans tête, qui sont des navigateurs sans interface graphique utilisateur. Par exemple, Chrome peut être utilisé en mode sans tête.

Les tests E2E sont potentiellement la catégorie de tests la plus utile car ils testent le système via la même interface que celle utilisée par les vrais utilisateurs.

Ils présentent toutefois certains inconvénients. Configurer des tests E2E est plus difficile que les tests unitaires ou d'intégration. Ils tendent également à être assez lents, et avec un grand système, leur temps d'exécution peut être de minutes ou même d'heures. Cela est mauvais pour le développement car pendant la codification, il est bénéfique de pouvoir exécuter des tests aussi souvent que possible en cas de régressions de code.

Les tests E2E peuvent également être instables. Certains tests peuvent réussir une fois et échouer une autre, même si le code ne change pas du tout.

Cypress

La bibliothèque E2E Cypress est devenue populaire au cours de la dernière année. Cypress est exceptionnellement facile à utiliser et, comparé à Selenium, par exemple, il nécessite beaucoup moins de tracas et de maux de tête. Son principe de fonctionnement est radicalement différent de celui de la plupart des bibliothèques de tests E2E parce que les tests Cypress sont exécutés entièrement dans le navigateur. D'autres bibliothèques exécutent les tests dans un processus Node, qui est connecté au navigateur via une API.

Faisons quelques tests de bout en bout pour notre application de notes.

Nous commençons par installer Cypress dans le frontend en tant que dépendance de développement

npm install --save-dev cypress

et en ajoutant un script npm pour l'exécuter:

{
  // ...
  "scripts": {
    "dev": "vite --host",    "build": "vite build",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview",
    "server": "json-server -p3001 --watch db.json",
    "test": "jest",
    "cypress:open": "cypress open"  },
  // ...
}

Nous avons également apporté un petit changement au script qui démarre l'application, sans ce changement Cypress ne peut pas accéder à l'appli.

Contrairement aux tests unitaires du frontend, les tests Cypress peuvent se trouver dans le dépôt du frontend ou du backend, ou même dans leur propre dépôt séparé.

Les tests nécessitent que le système testé soit en cours d'exécution. Contrairement à nos tests d'intégration backend, les tests Cypress ne démarrent pas le système lorsqu'ils sont exécutés.

Ajoutons un script npm à backend qui le démarre en mode test, ou de manière à ce que NODE_ENV soit test.

{
  // ...
  "scripts": {
    "start": "NODE_ENV=production node index.js",
    "dev": "NODE_ENV=development nodemon index.js",
    "build:ui": "rm -rf build && cd ../frontend/ && npm run build && cp -r build ../backend",
    "deploy": "fly deploy",
    "deploy:full": "npm run build:ui && npm run deploy",
    "logs:prod": "fly logs",
    "lint": "eslint .",
    "test": "jest --verbose --runInBand",
    "start:test": "NODE_ENV=test node index.js"  },
  // ...
}

NB Pour que Cypress fonctionne avec WSL2, il peut être nécessaire d'effectuer d'abord quelques configurations supplémentaires. Ces deux liens sont de bons points de départ.

Lorsque le backend et le frontend sont en cours d'exécution, nous pouvons démarrer Cypress avec la commande

npm run cypress:open

Cypress demande quel type de tests nous effectuons. Choisissons "E2E Testing" (Tests E2E):

flèche cypress vers l'option de test e2e

Ensuite, un navigateur est sélectionné (par exemple, Chrome) et nous cliquons sur "Create new spec" (Créer une nouvelle spécification):

créer une nouvelle spécification avec une flèche pointant vers celle-ci

Créons le fichier de test cypress/e2e/note_app.cy.js:

cypress avec le chemin cypress/e2e/note_app.cy.js

Nous pourrions modifier les tests dans Cypress, mais utilisons plutôt VS Code:

vscode montrant les modifications du test et cypress montrant la spécification ajoutée

Nous pouvons maintenant fermer la vue d'édition de Cypress.

Changeons le contenu du test comme suit:

describe('Note app', function() {
  it('front page can be opened', function() {
    cy.visit('http://localhost:5173')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })
})

Le test est exécuté en cliquant sur le test dans Cypress:

L'exécution du test montre comment l'application se comporte pendant l'exécution du test:

cypress montrant l'automatisation du test de note

La structure du test devrait vous sembler familière. Ils utilisent des blocs describe pour regrouper différents cas de test, tout comme Jest. Les cas de test ont été définis avec la méthode it. Cypress a emprunté ces parties à la bibliothèque de tests Mocha qu'il utilise en interne.

cy.visit et cy.contains sont des commandes Cypress, et leur but est assez évident. cy.visit ouvre l'adresse web qui lui est donnée en paramètre dans le navigateur utilisé par le test. cy.contains recherche la chaîne qu'il a reçue en paramètre sur la page.

Nous aurions pu déclarer le test en utilisant une fonction fléchée

describe('Note app', () => {  it('front page can be opened', () => {    cy.visit('http://localhost:5173')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })
})

Cependant, Mocha recommande de ne pas utiliser de fonctions fléchées, car elles pourraient causer certains problèmes dans certaines situations.

Si cy.contains ne trouve pas le texte qu'il recherche, le test ne passe pas. Donc, si nous étendons notre test ainsi

describe('Note app', function() {
  it('front page can be opened',  function() {
    cy.visit('http://localhost:5173')
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })

  it('front page contains random text', function() {    cy.visit('http://localhost:5173')    cy.contains('wtf is this app?')  })})

le test échoue

cypress montrant l'échec s'attendant à trouver wtf mais non

Supprimons le code qui échoue du test.

La variable cy que nos tests utilisent nous donne une vilaine erreur Eslint

capture d'écran vscode montrant cy n'est pas défini

Nous pouvons nous en débarrasser en installant eslint-plugin-cypress en tant que dépendance de développement

npm install eslint-plugin-cypress --save-dev

et en changeant la configuration dans .eslintrc.cjs comme suit:

module.exports = {
  "env": {
    browser: true,
    es2020: true,
    "jest/globals": true,
    "cypress/globals": true  },
  "extends": [ 
    // ...
  ],
  "parserOptions": {
    // ...
  },
  "plugins": [
      "react", "jest", "cypress"  ],
  "rules": {
    // ...
  }
}

Écrire dans un formulaire

Étendons nos tests de manière à ce que le test essaie de se connecter à notre application. Nous supposons que notre backend contient un utilisateur avec le nom d'utilisateur mluukkai et le mot de passe salainen.

Le test commence par ouvrir le formulaire de connexion.

describe('Note app',  function() {
  // ...

  it('login form can be opened', function() {
    cy.visit('http://localhost:5173')
    cy.contains('log in').click()
  })
})

Le test recherche d'abord le bouton de connexion par son texte et clique sur le bouton avec la commande cy.click.

Comme nos deux tests commencent de la même manière, par l'ouverture de la page http://localhost:5173, nous devrions séparer la partie commune dans un bloc beforeEach exécuté avant chaque test:

describe('Note app', function() {
  beforeEach(function() {    cy.visit('http://localhost:5173')  })
  it('front page can be opened', function() {
    cy.contains('Notes')
    cy.contains('Note app, Department of Computer Science, University of Helsinki 2023')
  })

  it('login form can be opened', function() {
    cy.contains('log in').click()
  })
})

Le champ de connexion contient deux champs input, dans lesquels le test devrait écrire.

La commande cy.get permet de rechercher des éléments par sélecteurs CSS.

Nous pouvons accéder au premier et au dernier champ de saisie sur la page, et y écrire avec la commande cy.type comme suit:

it('user can login', function () {
  cy.contains('log in').click()
  cy.get('input:first').type('mluukkai')
  cy.get('input:last').type('salainen')
})  

Le test fonctionne. Le problème est que si nous ajoutons plus tard d'autres champs de saisie, le test échouera car il s'attend à ce que les champs dont il a besoin soient le premier et le dernier sur la page.

Il serait préférable de donner à nos entrées des ids uniques et de les utiliser pour les trouver. Nous modifions notre formulaire de connexion comme suit:

const LoginForm = ({ ... }) => {
  return (
    <div>
      <h2>Login</h2>
      <form onSubmit={handleSubmit}>
        <div>
          username
          <input
            id='username'            value={username}
            onChange={handleUsernameChange}
          />
        </div>
        <div>
          password
          <input
            id='password'            type="password"
            value={password}
            onChange={handlePasswordChange}
          />
        </div>
        <button id="login-button" type="submit">          login
        </button>
      </form>
    </div>
  )
}

Nous avons également ajouté un id à notre bouton de soumission afin de pouvoir y accéder dans nos tests.

Le test devient:

describe('Note app',  function() {
  // ..
  it('user can log in', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')    cy.get('#password').type('salainen')    cy.get('#login-button').click()
    cy.contains('Matti Luukkainen logged in')  })
})

La dernière ligne assure que la connexion a été réussie.

Notez que le sélecteur d'id CSS est #, donc si nous voulons rechercher un élément avec l'id username, le sélecteur CSS est #username.

Veuillez noter que réussir le test à ce stade nécessite qu'il y ait un utilisateur dans la base de données de test de l'environnement backend dont le nom d'utilisateur est mluukkai et le mot de passe est salainen. Créez un utilisateur si nécessaire !

Tester le formulaire de nouvelle note

Ajoutons ensuite des méthodes de test pour tester la fonctionnalité "nouvelle note":

describe('Note app', function() {
  // ..
  describe('when logged in', function() {    beforeEach(function() {      cy.contains('log in').click()      cy.get('input:first').type('mluukkai')      cy.get('input:last').type('salainen')      cy.get('#login-button').click()    })
    it('a new note can be created', function() {      cy.contains('new note').click()      cy.get('input').type('a note created by cypress')      cy.contains('save').click()      cy.contains('a note created by cypress')    })  })})

Le test a été défini dans son propre bloc describe. Seuls les utilisateurs connectés peuvent créer de nouvelles notes, donc nous avons ajouté la connexion à l'application dans un bloc beforeEach.

Le test suppose que lors de la création d'une nouvelle note, la page contient un seul champ de saisie, donc il le recherche comme suit:

cy.get('input')

Si la page contenait plus d'entrées, le test échouerait

erreur cypress - cy.type ne peut être appelé que sur un seul élément

À cause de ce problème, il serait à nouveau préférable de donner un id à l'entrée et de rechercher l'élément par son id.

La structure des tests ressemble à ceci:

describe('Note app', function() {
  // ...

  it('user can log in', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('log in').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      // ...
    })
  })
})

Cypress exécute les tests dans l'ordre où ils figurent dans le code. Ainsi, il exécute d'abord user can log in (l'utilisateur peut se connecter), où l'utilisateur se connecte. Ensuite, Cypress exécutera a new note can be created (une nouvelle note peut être créée) pour lequel un bloc beforeEach se connecte également. Pourquoi faire cela ? L'utilisateur n'est-il pas déjà connecté après le premier test? Non, car chaque test commence de zéro en ce qui concerne le navigateur. Tous les changements dans l'état du navigateur sont réinitialisés après chaque test.

Contrôler l'état de la base de données

Si les tests doivent pouvoir modifier la base de données du serveur, la situation devient immédiatement plus compliquée. Idéalement, la base de données du serveur devrait être la même chaque fois que nous exécutons les tests, pour que nos tests puissent être fiables et facilement répétables.

Comme avec les tests unitaires et d'intégration, avec les tests E2E, il est préférable de vider la base de données et éventuellement de la formater avant l'exécution des tests. Le défi avec les tests E2E est qu'ils n'ont pas accès à la base de données.

La solution est de créer des points d'API pour les tests backend. Nous pouvons vider la base de données en utilisant ces points d'API. Créons un nouveau routeur pour les tests à l'intérieur du dossier controllers, dans le fichier testing.js

const testingRouter = require('express').Router()
const Note = require('../models/note')
const User = require('../models/user')

testingRouter.post('/reset', async (request, response) => {
  await Note.deleteMany({})
  await User.deleteMany({})

  response.status(204).end()
})

module.exports = testingRouter

et l'ajouter au backend uniquement si l'application est exécutée en mode test:

// ...

app.use('/api/login', loginRouter)
app.use('/api/users', usersRouter)
app.use('/api/notes', notesRouter)

if (process.env.NODE_ENV === 'test') {  const testingRouter = require('./controllers/testing')  app.use('/api/testing', testingRouter)}
app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

Après les modifications, une requête HTTP POST vers le point de terminaison /api/testing/reset vide la base de données. Assurez-vous que votre backend est exécuté en mode test en le démarrant avec cette commande (préalablement configurée dans le fichier package.json):

  npm run start:test

Le code backend modifié peut être trouvé sur la branche GitHub part5-1.

Ensuite, nous allons modifier le bloc beforeEach de sorte qu'il vide la base de données du serveur avant l'exécution des tests.

Actuellement, il n'est pas possible d'ajouter de nouveaux utilisateurs via l'interface utilisateur du frontend, donc nous ajoutons un nouvel utilisateur au backend depuis le bloc beforeEach.

describe('Note app', function() {
   beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/testing/reset')    const user = {      name: 'Matti Luukkainen',      username: 'mluukkai',      password: 'salainen'    }    cy.request('POST', 'http://localhost:3001/api/users/', user)     cy.visit('http://localhost:5173')
  })
  
  it('front page can be opened', function() {
    // ...
  })

  it('user can login', function() {
    // ...
  })

  describe('when logged in', function() {
    // ...
  })
})

Lors du formatage, le test effectue des requêtes HTTP vers le backend avec cy.request.

Contrairement à avant, maintenant, les tests commencent avec le backend dans le même état à chaque fois. Le backend contiendra un utilisateur et aucune note.

Ajoutons un autre test pour vérifier que nous pouvons changer l'importance des notes.

Il y a quelque temps, nous avons modifié le frontend de sorte qu'une nouvelle note soit importante par défaut, ou que le champ important soit true:

const NoteForm = ({ createNote }) => {
  // ...

  const addNote = (event) => {
    event.preventDefault()
    createNote({
      content: newNote,
      important: true    })

    setNewNote('')
  }
  // ...
} 

Il existe plusieurs manières de tester cela. Dans l'exemple suivant, nous recherchons d'abord une note et cliquons sur son bouton rendre non important. Ensuite, nous vérifions que la note contient maintenant un bouton rendre important.

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    // ...

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made not important', function () {
        cy.contains('another note cypress')
          .contains('make not important')
          .click()

        cy.contains('another note cypress')
          .contains('make important')
      })
    })
  })
})

La première commande recherche un composant contenant le texte another note cypress, puis un bouton rendre non important à l'intérieur. Elle clique ensuite sur le bouton.

La deuxième commande vérifie que le texte sur le bouton a changé en rendre important.

Les tests et le code frontend actuel peuvent être trouvés sur la branche GitHub part5-9.

Test d'échec de connexion

Faisons un test pour s'assurer qu'une tentative de connexion échoue si le mot de passe est incorrect.

Par défaut, Cypress exécutera tous les tests chaque fois, et à mesure que le nombre de tests augmente, cela commence à devenir assez chronophage. Lors du développement d'un nouveau test ou lors du débogage d'un test en échec, nous pouvons définir le test avec it.only au lieu de it, de sorte que Cypress n'exécute que le test requis. Lorsque le test fonctionne, nous pouvons retirer .only.

La première version de nos tests est la suivante:

describe('Note app', function() {
  // ...

  it.only('login fails with wrong password', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('wrong')
    cy.get('#login-button').click()

    cy.contains('wrong credentials')
  })

  // ...
)}

Le test utilise cy.contains pour s'assurer que l'application affiche un message d'erreur.

L'application rend le message d'erreur dans un composant avec la classe CSS error:

const Notification = ({ message }) => {
  if (message === null) {
    return null
  }

  return (
    <div className="error">      {message}
    </div>
  )
}

Nous pourrions faire en sorte que le test s'assure que le message d'erreur est rendu dans le composant correct, c'est-à-dire, le composant avec la classe CSS error:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').contains('wrong credentials')})

D'abord, nous utilisons cy.get pour rechercher un composant avec la classe CSS error. Ensuite, nous vérifions que le message d'erreur peut être trouvé dans ce composant. Notez que le sélecteur de classe CSS commence par un point, donc le sélecteur pour la classe error est .error.

Nous pourrions faire la même chose en utilisant la syntaxe should:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials')})

Utiliser should est un peu plus délicat que d'utiliser contains, mais cela permet des tests plus divers que contains, qui fonctionne uniquement sur la base du contenu textuel.

Une liste des assertions les plus courantes qui peuvent être utilisées avec should peut être trouvée ici.

Nous pouvons, par exemple, nous assurer que le message d'erreur est rouge et qu'il a une bordure:

it('login fails with wrong password', function() {
  // ...

  cy.get('.error').should('contain', 'wrong credentials') 
  cy.get('.error').should('have.css', 'color', 'rgb(255, 0, 0)')
  cy.get('.error').should('have.css', 'border-style', 'solid')
})

Cypress exige que les couleurs soient données en rgb.

Puisque tous les tests concernent le même composant auquel nous avons accédé en utilisant cy.get, nous pouvons les enchaîner en utilisant and.

it('login fails with wrong password', function() {
  // ...

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')
})

Terminons le test de sorte qu'il vérifie également que l'application ne rend pas le message de succès 'Matti Luukkainen connecté':

it('login fails with wrong password', function() {
  cy.contains('log in').click()
  cy.get('#username').type('mluukkai')
  cy.get('#password').type('wrong')
  cy.get('#login-button').click()

  cy.get('.error')
    .should('contain', 'wrong credentials')
    .and('have.css', 'color', 'rgb(255, 0, 0)')
    .and('have.css', 'border-style', 'solid')

  cy.get('html').should('not.contain', 'Matti Luukkainen logged in')})

La commande should est le plus souvent utilisée en la chaînant après la commande get (ou une autre commande similaire qui peut être enchaînée). Le cy.get('html') utilisé dans le test signifie pratiquement le contenu visible de toute l'application.

Nous pourrions également vérifier la même chose en chaînant la commande contains avec la commande should avec un paramètre légèrement différent:

cy.contains('Matti Luukkainen logged in').should('not.exist')

REMARQUE: Certaines propriétés CSS se comportent différemment sur Firefox. Si vous exécutez les tests avec Firefox:

running

alors les tests qui impliquent, par exemple, border-style, border-radius et padding, passeront dans Chrome ou Electron, mais échoueront dans Firefox :

borderstyle

Contournement de l'UI

Actuellement, nous avons les tests suivants:

describe('Note app', function() {
  it('user can login', function() {
    cy.contains('log in').click()
    cy.get('#username').type('mluukkai')
    cy.get('#password').type('salainen')
    cy.get('#login-button').click()

    cy.contains('Matti Luukkainen logged in')
  })

  it('login fails with wrong password', function() {
    // ...
  })

  describe('when logged in', function() {
    beforeEach(function() {
      cy.contains('log in').click()
      cy.get('input:first').type('mluukkai')
      cy.get('input:last').type('salainen')
      cy.get('#login-button').click()
    })

    it('a new note can be created', function() {
      // ... 
    })
   
  })
})

D'abord, nous testons la connexion. Ensuite, dans leur propre bloc describe, nous avons un ensemble de tests, qui supposent que l'utilisateur est connecté. L'utilisateur est connecté dans le bloc beforeEach.

Comme nous l'avons dit ci-dessus, chaque test commence à zéro! Les tests ne commencent pas à partir de l'état où les tests précédents se sont terminés.

La documentation de Cypress nous donne le conseil suivant: Tester complètement le flux de connexion – mais une seule fois. Donc, au lieu de connecter un utilisateur en utilisant le formulaire dans le bloc beforeEach, Cypress recommande de contourner l'UI et de faire une requête HTTP au backend pour se connecter. La raison en est que se connecter avec une requête HTTP est beaucoup plus rapide que de remplir un formulaire.

Notre situation est un peu plus compliquée que dans l'exemple de la documentation de Cypress car, lorsqu'un utilisateur se connecte, notre application sauvegarde ses détails dans le localStorage. Cependant, Cypress peut également gérer cela. Le code est le suivant

describe('when logged in', function() {
  beforeEach(function() {
    cy.request('POST', 'http://localhost:3001/api/login', {      username: 'mluukkai', password: 'salainen'    }).then(response => {      localStorage.setItem('loggedNoteappUser', JSON.stringify(response.body))      cy.visit('http://localhost:5173')    })  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

Nous pouvons accéder à la réponse d'une cy.request avec la méthode then. Sous le capot, cy.request, comme toutes les commandes Cypress, sont des promesses. La fonction de rappel sauvegarde les détails d'un utilisateur connecté dans le localStorage, et recharge la page. Maintenant, il n'y a aucune différence avec un utilisateur se connectant avec le formulaire de connexion.

Si et lorsque nous écrivons de nouveaux tests pour notre application, nous devons utiliser le code de connexion à plusieurs endroits. Nous devrions en faire une commande personnalisée.

Les commandes personnalisées sont déclarées dans cypress/support/commands.js. Le code pour se connecter est le suivant:

Cypress.Commands.add('login', ({ username, password }) => {
  cy.request('POST', 'http://localhost:3001/api/login', {
    username, password
  }).then(({ body }) => {
    localStorage.setItem('loggedNoteappUser', JSON.stringify(body))
    cy.visit('http://localhost:5173')
  })
})

Utiliser notre commande personnalisée est facile, et notre test devient plus clair:

describe('when logged in', function() {
  beforeEach(function() {
    cy.login({ username: 'mluukkai', password: 'salainen' })  })

  it('a new note can be created', function() {
    // ...
  })

  // ...
})

La même chose s'applique à la création d'une nouvelle note maintenant que nous y pensons. Nous avons un test qui crée une nouvelle note en utilisant le formulaire. Nous créons également une nouvelle note dans le bloc beforeEach du test testant le changement de l'importance d'une note:

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    it('a new note can be created', function() {
      cy.contains('new note').click()
      cy.get('input').type('a note created by cypress')
      cy.contains('save').click()

      cy.contains('a note created by cypress')
    })

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.contains('new note').click()
        cy.get('input').type('another note cypress')
        cy.contains('save').click()
      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

Créons une nouvelle commande personnalisée pour créer une nouvelle note. La commande créera une nouvelle note avec une requête HTTP POST:

Cypress.Commands.add('createNote', ({ content, important }) => {
  cy.request({
    url: 'http://localhost:3001/api/notes',
    method: 'POST',
    body: { content, important },
    headers: {
      'Authorization': `Bearer ${JSON.parse(localStorage.getItem('loggedNoteappUser')).token}`
    }
  })

  cy.visit('http://localhost:5173')
})

La commande suppose que l'utilisateur est connecté et que les détails de l'utilisateur sont sauvegardés dans le localStorage.

Maintenant, le bloc de formatage devient:

describe('Note app', function() {
  // ...

  describe('when logged in', function() {
    it('a new note can be created', function() {
      // ...
    })

    describe('and a note exists', function () {
      beforeEach(function () {
        cy.createNote({          content: 'another note cypress',          important: true        })      })

      it('it can be made important', function () {
        // ...
      })
    })
  })
})

Il y a encore une caractéristique ennuyeuse dans nos tests. L'adresse de l'application http://localhost:5173 est codée en dur à de nombreux endroits.

Définissons l'baseUrl pour l'application dans le fichier de configuration pré-généré par Cypress cypress.config.js:

const { defineConfig } = require("cypress")

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
    },
    baseUrl: 'http://localhost:5173'  },
})

Toutes les commandes dans les tests utilisent l'adresse de l'application

cy.visit('http://localhost:5173' )

peuvent être transformées en

cy.visit('')

L'adresse codée en dur du backend http://localhost:3001 est encore dans les tests. La documentation de Cypress recommande de définir les autres adresses utilisées par les tests comme variables d'environnement.

Étendons le fichier de configuration cypress.config.js comme suit:

const { defineConfig } = require("cypress")

module.exports = defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
    },
    baseUrl: 'http://localhost:5173',
    env: {
      BACKEND: 'http://localhost:3001/api'    }
  },
})

Remplaçons toutes les adresses du backend dans les tests de la manière suivante

describe('Note ', function() {
  beforeEach(function() {

    cy.request('POST', `${Cypress.env('BACKEND')}/testing/reset`)    const user = {
      name: 'Matti Luukkainen',
      username: 'mluukkai',
      password: 'secret'
    }
    cy.request('POST', `${Cypress.env('BACKEND')}/users`, user)    cy.visit('')
  })
  // ...
})

Les tests et le code frontend peuvent être trouvés sur la branche GitHub part5-10.

Changer l'importance d'une note

Enfin, examinons le test que nous avons réalisé pour changer l'importance d'une note. D'abord, nous allons changer le bloc de formatage pour qu'il crée trois notes au lieu d'une:

describe('when logged in', function() {
  describe('and several notes exist', function () {
    beforeEach(function () {
      cy.login({ username: 'mluukkai', password: 'salainen' })      cy.createNote({ content: 'first note', important: false })      cy.createNote({ content: 'second note', important: false })      cy.createNote({ content: 'third note', important: false })    })

    it('one of those can be made important', function () {
      cy.contains('second note')
        .contains('make important')
        .click()

      cy.contains('second note')
        .contains('make not important')
    })
  })
})

Comment la commande cy.contains fonctionne-t-elle réellement ?

Lorsque nous cliquons sur la commande cy.contains('second note') dans le Test Runner de Cypress, nous voyons que la commande recherche l'élément contenant le texte second note:

cypress test runner cliquant sur testbody et second note

En cliquant sur la ligne suivante .contains('make important'), nous voyons que le test utilise le bouton 'make important' correspondant à la second note:

cypress test runner cliquant sur make important

Lorsqu'elles sont enchaînées, la seconde commande contains continue la recherche à partir du composant trouvé par la première commande.

Si nous n'avions pas enchaîné les commandes, et avions écrit à la place:

cy.contains('second note')
cy.contains('make important').click()

le résultat aurait été totalement différent. La seconde ligne du test aurait cliqué sur le bouton d'une mauvaise note:

cypress montrant une erreur et essayant incorrectement de cliquer sur le premier bouton

Lors de l'écriture des tests, vous devriez vérifier dans le test runner que les tests utilisent les bons composants!

Changeons le composant Note pour que le texte de la note soit rendu dans un span.

const Note = ({ note, toggleImportance }) => {
  const label = note.important
    ? 'make not important' : 'make important'

  return (
    <li className='note'>
      <span>{note.content}</span>      <button onClick={toggleImportance}>{label}</button>
    </li>
  )
}

Nos tests échouent ! Comme le révèle le test runner, cy.contains('second note') retourne maintenant le composant contenant le texte, et le bouton n'y est pas.

cypress montrant que le test est cassé en essayant de cliquer sur rendre important

Une façon de corriger cela est la suivante:

it('one of those can be made important', function () {
  cy.contains('second note').parent().find('button').click()
  cy.contains('second note').parent().find('button')
    .should('contain', 'make not important')
})

Dans la première ligne, nous utilisons la commande parent pour accéder à l'élément parent de l'élément contenant second note et trouver le bouton à l'intérieur de celui-ci. Ensuite, nous cliquons sur le bouton et vérifions que le texte dessus change.

Notez que nous utilisons la commande find pour rechercher le bouton. Nous ne pouvons pas utiliser cy.get ici, car cela recherche toujours dans la totalité de la page et retournerait les 5 boutons sur la page.

Malheureusement, nous avons maintenant un peu de copier-coller dans les tests, car le code pour rechercher le bon bouton est toujours le même.

Dans ce genre de situations, il est possible d'utiliser la commande as:

it('one of those can be made important', function () {
  cy.contains('second note').parent().find('button').as('theButton')
  cy.get('@theButton').click()
  cy.get('@theButton').should('contain', 'make not important')
})

Maintenant, la première ligne trouve le bon bouton et utilise as pour le sauvegarder sous le nom theButton. Les lignes suivantes peuvent utiliser l'élément nommé avec cy.get('@theButton').

Exécution et débogage des tests

Enfin, quelques notes sur le fonctionnement de Cypress et le débogage de vos tests.

La forme des tests Cypress donne l'impression que les tests sont du code JavaScript normal, et nous pourrions par exemple essayer ceci:

const button = cy.contains('log in')
button.click()
debugger
cy.contains('logout').click()

Cela ne fonctionnera cependant pas. Lorsque Cypress exécute un test, il ajoute chaque commande cy à une file d'exécution. Quand le code de la méthode de test a été exécuté, Cypress exécutera chaque commande dans la file une par une.

Les commandes Cypress retournent toujours undefined, donc button.click() dans le code ci-dessus provoquerait une erreur. Une tentative de démarrer le débogueur ne stopperait pas le code entre l'exécution des commandes, mais avant que toute commande ait été exécutée.

Les commandes Cypress sont comme des promesses, donc si nous voulons accéder à leurs valeurs de retour, nous devons le faire en utilisant la commande then. Par exemple, le test suivant imprimerait le nombre de boutons dans l'application et cliquerait sur le premier bouton:

it('then example', function() {
  cy.get('button').then( buttons => {
    console.log('number of buttons', buttons.length)
    cy.wrap(buttons[0]).click()
  })
})

Arrêter l'exécution du test avec le débogueur est possible. Le débogueur se lance uniquement si la console de développement du test runner de Cypress est ouverte.

La console de développement est très utile pour déboguer vos tests. Vous pouvez voir les requêtes HTTP effectuées par les tests dans l'onglet Réseau, et l'onglet Console vous montrera des informations sur vos tests:

console de développement lors de l'exécution de cypress

Jusqu'à présent, nous avons exécuté nos tests Cypress en utilisant le test runner graphique. Il est également possible de les exécuter depuis la ligne de commande. Il suffit d'ajouter un script npm pour cela:

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "json-server -p3001 --watch db.json",
    "cypress:open": "cypress open",
    "test:e2e": "cypress run"  },

Maintenant, nous pouvons exécuter nos tests depuis la ligne de commande avec la commande npm run test:e2e

sortie terminal de l'exécution des tests npm e2e montrant réussi

Notez que des vidéos de l'exécution des tests seront sauvegardées dans cypress/videos/, vous devriez donc probablement ignorer ce répertoire avec git. Il est également possible de désactiver la création de vidéos.

Le frontend et le code de test peuvent être trouvés sur la branche GitHub part5-11.