Testing¶
This guide covers how to write unit and integration tests for EDRI applications. The framework's explicit, event-driven design makes components straightforward to test in isolation.
Overview¶
EDRI applications have three layers to test:
Layer |
Test focus |
Isolation level |
|---|---|---|
Event definitions |
Correct field types, response auto-init |
Pure Python |
Manager handlers |
Business logic, response fields, emitted events |
Unit — mock router queue |
API layer |
URL matching, input extraction, validation |
Integration — live Listener |
The standard library unittest is used throughout the project. unittest.mock handles the multiprocessing primitives (Queue, Connection, Process).
Testing event definitions¶
Events are dataclasses — test that they construct correctly and that their responses auto-initialize.
import unittest
from edri.dataclass.event import event, Event
from edri.dataclass.response import Response, response, ResponseStatus
@response
class GreetingResponse(Response):
message: str
@event
class Greeting(Event):
name: str
response: GreetingResponse
class TestGreetingEvent(unittest.TestCase):
def test_response_auto_instantiated(self):
"""response field is created automatically — not passed by the caller."""
ev = Greeting(name="Alice")
self.assertIsInstance(ev.response, GreetingResponse)
self.assertIsNone(ev.response.message)
def test_status_starts_none(self):
ev = Greeting(name="Alice")
self.assertEqual(ev.response.get_status(), ResponseStatus.NONE)
def test_status_auto_set_on_field_assignment(self):
ev = Greeting(name="Alice")
ev.response.message = "Hello, Alice!"
self.assertEqual(ev.response.get_status(), ResponseStatus.OK)
def test_has_response_false_before_modification(self):
ev = Greeting(name="Alice")
self.assertFalse(ev.has_response())
def test_has_response_true_after_modification(self):
ev = Greeting(name="Alice")
ev.response.message = "Hello, Alice!"
self.assertTrue(ev.has_response())
Testing Manager handlers¶
The recommended test pattern uses the ManagerTestHarness helper (edri.testing, Experimental). It wraps the internal setup steps so your tests use only public API:
import unittest
from unittest.mock import MagicMock
from multiprocessing.queues import Queue
from edri.abstract import ManagerBase, request
from edri.dataclass.directive.http import NotFoundResponseDirective
from edri.dataclass.response import ResponseStatus
from edri.testing import ManagerTestHarness
# Import the events being tested
from events.items import ItemGet, ItemCreate
class ItemsManager(ManagerBase):
def __init__(self, router_queue=None):
super().__init__(router_queue)
self._items: dict[int, dict] = {}
@request
def get_item(self, event: ItemGet) -> None:
item = self._items.get(event.item_id)
if item is None:
event.response.set_status(ResponseStatus.FAILED)
event.response.add_directive(NotFoundResponseDirective())
return
event.response.name = item["name"]
class TestItemsManager(unittest.TestCase):
def setUp(self):
self.router_queue = MagicMock(spec=Queue)
self.harness = ManagerTestHarness(
ItemsManager(router_queue=self.router_queue)
)
self.harness.prepare()
# Pre-seed some data
self.harness.manager._items = {1: {"id": 1, "name": "widget", "quantity": 5}}
def test_get_existing_item(self):
event = ItemGet(item_id=1)
self.harness.resolve(event)
self.assertEqual(self.harness.get_status(event), ResponseStatus.OK)
self.assertEqual(event.response.name, "widget")
def test_get_missing_item_returns_404(self):
event = ItemGet(item_id=999)
self.harness.resolve(event)
self.assertEqual(self.harness.get_status(event), ResponseStatus.FAILED)
directives = self.harness.get_directives(event)
self.assertTrue(any(isinstance(d, NotFoundResponseDirective) for d in directives))
Testing that a Manager emits a new event¶
When a handler emits a new event via self.router_queue.put(...), assert on the mock:
def test_manager_emits_notification(self):
event = ItemCreate(name="gadget", quantity=3)
self.harness.resolve(event)
# Verify the response was set
self.assertEqual(self.harness.get_status(event), ResponseStatus.OK)
# Verify a new event was emitted to the router
self.router_queue.put.assert_called_once()
emitted = self.router_queue.put.call_args[0][0]
self.assertIsInstance(emitted, SomeNotificationEvent)
Testing after_start()¶
Override the method and call it directly — there is no need to start an actual process:
def test_after_start_seeds_cache(self):
self.harness.manager.after_start()
self.assertTrue(
self.harness.manager.has_config("default-config")
)
Testing response directives¶
Check that the correct directives are attached to the response:
from edri.dataclass.directive.http import (
NotFoundResponseDirective,
AccessDeniedResponseDirective,
BadRequestResponseDirective,
)
def test_access_denied_directive_attached(self):
event = ProtectedEvent(user_id=None)
self.harness.resolve(event)
directives = self.harness.get_directives(event)
self.assertTrue(
any(isinstance(d, AccessDeniedResponseDirective) for d in directives),
"Expected AccessDeniedResponseDirective in response directives"
)
Testing Workers¶
Workers run their do() method synchronously in a test — no process needed. Pass a mocked Manager connection to capture emitted events.
import unittest
from unittest.mock import MagicMock
from multiprocessing.connection import Connection
from edri.abstract.worker import WorkerProcess
from events.items import ItemCreate
class ProcessingWorker(WorkerProcess[ItemCreate]):
def do(self) -> None:
result = self.event.name.upper()
self.event.response.processed_name = result
self.event_send(self.event)
class TestProcessingWorker(unittest.TestCase):
def setUp(self):
self.pipe = MagicMock(spec=Connection)
self.event = ItemCreate(name="widget", quantity=1)
self.worker = ProcessingWorker(self.pipe, self.event, "TestWorker")
def test_do_sends_event_with_processed_name(self):
self.worker.do()
self.pipe.send.assert_called_once()
sent_event = self.pipe.send.call_args[0][0]
self.assertEqual(sent_event.response.processed_name, "WIDGET")
Testing that Worker handles errors gracefully¶
class FailingWorker(WorkerProcess[ItemCreate]):
def do(self) -> None:
raise ValueError("something went wrong")
def test_worker_sends_quit_on_exception(self):
worker = FailingWorker(self.pipe, self.event, "FailWorker")
worker.run() # must not raise
# The last event sent is WorkerQuit — an internal framework event.
# Application tests typically match on event attributes instead of
# importing internal types, but contributor tests may need to assert
# on the exact event class.
last_sent = self.pipe.send.call_args_list[-1][0][0]
self.assertIsNotNone(last_sent)
Testing Middleware¶
Middleware can be tested without starting a server — call process_request and process_response directly.
import unittest
from unittest.mock import MagicMock
from edri.api.middleware import Middleware, MiddlewareControl
from edri.dataclass.directive.base import UnauthorizedResponseDirective
from edri.dataclass.response import ResponseStatus
class AuthMiddleware(Middleware):
def process_request(self, event, info) -> MiddlewareControl:
if not getattr(event, "bearer_token", None):
event.response.set_status(ResponseStatus.FAILED)
event.response.add_directive(UnauthorizedResponseDirective("Missing token"))
return MiddlewareControl.continue_processing()
class TestAuthMiddleware(unittest.TestCase):
def setUp(self):
self.middleware = AuthMiddleware()
def test_missing_token_sets_failed_status(self):
event = MagicMock()
event.bearer_token = None
event.response = MagicMock()
result = self.middleware.process_request(event, None)
self.assertEqual(result.action, MiddlewareControl.ActionType.CONTINUE)
event.response.set_status.assert_called_once_with(ResponseStatus.FAILED)
event.response.add_directive.assert_called_once()
def test_valid_token_continues(self):
event = MagicMock()
event.bearer_token = ["Bearer valid-token"]
event.response = MagicMock()
result = self.middleware.process_request(event, None)
self.assertEqual(result.action, MiddlewareControl.ActionType.CONTINUE)
event.response.set_status.assert_not_called()
Testing validators¶
Validators are plain callables — call them directly:
import unittest
from edri.utility.validation import IntegerValidator, StringValidator
class TestValidators(unittest.TestCase):
def test_integer_validator_accepts_valid(self):
result = IntegerValidator("42", minimum=1, maximum=100)
self.assertEqual(result, 42)
def test_integer_validator_rejects_below_minimum(self):
with self.assertRaises(ValueError):
IntegerValidator("0", minimum=1)
def test_string_validator_rejects_too_short(self):
with self.assertRaises(ValueError):
StringValidator("ab", minimum_length=3)
Integration tests¶
To test an EDRI application against a live Listener, use the subprocess pattern
from examples/integration_test.py. The test spawns a minimal application
defined in examples/integration_app/main.py as a subprocess:
1"""Minimal EDRI app for integration test."""
2import os
3from http import HTTPMethod
4
5from edri import EDRI
6from edri.abstract import ManagerBase, request
7from edri.api.dataclass.api_event import ApiType, api
8from edri.dataclass.event import Event
9from edri.dataclass.response import Response, response
10
11
12@response
13class GreetingResponse(Response):
14 message: str
15
16
17@api(
18 url="/hello/{name}",
19 method=HTTPMethod.GET,
20 exclude={ApiType.HTML, ApiType.WS},
21)
22class Greeting(Event):
23 name: str
24 response: GreetingResponse
25
26
27class GreetingManager(ManagerBase):
28 @request
29 def greet(self, event: Greeting) -> None:
30 event.response.message = f"Hello, {event.name}!"
31
32
33if __name__ == "__main__":
34 os.environ.setdefault("EDRI_HOST", "127.0.0.1")
35 os.environ.setdefault("EDRI_PORT", "8080")
36
37 edri = EDRI()
38 edri.add_component(GreetingManager(edri.router_queue))
39 edri.start_api()
40 edri.run()
The test fixture:
1"""tests for my app -- integration"""
2import http.client
3import json
4import os
5import signal
6import socket
7import subprocess
8import sys
9import tempfile
10import time
11import unittest
12
13
14def _free_port():
15 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
16 s.bind(("127.0.0.1", 0))
17 return s.getsockname()[1]
18
19
20def _wait_for_port(host, port, proc, timeout=30):
21 deadline = time.monotonic() + timeout
22 while time.monotonic() < deadline:
23 if proc.poll() is not None:
24 return False
25 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
26 s.settimeout(1)
27 if s.connect_ex((host, port)) == 0:
28 return True
29 time.sleep(0.1)
30 return False
31
32
33@unittest.skipUnless(os.environ.get("MYAPP_RUN_INTEGRATION"), "set MYAPP_RUN_INTEGRATION=1")
34class TestMyAppIntegration(unittest.TestCase):
35 @classmethod
36 def setUpClass(cls):
37 cls.host = "127.0.0.1"
38 cls.port = _free_port()
39 examples_dir = os.path.dirname(__file__)
40 docs_source_dir = os.path.dirname(examples_dir)
41 repo_root = os.path.dirname(os.path.dirname(docs_source_dir))
42 env = dict(os.environ, EDRI_HOST=cls.host, EDRI_PORT=str(cls.port))
43 env.setdefault("PYTHONPATH", repo_root)
44 cls.stdout_file = tempfile.NamedTemporaryFile(delete=False, suffix=".stdout")
45 cls.stderr_file = tempfile.NamedTemporaryFile(delete=False, suffix=".stderr")
46 cls.proc = subprocess.Popen(
47 [sys.executable, "-m", "examples.integration_app.main"],
48 cwd=docs_source_dir,
49 env=env,
50 start_new_session=True,
51 stdout=cls.stdout_file,
52 stderr=cls.stderr_file,
53 )
54 cls.stdout_file.close()
55 cls.stderr_file.close()
56 if not _wait_for_port(cls.host, cls.port, cls.proc):
57 stdout = cls._read_log(cls.stdout_file.name)
58 stderr = cls._read_log(cls.stderr_file.name)
59 cls._kill_process()
60 cls._cleanup_tempfiles()
61 raise RuntimeError(
62 f"server did not start on {cls.host}:{cls.port}\n"
63 f"stdout:\n{stdout}\n"
64 f"stderr:\n{stderr}"
65 )
66
67 @classmethod
68 def tearDownClass(cls):
69 cls._kill_process()
70 cls._cleanup_tempfiles()
71
72 @classmethod
73 def _cleanup_tempfiles(cls):
74 for path in (cls.stdout_file.name, cls.stderr_file.name):
75 if os.path.exists(path):
76 os.unlink(path)
77
78 @classmethod
79 def _kill_process(cls):
80 if cls.proc.poll() is not None:
81 return
82 # os.killpg is POSIX-only (macOS, Linux). On Windows, use
83 # cls.proc.terminate() instead.
84 try:
85 os.killpg(os.getpgid(cls.proc.pid), signal.SIGTERM)
86 cls.proc.wait(timeout=10)
87 except (OSError, subprocess.TimeoutExpired):
88 try:
89 os.killpg(os.getpgid(cls.proc.pid), signal.SIGKILL)
90 cls.proc.wait(timeout=5)
91 except (OSError, subprocess.TimeoutExpired):
92 pass
93
94 @classmethod
95 def _read_log(cls, path):
96 try:
97 with open(path) as f:
98 return f.read()
99 except OSError:
100 return "(unreadable)"
101
102 def test_greeting(self):
103 conn = http.client.HTTPConnection(self.host, self.port, timeout=15)
104 try:
105 conn.request("GET", "/hello/World", headers={"Accept": "application/json"})
106 resp = conn.getresponse()
107 self.assertEqual(resp.status, 200)
108 data = json.loads(resp.read())
109 self.assertEqual(data["data"]["message"], "Hello, world!")
110 finally:
111 conn.close()
112
113
114if __name__ == "__main__":
115 unittest.main()
Running tests¶
# All tests with coverage
coverage run -m xmlrunner discover -s tests -p "test_*.py" --output-file report.xml
coverage report -m
# A single test module
python -m unittest tests.abstract.manager.test_manager_base
# A single test case
python -m unittest tests.abstract.manager.test_manager_base.TestManagerBase.test_resolve
# Integration tests (example from the tutorial above)
MYAPP_RUN_INTEGRATION=1 python docs/source/examples/integration_test.py
# EDRI's own integration tests
EDRI_RUN_INTEGRATION=1 python -m xmlrunner discover -s tests/integration -p "test_*.py" -t .
Tips for testing in EDRI¶
Do not start actual processes in unit tests. Mock
QueueandConnection— test the logic, not the multiprocessing plumbing.Use
ManagerTestHarnessto callprepare()thenresolve()— it uses only public API so your tests survive internal refactoring.Check
router_queue.put.call_args_listto assert on all emitted events, not just the first.Workers'
run()is safe to call in tests — it callsdo(), catches exceptions, and sendsWorkerQuit. Testdo()directly if you want to isolate the work itself.Event fields are plain Python attributes after construction — read and write them directly in tests.
Use
get_directives(event)(fromManagerTestHarness) to assert on directive types — avoids accessing private_directives.