├── .github ├── dependabot.yml └── workflows │ └── tests.yml ├── .gitignore ├── CHANGES.rst ├── LICENSE ├── README.rst ├── pyproject.toml ├── pytest_mqtt ├── __init__.py ├── capmqtt.py ├── model.py ├── mosquitto.py └── util.py ├── setup.py └── testing ├── __init__.py ├── test_app.py ├── test_capmqtt.py ├── test_integration.py ├── test_module_settings.py ├── test_mosquitto.py ├── test_util.py └── util.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "pip" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "github-actions" 10 | directory: "/" 11 | schedule: 12 | interval: "monthly" 13 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | # Allow job to be triggered manually. 10 | workflow_dispatch: 11 | 12 | # Cancel in-progress jobs when pushing to the same branch. 13 | concurrency: 14 | cancel-in-progress: true 15 | group: ${{ github.workflow }}-${{ github.ref }} 16 | 17 | jobs: 18 | 19 | tests: 20 | 21 | runs-on: ${{ matrix.os }} 22 | strategy: 23 | matrix: 24 | os: ["ubuntu-22.04"] 25 | python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 26 | paho-mqtt-version: ["1.*", "2.*"] 27 | fail-fast: false 28 | 29 | env: 30 | OS: ${{ matrix.os }} 31 | PYTHON: ${{ matrix.python-version }} 32 | 33 | name: 34 | Python ${{ matrix.python-version }}, 35 | paho-mqtt ${{ matrix.paho-mqtt-version }} 36 | on OS ${{ matrix.os }} 37 | steps: 38 | 39 | - name: Acquire sources 40 | uses: actions/checkout@v4 41 | 42 | - name: Setup Python 43 | uses: actions/setup-python@v5 44 | with: 45 | python-version: ${{ matrix.python-version }} 46 | architecture: x64 47 | cache: 'pip' 48 | cache-dependency-path: 'pyproject.toml' 49 | 50 | - name: Setup project 51 | run: | 52 | 53 | # `setuptools 0.64.0` adds support for editable install hooks (PEP 660). 54 | # https://github.com/pypa/setuptools/blob/main/CHANGES.rst#v6400 55 | pip install "setuptools>=64" --upgrade 56 | 57 | # Install package in editable mode. 58 | pip install --editable=.[test,develop] 59 | 60 | # Explicitly install designated version of paho-mqtt. 61 | pip install --upgrade 'paho-mqtt==${{ matrix.paho-mqtt-version }}' 62 | 63 | - name: Check code style 64 | if: matrix.python-version != '3.6' && matrix.python-version != '3.7' 65 | run: | 66 | poe lint 67 | 68 | - name: Run tests 69 | run: | 70 | poe test 71 | 72 | - name: Upload coverage to Codecov 73 | uses: codecov/codecov-action@v5 74 | env: 75 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 76 | with: 77 | files: ./coverage.xml 78 | flags: unittests 79 | env_vars: OS,PYTHON 80 | name: codecov-umbrella 81 | fail_ci_if_error: true 82 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /.idea 3 | /.pytest_cache 4 | /.ruff_cache 5 | /.vagrant 6 | /.venv* 7 | /build 8 | 9 | /dist 10 | 11 | .coverage* 12 | coverage.xml 13 | *.egg-info 14 | *.pyc 15 | __pycache__ -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ##################### 2 | pytest-mqtt changelog 3 | ##################### 4 | 5 | 6 | in progress 7 | =========== 8 | 9 | 2025-01-07 0.5.0 10 | ================ 11 | - paho-mqtt: Ignore deprecation warnings about Callback API v1 12 | - mosquitto: Don't always pull OCI image 13 | - Updated Paho API version to V2. Thank you, @hyperspacex2. 14 | 15 | 2024-07-29 0.4.2 16 | ================ 17 | - Added a little delay to the Mosquitto fixture. Possibly faster GitHub 18 | runners made MQTT software tests fail on the LorryStream project. 19 | 20 | 2024-05-08 0.4.1 21 | ================ 22 | - Fix command line options machinery by refactoring essential 23 | pytest fixtures to the main package. They have been added to ``testing`` 24 | beforehand, which is just plain wrong, and broke the 0.4.0 release. 25 | 26 | 2024-03-31 0.4.0 27 | ================ 28 | - Accept command line options ``--mqtt-host`` and ``--mqtt-port``, 29 | in order to connect to an MQTT broker on a different endpoint 30 | than ``localhost:1883``. Thanks, @zedfmario. 31 | - Add support for paho-mqtt 2.x, retaining compatibility for 1.x 32 | 33 | 2023-08-03 0.3.1 34 | ================ 35 | 36 | - Fix improving error handling when Docker daemon is not running. 37 | 38 | 39 | 2023-07-28 0.3.0 40 | ================ 41 | 42 | - Improve error handling when Docker daemon is not running. Thanks, @horta. 43 | 44 | 45 | 2023-03-15 0.2.0 46 | ================ 47 | 48 | - Mosquitto fixture: Add missing ``port`` attribute and fix return tuple 49 | of ``BaseImage.run``. Thanks, @edenhaus! 50 | 51 | 52 | 2022-09-20 0.1.0 53 | ================ 54 | 55 | - Initial commit, spawned from Terkin and mqttwarn. 56 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2022 Andreas Motl 2 | Copyright (c) 2020-2022 Richard Pobering 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ########### 2 | pytest-mqtt 3 | ########### 4 | 5 | | 6 | 7 | .. start-badges 8 | 9 | |status| |license| |pypi-downloads| |pypi-version| 10 | 11 | |ci-tests| |ci-coverage| |python-versions| 12 | 13 | .. |ci-tests| image:: https://github.com/mqtt-tools/pytest-mqtt/actions/workflows/tests.yml/badge.svg 14 | :target: https://github.com/mqtt-tools/pytest-mqtt/actions/workflows/tests.yml 15 | :alt: CI outcome 16 | 17 | .. |ci-coverage| image:: https://codecov.io/gh/mqtt-tools/pytest-mqtt/branch/main/graph/badge.svg 18 | :target: https://codecov.io/gh/mqtt-tools/pytest-mqtt 19 | :alt: Code coverage 20 | 21 | .. |pypi-downloads| image:: https://pepy.tech/badge/pytest-mqtt/month 22 | :target: https://pepy.tech/project/pytest-mqtt 23 | :alt: PyPI downloads per month 24 | 25 | .. |pypi-version| image:: https://img.shields.io/pypi/v/pytest-mqtt.svg 26 | :target: https://pypi.org/project/pytest-mqtt/ 27 | :alt: Package version on PyPI 28 | 29 | .. |status| image:: https://img.shields.io/pypi/status/pytest-mqtt.svg 30 | :target: https://pypi.org/project/pytest-mqtt/ 31 | :alt: Project status (alpha, beta, stable) 32 | 33 | .. |python-versions| image:: https://img.shields.io/pypi/pyversions/pytest-mqtt.svg 34 | :target: https://pypi.org/project/pytest-mqtt/ 35 | :alt: Supported Python versions 36 | 37 | .. |license| image:: https://img.shields.io/pypi/l/pytest-mqtt.svg 38 | :target: https://github.com/mqtt-tools/pytest-mqtt/blob/main/LICENSE 39 | :alt: Project license 40 | 41 | .. end-badges 42 | 43 | 44 | ***** 45 | About 46 | ***** 47 | 48 | ``pytest-mqtt`` supports testing systems based on MQTT by providing test 49 | fixtures for ``pytest``. It has been conceived for the fine 50 | `terkin-datalogger`_ and `mqttwarn`_ programs. 51 | 52 | ``capmqtt`` fixture 53 | =================== 54 | 55 | Capture MQTT messages, using the `Paho MQTT Python Client`_, in the spirit of 56 | ``caplog`` and ``capsys``. It can also be used to publish MQTT messages. 57 | 58 | MQTT server host and port are configurable via pytest cli arguments: 59 | ``--mqtt-host`` and ``--mqtt-port``. Default values are ``localhost``/``1883``. 60 | 61 | ``mosquitto`` fixture 62 | ===================== 63 | 64 | Provide the `Mosquitto`_ MQTT broker as a session-scoped fixture to your test 65 | cases. 66 | 67 | 68 | ***** 69 | Usage 70 | ***** 71 | 72 | :: 73 | 74 | import pytest 75 | from pytest_mqtt.model import MqttMessage 76 | 77 | @pytest.mark.capmqtt_decode_utf8 78 | def test_mqtt_send_receive(mosquitto, capmqtt): 79 | """ 80 | Basic send/receive roundtrip, using text payload (`str`). 81 | 82 | By using the `capmqtt_decode_utf8` marker, the message payloads 83 | will be recorded as `str`, after decoding them from `utf-8`. 84 | Otherwise, message payloads would be recorded as `bytes`. 85 | """ 86 | 87 | # Submit a basic MQTT message. 88 | capmqtt.publish(topic="foo", payload="bar") 89 | 90 | # Demonstrate the "messages" property. 91 | # It returns a list of "MqttMessage" objects. 92 | assert capmqtt.messages == [ 93 | MqttMessage(topic="foo", payload="bar", userdata=None), 94 | ] 95 | 96 | # Demonstrate the "records" property. 97 | # It returns tuples of "(topic, payload, userdata)". 98 | assert capmqtt.records == [ 99 | ("foo", "bar", None), 100 | ] 101 | 102 | 103 | The ``capmqtt_decode_utf8`` setting can be enabled in three ways. 104 | 105 | 106 | 1. Session-wide, per ``pytestconfig`` option, for example within ``conftest.py``:: 107 | 108 | @pytest.fixture(scope="session", autouse=True) 109 | def configure_capmqtt_decode_utf8(pytestconfig): 110 | pytestconfig.option.capmqtt_decode_utf8 = True 111 | 112 | 2. On the module level, just say ``capmqtt_decode_utf8 = True`` on top of your file. 113 | 3. On individual test cases as a test case marker, using ``@pytest.mark.capmqtt_decode_utf8``. 114 | 115 | 116 | ****** 117 | Issues 118 | ****** 119 | 120 | - The ``mosquitto`` fixture currently does not support either authentication or 121 | encryption. 122 | 123 | - ``capmqtt`` should be able to capture messages only from specified topics. 124 | 125 | 126 | *********** 127 | Development 128 | *********** 129 | 130 | :: 131 | 132 | git clone https://github.com/mqtt-tools/pytest-mqtt 133 | cd pytest-mqtt 134 | python3 -m venv .venv 135 | source .venv/bin/activate 136 | pip install --editable=.[test,develop] 137 | poe test 138 | 139 | 140 | ******************* 141 | Project information 142 | ******************* 143 | 144 | Contributions 145 | ============= 146 | 147 | Every kind of contribution, feedback, or patch, is much welcome. `Create an 148 | issue`_ or submit a patch if you think we should include a new feature, or to 149 | report or fix a bug. 150 | 151 | Resources 152 | ========= 153 | 154 | - `Source code `_ 155 | - `Documentation `_ 156 | - `Python Package Index (PyPI) `_ 157 | 158 | License 159 | ======= 160 | 161 | The project is licensed under the terms of the MIT license, see `LICENSE`_. 162 | 163 | 164 | .. _Create an issue: https://github.com/mqtt-tools/pytest-mqtt/issues/new 165 | .. _LICENSE: https://github.com/mqtt-tools/pytest-mqtt/blob/main/LICENSE 166 | .. _Mosquitto: https://github.com/eclipse/mosquitto 167 | .. _mqttwarn: https://github.com/mqtt-tools/mqttwarn 168 | .. _Paho MQTT Python Client: https://github.com/eclipse/paho.mqtt.python 169 | .. _terkin-datalogger: https://github.com/hiveeyes/terkin-datalogger/ 170 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # ================== 2 | # Project definition 3 | # ================== 4 | 5 | # Derived from https://peps.python.org/pep-0621/ 6 | 7 | [project] 8 | name = "pytest-mqtt" 9 | version = "0.5.0" 10 | description = "pytest-mqtt supports testing systems based on MQTT" 11 | readme = "README.rst" 12 | keywords = [ "mosquitto", "mqtt", "paho", "pytest", "testing" ] 13 | license = { text = "MIT" } 14 | authors = [ 15 | { name = "Andreas Motl", email = "andreas.motl@panodata.org" }, 16 | { name = "Richard Pobering", email = "richard.pobering@panodata.org" }, 17 | ] 18 | requires-python = ">=3.7" 19 | classifiers = [ 20 | "Development Status :: 5 - Production/Stable", 21 | "Environment :: Console", 22 | "Framework :: Pytest", 23 | "Intended Audience :: Customer Service", 24 | "Intended Audience :: Developers", 25 | "Intended Audience :: Education", 26 | "Intended Audience :: Information Technology", 27 | "Intended Audience :: Legal Industry", 28 | "Intended Audience :: Manufacturing", 29 | "Intended Audience :: System Administrators", 30 | "Intended Audience :: Telecommunications Industry", 31 | "License :: OSI Approved :: MIT License", 32 | "Operating System :: MacOS :: MacOS X", 33 | "Operating System :: POSIX :: Linux", 34 | "Operating System :: Unix", 35 | "Programming Language :: Python", 36 | "Programming Language :: Python :: 3 :: Only", 37 | "Programming Language :: Python :: 3.7", 38 | "Programming Language :: Python :: 3.8", 39 | "Programming Language :: Python :: 3.9", 40 | "Programming Language :: Python :: 3.10", 41 | "Programming Language :: Python :: 3.11", 42 | "Programming Language :: Python :: 3.12", 43 | "Programming Language :: Python :: 3.13", 44 | "Topic :: Communications", 45 | "Topic :: Education", 46 | "Topic :: Home Automation", 47 | "Topic :: Software Development :: Libraries", 48 | "Topic :: Software Development :: Object Brokering", 49 | "Topic :: System :: Hardware", 50 | "Topic :: System :: Logging", 51 | "Topic :: System :: Monitoring", 52 | "Topic :: System :: Networking", 53 | "Topic :: System :: Networking :: Monitoring", 54 | "Topic :: System :: Systems Administration", 55 | "Topic :: Utilities", 56 | ] 57 | 58 | dependencies = [ 59 | "dataclasses; python_version<'3.7'", 60 | "importlib-metadata; python_version<'3.8'", 61 | "paho-mqtt<3", 62 | "pytest-docker-fixtures<2", 63 | ] 64 | 65 | optional-dependencies.develop = [ 66 | "mypy<1.17", 67 | "poethepoet<1", 68 | "pyproject-fmt<3", 69 | "ruff<0.12", 70 | "validate-pyproject<1", 71 | ] 72 | optional-dependencies.release = [ 73 | "build<2", 74 | "twine<7", 75 | ] 76 | optional-dependencies.test = [ 77 | "coverage<8", 78 | "pytest<9", 79 | "pytest-fixture-order<1", 80 | "pytest-httpserver<2", 81 | "pytest-ordering<1", 82 | ] 83 | urls.changelog = "https://github.com/mqtt-tools/pytest-mqtt/blob/main/CHANGES.rst" 84 | urls.documentation = "https://github.com/mqtt-tools/pytest-mqtt" 85 | urls.homepage = "https://github.com/mqtt-tools/pytest-mqtt" 86 | urls.repository = "https://github.com/mqtt-tools/pytest-mqtt" 87 | entry-points.pytest11.capmqtt = "pytest_mqtt.capmqtt" 88 | entry-points.pytest11.mosquitto = "pytest_mqtt.mosquitto" 89 | 90 | [tool.setuptools] 91 | # https://setuptools.pypa.io/en/latest/userguide/package_discovery.html 92 | packages = [ "pytest_mqtt" ] 93 | 94 | # ================== 95 | # Tool configuration 96 | # ================== 97 | # Configuration snippets for pytest, coverage, and ruff. 98 | 99 | [tool.ruff] 100 | line-length = 120 101 | 102 | [tool.pytest.ini_options] 103 | minversion = "2.0" 104 | addopts = """ 105 | -rfEX -p pytester --strict-markers --verbosity=3 106 | """ 107 | log_level = "DEBUG" 108 | log_cli_level = "DEBUG" 109 | testpaths = [ "testing" ] 110 | xfail_strict = true 111 | markers = [ 112 | "capmqtt_decode_utf8: Capture MQTT messages as `str`, not `bytes`", 113 | ] 114 | 115 | [tool.coverage.run] 116 | branch = false 117 | source = [ "pytest_mqtt" ] 118 | 119 | [tool.coverage.report] 120 | fail_under = 0 121 | show_missing = true 122 | omit = [ 123 | ] 124 | 125 | [tool.mypy] 126 | packages = [ "pytest_mqtt" ] 127 | exclude = [ 128 | ] 129 | ignore_missing_imports = true 130 | check_untyped_defs = false 131 | implicit_optional = true 132 | install_types = true 133 | no_implicit_optional = true 134 | non_interactive = true 135 | show_error_codes = true 136 | strict_equality = true 137 | warn_unused_ignores = true 138 | warn_redundant_casts = true 139 | 140 | 141 | # =================== 142 | # Tasks configuration 143 | # =================== 144 | 145 | [tool.poe.tasks] 146 | 147 | check = [ 148 | "lint", 149 | "test", 150 | ] 151 | 152 | format = [ 153 | { cmd = "ruff format ." }, 154 | # Configure Ruff not to auto-fix (remove!): 155 | # unused imports (F401), unused variables (F841), `print` statements (T201), and commented-out code (ERA001). 156 | { cmd = "ruff check --fix --ignore=ERA --ignore=F401 --ignore=F841 --ignore=T20 --ignore=ERA001 ." }, 157 | { cmd = "pyproject-fmt --keep-full-version pyproject.toml" }, 158 | ] 159 | 160 | lint = [ 161 | { cmd = "ruff format --check ." }, 162 | { cmd = "ruff check ." }, 163 | { cmd = "validate-pyproject pyproject.toml" }, 164 | { cmd = "mypy" }, 165 | ] 166 | 167 | # When testing a pytest plugin, `coverage` needs to be 168 | # started before `pytest`. `pytest-cov` will not work. 169 | # https://stackoverflow.com/a/62224494 170 | test = [ 171 | { cmd = "coverage run -m pytest" }, 172 | { cmd = "coverage report" }, 173 | { cmd = "coverage xml" }, 174 | ] 175 | 176 | release = [ 177 | { cmd = "python -m build" }, 178 | { cmd = "twine upload --skip-existing dist/*.tar.gz dist/*.whl" }, 179 | ] 180 | -------------------------------------------------------------------------------- /pytest_mqtt/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from importlib.metadata import PackageNotFoundError, version # noqa 3 | except ImportError: # pragma: no cover 4 | from importlib_metadata import PackageNotFoundError, version # type: ignore[no-redef] # noqa 5 | 6 | from .model import MqttMessage # noqa: F401 7 | 8 | try: 9 | __version__ = version("pytest-mqtt") 10 | except PackageNotFoundError: # pragma: no cover 11 | __version__ = "unknown" 12 | -------------------------------------------------------------------------------- /pytest_mqtt/capmqtt.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Copyright (c) 2020-2022 Andreas Motl 3 | # Copyright (c) 2020-2022 Richard Pobering 4 | # 5 | # Use of this source code is governed by an MIT-style 6 | # license that can be found in the LICENSE file or at 7 | # https://opensource.org/licenses/MIT. 8 | """ 9 | Capture MQTT messages, using the `Paho MQTT Python Client`_, in the spirit of 10 | `caplog` and `capsys`. 11 | 12 | Source: https://github.com/hiveeyes/terkin-datalogger/blob/0.13.0/test/fixtures/mqtt.py 13 | 14 | .. _Paho MQTT Python Client: https://github.com/eclipse/paho.mqtt.python 15 | """ 16 | 17 | import logging 18 | import threading 19 | import typing as t 20 | 21 | import paho.mqtt.client as mqtt 22 | import pytest 23 | 24 | from pytest_mqtt.model import MqttMessage, MqttSettings 25 | from pytest_mqtt.util import delay 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class MqttClientAdapter(threading.Thread): 31 | def __init__(self, on_message_callback: t.Optional[t.Callable] = None, host: str = "localhost", port: int = 1883): 32 | super().__init__() 33 | self.client: mqtt.Client 34 | if not hasattr(mqtt, "CallbackAPIVersion"): 35 | # paho-mqtt 1.x 36 | self.client = mqtt.Client() 37 | self.use_legacy_api = True 38 | else: 39 | # paho-mqtt 2.x 40 | self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2) 41 | self.use_legacy_api = False 42 | self.on_message_callback = on_message_callback 43 | self.host = host 44 | self.port = int(port) 45 | self.setup() 46 | 47 | def setup(self): 48 | client = self.client 49 | client.on_socket_open = self.on_socket_open 50 | client.on_connect = self.on_connect_v1 if self.use_legacy_api else self.on_connect 51 | client.on_subscribe = self.on_subscribe_v1 if self.use_legacy_api else self.on_subscribe 52 | client.on_message = self.on_message 53 | if self.on_message_callback: 54 | client.on_message = self.on_message_callback 55 | 56 | logger.debug("[PYTEST] Connecting to MQTT broker") 57 | client.connect(host=self.host, port=self.port) 58 | client.subscribe("#") 59 | 60 | def run(self): 61 | self.client.loop_start() 62 | 63 | def stop(self): 64 | logger.debug("[PYTEST] Disconnecting from MQTT broker") 65 | self.client.disconnect() 66 | self.client.loop_stop() 67 | 68 | def on_socket_open(self, client, userdata, sock): 69 | logger.debug("[PYTEST] Opened socket to MQTT broker") 70 | 71 | def on_connect_v1(self, client, userdata, flags, rc): # legacy API version 1 72 | logger.debug("[PYTEST] Connected to MQTT broker") 73 | 74 | def on_connect(self, client, userdata, flags, reason_code, properties): 75 | logger.debug("[PYTEST] Connected to MQTT broker") 76 | 77 | def on_subscribe_v1(self, client, userdata, mid, granted_qos, properties=None): # legacy API version 1 78 | logger.debug("[PYTEST] Subscribed to MQTT topic(s)") 79 | 80 | def on_subscribe(self, client, userdata, mid, reason_codes, properties): 81 | logger.debug("[PYTEST] Subscribed to MQTT topic(s)") 82 | 83 | def on_message(self, client, userdata, msg): 84 | logger.debug("[PYTEST] MQTT message received: %s", msg) 85 | 86 | def publish(self, topic: str, payload: str, **kwargs) -> mqtt.MQTTMessageInfo: 87 | message_info = self.client.publish(topic, payload, **kwargs) 88 | message_info.wait_for_publish() 89 | return message_info 90 | 91 | 92 | class MqttCaptureFixture: 93 | """Provides access and control of log capturing.""" 94 | 95 | def __init__(self, decode_utf8: t.Optional[bool], host: str = "localhost", port: int = 1883) -> None: 96 | """Creates a new funcarg.""" 97 | self._buffer: t.List[MqttMessage] = [] 98 | self._decode_utf8: bool = decode_utf8 or False 99 | 100 | self.mqtt_client = MqttClientAdapter(on_message_callback=self.on_message, host=host, port=port) 101 | self.mqtt_client.start() 102 | # time.sleep(0.1) 103 | 104 | def on_message(self, client, userdata, msg): 105 | payload = msg.payload 106 | if self._decode_utf8: 107 | payload = payload.decode("utf-8") 108 | 109 | message = MqttMessage( 110 | topic=msg.topic, 111 | payload=payload, 112 | userdata=userdata, 113 | ) 114 | self._buffer.append(message) 115 | logger.debug("[PYTEST] MQTT message received: %s", str(message)[:200]) 116 | 117 | def finalize(self) -> None: 118 | """Finalizes the fixture.""" 119 | self.mqtt_client.stop() 120 | self.mqtt_client.join(timeout=4.2) 121 | self._buffer = [] 122 | 123 | @property 124 | def messages(self) -> t.List[MqttMessage]: 125 | return self._buffer 126 | 127 | @property 128 | def records(self) -> t.List[t.Tuple[str, t.Union[str, bytes], t.Union[t.Dict, None]]]: 129 | return [(item.topic, item.payload, item.userdata) for item in self._buffer] 130 | 131 | def publish(self, topic: str, payload: str, **kwargs) -> mqtt.MQTTMessageInfo: 132 | message_info = self.mqtt_client.publish(topic=topic, payload=payload, **kwargs) 133 | # Make the MQTT client publish and receive the message. 134 | delay() 135 | return message_info 136 | 137 | 138 | @pytest.fixture(scope="function") 139 | def capmqtt(request, mqtt_settings: MqttSettings): 140 | """Access and control MQTT messages.""" 141 | 142 | # Configure `capmqtt` fixture, obtaining the `capmqtt_decode_utf8` setting from 143 | # either a global or module-wide setting, or from a test case marker. 144 | # https://docs.pytest.org/en/7.1.x/how-to/fixtures.html#fixtures-can-introspect-the-requesting-test-context 145 | # https://docs.pytest.org/en/7.1.x/how-to/fixtures.html#using-markers-to-pass-data-to-fixtures 146 | 147 | host, port = mqtt_settings.host, mqtt_settings.port 148 | 149 | capmqtt_decode_utf8 = ( 150 | getattr(request.config.option, "capmqtt_decode_utf8", False) 151 | or getattr(request.module, "capmqtt_decode_utf8", False) 152 | or request.node.get_closest_marker("capmqtt_decode_utf8") is not None 153 | ) 154 | result = MqttCaptureFixture(decode_utf8=capmqtt_decode_utf8, host=host, port=port) 155 | delay() 156 | yield result 157 | result.finalize() 158 | -------------------------------------------------------------------------------- /pytest_mqtt/model.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import typing as t 3 | 4 | 5 | @dataclasses.dataclass 6 | class MqttMessage: 7 | """ 8 | Container for `capmqtt`'s `message` response items. 9 | """ 10 | 11 | topic: str 12 | payload: t.Union[str, bytes] 13 | userdata: t.Optional[t.Union[t.Dict, None]] 14 | 15 | 16 | @dataclasses.dataclass 17 | class MqttSettings: 18 | host: str 19 | port: int 20 | -------------------------------------------------------------------------------- /pytest_mqtt/mosquitto.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Copyright (c) 2020-2022 Andreas Motl 4 | # Copyright (c) 2020-2022 Richard Pobering 5 | # 6 | # Use of this source code is governed by an MIT-style 7 | # license that can be found in the LICENSE file or at 8 | # https://opensource.org/licenses/MIT. 9 | """ 10 | Provide the `Mosquitto`_ MQTT broker as a session-scoped fixture to your test 11 | harness. 12 | 13 | Source: https://github.com/hiveeyes/terkin-datalogger/blob/0.13.0/test/fixtures/mosquitto.py 14 | 15 | .. _Mosquitto: https://github.com/eclipse/mosquitto 16 | """ 17 | 18 | import os 19 | 20 | import docker 21 | import pytest 22 | from pytest_docker_fixtures import images 23 | from pytest_docker_fixtures.containers._base import BaseImage 24 | 25 | from pytest_mqtt.model import MqttSettings 26 | from pytest_mqtt.util import delay, probe_tcp_connect 27 | 28 | images.settings["mosquitto"] = { 29 | "image": "eclipse-mosquitto", 30 | "version": "2.0.15", 31 | "options": { 32 | "command": "mosquitto -c /mosquitto-no-auth.conf", 33 | "publish_all_ports": False, 34 | "ports": {"1883/tcp": "1883"}, 35 | }, 36 | } 37 | 38 | 39 | class Mosquitto(BaseImage): 40 | name = "mosquitto" 41 | 42 | def __init__(self, host: str = "localhost", port: int = 1883) -> None: 43 | self.host = host 44 | self.port = port 45 | 46 | def check(self): 47 | # TODO: Add real implementation. 48 | return True 49 | 50 | def pull_image(self): 51 | """ 52 | Image needs to be pulled explicitly. 53 | Workaround against `404 Client Error: Not Found for url: http+docker://localhost/v1.23/containers/create`. 54 | 55 | - https://github.com/mqtt-tools/mqttwarn/pull/589#issuecomment-1249680740 56 | - https://github.com/docker/docker-py/issues/2101 57 | """ 58 | docker_client = docker.from_env(version=self.docker_version) 59 | image_name = self.image 60 | if not docker_client.images.list(name=image_name): 61 | docker_client.images.pull(image_name) 62 | 63 | def run(self): 64 | try: 65 | docker_client = docker.from_env(version=self.docker_version) 66 | docker_url = docker_client.api.base_url 67 | except Exception: 68 | raise ConnectionError("Cannot connect to the Docker daemon. Is the docker daemon running?") 69 | try: 70 | docker_client.ping() 71 | except Exception: 72 | raise ConnectionError(f"Cannot connect to the Docker daemon at {docker_url}. Is the docker daemon running?") 73 | self.pull_image() 74 | return super(Mosquitto, self).run() 75 | 76 | 77 | mosquitto_image = Mosquitto() 78 | 79 | 80 | def is_mosquitto_running(host: str, port: int) -> bool: 81 | return probe_tcp_connect(host, port) 82 | 83 | 84 | def pytest_addoption(parser) -> None: 85 | parser.addoption("--mqtt-host", action="store", type=str, default="localhost", help="MQTT host name") 86 | parser.addoption("--mqtt-port", action="store", type=int, default=1883, help="MQTT port number") 87 | 88 | 89 | @pytest.fixture(scope="session") 90 | def mqtt_settings(pytestconfig) -> MqttSettings: 91 | return MqttSettings( 92 | host=pytestconfig.getoption("--mqtt-host"), 93 | port=pytestconfig.getoption("--mqtt-port"), 94 | ) 95 | 96 | 97 | @pytest.fixture(scope="session") 98 | def mosquitto(mqtt_settings: MqttSettings): 99 | host, port = mqtt_settings.host, mqtt_settings.port 100 | 101 | # Gracefully skip spinning up the Docker container if Mosquitto is already running. 102 | if is_mosquitto_running(host, port): 103 | yield host, port 104 | return 105 | 106 | # Spin up Mosquitto container. 107 | if os.environ.get("MOSQUITTO"): 108 | yield os.environ["MOSQUITTO"].split(":") 109 | else: 110 | yield mosquitto_image.run() 111 | delay() 112 | mosquitto_image.stop() 113 | -------------------------------------------------------------------------------- /pytest_mqtt/util.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import threading 3 | 4 | 5 | def delay(seconds: float = 0.075) -> None: 6 | """ 7 | Wait for designated number of seconds. 8 | """ 9 | threading.Event().wait(seconds) 10 | 11 | 12 | def probe_tcp_connect(host: str, port: int) -> bool: 13 | """ 14 | Test connecting to a remote TCP socket. 15 | https://github.com/lovelysystems/lovely.testlayers/blob/0.7.0/src/lovely/testlayers/util.py#L6-L13 16 | """ 17 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 18 | s.settimeout(0.1) 19 | ex = s.connect_ex((host, port)) 20 | if ex == 0: 21 | s.close() 22 | return True 23 | return False 24 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # This is a shim to hopefully allow GitHub to detect the package. 4 | # The build is done with Poetry. 5 | 6 | import setuptools 7 | 8 | if __name__ == "__main__": 9 | setuptools.setup(name="pytest-mqtt") 10 | -------------------------------------------------------------------------------- /testing/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mqtt-tools/pytest-mqtt/ab524fdfd3d936eaf66f893c347b9c002ab3b9ee/testing/__init__.py -------------------------------------------------------------------------------- /testing/test_app.py: -------------------------------------------------------------------------------- 1 | from packaging.version import Version 2 | from packaging.version import parse as parse_version 3 | 4 | from pytest_mqtt import __version__ 5 | 6 | 7 | def test_app_version(): 8 | assert isinstance(parse_version(__version__), Version) 9 | -------------------------------------------------------------------------------- /testing/test_capmqtt.py: -------------------------------------------------------------------------------- 1 | from pytest_mqtt.capmqtt import MqttClientAdapter 2 | from pytest_mqtt.util import delay 3 | 4 | 5 | def test_mqtt_client_adapter(mosquitto): 6 | host, port = mosquitto 7 | mqtt_client = MqttClientAdapter(host=host, port=port) 8 | mqtt_client.start() 9 | 10 | assert mqtt_client.client._host == host 11 | assert mqtt_client.client._port == int(port) 12 | 13 | # Submit MQTT message. 14 | message_info = mqtt_client.publish("foo", "bar") 15 | message_info.wait_for_publish(timeout=0.5) 16 | assert message_info.is_published() is True 17 | 18 | delay() # Only needed to let the coverage tracker fulfil its job. 19 | mqtt_client.stop() 20 | -------------------------------------------------------------------------------- /testing/test_integration.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytest_mqtt.model import MqttMessage 4 | 5 | 6 | def test_basic_submit_text_receive_binary(mosquitto, capmqtt): 7 | """ 8 | Basic submit/receive roundtrip, with text payload (`str`). 9 | 10 | Without changing the default settings, the payloads will be received as `bytes`. 11 | """ 12 | # Submit two MQTT messages. 13 | capmqtt.publish(topic="foo", payload="bar") 14 | capmqtt.publish(topic="baz", payload="qux") 15 | 16 | # Demonstrate the `messages` property. 17 | assert capmqtt.messages == [ 18 | MqttMessage(topic="foo", payload=b"bar", userdata=None), 19 | MqttMessage(topic="baz", payload=b"qux", userdata=None), 20 | ] 21 | 22 | # Demonstrate the `records` property. 23 | assert capmqtt.records == [ 24 | ("foo", b"bar", None), 25 | ("baz", b"qux", None), 26 | ] 27 | 28 | 29 | def test_basic_submit_and_receive_binary(mosquitto, capmqtt): 30 | """ 31 | Basic submit/receive roundtrip, with binary payload (`bytes`). 32 | 33 | Without changing the default settings, the payloads will be received as `bytes`. 34 | """ 35 | 36 | # Submit two MQTT messages. 37 | capmqtt.publish(topic="foo", payload=b"bar") 38 | capmqtt.publish(topic="baz", payload=b"qux") 39 | 40 | # Demonstrate the `messages` property. 41 | assert capmqtt.messages == [ 42 | MqttMessage(topic="foo", payload=b"bar", userdata=None), 43 | MqttMessage(topic="baz", payload=b"qux", userdata=None), 44 | ] 45 | 46 | # Demonstrate the `records` property. 47 | assert capmqtt.records == [ 48 | ("foo", b"bar", None), 49 | ("baz", b"qux", None), 50 | ] 51 | 52 | 53 | @pytest.mark.capmqtt_decode_utf8 54 | def test_basic_submit_text_receive_text_marker(mosquitto, capmqtt): 55 | """ 56 | Basic submit/receive roundtrip, with text payload (`str`). 57 | 58 | By using the `capmqtt_decode_utf8` marker, the payloads will be received 59 | as `str`, after decoding them from `utf-8`. 60 | Otherwise, message payloads would be recorded as `bytes`. 61 | """ 62 | 63 | # Submit two MQTT messages. 64 | capmqtt.publish(topic="foo", payload="bar") 65 | capmqtt.publish(topic="baz", payload="qux") 66 | 67 | # Demonstrate the `messages` property. 68 | assert capmqtt.messages == [ 69 | MqttMessage(topic="foo", payload="bar", userdata=None), 70 | MqttMessage(topic="baz", payload="qux", userdata=None), 71 | ] 72 | 73 | # Demonstrate the `records` property. 74 | assert capmqtt.records == [ 75 | ("foo", "bar", None), 76 | ("baz", "qux", None), 77 | ] 78 | 79 | 80 | @pytest.fixture 81 | def configure_capmqtt_decode_utf8(pytestconfig): 82 | pytestconfig.option.capmqtt_decode_utf8 = True 83 | 84 | 85 | def test_basic_submit_text_receive_text_config(configure_capmqtt_decode_utf8, mosquitto, capmqtt): 86 | """ 87 | Basic submit/receive roundtrip, with text payload (`str`). 88 | 89 | By using the global `capmqtt_decode_utf8` config option, the payloads 90 | will be received as `str`, after decoding them from `utf-8`. 91 | """ 92 | 93 | # Submit two MQTT messages. 94 | capmqtt.publish(topic="foo", payload="bar") 95 | capmqtt.publish(topic="baz", payload="qux") 96 | 97 | # Demonstrate the `messages` property. 98 | assert capmqtt.messages == [ 99 | MqttMessage(topic="foo", payload="bar", userdata=None), 100 | MqttMessage(topic="baz", payload="qux", userdata=None), 101 | ] 102 | 103 | # Demonstrate the `records` property. 104 | assert capmqtt.records == [ 105 | ("foo", "bar", None), 106 | ("baz", "qux", None), 107 | ] 108 | -------------------------------------------------------------------------------- /testing/test_module_settings.py: -------------------------------------------------------------------------------- 1 | from pytest_mqtt.model import MqttMessage 2 | 3 | # Configure `capmqtt` to return `MqttMessage.payload` as `str`, decoded from `utf-8`. 4 | capmqtt_decode_utf8 = True 5 | 6 | 7 | def test_basic_submit_text_receive_text(mosquitto, capmqtt): 8 | """ 9 | Basic submit/receive roundtrip, with text payload (`str`). 10 | 11 | By using the module-wide `capmqtt_decode_utf8` setting, the payloads 12 | will be received as `str`, after decoding them from `utf-8`. 13 | Otherwise, message payloads would be recorded as `bytes`. 14 | """ 15 | 16 | # Submit MQTT message. 17 | capmqtt.publish(topic="foo", payload="bar") 18 | 19 | # Demonstrate `messages` property. 20 | assert capmqtt.messages == [ 21 | MqttMessage(topic="foo", payload="bar", userdata=None), 22 | ] 23 | 24 | # Demonstrate `records` property. 25 | assert capmqtt.records == [ 26 | ("foo", "bar", None), 27 | ] 28 | -------------------------------------------------------------------------------- /testing/test_mosquitto.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytest_mqtt.util import delay 4 | from testing.util import DummyServer 5 | 6 | 7 | @pytest.fixture(scope="session") 8 | @pytest.mark.early 9 | def no_mqtt_broker(request): 10 | server = DummyServer("localhost", 1883) 11 | server.start() 12 | delay() 13 | request.addfinalizer(server.shutdown) 14 | return server 15 | 16 | 17 | @pytest.fixture(scope="session") 18 | @pytest.mark.late 19 | def mosquitto_mqtt_broker(mosquitto): 20 | return mosquitto 21 | 22 | 23 | @pytest.mark.skip(reason="Unable to run together with other test cases") 24 | @pytest.mark.run(order=1) 25 | def test_mosquitto_running(no_mqtt_broker, mosquitto_mqtt_broker): 26 | assert mosquitto_mqtt_broker == ("localhost", 1883) 27 | # no_mqtt_broker.shutdown() 28 | -------------------------------------------------------------------------------- /testing/test_util.py: -------------------------------------------------------------------------------- 1 | from pytest_mqtt.util import probe_tcp_connect 2 | 3 | 4 | def test_probe_tcp_connect_available(httpserver): 5 | assert probe_tcp_connect(httpserver.host, httpserver.port) is True 6 | 7 | 8 | def test_probe_tcp_connect_unavailable(): 9 | assert probe_tcp_connect("localhost", 12345) is False 10 | -------------------------------------------------------------------------------- /testing/util.py: -------------------------------------------------------------------------------- 1 | import socketserver 2 | import threading 3 | 4 | 5 | class DummyServer(threading.Thread): 6 | class TcpServer(socketserver.TCPServer): 7 | allow_reuse_address = True 8 | 9 | class TCPHandler(socketserver.BaseRequestHandler): 10 | def handle(self): 11 | pass 12 | 13 | def __init__(self, host, port): 14 | super().__init__() 15 | self.host = host 16 | self.port = port 17 | self.server = None 18 | 19 | def run(self): 20 | self.server = self.TcpServer((self.host, self.port), self.TCPHandler) 21 | self.server.serve_forever(poll_interval=0.01) 22 | 23 | def shutdown(self): 24 | if self.server is not None: 25 | # scdsc 26 | # threading.Thread(target=self.server.shutdown).start() 27 | self.server.shutdown() 28 | self.join() 29 | --------------------------------------------------------------------------------