mcmqtt/tests/unit/test_mqtt_client_comprehensive.py
Ryan Malloy 8ab61eb1df 🚀 Initial release: mcmqtt FastMCP MQTT Server v2025.09.17
Complete FastMCP MQTT integration server featuring:

 Core Features:
- FastMCP native Model Context Protocol server with MQTT tools
- Embedded MQTT broker support with zero-configuration spawning
- Modular architecture: CLI, config, logging, server, MQTT, MCP, broker
- Comprehensive testing: 70+ tests with 96%+ coverage
- Cross-platform support: Linux, macOS, Windows

🏗️ Architecture:
- Clean separation of concerns across 7 modules
- Async/await patterns throughout for maximum performance
- Pydantic models with validation and configuration management
- AMQTT pure Python embedded brokers
- Typer CLI framework with rich output formatting

🧪 Quality Assurance:
- pytest-cov with HTML reporting
- AsyncMock comprehensive unit testing
- Edge case coverage for production reliability
- Pre-commit hooks with black, ruff, mypy

📦 Production Ready:
- PyPI package with proper metadata
- MIT License
- Professional documentation
- uvx installation support
- MCP client integration examples

Perfect for AI agent coordination, IoT data collection, and
microservice communication with MQTT messaging patterns.
2025-09-17 05:46:08 -06:00

598 lines
24 KiB
Python

"""Comprehensive unit tests for MQTT Client functionality."""
import asyncio
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import datetime
from mcmqtt.mqtt.client import MQTTClient
from mcmqtt.mqtt.connection import MQTTConnectionManager
from mcmqtt.mqtt.types import MQTTConfig, MQTTMessage, MQTTQoS, MQTTStats, MQTTConnectionState
class TestMQTTClientComprehensive:
"""Comprehensive test cases for MQTTClient class."""
@pytest.fixture
def mqtt_config(self):
"""Create a test MQTT configuration."""
return MQTTConfig(
broker_host="localhost",
broker_port=1883,
client_id="test-client",
qos=MQTTQoS.AT_LEAST_ONCE
)
@pytest.fixture
def mock_connection_manager(self):
"""Create a mock connection manager."""
manager = MagicMock(spec=MQTTConnectionManager)
manager.is_connected = False
manager._connected_at = None
manager.connection_info = MagicMock()
# Mock async methods
manager.connect = AsyncMock(return_value=True)
manager.disconnect = AsyncMock(return_value=True)
manager.publish = AsyncMock(return_value=True)
manager.subscribe = AsyncMock(return_value=True)
manager.unsubscribe = AsyncMock(return_value=True)
manager.set_callbacks = MagicMock()
return manager
@pytest.fixture
def client(self, mqtt_config, mock_connection_manager):
"""Create a client instance with mocked connection."""
with patch('mcmqtt.mqtt.client.MQTTConnectionManager', return_value=mock_connection_manager):
client = MQTTClient(mqtt_config)
client._connection_manager = mock_connection_manager
return client
def test_client_initialization(self, mqtt_config, mock_connection_manager):
"""Test client initialization with proper setup."""
with patch('mcmqtt.mqtt.client.MQTTConnectionManager', return_value=mock_connection_manager):
client = MQTTClient(mqtt_config)
assert client.config == mqtt_config
assert client._connection_manager == mock_connection_manager
assert isinstance(client._stats, MQTTStats)
assert client._message_handlers == {}
assert client._pattern_handlers == {}
assert client._subscriptions == {}
assert client._offline_queue == []
assert client._max_offline_queue == 1000
# Verify callbacks were set
mock_connection_manager.set_callbacks.assert_called_once()
def test_is_connected_property(self, client, mock_connection_manager):
"""Test is_connected property."""
mock_connection_manager.is_connected = False
assert client.is_connected is False
mock_connection_manager.is_connected = True
assert client.is_connected is True
def test_connection_info_property(self, client, mock_connection_manager):
"""Test connection_info property."""
mock_info = MagicMock()
mock_connection_manager.connection_info = mock_info
assert client.connection_info == mock_info
def test_stats_property_without_connection(self, client, mock_connection_manager):
"""Test stats property when not connected."""
mock_connection_manager.is_connected = False
mock_connection_manager._connected_at = None
stats = client.stats
assert isinstance(stats, MQTTStats)
assert stats.connection_uptime is None
def test_stats_property_with_connection(self, client, mock_connection_manager):
"""Test stats property when connected."""
mock_connection_manager.is_connected = True
mock_connection_manager._connected_at = datetime.utcnow()
stats = client.stats
assert isinstance(stats, MQTTStats)
assert stats.connection_uptime is not None
assert stats.connection_uptime >= 0
@pytest.mark.asyncio
async def test_connect_success(self, client, mock_connection_manager):
"""Test successful connection."""
mock_connection_manager.connect.return_value = True
result = await client.connect()
assert result is True
mock_connection_manager.connect.assert_called_once()
@pytest.mark.asyncio
async def test_connect_failure(self, client, mock_connection_manager):
"""Test connection failure."""
mock_connection_manager.connect.return_value = False
result = await client.connect()
assert result is False
mock_connection_manager.connect.assert_called_once()
@pytest.mark.asyncio
async def test_disconnect_success(self, client, mock_connection_manager):
"""Test successful disconnection."""
mock_connection_manager.disconnect.return_value = True
result = await client.disconnect()
assert result is True
mock_connection_manager.disconnect.assert_called_once()
@pytest.mark.asyncio
async def test_publish_when_connected(self, client, mock_connection_manager):
"""Test publish when connected."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = True
result = await client.publish("test/topic", "test message")
assert result is True
mock_connection_manager.publish.assert_called_once()
assert client._stats.messages_sent == 1
assert client._stats.bytes_sent > 0
assert client._stats.last_message_time is not None
@pytest.mark.asyncio
async def test_publish_dict_payload(self, client, mock_connection_manager):
"""Test publish with dictionary payload."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = True
test_dict = {"key": "value", "number": 42}
result = await client.publish("test/topic", test_dict)
assert result is True
# Verify the payload was JSON encoded
call_args = mock_connection_manager.publish.call_args[0]
payload_bytes = call_args[1]
assert isinstance(payload_bytes, bytes)
assert json.loads(payload_bytes.decode()) == test_dict
@pytest.mark.asyncio
async def test_publish_bytes_payload(self, client, mock_connection_manager):
"""Test publish with bytes payload."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = True
test_bytes = b"binary data"
result = await client.publish("test/topic", test_bytes)
assert result is True
call_args = mock_connection_manager.publish.call_args[0]
payload_bytes = call_args[1]
assert payload_bytes == test_bytes
@pytest.mark.asyncio
async def test_publish_when_offline_queue_not_full(self, client, mock_connection_manager):
"""Test publish when offline and queue not full."""
mock_connection_manager.is_connected = False
result = await client.publish("test/topic", "test message")
assert result is False
assert len(client._offline_queue) == 1
assert client._offline_queue[0].topic == "test/topic"
assert client._offline_queue[0].payload == "test message"
@pytest.mark.asyncio
async def test_publish_when_offline_queue_full(self, client, mock_connection_manager):
"""Test publish when offline and queue is full."""
mock_connection_manager.is_connected = False
client._max_offline_queue = 2
# Fill the queue
await client.publish("test/topic1", "message1")
await client.publish("test/topic2", "message2")
# This should be dropped
result = await client.publish("test/topic3", "message3")
assert result is False
assert len(client._offline_queue) == 2
@pytest.mark.asyncio
async def test_publish_failure_when_connected(self, client, mock_connection_manager):
"""Test publish failure when connected."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = False
result = await client.publish("test/topic", "test message")
assert result is False
assert client._stats.messages_sent == 0
@pytest.mark.asyncio
async def test_subscribe_with_default_qos(self, client, mock_connection_manager):
"""Test subscribe with default QoS."""
mock_connection_manager.subscribe.return_value = True
result = await client.subscribe("test/topic")
assert result is True
mock_connection_manager.subscribe.assert_called_once_with("test/topic", MQTTQoS.AT_LEAST_ONCE)
assert "test/topic" in client._subscriptions
assert client._subscriptions["test/topic"] == MQTTQoS.AT_LEAST_ONCE
assert client._stats.topics_subscribed == 1
@pytest.mark.asyncio
async def test_subscribe_with_custom_qos(self, client, mock_connection_manager):
"""Test subscribe with custom QoS."""
mock_connection_manager.subscribe.return_value = True
result = await client.subscribe("test/topic", qos=MQTTQoS.EXACTLY_ONCE)
assert result is True
mock_connection_manager.subscribe.assert_called_once_with("test/topic", MQTTQoS.EXACTLY_ONCE)
assert client._subscriptions["test/topic"] == MQTTQoS.EXACTLY_ONCE
@pytest.mark.asyncio
async def test_subscribe_with_handler(self, client, mock_connection_manager):
"""Test subscribe with message handler."""
mock_connection_manager.subscribe.return_value = True
def test_handler(message):
pass
result = await client.subscribe("test/topic", handler=test_handler)
assert result is True
assert "test/topic" in client._message_handlers
assert test_handler in client._message_handlers["test/topic"]
@pytest.mark.asyncio
async def test_subscribe_failure(self, client, mock_connection_manager):
"""Test subscribe failure."""
mock_connection_manager.subscribe.return_value = False
result = await client.subscribe("test/topic")
assert result is False
assert "test/topic" not in client._subscriptions
assert client._stats.topics_subscribed == 0
@pytest.mark.asyncio
async def test_unsubscribe_success(self, client, mock_connection_manager):
"""Test successful unsubscribe."""
# First subscribe
client._subscriptions["test/topic"] = MQTTQoS.AT_LEAST_ONCE
client._message_handlers["test/topic"] = [lambda x: None]
client._stats.topics_subscribed = 1
mock_connection_manager.unsubscribe.return_value = True
result = await client.unsubscribe("test/topic")
assert result is True
assert "test/topic" not in client._subscriptions
assert "test/topic" not in client._message_handlers
assert client._stats.topics_subscribed == 0
@pytest.mark.asyncio
async def test_unsubscribe_failure(self, client, mock_connection_manager):
"""Test unsubscribe failure."""
client._subscriptions["test/topic"] = MQTTQoS.AT_LEAST_ONCE
mock_connection_manager.unsubscribe.return_value = False
result = await client.unsubscribe("test/topic")
assert result is False
assert "test/topic" in client._subscriptions
def test_add_message_handler_new_topic(self, client):
"""Test adding message handler for new topic."""
def test_handler(message):
pass
client.add_message_handler("test/topic", test_handler)
assert "test/topic" in client._message_handlers
assert test_handler in client._message_handlers["test/topic"]
def test_add_message_handler_existing_topic(self, client):
"""Test adding message handler for existing topic."""
def handler1(message):
pass
def handler2(message):
pass
client.add_message_handler("test/topic", handler1)
client.add_message_handler("test/topic", handler2)
assert len(client._message_handlers["test/topic"]) == 2
assert handler1 in client._message_handlers["test/topic"]
assert handler2 in client._message_handlers["test/topic"]
def test_add_pattern_handler(self, client):
"""Test adding pattern handler."""
def test_handler(message):
pass
client.add_pattern_handler("test/+/sensor", test_handler)
assert "test/+/sensor" in client._pattern_handlers
assert test_handler in client._pattern_handlers["test/+/sensor"]
def test_remove_message_handler_success(self, client):
"""Test removing message handler successfully."""
def test_handler(message):
pass
client.add_message_handler("test/topic", test_handler)
client.remove_message_handler("test/topic", test_handler)
assert "test/topic" not in client._message_handlers
def test_remove_message_handler_nonexistent(self, client):
"""Test removing nonexistent message handler."""
def test_handler(message):
pass
# Should not raise exception
client.remove_message_handler("test/topic", test_handler)
def test_remove_message_handler_wrong_handler(self, client):
"""Test removing wrong handler from topic."""
def handler1(message):
pass
def handler2(message):
pass
client.add_message_handler("test/topic", handler1)
client.remove_message_handler("test/topic", handler2)
# Handler1 should still be there
assert "test/topic" in client._message_handlers
assert handler1 in client._message_handlers["test/topic"]
@pytest.mark.asyncio
async def test_publish_json(self, client, mock_connection_manager):
"""Test publish_json method."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = True
test_data = {"key": "value", "number": 42}
result = await client.publish_json("test/topic", test_data)
assert result is True
mock_connection_manager.publish.assert_called_once()
@pytest.mark.asyncio
async def test_wait_for_message_new_subscription(self, client, mock_connection_manager):
"""Test wait_for_message with new subscription."""
mock_connection_manager.subscribe.return_value = True
mock_connection_manager.unsubscribe.return_value = True
# Simulate timeout
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(client.wait_for_message("test/topic", timeout=0.1), timeout=0.2)
# Verify subscribe and unsubscribe were called
mock_connection_manager.subscribe.assert_called_once_with("test/topic")
mock_connection_manager.unsubscribe.assert_called_once_with("test/topic")
@pytest.mark.asyncio
async def test_wait_for_message_existing_subscription(self, client, mock_connection_manager):
"""Test wait_for_message with existing subscription."""
client._subscriptions["test/topic"] = MQTTQoS.AT_LEAST_ONCE
# Simulate timeout
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(client.wait_for_message("test/topic", timeout=0.1), timeout=0.2)
# Should not unsubscribe from existing subscription
mock_connection_manager.unsubscribe.assert_not_called()
@pytest.mark.asyncio
async def test_request_response_pattern(self, client, mock_connection_manager):
"""Test request/response pattern."""
mock_connection_manager.subscribe.return_value = True
mock_connection_manager.publish.return_value = True
mock_connection_manager.unsubscribe.return_value = True
# Simulate timeout since we can't easily simulate actual response
result = await client.request_response(
"request/topic", "response/topic", "test request", timeout=0.1
)
assert result is None # Timeout
mock_connection_manager.subscribe.assert_called_with("response/topic")
mock_connection_manager.publish.assert_called_once()
mock_connection_manager.unsubscribe.assert_called_with("response/topic")
def test_get_subscriptions(self, client):
"""Test get_subscriptions method."""
client._subscriptions = {
"topic1": MQTTQoS.AT_LEAST_ONCE,
"topic2": MQTTQoS.EXACTLY_ONCE
}
subscriptions = client.get_subscriptions()
assert subscriptions == client._subscriptions
assert subscriptions is not client._subscriptions # Should be a copy
@pytest.mark.asyncio
async def test_on_connect_callback(self, client, mock_connection_manager):
"""Test _on_connect callback functionality."""
client._subscriptions = {
"topic1": MQTTQoS.AT_LEAST_ONCE,
"topic2": MQTTQoS.EXACTLY_ONCE
}
# Add some offline messages
client._offline_queue = [
MQTTMessage(topic="offline/topic", payload="message1"),
MQTTMessage(topic="offline/topic", payload="message2")
]
mock_connection_manager.subscribe.return_value = True
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = True
# Call the callback
await client._on_connect()
# Verify resubscription
assert mock_connection_manager.subscribe.call_count == 2
# Verify offline messages were processed
assert mock_connection_manager.publish.call_count == 2
@pytest.mark.asyncio
async def test_on_disconnect_callback_clean(self, client):
"""Test _on_disconnect callback for clean disconnection."""
await client._on_disconnect(0) # Clean disconnect
# Should log info message
@pytest.mark.asyncio
async def test_on_disconnect_callback_unexpected(self, client):
"""Test _on_disconnect callback for unexpected disconnection."""
await client._on_disconnect(1) # Unexpected disconnect
# Should log warning message
@pytest.mark.asyncio
async def test_on_message_callback_with_topic_handler(self, client):
"""Test _on_message callback with topic-specific handler."""
handler_called = []
def test_handler(message):
handler_called.append(message)
client._message_handlers["test/topic"] = [test_handler]
await client._on_message("test/topic", b"test payload", 1, False)
assert len(handler_called) == 1
assert handler_called[0].topic == "test/topic"
assert handler_called[0].payload == b"test payload"
assert client._stats.messages_received == 1
assert client._stats.bytes_received == len(b"test payload")
@pytest.mark.asyncio
async def test_on_message_callback_with_async_handler(self, client):
"""Test _on_message callback with async handler."""
handler_called = []
async def async_handler(message):
handler_called.append(message)
client._message_handlers["test/topic"] = [async_handler]
await client._on_message("test/topic", b"test payload", 1, False)
assert len(handler_called) == 1
@pytest.mark.asyncio
async def test_on_message_callback_with_pattern_handler(self, client):
"""Test _on_message callback with pattern handler."""
handler_called = []
def pattern_handler(message):
handler_called.append(message)
client._pattern_handlers["test/+"] = [pattern_handler]
await client._on_message("test/sensor", b"sensor data", 1, False)
assert len(handler_called) == 1
assert handler_called[0].topic == "test/sensor"
@pytest.mark.asyncio
async def test_on_message_callback_handler_exception(self, client):
"""Test _on_message callback when handler raises exception."""
def failing_handler(message):
raise Exception("Handler error")
client._message_handlers["test/topic"] = [failing_handler]
# Should not raise exception
await client._on_message("test/topic", b"test payload", 1, False)
# Stats should still be updated
assert client._stats.messages_received == 1
@pytest.mark.asyncio
async def test_on_error_callback(self, client):
"""Test _on_error callback."""
await client._on_error("Connection failed")
# Should log error message
@pytest.mark.asyncio
async def test_send_offline_messages_empty_queue(self, client):
"""Test _send_offline_messages with empty queue."""
await client._send_offline_messages()
# Should return early
@pytest.mark.asyncio
async def test_send_offline_messages_with_success(self, client, mock_connection_manager):
"""Test _send_offline_messages with successful sends."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = True
client._offline_queue = [
MQTTMessage(topic="offline/topic1", payload="message1"),
MQTTMessage(topic="offline/topic2", payload="message2")
]
await client._send_offline_messages()
assert len(client._offline_queue) == 0
assert mock_connection_manager.publish.call_count == 2
@pytest.mark.asyncio
async def test_send_offline_messages_with_failure(self, client, mock_connection_manager):
"""Test _send_offline_messages with failed sends."""
mock_connection_manager.is_connected = True
mock_connection_manager.publish.return_value = False
original_message = MQTTMessage(topic="offline/topic", payload="message")
client._offline_queue = [original_message]
await client._send_offline_messages()
# Failed message should be re-queued
assert len(client._offline_queue) == 1
def test_topic_matches_pattern_exact_match(self, client):
"""Test topic pattern matching with exact match."""
assert client._topic_matches_pattern("test/topic", "test/topic") is True
def test_topic_matches_pattern_single_wildcard(self, client):
"""Test topic pattern matching with single-level wildcard."""
assert client._topic_matches_pattern("test/sensor", "test/+") is True
assert client._topic_matches_pattern("test/sensor/data", "test/+") is False
def test_topic_matches_pattern_multi_wildcard(self, client):
"""Test topic pattern matching with multi-level wildcard."""
assert client._topic_matches_pattern("test/sensor/data", "test/#") is True
assert client._topic_matches_pattern("test/sensor/data/value", "test/#") is True
assert client._topic_matches_pattern("other/sensor", "test/#") is False
def test_topic_matches_pattern_complex(self, client):
"""Test topic pattern matching with complex patterns."""
assert client._topic_matches_pattern("home/bedroom/temperature", "home/+/temperature") is True
assert client._topic_matches_pattern("home/bedroom/humidity", "home/+/temperature") is False
assert client._topic_matches_pattern("home/bedroom/sensor/temperature", "home/+/temperature") is False
def test_topic_matches_pattern_pattern_longer_than_topic(self, client):
"""Test topic pattern matching when pattern is longer than topic."""
assert client._topic_matches_pattern("test", "test/sensor/data") is False
if __name__ == "__main__":
pytest.main([__file__])