Pruebas de brokers

Probar código MQTT.js con Jest y Vitest

Tests unitarios y de integración para apps MQTT.js 5 con Jest o Vitest: clientes falsos, un helper waitForMessage con timeout, broker real y teardown limpio.

Actualizado · 9 min de lectura

Probar código MQTT.js con Jest y Vitest

Probar código MQTT.js se reduce a dos capas. Los tests unitarios comprueban tu lógica de manejo de mensajes con un cliente falso, en milisegundos y sin red. Los tests de integración se conectan a un broker real y demuestran que topics, QoS y payloads funcionan de extremo a extremo. Ambos funcionan bien en Jest o Vitest, siempre que evites las tres trampas clásicas: publicar antes de que la suscripción esté activa, esperar con sleeps fijos y dejar clientes abiertos al terminar.

Esta guía usa MQTT.js 5 y su API de promesas (connectAsync, subscribeAsync, publishAsync, endAsync). Los ejemplos están escritos para Vitest; las diferencias de Jest se indican donde importan.

Primero, haz el código testeable

La decisión más útil es pasar el cliente como parámetro en lugar de crearlo dentro de tu módulo. Aquí tienes un pequeño manejador de comandos: escucha en <prefix>/cmd, ejecuta el comando y publica una confirmación en <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);
  };
}

Dos detalles lo hacen fácil de probar. La función espera el SUBACK antes de volver, así quien la llama sabe que la suscripción está activa. Y devuelve una función de parada, para que los tests puedan desenganchar el manejador limpiamente. Si eres nuevo en la API del cliente, empieza por MQTT en JavaScript o MQTT con TypeScript; el generador de código produce un snippet de conexión funcional para tu broker.

Tests unitarios con un cliente falso

MqttClient es un EventEmitter. Un fake solo necesita los métodos que llama tu código y la capacidad de emitir 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();
  });
});

El manejador de mensajes es async, así que la confirmación se publica unas cuantas microtareas después de emit(). vi.waitFor() reintenta la aserción hasta que pasa o vence el timeout. En Jest, sustituye vi.fn() por jest.fn() y vacía el trabajo pendiente con un pequeño helper en lugar 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();

También puedes mockear el módulo entero con vi.mock('mqtt') o jest.mock('mqtt'), pero un fake inyectado es más sencillo, sobrevive a los refactors y no depende de rutas de importación. Para comparar fakes, brokers en memoria y contenedores, consulta brokers simulados para tests unitarios.

Un helper waitForMessage

Los tests de integración necesitan esperar un mensaje concreto sin setTimeout(…, 2000). Este helper se resuelve con el primer mensaje que coincide y se rechaza con un error legible tras un plazo:

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);
  });
}

Elimina su listener en ambos caminos, así una suite larga no acumula manejadores (ni el MaxListenersExceededWarning de Node). La regla de uso importante: crea la promesa antes de publicar y luego espérala. Si publicas primero, un broker rápido puede entregar la respuesta antes de que exista el listener.

Tests de integración contra un broker real

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();
  });
});

El prefix aleatorio aísla cada ejecución, de modo que los jobs paralelos de CI y tus compañeros nunca ven los mensajes de los demás. Mientras corre un test, suscríbete a testmqtt/vitest/# en el cliente MQTT online para ver pasar los comandos y los acks: es la forma más rápida de saber si un fallo está en tu código o en la red.

Configuración del 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"
  }
}

Usa el entorno node para todo lo que abra una conexión TCP: con jsdom, la resolución al estilo bundler puede elegir el build de navegador de MQTT.js, que solo habla WebSockets. Mantén los tests unitarios y de integración en carpetas separadas para que la suite rápida corra en cada guardado y la del broker en CI; consulta tests MQTT en GitHub Actions para la parte del pipeline.

Resolver problemas en las ejecuciones

  • Jest did not exit one second after the test run has completed. Hay un cliente abierto. Cierra todos los clientes en afterAll con await client.endAsync() y ejecuta jest --detectOpenHandles para localizar los rezagados. Vitest muestra el mismo problema como una ejecución que no termina o un mensaje close timed out after 10000ms.
  • Exceeded timeout of 5000 ms for a hook (Jest) o un timeout de hook en Vitest. El broker no era alcanzable o rechazó las credenciales. Con reconnectPeriod: 0 y connectAsync(url, opts, false), aflora el error real (ECONNREFUSED, Not authorized) en lugar de un reintento infinito; búscalo en la referencia de reason codes.
  • Fallos aleatorios, el mensaje nunca llega. Publicaste antes de suscribirte, o antes del SUBACK del otro cliente. Espera a subscribeAsync (o a una función como handleCommands que lo haga) y crea waitForMessage antes de publicar.
  • Los tests pasan solos y fallan juntos. Topics compartidos o un clientId fijo. Dos conexiones con el mismo client ID se expulsan mutuamente del broker. Usa IDs aleatorios y un prefijo por ejecución.
  • Tests congelados tras vi.useFakeTimers(). Los fake timers detienen el keep-alive de MQTT.js y el timeout de tu waitForMessage. Limítalos a tests unitarios con un cliente falso y llama a vi.useRealTimers() en afterEach.

Las mismas ideas se aplican en Python: la guía de fixtures de pytest para MQTT construye los colectores equivalentes con hilos en lugar de promesas. Inyecta el cliente, fálsealo para la lógica, usa un broker real para el cableado, espera cada confirmación y cierra cada conexión: esos pocos hábitos son los que hacen que una suite de tests de MQTT.js sea rápida, determinista y aburrida en el mejor sentido.

Preguntas frecuentes

¿Debo mockear el módulo mqtt o inyectar un cliente?

Inyecta el cliente. Pasar un MqttClient a tus funciones permite que los tests unitarios entreguen un pequeño fake basado en EventEmitter sin mockear módulos, y las mismas funciones corren sin cambios contra un broker real en los tests de integración. Los mocks de módulo con vi.mock o jest.mock funcionan, pero atan los tests a las rutas de importación.

¿Por qué Jest dice que no terminó después de mis tests MQTT?

"Jest did not exit one second after the test run has completed" significa que sigue abierto un socket o un timer; casi siempre un cliente MQTT que nunca se cerró. Llama a await client.endAsync() en afterAll para cada cliente que conectes y ejecuta con --detectOpenHandles para encontrar el que se te escapó.

¿Puedo usar fake timers con MQTT.js?

Solo con un cliente falso. Un MqttClient real depende de timers para el keep-alive, los timeouts de conexión y las reconexiones, y los timers falsos los congelan, así que los tests de integración se cuelgan o fallan por timeout de forma extraña. Reserva los fake timers para tests unitarios de tu propia lógica de reintentos o debounce.