Version initiale : février 2022
Cet article s'inspire de la section Integration testing de la documentation d'Apollo Server (v3 au moment où j'écris).
Ce repo est fourni avec un exemple de test (simple) sur Apollo Server.
Installez Yarn si vous ne l'avez pas encore fait : npm i -g yarn
Puis installez les dépendances : yarn
D'une façon générale, tester son code permet :
- De vérifier qu'il répond bien à des spécifications, qu'il fait ce qu'on attend de lui.
- De vérifier qu'une fonctionnalité existante n'est pas cassée par l'ajout de nouveau code ("non-régression")
Toujours de façon générale, du code va agir sur des données d'entrée, et produire un certain résultat en sortie.
Contexte : une fonction qui calcule une moyenne sur un tableau de nombres [4, 8, 9]
devrait renvoyer 7
(somme des nombres → 21
, divisée par trois → 7
).
Un exemple de test "naïf" (sans utiliser d'outil particulier, et en écrivant le code et le test dans le même fichier) :
// Code à tester - implémentation simple
function computeAverage(numbersList: number[]): number {
// Initialise une variable qui stockera la somme
let sum = 0;
// Additionne tous les nombres
for (let i = 0; i < numbersList.length; i++) {
sum += numbersList[i];
}
// Renvoye la somme divisée par le nombre d'éléments
return sum / numbersList.length;
}
// "Test" - Si la valeur calculée est différente de celle attendue,
// on "throw" une erreur (le code qui suit le throw ne sera pas exécuté)
const numbers = [4, 8, 9];
// à gauche du !== se trouve la valeur calculée, à droite la valeur attendue
if (computeAverage(numbers) !== 7) {
throw new Error('Moyenne calculée non-conforme');
}
// Si on arrive ici c'est que la fonction a fait ce qui était attendu
console.log('Test passé !');
⚠️ Attention, le test ci-dessus est volontairement simpliste !
Dans un cas réel, le test sera toujours écrit dans un fichier séparé du code à tester.
De plus, on utilisera des outils comme Jest pour pouvoir tester les différents cas. Si on voulait ajouter d'autres tests au code ci-dessus, que se passerait-il ? En cas d'erreur dans le premier test, le throw
empêcherait de passer aux suivants.
Un outil comme Jest permet de tester plusieurs cas de façon indépendante.
Contexte : une fonction "resolver" pour une mutation GraphQL qui sert à enregistrer un utilisateur.
Elle va prendre en entrée au moins deux champs - par exemple email et mot de passe - pouvant être transmis à resolver sous forme d'un objet { email: "[email protected]", password: "pass" }
.
Si on suit une approche "TDD" stricte, avant même d'écrire le code du resolver, on peut réfléchir aux différents cas pouvant survenir :
-
Le "happy path" ("chemin heureux") :
- SI l'email est valide,
- ET que le password est conforme (par exemple, nombre de caractères >= 8),
- ET que l'email n'existe pas déjà dans la BDD,
- ALORS on inscrit l'utilisateur dans la BDD
-
Un ou plusieurs "chemin(s) d'erreur" (ou plus simplement, cas d'erreur) :
- SI l'email et/ou le password sont vides, renvoyer une erreur
- SI l'email et/ou le password est invalide, renvoyer une erreur
- SI l'email et le password sont valides, MAIS que l'email existe déjà dans la BDD, renvoyer une erreur
Il n'est pas toujours faisable / réaliste de tester absolument tous les cas.
On va s'inspirer du code présenté dans la doc d'Apollo Server, en modifiant les "pré-requis" :
- On veut écrire une fonction resolver, pour une query
hello
qui accepte un paramètrename
. Ce resolver doit renvoyerhello <name>
. Par exemplehello Toto
siname
vaut"Toto"
. - Différence avec l'exemple initial : si le paramètre
name
estnull
ou une string vide, on veut renvoyer une erreur.
À nouveau, dans cet exemple, on a tout mis au même endroit, ce qui n'est pas très réaliste. C'est le fichier src/hello.test.ts
.
Pour lancer les tests :
yarn test
.
// src/hello.test.ts
import { ApolloServer, gql, UserInputError } from 'apollo-server';
import { GraphQLError } from 'graphql';
const typeDefs = gql`
type Query {
hello(name: String): String!
}
`;
const resolvers = {
Query: {
// ICI, différence d'implémentation avec l'exemple original
hello: (_: any, { name }: { name: string }) => {
// Si name est absent/vide, on renvoie une erreur particulière
// (UserInputError qui est une classe héritée de Error)
if (!name) {
throw new UserInputError('name should be provided')
}
// Sinon on renvoie le nom
return `Hello ${name}!`;
}
},
};
// describe permet d'envelopper une série de tests apparentés
// à l'intérieur, on trouvera les différents cas (cas "normal" et cas d'erreur)
describe('test hello resolver', () => {
// Initialisation : le même serveur sera utilisé pour les deux tests
// Notez qu'on ne DÉMARRE PAS le serveur
const testServer = new ApolloServer({
typeDefs,
resolvers
});
// Cas optimal/normal : le nom est fourni
it('returns hello with the provided name', async () => {
// executeOperation permet d'envoyer une query/mutation
// comme si le serveur tournait
const result = await testServer.executeOperation({
query: 'query SayHelloWorld($name: String) { hello(name: $name) }',
variables: { name: 'world' },
});
// On s'attend (expect) à ce que la propriété `errors` soit undefined
expect(result.errors).toBeUndefined();
// On s'attend à ce que le résultat retourné soit "Hello world"
expect(result.data?.hello).toBe('Hello world!');
});
// Cas d'erreur : le nom n'est pas fourni
it('returns an error', async () => {
const result = await testServer.executeOperation({
query: 'query SayHelloWorld($name: String) { hello(name: $name) }',
// Cette fois name est vide !!
variables: { name: '' },
});
// `errors` ne DOIT PAS être undefined
expect(result.errors).toBeDefined();
// `errors` est un tableau d'objets, chacun contenant une clé message
const errors = result?.errors as GraphQLError[];
expect(errors[0]?.message).toBe('name should be provided');
// data DOIT être null
expect(result.data).toBe(null);
});
})
La doc fournit un exemple plus complexe avec simulation de l'envoi de requêtes HTTP.