Testes de brokers

Testando código MQTT.js com Jest e Vitest

Testes unitários e de integração para apps MQTT.js 5 com Jest ou Vitest: clientes falsos, um helper waitForMessage com timeout, broker real e teardown limpo.

Atualizado · 9 min de leitura

Testando código MQTT.js com Jest e Vitest

Testar código MQTT.js se resume a duas camadas. Testes unitários verificam sua lógica de tratamento de mensagens com um cliente falso, em milissegundos e sem rede. Testes de integração se conectam a um broker real e provam que topics, QoS e payloads funcionam de ponta a ponta. Os dois rodam bem no Jest ou no Vitest, desde que você evite as três armadilhas clássicas: publicar antes de a assinatura estar ativa, esperar com sleeps fixos e deixar clientes abertos depois da execução.

Este guia usa o MQTT.js 5 e sua API de promises (connectAsync, subscribeAsync, publishAsync, endAsync). Os exemplos são escritos para Vitest; as diferenças do Jest aparecem onde importam.

Primeiro, deixe o código testável

A decisão mais útil é receber o cliente como parâmetro em vez de criá-lo dentro do módulo. Aqui vai um pequeno tratador de comandos: ele escuta em <prefix>/cmd, executa o comando e publica uma confirmação em <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);
  };
}

Dois detalhes o tornam fácil de testar. A função espera o SUBACK antes de retornar, então quem chama sabe que a assinatura está ativa. E ela retorna uma função de parada, para que os testes desconectem o tratador de forma limpa. Se você é novo na API do cliente, comece por MQTT em JavaScript ou MQTT com TypeScript; o gerador de código produz um snippet de conexão funcional para o seu broker.

Testes unitários com um cliente falso

MqttClient é um EventEmitter. Um fake só precisa dos métodos que seu código chama e da capacidade 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();
  });
});

O tratador de mensagens é async, então a confirmação é publicada algumas microtarefas depois do emit(). O vi.waitFor() tenta a asserção de novo até ela passar ou o tempo acabar. No Jest, troque vi.fn() por jest.fn() e esvazie o trabalho pendente com um helper simples em vez 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();

Você também pode mockar o módulo inteiro com vi.mock('mqtt') ou jest.mock('mqtt'), mas um fake injetado é mais simples, sobrevive a refatorações e não depende de caminhos de import. Para comparar fakes, brokers em memória e containers, veja brokers mock para testes unitários.

Um helper waitForMessage

Testes de integração precisam esperar uma mensagem específica sem setTimeout(…, 2000). Este helper resolve com a primeira mensagem que bate e rejeita com um erro legível depois de um prazo:

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

Ele remove o listener nos dois caminhos, então uma suíte longa não acumula handlers (nem o MaxListenersExceededWarning do Node). A regra de uso importante: crie a promise antes de publicar e depois aguarde. Se publicar primeiro, um broker rápido pode entregar a resposta antes de o listener existir.

Testes de integração contra um 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();
  });
});

O prefix aleatório isola cada execução, então jobs paralelos de CI e colegas de equipe nunca veem as mensagens uns dos outros. Enquanto um teste roda, assine testmqtt/vitest/# no cliente MQTT online para ver os comandos e acks passando — o jeito mais rápido de saber se a falha está no seu código ou na rede.

Configuração do 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"
  }
}

Use o ambiente node para tudo que abre uma conexão TCP: no jsdom, a resolução no estilo bundler pode escolher o build de navegador do MQTT.js, que só fala WebSockets. Mantenha testes unitários e de integração em pastas separadas, para que a suíte rápida rode a cada salvamento e a do broker rode no CI — veja testes MQTT no GitHub Actions para a parte do pipeline.

Resolvendo problemas nas execuções

  • Jest did not exit one second after the test run has completed. Algum cliente continua aberto. Encerre todos os clientes no afterAll com await client.endAsync() e rode jest --detectOpenHandles para achar os retardatários. O Vitest mostra o mesmo problema como uma execução que não termina ou uma mensagem close timed out after 10000ms.
  • Exceeded timeout of 5000 ms for a hook (Jest) ou um timeout de hook no Vitest. O broker estava inacessível ou recusou as credenciais. Com reconnectPeriod: 0 e connectAsync(url, opts, false), o erro real (ECONNREFUSED, Not authorized) aparece em vez de um retry infinito; consulte-o na referência de reason codes.
  • Falhas aleatórias, a mensagem nunca chega. Você publicou antes de assinar, ou antes do SUBACK do outro cliente. Aguarde o subscribeAsync (ou uma função como handleCommands que faça isso) e crie o waitForMessage antes de publicar.
  • Testes passam sozinhos e falham juntos. Topics compartilhados ou um clientId fixo. Duas conexões com o mesmo client ID derrubam uma à outra do broker. Use IDs aleatórios e um prefixo por execução.
  • Testes congelados depois de vi.useFakeTimers(). Fake timers param o keep-alive do MQTT.js e o timeout do seu waitForMessage. Restrinja-os a testes unitários com cliente falso e chame vi.useRealTimers() no afterEach.

As mesmas ideias valem em Python — o guia de fixtures do pytest para MQTT monta os coletores equivalentes com threads em vez de promises. Injete o cliente, use um fake para a lógica, um broker real para a integração, aguarde cada confirmação e encerre cada conexão: esses poucos hábitos deixam uma suíte de testes MQTT.js rápida, determinística e entediante no melhor sentido.

Perguntas frequentes

Devo mockar o módulo mqtt ou injetar um cliente?

Injete o cliente. Passar um MqttClient para suas funções permite que os testes unitários entreguem um fake pequeno baseado em EventEmitter, sem mock de módulo, e as mesmas funções rodam sem mudanças contra um broker real nos testes de integração. Mocks de módulo com vi.mock ou jest.mock funcionam, mas amarram os testes aos caminhos de import.

Por que o Jest diz que não terminou depois dos meus testes MQTT?

"Jest did not exit one second after the test run has completed" significa que um socket ou timer ainda está aberto — quase sempre um cliente MQTT que nunca foi encerrado. Chame await client.endAsync() no afterAll para cada cliente conectado e rode com --detectOpenHandles para achar o que ficou para trás.

Posso usar fake timers com o MQTT.js?

Só com um cliente falso. Um MqttClient real depende de timers para keep-alive, timeouts de conexão e reconexões, e timers falsos os congelam, então testes de integração travam ou estouram o timeout de forma estranha. Use fake timers apenas em testes unitários da sua própria lógica de retry ou debounce.