Aller au contenu

Tests natifs de Node.js : asynchrone et sous-tests

Une fonction de test peut être async : le test attend la fin de la promesse.

import { test } from 'node:test';
import assert from 'node:assert/strict';
test('charge un client', async () => {
const client = await chargerClient(1);
assert.equal(client.nom, 'Alice');
});
test('rejette un client inconnu', async () => {
await assert.rejects(chargerClient(999), /introuvable/);
});

Toujours attendre les promesses (await ou return) : une promesse oubliée peut échouer après la fin du test, ou laisser le processus ouvert.

Un test peut contenir des sous-tests (t.test), à attendre :

test('API clients', async (t) => {
await t.test('liste', async () => {
const liste = await lister();
assert.ok(Array.isArray(liste));
});
await t.test('création', async () => {
const c = await creer({ nom: 'Alice' });
assert.equal(c.nom, 'Alice');
});
});

describe et it produisent la même hiérarchie avec une syntaxe plus lisible pour de nombreux cas. Un sous-test non attendu est annulé à la fin du parent : toujours await t.test(...).

test('avec ressources', async (t) => {
const base = await ouvrirBaseDeTest();
t.after(() => base.fermer()); // nettoyage à la fin du test
t.beforeEach(() => base.vider()); // avant chaque sous-test
await t.test('insère', async () => {
/* ... */
});
await t.test('lit', async () => {
/* ... */
});
});

t.after est le bon endroit pour fermer serveurs, connexions et fichiers ouverts : il s’exécute même si le test échoue.

import { after, before, describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { app } from '../src/app.js';
describe('API', () => {
let serveur;
let base;
before(async () => {
serveur = app.listen(0, '127.0.0.1');
await new Promise((resolve) => serveur.once('listening', resolve));
base = `http://127.0.0.1:${serveur.address().port}`;
});
after(() => new Promise((resolve) => serveur.close(resolve)));
it('GET /sante', async () => {
const reponse = await fetch(`${base}/sante`);
assert.equal(reponse.status, 200);
});
it('POST /clients refuse une entrée invalide', async () => {
const reponse = await fetch(`${base}/api/clients`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
assert.equal(reponse.status, 400);
});
});

Un serveur non fermé empêche le processus de se terminer : node --test finit par signaler un délai dépassé.

Pas de test.each natif : boucler pour créer un test par cas.

const cas = [
['alice@exemple.com', true],
['alice@', false],
['', false],
];
for (const [entree, attendu] of cas) {
test(`estEmailValide(${JSON.stringify(entree)}) vaut ${attendu}`, () => {
assert.equal(estEmailValide(entree), attendu);
});
}

Chaque cas apparaît comme un test distinct dans le rapport.

test('opération lente', { timeout: 10_000 }, async () => {
await operationLente();
});
test('annulable', async (t) => {
const controle = new AbortController();
t.signal.addEventListener('abort', () => controle.abort());
await fetch('http://127.0.0.1:3000/lent', { signal: controle.signal });
});

t.signal s’annule quand le test est interrompu (délai dépassé, arrêt de la suite). Le délai par défaut est illimité : fixer un timeout (ou --test-timeout) pour éviter les blocages en intégration continue.

Par défaut, les fichiers de test s’exécutent en parallèle dans des processus séparés, et les tests d’un même fichier un par un. Pour faire tourner des tests indépendants simultanément :

describe('requêtes indépendantes', { concurrency: true }, () => {
it('a', async () => {
/* ... */
});
it('b', async () => {
/* ... */
});
});
Fenêtre de terminal
node --test --test-concurrency=2 # nombre de fichiers simultanés

Réduire la concurrence quand les tests partagent une ressource unique (base de données, port, fichier).

Fenêtre de terminal
node --test --test-name-pattern="clients" # par nom (expression régulière)
node --test --test-skip-pattern="lent"
node --test --test-only # tests marqués { only: true }
test('seul ce test', { only: true }, () => {}); // à ne jamais committer

only demande le drapeau --test-only : sans lui, tous les tests s’exécutent (pas de risque de tests désactivés par erreur en CI).

Exécuter du code avant chaque fichier de test (variables d’environnement, simulations globales) :

Fenêtre de terminal
node --import ./tests/setup.js --test
tests/setup.js
process.env.NODE_ENV = 'test';
process.env.TZ = 'UTC';
Cause Solution
await manquant sur une assertion de promesse Toujours await assert.rejects(...)
Ressource non fermée (serveur, base, minuteur) t.after, after ou afterEach
Sous-test non attendu await t.test(...)
Tests qui s’influencent (données partagées) Réinitialiser dans beforeEach ; base de test dédiée
Port fixe en conflit entre fichiers parallèles Port 0 (choisi par le système)
Dépendance à l’heure ou à l’aléatoire Simuler (voir Mocks et minuteurs)
Symptôme Piste
Promise resolution is still pending but the event loop has already resolved Promesse jamais résolue : vérifier les await, les rappels non appelés
Le processus ne s’arrête pas Serveur, socket ou minuteur ouvert : fermer dans after
test did not finish before its parent and was cancelled Sous-test non attendu
Tests qui passent seuls et échouent ensemble État partagé entre fichiers ou tests (base, variables d’environnement)
Timeout en CI seulement Machine plus lente : timeout plus généreux, moins de concurrence
EADDRINUSE Port fixe : utiliser le port 0

Ubuntu 24.04 et 26.04 : différences à connaître

Section intitulée « Ubuntu 24.04 et 26.04 : différences à connaître »

Aucune propre à Ubuntu : le comportement dépend de la version de Node.js.