Tests de brokers

Tester du code MQTT.js avec Jest et Vitest

Tests unitaires et d'intégration pour applis MQTT.js 5 avec Jest ou Vitest : faux clients, helper waitForMessage avec timeout, vrai broker et teardown propre.

Mis à jour · 9 min de lecture

Tester du code MQTT.js avec Jest et Vitest

Tester du code MQTT.js se joue sur deux niveaux. Les tests unitaires vérifient votre logique de traitement des messages avec un faux client, en quelques millisecondes et sans réseau. Les tests d'intégration se connectent à un vrai broker et prouvent que topics, QoS et payloads fonctionnent de bout en bout. Les deux tournent très bien dans Jest ou Vitest, à condition d'éviter les trois pièges classiques : publier avant que l'abonnement soit actif, attendre avec des sleeps fixes et laisser des clients ouverts après l'exécution.

Ce guide utilise MQTT.js 5 et son API à base de promesses (connectAsync, subscribeAsync, publishAsync, endAsync). Les exemples sont écrits pour Vitest ; les différences avec Jest sont signalées là où elles comptent.

D'abord, rendre le code testable

La décision la plus utile est de passer le client en paramètre au lieu de le créer dans votre module. Voici un petit gestionnaire de commandes : il écoute sur <prefix>/cmd, exécute la commande et publie un accusé de réception sur <prefix>/ack.

src/commands.ts
import type { MqttClient } from 'mqtt';

export interface Command {
  id: string;
  action: string;
}

export async function handleCommands(
  client: MqttClient,
  prefix: string,
  run: (cmd: Command) => Promise<void> | void,
): Promise<() => void> {
  const cmdTopic = `${prefix}/cmd`;
  const ackTopic = `${prefix}/ack`;

  const onMessage = async (topic: string, payload: Buffer) => {
    if (topic !== cmdTopic) return;
    let cmd: Command;
    try {
      cmd = JSON.parse(payload.toString());
    } catch {
      client.publish(ackTopic, JSON.stringify({ ok: false, error: 'invalid json' }), { qos: 1 });
      return;
    }
    try {
      await run(cmd);
      client.publish(ackTopic, JSON.stringify({ id: cmd.id, ok: true }), { qos: 1 });
    } catch (err) {
      client.publish(ackTopic, JSON.stringify({ id: cmd.id, ok: false, error: String(err) }), { qos: 1 });
    }
  };

  client.on('message', onMessage);
  await client.subscribeAsync(cmdTopic, { qos: 1 });

  return () => {
    client.off('message', onMessage);
    client.unsubscribe(cmdTopic);
  };
}

Deux détails le rendent facile à tester. La fonction attend le SUBACK avant de rendre la main, donc l'appelant sait que l'abonnement est actif. Et elle renvoie une fonction d'arrêt, pour que les tests détachent proprement le gestionnaire. Si l'API du client vous est nouvelle, commencez par MQTT en JavaScript ou MQTT avec TypeScript ; le générateur de code produit un snippet de connexion fonctionnel pour votre broker.

Tests unitaires avec un faux client

MqttClient est un EventEmitter. Un faux n'a besoin que des méthodes appelées par votre code et de la possibilité d'émettre message :

test/unit/commands.test.ts
import { EventEmitter } from 'node:events';
import type { MqttClient } from 'mqtt';
import { describe, expect, it, vi } from 'vitest';
import { handleCommands } from '../../src/commands';

function createFakeClient() {
  const fake = Object.assign(new EventEmitter(), {
    subscribeAsync: vi.fn().mockResolvedValue([]),
    unsubscribe: vi.fn(),
    publish: vi.fn(),
  });
  return { fake, client: fake as unknown as MqttClient };
}

describe('handleCommands', () => {
  it('runs a valid command and acknowledges it', async () => {
    const { fake, client } = createFakeClient();
    const run = vi.fn();
    await handleCommands(client, 'dev/1', run);

    expect(fake.subscribeAsync).toHaveBeenCalledWith('dev/1/cmd', { qos: 1 });
    fake.emit('message', 'dev/1/cmd', Buffer.from('{"id":"c1","action":"reboot"}'));

    await vi.waitFor(() => expect(fake.publish).toHaveBeenCalledTimes(1));
    const [topic, body] = fake.publish.mock.calls[0];
    expect(topic).toBe('dev/1/ack');
    expect(JSON.parse(body)).toEqual({ id: 'c1', ok: true });
    expect(run).toHaveBeenCalledWith({ id: 'c1', action: 'reboot' });
  });

  it('rejects malformed JSON without calling run', async () => {
    const { fake, client } = createFakeClient();
    const run = vi.fn();
    await handleCommands(client, 'dev/1', run);

    fake.emit('message', 'dev/1/cmd', Buffer.from('{not json'));

    await vi.waitFor(() => expect(fake.publish).toHaveBeenCalled());
    expect(JSON.parse(fake.publish.mock.calls[0][1])).toMatchObject({ ok: false });
    expect(run).not.toHaveBeenCalled();
  });

  it('ignores other topics', async () => {
    const { fake, client } = createFakeClient();
    await handleCommands(client, 'dev/1', vi.fn());
    fake.emit('message', 'dev/2/cmd', Buffer.from('{"id":"x","action":"y"}'));
    expect(fake.publish).not.toHaveBeenCalled();
  });
});

Le gestionnaire de messages est async, l'accusé de réception est donc publié quelques microtâches après emit(). vi.waitFor() relance l'assertion jusqu'à ce qu'elle passe ou que le délai expire. Avec Jest, remplacez vi.fn() par jest.fn() et videz le travail en attente avec un petit helper au lieu de vi.waitFor :

test/helpers/flush.ts (Jest)
export const flush = () => new Promise((resolve) => setImmediate(resolve));
// usage: fake.emit(...); await flush(); expect(fake.publish).toHaveBeenCalled();

Vous pouvez aussi mocker tout le module avec vi.mock('mqtt') ou jest.mock('mqtt'), mais un faux injecté est plus simple, résiste aux refactorings et ne dépend pas des chemins d'import. Pour comparer faux clients, brokers en mémoire et conteneurs, voir les brokers simulés pour tests unitaires.

Un helper waitForMessage

Les tests d'intégration doivent attendre un message précis sans setTimeout(…, 2000). Ce helper se résout sur le premier message correspondant et se rejette avec une erreur lisible après une échéance :

test/helpers/waitForMessage.ts
import type { MqttClient } from 'mqtt';

export function waitForMessage(
  client: MqttClient,
  topic: string,
  { timeout = 5000, match = (_: Buffer) => true } = {},
): Promise<{ topic: string; payload: Buffer }> {
  return new Promise((resolve, reject) => {
    const onMessage = (t: string, payload: Buffer) => {
      if (t !== topic || !match(payload)) return;
      clearTimeout(timer);
      client.off('message', onMessage);
      resolve({ topic: t, payload });
    };
    const timer = setTimeout(() => {
      client.off('message', onMessage);
      reject(new Error(`no message on "${topic}" within ${timeout} ms`));
    }, timeout);
    client.on('message', onMessage);
  });
}

Il retire son listener dans les deux cas, si bien qu'une longue suite n'accumule pas de handlers (ni de MaxListenersExceededWarning de Node). La règle d'usage importante : créez la promesse avant de publier, puis attendez-la. Si vous publiez d'abord, un broker rapide peut livrer la réponse avant que le listener existe.

Tests d'intégration contre un vrai broker

test/integration/commands.int.test.ts
import { randomUUID } from 'node:crypto';
import { connectAsync, type MqttClient } from 'mqtt';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { handleCommands } from '../../src/commands';
import { waitForMessage } from '../helpers/waitForMessage';

const url = process.env.MQTT_URL ?? 'mqtt://broker.connectmq.com:1883';
const options = {
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
  connectTimeout: 5000,
  reconnectPeriod: 0, // fail fast instead of retrying forever
};

describe('command handler on a real broker', () => {
  const prefix = `testmqtt/vitest/${randomUUID().slice(0, 8)}`;
  let device: MqttClient;
  let operator: MqttClient;
  let stop: (() => void) | undefined;

  beforeAll(async () => {
    // third argument false: reject on the first failed attempt
    [device, operator] = await Promise.all([
      connectAsync(url, { ...options, clientId: `device-${randomUUID()}` }, false),
      connectAsync(url, { ...options, clientId: `operator-${randomUUID()}` }, false),
    ]);
    stop = await handleCommands(device, prefix, async () => {});
    await operator.subscribeAsync(`${prefix}/ack`, { qos: 1 });
  });

  afterAll(async () => {
    stop?.();
    await Promise.all([device?.endAsync(), operator?.endAsync()]);
  });

  it('acknowledges a command end to end', async () => {
    const ack = waitForMessage(operator, `${prefix}/ack`);
    await operator.publishAsync(`${prefix}/cmd`, JSON.stringify({ id: 'c1', action: 'ping' }), { qos: 1 });

    const { payload } = await ack;
    expect(JSON.parse(payload.toString())).toEqual({ id: 'c1', ok: true });
  });

  it('reports malformed commands', async () => {
    const ack = waitForMessage(operator, `${prefix}/ack`, {
      match: (p) => JSON.parse(p.toString()).ok === false,
    });
    await operator.publishAsync(`${prefix}/cmd`, 'not json', { qos: 1 });
    await expect(ack).resolves.toBeDefined();
  });
});

Le prefix aléatoire isole chaque exécution : les jobs de CI parallèles et vos collègues ne voient jamais les messages des autres. Pendant l'exécution d'un test, abonnez-vous à testmqtt/vitest/# dans le client MQTT en ligne pour voir passer commandes et acks — le moyen le plus rapide de savoir si un échec vient de votre code ou du réseau.

Configuration du runner

vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    testTimeout: 15_000,
    hookTimeout: 15_000,
  },
});
jest.config.js
/** @type {import('jest').Config} */
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testTimeout: 15000,
};
package.json (scripts)
{
  "scripts": {
    "test:unit": "vitest run test/unit",
    "test:int": "vitest run test/integration"
  }
}

Utilisez l'environnement node pour tout ce qui ouvre une connexion TCP : sous jsdom, une résolution à la manière d'un bundler peut sélectionner le build navigateur de MQTT.js, qui ne parle que WebSockets. Rangez tests unitaires et d'intégration dans des dossiers séparés, pour que la suite rapide tourne à chaque sauvegarde et la suite broker en CI — voir les tests MQTT dans GitHub Actions pour la partie pipeline.

Dépanner les exécutions de tests

  • Jest did not exit one second after the test run has completed. Un client est resté ouvert. Fermez chaque client dans afterAll avec await client.endAsync() et lancez jest --detectOpenHandles pour repérer les retardataires. Vitest montre le même problème sous forme d'exécution qui ne se termine pas ou d'un message close timed out after 10000ms.
  • Exceeded timeout of 5000 ms for a hook (Jest) ou un timeout de hook dans Vitest. Le broker était injoignable ou a refusé les identifiants. Avec reconnectPeriod: 0 et connectAsync(url, opts, false), l'erreur réelle (ECONNREFUSED, Not authorized) remonte au lieu d'un retry sans fin ; cherchez-la dans la référence des reason codes.
  • Échecs aléatoires, le message n'arrive jamais. Vous avez publié avant de vous abonner, ou avant le SUBACK de l'autre client. Attendez subscribeAsync (ou une fonction comme handleCommands qui le fait) et créez waitForMessage avant de publier.
  • Les tests passent seuls, échouent ensemble. Topics partagés ou clientId fixe. Deux connexions avec le même client ID s'éjectent mutuellement du broker. Utilisez des IDs aléatoires et un préfixe par exécution.
  • Tests figés après vi.useFakeTimers(). Les fake timers arrêtent le keep-alive de MQTT.js et le timeout de votre waitForMessage. Limitez-les aux tests unitaires avec un faux client et appelez vi.useRealTimers() dans afterEach.

Les mêmes idées s'appliquent en Python — le guide des fixtures pytest pour MQTT construit les collecteurs équivalents avec des threads au lieu de promesses. Injectez le client, simulez-le pour la logique, utilisez un vrai broker pour le câblage, attendez chaque accusé de réception et fermez chaque connexion : ces quelques habitudes rendent une suite de tests MQTT.js rapide, déterministe et ennuyeuse au meilleur sens du terme.

Questions fréquentes

Faut-il mocker le module mqtt ou injecter un client ?

Injectez le client. Passer un MqttClient à vos fonctions permet aux tests unitaires de fournir un petit faux basé sur EventEmitter, sans mock de module, et les mêmes fonctions tournent sans changement contre un vrai broker dans les tests d'intégration. Les mocks de module avec vi.mock ou jest.mock fonctionnent, mais lient les tests aux chemins d'import.

Pourquoi Jest dit-il qu'il ne s'est pas arrêté après mes tests MQTT ?

« Jest did not exit one second after the test run has completed » signifie qu'un socket ou un timer est encore ouvert — presque toujours un client MQTT jamais fermé. Appelez await client.endAsync() dans afterAll pour chaque client connecté, et lancez avec --detectOpenHandles pour trouver celui qui manque.

Peut-on utiliser des fake timers avec MQTT.js ?

Seulement avec un faux client. Un vrai MqttClient s'appuie sur des timers pour le keep-alive, les timeouts de connexion et les reconnexions ; les fake timers les figent, et les tests d'intégration se bloquent ou expirent de façon étrange. Réservez-les aux tests unitaires de votre propre logique de retry ou de debounce.