a
Structure de l'application backend, introduction aux tests
Poursuivons notre travail sur le backend de l'application de notes que nous avons commencé dans la partie 3.
Structure du projet
Avant de nous plonger dans le sujet des tests, nous allons modifier la structure de notre projet pour nous conformer aux meilleures pratiques de Node.js.
Une fois que nous aurons apporté les modifications à la structure de répertoire de notre projet, nous obtiendrons la structure suivante:
├── index.js
├── app.js
├── dist
│ └── ...
├── controllers
│ └── notes.js
├── models
│ └── note.js
├── package-lock.json
├── package.json
├── utils
│ ├── config.js
│ ├── logger.js
│ └── middleware.js
Jusqu'à présent, nous avons utilisé console.log et console.error pour afficher différentes informations provenant du code. Cependant, ce n'est pas une très bonne manière de procéder. Séparons toute impression sur la console dans son propre module utils/logger.js:
const info = (...params) => {
console.log(...params)
}
const error = (...params) => {
console.error(...params)
}
module.exports = {
info, error
}
Le logger possède deux fonctions, info pour imprimer les messages de log normaux, et error pour tous les messages d'erreur.
Extraire le logging dans son propre module est une bonne idée à plus d'un titre. Si nous voulions commencer à écrire des logs dans un fichier ou les envoyer à un service de logging externe comme graylog ou papertrail, nous n'aurions à faire des modifications qu'à un seul endroit.
La gestion des variables d'environnement est extraite dans un fichier séparé utils/config.js:
require('dotenv').config()
const PORT = process.env.PORT
const MONGODB_URI = process.env.MONGODB_URI
module.exports = {
MONGODB_URI,
PORT
}
Les autres parties de l'application peuvent accéder aux variables d'environnement en important le module de configuration:
const config = require('./utils/config')
logger.info(`Server running on port ${config.PORT}`)
Le contenu du fichier index.js utilisé pour démarrer l'application est simplifié comme suit:
const app = require('./app') // the actual Express application
const config = require('./utils/config')
const logger = require('./utils/logger')
app.listen(config.PORT, () => {
logger.info(`Server running on port ${config.PORT}`)
})
Le fichier index.js importe seulement l'application réelle du fichier app.js et lance ensuite l'application. La fonction info du module logger est utilisée pour l'affichage dans la console indiquant que l'application fonctionne.
Maintenant, l'application Express et le code s'occupant du serveur web sont séparés l'un de l'autre, suivant les meilleures pratiques. L'un des avantages de cette méthode est que l'application peut maintenant être testée au niveau des appels API HTTP sans faire réellement des appels via HTTP sur le réseau, ce qui rend l'exécution des tests plus rapide.
Les gestionnaires de route ont également été déplacés dans un module dédié. Les gestionnaires d'événements des routes sont communément appelés contrôleurs, et pour cette raison, nous avons créé un nouveau répertoire controllers. Toutes les routes liées aux notes se trouvent maintenant dans le module notes.js sous le répertoire controllers.
Le contenu du module notes.js est le suivant:
const notesRouter = require('express').Router()
const Note = require('../models/note')
notesRouter.get('/', (request, response) => {
Note.find({}).then(notes => {
response.json(notes)
})
})
notesRouter.get('/:id', (request, response, next) => {
Note.findById(request.params.id)
.then(note => {
if (note) {
response.json(note)
} else {
response.status(404).end()
}
})
.catch(error => next(error))
})
notesRouter.post('/', (request, response, next) => {
const body = request.body
const note = new Note({
content: body.content,
important: body.important || false,
})
note.save()
.then(savedNote => {
response.json(savedNote)
})
.catch(error => next(error))
})
notesRouter.delete('/:id', (request, response, next) => {
Note.findByIdAndDelete(request.params.id)
.then(() => {
response.status(204).end()
})
.catch(error => next(error))
})
notesRouter.put('/:id', (request, response, next) => {
const body = request.body
const note = {
content: body.content,
important: body.important,
}
Note.findByIdAndUpdate(request.params.id, note, { new: true })
.then(updatedNote => {
response.json(updatedNote)
})
.catch(error => next(error))
})
module.exports = notesRouter
Ceci est presque une copie exacte de notre précédent fichier index.js.
Cependant, il y a quelques changements significatifs. Au tout début du fichier, nous créons un nouvel objet router:
const notesRouter = require('express').Router()
//...
module.exports = notesRouter
Le module exporte le routeur pour qu'il soit disponible pour tous les consommateurs du module.
Toutes les routes sont maintenant définies pour l'objet routeur, de manière similaire à ce que j'ai fait auparavant avec l'objet représentant l'ensemble de l'application.
Il est important de noter que les chemins dans les gestionnaires de route ont été raccourcis. Dans la version précédente, nous avions:
app.delete('/api/notes/:id', (request, response) => {
And in the current version, we have:
notesRouter.delete('/:id', (request, response) => {
Alors, que sont exactement ces objets routeur? Le manuel Express fournit l'explication suivante :
Un objet routeur est une instance isolée de middleware et de routes. Vous pouvez le considérer comme une “mini-application”, capable uniquement de réaliser des fonctions de middleware et de routage. Chaque application Express possède un routeur intégré à l'application.
Le routeur est en fait un middleware, qui peut être utilisé pour définir des "routes connexes" en un seul endroit, généralement placé dans son propre module.
Le fichier app.js qui crée l'application réelle prend le routeur en utilisation comme montré ci-dessous:
const notesRouter = require('./controllers/notes')
app.use('/api/notes', notesRouter)
Le routeur que nous avons défini plus tôt est utilisé si l'URL de la requête commence par /api/notes. Pour cette raison, l'objet notesRouter doit uniquement définir les parties relatives des routes, c'est-à-dire le chemin vide / ou simplement le paramètre /:id.
Après avoir apporté ces modifications, notre fichier app.js ressemble à ceci:
const config = require('./utils/config')
const express = require('express')
const app = express()
const cors = require('cors')
const notesRouter = require('./controllers/notes')
const middleware = require('./utils/middleware')
const logger = require('./utils/logger')
const mongoose = require('mongoose')
mongoose.set('strictQuery', false)
logger.info('connecting to', config.MONGODB_URI)
mongoose.connect(config.MONGODB_URI)
.then(() => {
logger.info('connected to MongoDB')
})
.catch((error) => {
logger.error('error connecting to MongoDB:', error.message)
})
app.use(cors())
app.use(express.static('dist'))
app.use(express.json())
app.use(middleware.requestLogger)
app.use('/api/notes', notesRouter)
app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)
module.exports = app
Le fichier utilise différents middleware, et l'un d'entre eux est le notesRouter qui est attaché à la route /api/notes.
Notre middleware personnalisé a été déplacé dans un nouveau module utils/middleware.js:
const logger = require('./logger')
const requestLogger = (request, response, next) => {
logger.info('Method:', request.method)
logger.info('Path: ', request.path)
logger.info('Body: ', request.body)
logger.info('---')
next()
}
const unknownEndpoint = (request, response) => {
response.status(404).send({ error: 'unknown endpoint' })
}
const errorHandler = (error, request, response, next) => {
logger.error(error.message)
if (error.name === 'CastError') {
return response.status(400).send({ error: 'malformatted id' })
} else if (error.name === 'ValidationError') {
return response.status(400).json({ error: error.message })
}
next(error)
}
module.exports = {
requestLogger,
unknownEndpoint,
errorHandler
}
La responsabilité d'établir la connexion à la base de données a été attribuée au module app.js. Le fichier note.js situé dans le répertoire models définit uniquement le schéma Mongoose pour les notes.
const mongoose = require('mongoose')
const noteSchema = new mongoose.Schema({
content: {
type: String,
required: true,
minlength: 5
},
important: Boolean,
})
noteSchema.set('toJSON', {
transform: (document, returnedObject) => {
returnedObject.id = returnedObject._id.toString()
delete returnedObject._id
delete returnedObject.__v
}
})
module.exports = mongoose.model('Note', noteSchema)
Pour récapituler, la structure de répertoire ressemble à ceci après que les modifications ont été apportées:
├── index.js
├── app.js
├── dist
│ └── ...
├── controllers
│ └── notes.js
├── models
│ └── note.js
├── package-lock.json
├── package.json
├── utils
│ ├── config.js
│ ├── logger.js
│ └── middleware.js
Pour les applications plus petites, la structure n'a pas beaucoup d'importance. Une fois que l'application commence à grandir en taille, vous allez devoir établir une sorte de structure et séparer les différentes responsabilités de l'application en modules distincts. Cela rendra le développement de l'application beaucoup plus facile.
Il n'y a pas de structure de répertoire stricte ou de convention de nommage de fichiers requise pour les applications Express. En contraste, Ruby on Rails exige une structure spécifique. Notre structure actuelle suit simplement certaines des meilleures pratiques que vous pouvez trouver sur Internet.
Vous pouvez trouver le code de notre application actuelle dans son intégralité dans la branche part4-1 de ce dépôt GitHub.
Si vous clonez le projet pour vous-même, exécutez la commande npm install avant de démarrer l'application avec npm run dev.
Note sur les exports
Nous avons utilisé deux types différents d'exports dans cette partie. Tout d'abord, par exemple, le fichier utils/logger.js fait l'exportation comme suit:
const info = (...params) => {
console.log(...params)
}
const error = (...params) => {
console.error(...params)
}
module.exports = { info, error}
Le fichier exporte un objet qui a deux champs, qui sont tous les deux des fonctions. Les fonctions peuvent être utilisées de deux manières différentes. La première option est d'importer l'objet entier et de se référer aux fonctions à travers l'objet en utilisant la notation par point:
const logger = require('./utils/logger')
logger.info('message')
logger.error('error message')
L'autre option est de décomposer les fonctions en leurs propres variables dans l'instruction require:
const { info, error } = require('./utils/logger')
info('message')
error('error message')
La deuxième manière d'exporter peut être préférable si seulement une petite partie des fonctions exportées est utilisée dans un fichier. Par exemple, dans le fichier controller/notes.js, l'exportation se fait comme suit:
const notesRouter = require('express').Router()
const Note = require('../models/note')
// ...
module.exports = notesRouter
Dans ce cas, il n'y a qu'une seule "chose" exportée, donc la seule façon de l'utiliser est la suivante:
const notesRouter = require('./controllers/notes')
// ...
app.use('/api/notes', notesRouter)
Maintenant, la "chose" exportée (dans ce cas, un objet routeur) est assignée à une variable et utilisée comme telle.
Trouver les utilisations de vos exports avec VS Code
VS Code dispose d'une fonctionnalité pratique qui vous permet de voir où vos modules ont été exportés. Cela peut être très utile pour le refactoring. Par exemple, si vous décidez de diviser une fonction en deux fonctions séparées, votre code pourrait se casser si vous ne modifiez pas toutes les utilisations. C'est difficile si vous ne savez pas où elles se trouvent. Cependant, vous devez définir vos exports d'une manière particulière pour que cela fonctionne.
Si vous faites un clic droit sur une variable à l'endroit où elle est exportée et que vous sélectionnez "Trouver toutes les références", cela vous montrera partout où la variable est importée. Cependant, si vous assignez directement un objet à module.exports, cela ne fonctionnera pas. Une solution consiste à assigner l'objet que vous souhaitez exporter à une variable nommée, puis à exporter la variable nommée. Cela ne fonctionnera pas non plus si vous déstructurez là où vous importez ; vous devez importer la variable nommée puis la déstructurer, ou simplement utiliser la notation par point pour utiliser les fonctions contenues dans la variable nommée.
Le fait que la nature de VS Code influe sur la manière dont vous écrivez votre code n'est probablement pas idéal, donc vous devez décider par vous-même si le compromis en vaut la peine.
Tester les applications Node
Nous avons complètement négligé un domaine essentiel du développement logiciel, à savoir les tests automatisés.
Commençons notre parcours de test en examinant les tests unitaires. La logique de notre application est si simple qu'il n'y a pas grand-chose qui a du sens à tester avec des tests unitaires. Créons un nouveau fichier utils/for_testing.js et écrivons quelques fonctions simples que nous pouvons utiliser pour pratiquer l'écriture de tests :
const reverse = (string) => {
return string
.split('')
.reverse()
.join('')
}
const average = (array) => {
const reducer = (sum, item) => {
return sum + item
}
return array.reduce(reducer, 0) / array.length
}
module.exports = {
reverse,
average,
}
La fonction average utilise la méthode reduce de l'array. Si cette méthode ne vous est pas encore familière, c'est le moment idéal pour regarder les trois premières vidéos de la série Functional Javascript sur YouTube.
Il existe de nombreuses bibliothèques de test différentes ou des test runners disponibles pour JavaScript. Dans ce cours, nous utiliserons une bibliothèque de test développée et utilisée en interne par Facebook appelée jest, qui ressemble à l'ancien roi des bibliothèques de test JavaScript Mocha.
Jest est un choix naturel pour ce cours, car il fonctionne bien pour tester les backends, et il brille lorsqu'il s'agit de tester des applications React.
Utilisateurs Windows: Jest peut ne pas fonctionner si le chemin du répertoire du projet contient un répertoire ayant des espaces dans son nom.
Puisque les tests ne sont exécutés que pendant le développement de notre application, nous installerons jest comme une dépendance de développement avec la commande:
npm install --save-dev jest
Définissons le script npm test pour exécuter les tests avec Jest et pour rapporter sur l'exécution des tests avec le style verbose:
{
//...
"scripts": {
"start": "node index.js",
"dev": "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" },
//...
}
Jest nécessite de spécifier que l'environnement d'exécution est Node. Cela peut être fait en ajoutant ce qui suit à la fin de package.json:
{
//...
"jest": {
"testEnvironment": "node"
}
}
Créons un répertoire séparé pour nos tests appelé tests et créons un nouveau fichier appelé reverse.test.js avec le contenu suivant:
const reverse = require('../utils/for_testing').reverse
test('reverse of a', () => {
const result = reverse('a')
expect(result).toBe('a')
})
test('reverse of react', () => {
const result = reverse('react')
expect(result).toBe('tcaer')
})
test('reverse of releveler', () => {
const result = reverse('releveler')
expect(result).toBe('releveler')
})
La configuration ESLint que nous avons ajoutée au projet dans la partie précédente se plaint des commandes test et expect dans notre fichier de test puisque la configuration ne permet pas les globals. Débarrassons-nous de ces plaintes en ajoutant "jest": true à la propriété env dans le fichier .eslintrc.js.
module.exports = {
'env': {
'commonjs': true,
'es2021': true,
'node': true,
'jest': true, },
// ...
}
Dans la première ligne, le fichier de test importe la fonction à tester et l'assigne à une variable appelée reverse:
const reverse = require('../utils/for_testing').reverse
Les cas de test individuels sont définis avec la fonction test. Le premier paramètre de la fonction est la description du test sous forme de chaîne de caractères. Le deuxième paramètre est une fonction qui définit la fonctionnalité du cas de test. La fonctionnalité du deuxième cas de test ressemble à ceci:
() => {
const result = reverse('react')
expect(result).toBe('tcaer')
}
Tout d'abord, nous exécutons le code à tester, ce qui signifie que nous générons une inversion pour la chaîne de caractères react. Ensuite, nous vérifions les résultats avec la fonction expect. Expect encapsule la valeur résultante dans un objet qui offre une collection de fonctions de comparaison (matcher), qui peuvent être utilisées pour vérifier la correction du résultat. Étant donné que dans ce cas de test, nous comparons deux chaînes, nous pouvons utiliser le comparateur toBe.
Comme prévu, tous les tests passent :
Par défaut, Jest s'attend à ce que les noms des fichiers de test contiennent .test. Dans ce cours, nous suivrons la convention de nommer nos fichiers de test avec l'extension .test.js.
Jest offre d'excellents messages d'erreur. Intentionnellement, échouons le test pour illustrer:
test('palindrome of react', () => {
const result = reverse('react')
expect(result).toBe('tkaer')
})
Exécuter les tests ci-dessus génère le message d'erreur suivant:
Ajoutons quelques tests pour la fonction average dans un nouveau fichier tests/average.test.js.
const average = require('../utils/for_testing').average
describe('average', () => {
test('of one value is the value itself', () => {
expect(average([1])).toBe(1)
})
test('of many is calculated right', () => {
expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5)
})
test('of empty array is zero', () => {
expect(average([])).toBe(0)
})
})
Le test révèle que la fonction ne fonctionne pas correctement avec un tableau vide (cela est dû au fait qu'en JavaScript, diviser par zéro donne NaN):
Corriger la fonction est assez simple:
const average = array => {
const reducer = (sum, item) => {
return sum + item
}
return array.length === 0
? 0
: array.reduce(reducer, 0) / array.length
}
Si la longueur du tableau est de 0, nous renvoyons 0, et dans tous les autres cas, nous utilisons la méthode reduce pour calculer la moyenne.
Il y a quelques choses à noter à propos des tests que nous venons d'écrire. Nous avons défini un bloc describe autour des tests qui ont été nommés average:
describe('average', () => {
// tests
})
Les blocs describe peuvent être utilisés pour regrouper des tests en collections logiques. La sortie des tests de Jest utilise également le nom du bloc describe :
Comme nous le verrons plus tard, les blocs describe sont nécessaires lorsque nous voulons exécuter des opérations de configuration ou de nettoyage partagées pour un groupe de tests.
Une autre chose à noter est que nous avons écrit les tests de manière assez compacte, sans attribuer la sortie de la fonction testée à une variable:
test('of empty array is zero', () => {
expect(average([])).toBe(0)
})