MQTT.js kodunu test etmek iki katmana iner. Unit testler, mesaj işleme mantığınızı sahte bir client ile, milisaniyeler içinde ve ağ olmadan kontrol eder. Entegrasyon testleri gerçek bir broker'a bağlanır ve topic'lerin, QoS'un ve payload'ların uçtan uca gerçekten çalıştığını kanıtlar. Üç klasik tuzaktan kaçındığınız sürece ikisi de Jest veya Vitest'te sorunsuz çalışır: subscription aktif olmadan publish etmek, sabit sleep'lerle beklemek ve çalıştırma bittikten sonra client'ları açık bırakmak.
Bu rehber MQTT.js 5'i ve promise API'sini (connectAsync, subscribeAsync, publishAsync, endAsync) kullanır. Örnekler Vitest için yazılmıştır; Jest farkları önemli oldukları yerde belirtilir.
Önce kodu test edilebilir hâle getirin
En faydalı karar, client'ı modülünüzün içinde oluşturmak yerine dışarıdan vermektir. İşte küçük bir komut işleyici: <prefix>/cmd topic'ini dinler, komutu çalıştırır ve <prefix>/ack topic'ine bir onay publish eder.
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);
};
}İki ayrıntı onu teste uygun kılar. Fonksiyon dönmeden önce SUBACK'i bekler; böylece çağıran taraf subscription'ın aktif olduğunu bilir. Ayrıca bir durdurma fonksiyonu döndürür; testler işleyiciyi temiz biçimde ayırabilir. Client API'sinin kendisine yeniyseniz JavaScript ile MQTT veya TypeScript ile MQTT yazılarıyla başlayın; kod üreticisi broker'ınız için çalışan bir bağlantı snippet'i üretir.
Sahte client ile unit testler
MqttClient bir EventEmitter'dır. Sahte bir client'ın yalnızca kodunuzun çağırdığı metotlara ve message olayı yayınlayabilmeye ihtiyacı vardır:
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();
});
});Mesaj işleyici async olduğundan onay, emit() çağrısından birkaç microtask sonra publish edilir. vi.waitFor() assertion'ı geçene veya süre dolana kadar yeniden dener. Jest'te vi.fn() yerine jest.fn() kullanın ve vi.waitFor yerine bekleyen işleri küçük bir yardımcıyla boşaltın:
export const flush = () => new Promise((resolve) => setImmediate(resolve));
// usage: fake.emit(...); await flush(); expect(fake.publish).toHaveBeenCalled();Modülün tamamını vi.mock('mqtt') veya jest.mock('mqtt') ile de mock'layabilirsiniz; ancak enjekte edilen bir sahte client daha basittir, refactoring'lere dayanır ve import yollarına bağlı değildir. Sahte client'ları, bellek içi broker'ları ve container'ları karşılaştırmak için unit testler için mock broker'lar yazısına bakın.
Bir waitForMessage yardımcısı
Entegrasyon testlerinin setTimeout(…, 2000) olmadan belirli bir mesajı beklemesi gerekir. Bu yardımcı, eşleşen ilk mesajla resolve olur ve son tarihten sonra okunabilir bir hatayla reject eder:
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);
});
}Listener'ını her iki yolda da kaldırır; böylece uzun bir test paketi handler biriktirmez (ve Node'un MaxListenersExceededWarning uyarısını almaz). Önemli kullanım kuralı: promise'i publish etmeden önce oluşturun, sonra await edin. Önce publish ederseniz hızlı bir broker yanıtı listener oluşmadan teslim edebilir.
Gerçek bir broker'a karşı entegrasyon testleri
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();
});
});Rastgele prefix her çalıştırmayı izole eder; böylece paralel CI job'ları ve takım arkadaşları birbirlerinin mesajlarını asla görmez. Bir test çalışırken komutların ve ack'lerin akışını izlemek için online MQTT client üzerinden testmqtt/vitest/# topic'ine subscribe olun; bir hatanın kodunuzda mı yoksa hatta mı olduğunu anlamanın en hızlı yolu budur.
Runner yapılandırması
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
testTimeout: 15_000,
hookTimeout: 15_000,
},
});/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testTimeout: 15000,
};{
"scripts": {
"test:unit": "vitest run test/unit",
"test:int": "vitest run test/integration"
}
}TCP bağlantısı açan her şey için node ortamını kullanın: jsdom altında bundler tarzı çözümleme, yalnızca WebSocket konuşan MQTT.js tarayıcı build'ini seçebilir. Unit ve entegrasyon testlerini ayrı klasörlerde tutun; böylece hızlı paket her kayıtta, broker paketi ise CI'da çalışır. Pipeline tarafı için GitHub Actions'ta MQTT testleri yazısına bakın.
Test çalıştırmalarında sorun giderme
Jest did not exit one second after the test run has completed.Bir client hâlâ açık. Her client'ıafterAlliçindeawait client.endAsync()ile kapatın ve geride kalanları bulmak içinjest --detectOpenHandlesçalıştırın. Vitest aynı sorunu bitmeyen bir çalıştırma veyaclose timed out after 10000msmesajıyla gösterir.Exceeded timeout of 5000 ms for a hook(Jest) veya Vitest'te bir hook timeout'u. Broker'a erişilemedi ya da kimlik bilgilerini reddetti.reconnectPeriod: 0veconnectAsync(url, opts, false)ile sonsuz bir retry yerine gerçek hata (ECONNREFUSED,Not authorized) ortaya çıkar; bunu reason code referansında arayın.- Rastgele hatalar, mesaj hiç gelmiyor. Subscribe olmadan önce ya da diğer client'ın SUBACK'inden önce publish ettiniz.
subscribeAsync'i (veya bunu yapanhandleCommandsgibi bir fonksiyonu) await edin vewaitForMessage'ı publish etmeden önce oluşturun. - Testler tek başına geçiyor, birlikte başarısız oluyor. Paylaşılan topic'ler veya sabit bir
clientId. Aynı client ID'ye sahip iki bağlantı birbirini broker'dan düşürür. Rastgele ID'ler ve çalıştırma başına bir önek kullanın. vi.useFakeTimers()sonrasında donan testler. Fake timer'lar MQTT.js keep-alive'ını vewaitForMessagetimeout'unuzu durdurur. Bunları sahte client'lı unit testlerle sınırlayın veafterEachiçindevi.useRealTimers()çağırın.
Aynı fikirler Python'da da geçerlidir: MQTT için pytest fixture'ları rehberi eşdeğer collector'ları promise'ler yerine thread'lerle kurar. Client'ı enjekte edin, mantık için sahtesini kullanın, bağlantıları gerçek bir broker'la doğrulayın, her onayı bekleyin ve her bağlantıyı kapatın: bir MQTT.js test paketini hızlı, deterministik ve en iyi anlamıyla sıkıcı yapan bu birkaç alışkanlıktır.
Sıkça sorulan sorular
mqtt modülünü mock'lamalı mıyım, yoksa client'ı enjekte mi etmeliyim?
Client'ı enjekte edin. Fonksiyonlarınıza bir MqttClient geçirmek, unit testlerin modül mock'lamadan küçük, EventEmitter tabanlı bir sahte client vermesini sağlar; aynı fonksiyonlar entegrasyon testlerinde gerçek bir broker'a karşı değişmeden çalışır. vi.mock veya jest.mock ile modül mock'ları da çalışır ama testleri import yollarına bağlar.
Jest neden MQTT testlerimden sonra kapanmadığını söylüyor?
"Jest did not exit one second after the test run has completed" mesajı hâlâ açık bir soket veya timer olduğu anlamına gelir; neredeyse her zaman kapatılmamış bir MQTT client'ıdır. Bağladığınız her client için afterAll içinde await client.endAsync() çağırın ve unuttuğunuzu bulmak için --detectOpenHandles ile çalıştırın.
MQTT.js ile fake timer kullanabilir miyim?
Yalnızca sahte bir client ile. Gerçek bir MqttClient keep-alive, connect timeout ve yeniden bağlanma için timer'lara dayanır; fake timer'lar bunları dondurur ve entegrasyon testleri takılır ya da tuhaf şekilde timeout'a düşer. Fake timer'ları kendi retry veya debounce mantığınızın unit testlerine saklayın.



