Repository navigation
Testing Your Project
The emulator is designed to be fully testable. The server runs in a background thread, tags can be reset between tests, and on_change listeners fire synchronously (unless defer=True), making assertions straightforward.
This guide uses Python's built-in unittest framework, but the same patterns apply to pytest.
your_project/
├── tags.py
├── actions.py
├── __main__.py
└── tests/
├── __init__.py
└── test_myfeature.py
Tags and action listeners defined in your project are imported directly into your test files.
Each test class should start its own server instance on a unique port to avoid conflicts when tests run in parallel or in sequence. You can either keep track of this and use set ports for each test or a simple counter in tests/__init__.py can handle this:
# tests/__init__.py
import itertools
_port_counter = itertools.count(44818) # use 44819 or something if your main server is actively running
def next_port() -> int:
return next(_port_counter)Two module-level functions handle starting and stopping the server. Define them once and reuse across all test files.
import threading
import time
from ethernetip_emulator.server.device import AttributeDevice, apidict, device_controller
from ethernetip_emulator.server.tag_specs import tag_registry
def _start_server(port: int) -> tuple:
server_control = apidict(timeout=1.0)
AttributeDevice.set_server_control(server_control)
def background_task():
with AttributeDevice._actions.bind(AttributeDevice):
device_controller(
argv=tag_registry.build_argv(base_args=["--address", f":{port}"]),
attribute_class=AttributeDevice,
server={"control": server_control},
)
thread = threading.Thread(target=background_task, daemon=True)
thread.start()
time.sleep(0.5) # allow the server to finish initialising
return server_control, thread
def _stop_server(server_control, thread) -> None:
server_control["done"] = True
thread.join(timeout=5.0)
tag_registry.invalidate()
tag_registry._raw.clear()
AttributeDevice.reset_defaults()The tag_registry and AttributeDevice are module-level singletons. Without cleanup between test classes, tags registered in one class bleed into the next. _stop_server ensures a clean slate by:
- Signalling the server to exit (
server_control["done"] = True) - Waiting for the thread to finish (
thread.join) - Invalidating the built tag cache (
tag_registry.invalidate()) - Clearing the raw tag list (
tag_registry._raw.clear()) - Resetting device defaults and the attribute registry (
AttributeDevice.reset_defaults())
Use a separate test class for type_validator tests.
class TestMyTagRegistry(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server_control, cls.thread = _start_server(next_port())
@classmethod
def tearDownClass(cls):
_stop_server(cls.server_control, cls.thread)
def test_invalid_default_raises(self):
with self.assertRaises((ValueError, TypeError)):
tag_registry.register(
lambda: [("my_tag", actions.type.BOOL(42))]
)Register your tags in setUpClass, start the server, and reset tag values to a known state in setUp before each test.
from ethernetip_emulator.server.device import actions
from your_project import tags # triggers @tag_registry.register
from your_project import actions as app_actions # triggers actions
class TestMyTagActions(unittest.TestCase):
@classmethod
def setUpClass(cls):
# If your tags.py uses @tag_registry.register at import time,
# importing it here is enough — no manual registration needed.
cls.server_control, cls.thread = _start_server(next_port())
@classmethod
def tearDownClass(cls):
_stop_server(cls.server_control, cls.thread)
def setUp(self):
# Reset tags to a known state before every test
AttributeDevice._ensure_defaults()
actions._lookup("O_BOOL")[slice(0, 1)] = [False]Tip: Use
actions._lookup(tag_name)[slice(...)] = [value]insetUpto make sure they you are only using tested methods on setUp. Never use a method that you or ethernetip-emulator hasn't tested in setUp.
def test_get_val(self):
self.assertEqual(actions.bool.get_val("O_BOOL"), False)
def test_set_val(self):
actions.bool.set_val("O_BOOL", True)
self.assertEqual(actions.bool.get_val("O_BOOL"), True)Because non-deferred listeners fire synchronously inside set_val, you can assert on results immediately after the write:
def test_on_change(self):
changed = []
@actions.bool.on_change("O_BOOL")
def _(attr, key, value):
changed.append(value[0])
actions.bool.set_val("O_BOOL", True)
self.assertEqual(changed[-1], True)Deferred listeners run in a background thread. Add a short time.sleep after the write to give the thread time to execute before asserting:
def test_on_change_deferred(self):
changed = []
@actions.bool.on_change("O_BOOL", defer=True)
def _(attr, key, value):
changed.append(value[0])
actions.bool.set_val("O_BOOL", True)
time.sleep(0.1) # wait for the deferred thread
self.assertEqual(changed[-1], True)All get_val / set_val methods return None or [] for unknown tag names. Verify this explicitly:
def test_get_val_missing_tag(self):
self.assertIsNone(actions.bool.get_val("no_such_tag"))
def test_set_val_missing_tag(self):
result = actions.bool.set_val("no_such_tag", True)
self.assertIsNone(result)Use setUp to reset the array, then test each operation in isolation:
def setUp(self):
AttributeDevice._ensure_defaults()
actions._lookup("O_Steps")[slice(0, 4)] = [False, False, False, False]
def test_append(self):
actions.boolarray.append("O_Steps", True)
self.assertEqual(
actions.boolarray.get_val("O_Steps"),
[True, False, False, False]
)
def test_append_when_full(self):
actions.boolarray.set_val("O_Steps", [True, True, True, True])
result = actions.boolarray.append("O_Steps", True)
self.assertFalse(result)# tags.py
from ethernetip_emulator.server.tag_specs import tag_registry
@tag_registry.register
def _():
return [
("O_BOOL", actions.type.BOOL(False)),
("O_Steps", actions.type.BOOLARRAY([False, False, False, False])),
]# tests/test_bool.py
import unittest
import time
import itertools
from ethernetip_emulator.server.device import AttributeDevice, apidict, device_controller, actions
from ethernetip_emulator.server.tag_specs import tag_registry
from tests import next_port
# --- helpers ---
_port_counter = itertools.count(44818) # use 44819 or something if your main server is actively running
def _next_port() -> int:
return next(_port_counter)
def _start_server(port):
server_control = apidict(timeout=1.0)
AttributeDevice.set_server_control(server_control)
def _run():
with AttributeDevice._actions.bind(AttributeDevice):
device_controller(
argv=tag_registry.build_argv(base_args=["--address", f":{port}"]),
attribute_class=AttributeDevice,
server={"control": server_control},
)
import threading
t = threading.Thread(target=_run, daemon=True)
t.start()
time.sleep(0.5)
return server_control, t
def _stop_server(ctrl, t):
ctrl["done"] = True
t.join(timeout=5.0)
tag_registry.invalidate()
tag_registry._raw.clear()
AttributeDevice.reset_defaults()
# --- tests ---
class TestBoolActions(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server_control, cls.thread = _start_server(_next_port())
@classmethod
def tearDownClass(cls):
_stop_server(cls.server_control, cls.thread)
def setUp(self):
AttributeDevice._ensure_defaults()
actions._lookup("O_BOOL")[slice(0, 1)] = [False]
actions._lookup("O_Steps")[slice(0, 4)] = [False, False, False, False]
def test_set_and_get(self):
actions.bool.set_val("O_BOOL", True)
self.assertTrue(actions.bool.get_val("O_BOOL"))
def test_on_change_fires(self):
fired = []
@actions.bool.on_change("O_BOOL")
def _(attr, key, value):
fired.append(value[0])
actions.bool.set_val("O_BOOL", True)
self.assertEqual(fired[-1], True)
def test_array_toggle(self):
new_val = actions.boolarray.toggle_bit("O_Steps", 0)
self.assertTrue(new_val)
self.assertTrue(actions.boolarray.get_bit("O_Steps", 0))
if __name__ == "__main__":
unittest.main()| Problem | Cause | Fix |
|---|---|---|
| Tags from a previous test class appear in the next |
_raw not cleared between classes |
Always call _stop_server in tearDownClass
|
on_change fires during setUp
|
Using set_val to reset state |
Never rely on on_change events between tests |
| Deferred callback assertion fails | Thread hasn't run yet | Add time.sleep(0.1) after set_val when testing defer=True listeners |
| Port already in use | Two test classes share a port | Use next_port() for every _start_server call |
| Tag not found after server restart |
AttributeDevice.reset_defaults() clears the registry |
Re-register tags in the next class's setUpClass
|