Testing Guide¶
Comprehensive guide to testing OctoPrint-TempETA.
Test Framework¶
The plugin uses pytest for testing with additional plugins:
pytest- Test frameworkpytest-cov- Coverage reportingunittest.mock- Mocking OctoPrint components
Running Tests¶
All Tests¶
pytest
Specific Test File¶
pytest tests/test_calculator.py
Specific Test¶
pytest tests/test_calculator.py::test_linear_heating
With Coverage¶
pytest --cov=octoprint_temp_eta --cov-branch --cov-report=html
View coverage report:
open htmlcov/index.html
Verbose Output¶
pytest -v
Stop on First Failure¶
pytest -x
Test Structure¶
tests/
├── __init__.py
├── test_print_temp_eta.py # Main plugin tests
├── test_calculator.py # Calculator tests
└── test_mqtt_publisher.py # MQTT publisher tests
Writing Tests¶
Calculator tests (pure functions)¶
The estimators in octoprint_temp_eta/calculator.py are stateless module
functions, so their tests need no plugin setup at all
(tests/test_calculator.py uses unittest.TestCase style):
from collections import deque
from octoprint_temp_eta import calculator
class TestCalculateLinearETA(TestCase):
def test_heating_linear(self):
"""Constant 2 °C/s ramp, 30 °C remaining -> 15 s."""
history = deque([(0.0, 20.0, 60.0), (5.0, 30.0, 60.0)])
result = calculator.calculate_linear_eta(history, 60.0)
self.assertAlmostEqual(result, 15.0, places=3)
def test_cooling_returns_none(self):
"""A falling temperature must not produce a heating ETA."""
history = deque([(0.0, 60.0, 60.0), (5.0, 50.0, 60.0)])
self.assertIsNone(calculator.calculate_linear_eta(history, 60.0))
Plugin tests (stubbed OctoPrint)¶
tests/test_print_temp_eta.py instantiates the real TempETAPlugin and
injects small hand-written stubs (DummyLogger, DummySettings,
DummyPluginManager, DummyPrinterProfileManager) instead of OctoPrint's
components. A fixture wires them together:
@pytest.fixture(name="temp_eta_plugin")
def fixture_temp_eta_plugin(...) -> TempETAPlugin:
plugin = TempETAPlugin()
plugin._logger = DummyLogger()
plugin._settings = DummySettings(defaults=plugin.get_settings_defaults())
plugin._plugin_manager = DummyPluginManager() # records sent messages
...
return plugin
Internal members are accessed via the _get_attr/_set_attr/_call_attr/
_member helpers so linters don't flag protected-member access in tests.
Testing time-dependent code¶
Never sleep() in tests — monkeypatch time.time instead. The main test
file provides a _set_time helper for this:
def test_callback_records_history(monkeypatch, temp_eta_plugin) -> None:
_set_time(monkeypatch, 1000.0)
temp_eta_plugin.on_printer_add_temperature(
{"tool0": {"actual": 25.0, "target": 200.0}}
)
Faking the OctoPrint-MQTT helper¶
tests/test_mqtt_publisher.py subclasses the publisher with a test harness
that exposes internals and injects a fake mqtt_publish helper instead of a
broker connection:
def test_publish_delegates_to_helper(publisher) -> None:
fake_publish = Mock(return_value=True)
publisher.set_internal_state(mqtt_publish=fake_publish)
publisher.configure({"mqtt_enabled": True})
publisher.publish_eta_update("bed", 42.0, "heating", 60.0, 30.0)
assert fake_publish.called
Testing "no estimate" paths¶
The estimators signal failure by returning None — assert on that rather
than expecting exceptions:
def test_insufficient_data_empty_history(self):
self.assertIsNone(calculator.calculate_linear_eta(deque(), 60.0))
Test Data¶
Creating Mock Data¶
Heating history samples are (timestamp, actual_temp, target_temp) tuples;
cooldown samples are (timestamp, actual_temp) tuples:
import math
from collections import deque
def create_linear_heating(start=25.0, end=200.0, rate=2.0, samples=10):
"""Mock a constant-rate heating ramp."""
history = deque(maxlen=60)
for i in range(samples):
t = float(i)
history.append((t, start + rate * t, end))
return history
def create_exponential_heating(start=25.0, end=200.0, tau=30.0, samples=20):
"""Mock a first-order exponential approach to the target."""
history = deque(maxlen=60)
for i in range(samples):
t = float(i)
temp = end - (end - start) * math.exp(-t / tau)
history.append((t, temp, end))
return history
Coverage Requirements¶
Aim for:
- Overall: > 80%
- Critical paths: 100%
- New code: > 90%
Check coverage (per-module breakdown with missing line numbers):
pytest --cov=octoprint_temp_eta --cov-report=term-missing
Continuous Integration¶
Tests run automatically on:
- Every push
- Every pull request
- Before merge
CI configuration: .github/workflows/ci.yml
Testing with OctoPrint¶
Virtual Printer¶
Test with OctoPrint's virtual printer:
# Install OctoPrint
pip install "OctoPrint>=1.11.0,<2"
# Install plugin
pip install -e .
# Run OctoPrint
octoprint serve --debug
In OctoPrint:
- Enable the virtual printer (Settings → Serial Connection, or via the
devel.virtualPrintersection inconfig.yaml) - Connect to the
VIRTUALport - Set temperatures (e.g.
M104 S200) and observe the ETA
Manual Testing Checklist¶
- [ ] Plugin loads without errors
- [ ] Settings page displays correctly
- [ ] ETA appears in temperature graph
- [ ] ETA appears in sidebar
- [ ] ETA updates in real-time
- [ ] Linear algorithm works
- [ ] Exponential algorithm works (if available)
- [ ] Heating ETA is accurate
- [ ] Cooling ETA is accurate
- [ ] MQTT publishing works (if enabled)
- [ ] Sounds play correctly (if enabled)
- [ ] Settings save and load correctly
- [ ] Translations work (if available)
Performance Testing¶
The estimators run inside OctoPrint's ~2 Hz temperature callback, so keep an eye on their cost when changing them:
import cProfile
import pstats
def profile_linear_eta():
"""Profile the linear estimator over a full history."""
history = create_linear_heating(samples=60)
profiler = cProfile.Profile()
profiler.enable()
for _ in range(1000):
calculator.calculate_linear_eta(history, 200.0)
profiler.disable()
stats = pstats.Stats(profiler)
stats.sort_stats("cumulative")
stats.print_stats(10)
For live monitoring against a real OctoPrint instance, see
.development/monitor_octoprint_performance.sh.
Debugging Tests¶
Run with Debugger¶
# Using pdb
pytest --pdb
# Break on first failure
pytest -x --pdb
Print Debug Info¶
def test_with_debug():
"""Test with debug output."""
history = create_linear_heating()
print(f"History length: {len(history)}")
print(f"First sample: {history[0]}")
print(f"Last sample: {history[-1]}")
eta = calculator.calculate_linear_eta(history, 200.0)
print(f"Calculated ETA: {eta}")
assert eta is not None
Run with output:
pytest -s tests/test_calculator.py::test_with_debug
Test Best Practices¶
- One assertion per test: Keep tests focused
- Clear names: Test name describes what it tests
- Arrange-Act-Assert: Structure tests clearly
- Independent tests: Tests don't depend on each other
- Fast tests: Keep test suite fast
- Mock external dependencies: Don't rely on network, filesystem, etc.
- Test edge cases: Empty lists, None values, negative numbers
- Document complex tests: Add comments explaining logic
Common Test Patterns¶
Setup and Teardown¶
def setup_module():
"""Run before all tests in module."""
print("Setup module")
def teardown_module():
"""Run after all tests in module."""
print("Teardown module")
def setup_function():
"""Run before each test function."""
pass
def teardown_function():
"""Run after each test function."""
pass
Testing Time-Dependent Code¶
Patch time.time in the module under test (both the plugin module and the
calculator module read it):
def _set_time(monkeypatch: pytest.MonkeyPatch, now: float) -> None:
monkeypatch.setattr(octoprint_temp_eta.time, "time", lambda: now)
monkeypatch.setattr(calc_module.time, "time", lambda: now)
Test Documentation¶
Document tests with docstrings:
def test_exponential_fitting():
"""
Test exponential ETA with synthetic data.
Creates temperature history following exponential heating model
with tau=30s. Verifies that:
1. Fitting succeeds with sufficient data
2. ETA is within 10% of analytical solution
3. Returns None with insufficient data
"""
pass
Next Steps¶
- Contributing Guide - How to contribute tests
- Algorithms - What to test
- Python API - API to test