Tests Unitaires

Tester du Code Asynchrone : Promesses, Timers et Callbacks

Romain Lefebvre

Romain Lefebvre

4 avril 2026

Tester du Code Asynchrone : Promesses, Timers et Callbacks

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.