Tester du Code Asynchrone : Promesses, Timers et Callbacks
Romain Lefebvre
4 avril 2026

Pourquoi le code asynchrone est difficile à tester
Le code asynchrone casse les hypothèses implicites des tests synchrones. Un test qui "passe" peut en réalité ne pas avoir attendu la fin de l'opération asynchrone — il a juste vérifié l'état initial avant que quoi que ce soit se soit produit.
C'est le genre de bug silencieux qui donne de faux positifs pendant des mois :
// ❌ Ce test passe toujours, même si fetchUser est cassé
it('charge l\'utilisateur', () => {
let user: User | null = null;
fetchUser('123').then((u) => {
user = u;
});
// user est toujours null ici — la Promise n'est pas encore résolue !
expect(user).not.toBeNull(); // ← Passe faussement si Jest n'attend pas
});
Voici comment faire ça correctement.
Pattern 1 : async/await (recommandé)
C'est la façon la plus claire et la plus lisible de tester du code asynchrone. Retournez ou attendez la Promise :
// ✅ Correct — le test attend la résolution
it('charge l\'utilisateur', async () => {
const user = await fetchUser('123');
expect(user.name).toBe('Alice');
});
// ✅ Tester les rejets
it('lance une erreur pour un id invalide', async () => {
await expect(fetchUser('invalid')).rejects.toThrow('Utilisateur introuvable');
});
// ✅ Vérifier le type d'erreur et le message
it('lance une NotFoundError', async () => {
await expect(fetchUser('unknown')).rejects.toThrow(NotFoundError);
await expect(fetchUser('unknown')).rejects.toHaveProperty('statusCode', 404);
});
Un piège courant : oublier le await devant expect(...).rejects :
// ❌ Sans await — le test passe toujours (la vérification n'est jamais faite)
it('mauvais test', async () => {
expect(fetchUser('invalid')).rejects.toThrow('erreur'); // Pas de await !
});
// ✅ Avec await
it('bon test', async () => {
await expect(fetchUser('invalid')).rejects.toThrow('erreur');
});
Pattern 2 : Retourner la Promise (Jest/Vitest)
Si vous n'utilisez pas async/await, retournez la Promise — sinon Jest ne l'attend pas :
// ✅ Retourner la Promise explicitement
it('charge l\'utilisateur', () => {
return fetchUser('123').then((user) => {
expect(user.name).toBe('Alice');
});
});
// ✅ Avec .resolves et .rejects
it('résout avec les données utilisateur', () => {
return expect(fetchUser('123')).resolves.toMatchObject({ name: 'Alice' });
});
it('rejette pour un id invalide', () => {
return expect(fetchUser('invalid')).rejects.toThrow('introuvable');
});
Tester des fonctions qui prennent des callbacks
Les callbacks old-school (style Node.js) nécessitent une attention particulière :
// Code à tester
export function readFileAsync(
path: string,
callback: (error: Error | null, data: string | null) => void
): void {
fs.readFile(path, 'utf8', (err, data) => {
if (err) {
callback(new Error(`Impossible de lire ${path}`), null);
return;
}
callback(null, data);
});
}
// ✅ Test avec done
it('lit le fichier correctement', (done) => {
readFileAsync('/tmp/test.txt', (err, data) => {
expect(err).toBeNull();
expect(data).toBe('contenu du fichier');
done(); // Signale à Jest que le test est terminé
});
});
// ✅ Test avec done + gestion d'erreur
it('appelle le callback avec une erreur si le fichier n\'existe pas', (done) => {
readFileAsync('/fichier/inexistant.txt', (err, data) => {
try {
expect(err).not.toBeNull();
expect(err!.message).toContain('inexistant');
expect(data).toBeNull();
done();
} catch (assertionError) {
done(assertionError); // Passe l'erreur d'assertion à Jest
}
});
});
// ✅ Mieux : envelopper dans une Promise
it('lit le fichier correctement (version Promise)', () => {
return new Promise<void>((resolve, reject) => {
readFileAsync('/tmp/test.txt', (err, data) => {
try {
expect(err).toBeNull();
expect(data).toBe('contenu du fichier');
resolve();
} catch (e) {
reject(e);
}
});
});
});
Contrôler les timers avec les faux timers
setTimeout, setInterval, Date.now — tout ça peut être contrôlé par Jest/Vitest pour rendre les tests instantanés :
// Code à tester
export class RateLimiter {
private requestCount = 0;
private windowStart = Date.now();
private readonly maxRequests: number;
private readonly windowMs: number;
constructor(maxRequests: number, windowMs: number) {
this.maxRequests = maxRequests;
this.windowMs = windowMs;
}
isAllowed(): boolean {
const now = Date.now();
if (now - this.windowStart >= this.windowMs) {
this.requestCount = 0;
this.windowStart = now;
}
if (this.requestCount < this.maxRequests) {
this.requestCount++;
return true;
}
return false;
}
}
// Tests avec faux timers
describe('RateLimiter', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01T12:00:00Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('autorise les requêtes dans la limite', () => {
const limiter = new RateLimiter(3, 1000);
expect(limiter.isAllowed()).toBe(true); // 1/3
expect(limiter.isAllowed()).toBe(true); // 2/3
expect(limiter.isAllowed()).toBe(true); // 3/3
expect(limiter.isAllowed()).toBe(false); // Limite atteinte
});
it('réinitialise le compteur après la fenêtre', () => {
const limiter = new RateLimiter(2, 1000);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(false);
// Avancer le temps de 1 seconde
jest.advanceTimersByTime(1000);
// La fenêtre est réinitialisée
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(true);
expect(limiter.isAllowed()).toBe(false);
});
});
Tester les polling et les retries
Un pattern courant : réessayer une opération jusqu'à ce qu'elle réussisse ou qu'on atteigne un timeout.
// Code à tester
export async function pollUntilReady(
checkFn: () => Promise<boolean>,
options: { intervalMs: number; timeoutMs: number }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (await checkFn()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, options.intervalMs));
}
throw new Error(`Timeout après ${options.timeoutMs}ms`);
}
// Tests
describe('pollUntilReady', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('résout quand checkFn retourne true', async () => {
const checkFn = jest
.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
const promise = pollUntilReady(checkFn, { intervalMs: 100, timeoutMs: 1000 });
// Avancer les timers pour déclencher les intervalles
await jest.runAllTimersAsync();
await expect(promise).resolves.toBeUndefined();
expect(checkFn).toHaveBeenCalledTimes(3);
});
it('rejette avec une erreur de timeout', async () => {
const checkFn = jest.fn().mockResolvedValue(false);
const promise = pollUntilReady(checkFn, { intervalMs: 100, timeoutMs: 500 });
await jest.runAllTimersAsync();
await expect(promise).rejects.toThrow('Timeout après 500ms');
});
});
Gérer les Promises concurrentes
Tester Promise.all, Promise.race, et Promise.allSettled :
// Code à tester
export async function fetchMultipleUsers(ids: string[]): Promise<User[]> {
return Promise.all(ids.map((id) => fetchUser(id)));
}
export async function fetchUserWithFallback(
primaryId: string,
fallbackId: string
): Promise<User> {
return Promise.race([fetchUser(primaryId), fetchUser(fallbackId)]);
}
// Tests
describe('fetchMultipleUsers', () => {
it('retourne tous les utilisateurs', async () => {
const mockFetch = jest
.fn()
.mockResolvedValueOnce({ id: '1', name: 'Alice' })
.mockResolvedValueOnce({ id: '2', name: 'Bob' });
// Injecter le mock
const users = await fetchMultipleUsers(['1', '2'], mockFetch);
expect(users).toHaveLength(2);
expect(users[0].name).toBe('Alice');
expect(users[1].name).toBe('Bob');
});
it('rejette si l\'un des fetches échoue', async () => {
const mockFetch = jest
.fn()
.mockResolvedValueOnce({ id: '1', name: 'Alice' })
.mockRejectedValueOnce(new Error('Utilisateur 2 introuvable'));
await expect(
fetchMultipleUsers(['1', '2'], mockFetch)
).rejects.toThrow('Utilisateur 2 introuvable');
});
});
Observable et streams — tester des données en temps réel
Pour les WebSockets, les Server-Sent Events, ou RxJS :
// Tester du RxJS avec firstValueFrom/lastValueFrom
import { firstValueFrom, lastValueFrom } from 'rxjs';
import { take, toArray } from 'rxjs/operators';
describe('priceStream$', () => {
it('émet les mises à jour de prix', async () => {
const mockWebSocket = createMockWebSocket();
const stream$ = createPriceStream(mockWebSocket);
// Récupérer les 3 premières émissions
const prices = await firstValueFrom(stream$.pipe(take(3), toArray()));
// Simuler des messages WebSocket
mockWebSocket.emit('message', JSON.stringify({ price: 100 }));
mockWebSocket.emit('message', JSON.stringify({ price: 101 }));
mockWebSocket.emit('message', JSON.stringify({ price: 99 }));
expect(prices).toEqual([100, 101, 99]);
});
});
Pièges courants et comment les éviter
1. Oublier de retourner ou d'await
// ❌ Le test passe, peu importe ce que fetchUser retourne
it('charge l\'utilisateur', () => {
// Manque return ou await
expect(fetchUser('123')).resolves.toBeDefined();
});
// ✅
it('charge l\'utilisateur', async () => {
await expect(fetchUser('123')).resolves.toBeDefined();
});
2. Asserter trop tôt dans un callback
// ❌ La valeur n'est pas encore disponible
it('met à jour state après la requête', () => {
const store = createStore();
store.loadUser('123'); // async, mais pas attendu
expect(store.user).not.toBeNull(); // Toujours null !
});
// ✅ Utiliser waitFor pour attendre les mises à jour
import { waitFor } from '@testing-library/react';
it('met à jour state après la requête', async () => {
const store = createStore();
store.loadUser('123');
await waitFor(() => {
expect(store.user).not.toBeNull();
});
});
3. Les faux timers et les vraies Promises ne font pas bon ménage
// ❌ Problème : jest.runAllTimers() ne résout pas les Promises
it('timeout après 5 secondes', () => {
jest.useFakeTimers();
const promise = operationWithTimeout(5000);
jest.runAllTimers(); // Ne résout PAS les Promises internes
// La Promise n'est peut-être pas encore réjetée ici
expect(promise).rejects.toThrow('timeout');
});
// ✅ Utiliser runAllTimersAsync pour les Promises
it('timeout après 5 secondes', async () => {
jest.useFakeTimers();
const promise = operationWithTimeout(5000);
await jest.runAllTimersAsync(); // Résout aussi les microtasks/Promises
await expect(promise).rejects.toThrow('timeout');
});
4. Timeout de test trop court
// Si votre test asynchrone prend plus de 5s (défaut Jest)
it('opération longue', async () => {
const result = await longRunningOperation();
expect(result).toBeDefined();
}, 30_000); // Timeout de 30 secondes pour ce test
// Ou globalement dans jest.config.ts
const config = {
testTimeout: 10_000, // 10 secondes par défaut
};
Conclusion
Tester du code asynchrone correctement se résume à quelques règles simples : toujours await ou return les Promises dans vos tests, utiliser les faux timers pour contrôler le temps, et préférer async/await aux callbacks pour la clarté.
Les faux timers de Jest sont particulièrement puissants : ils transforment des tests qui prendraient des minutes en tests instantanés. Une fois que vous maîtrisez jest.useFakeTimers() et jest.runAllTimersAsync(), vous pouvez tester n'importe quel comportement temporel — debounce, throttle, polling, retry avec backoff — en quelques millisecondes.
