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:

examples/integration_app/main.py
 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:

examples/integration_test.py
  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 Queue and Connection — test the logic, not the multiprocessing plumbing.

  • Use ManagerTestHarness to call prepare() then resolve() — it uses only public API so your tests survive internal refactoring.

  • Check router_queue.put.call_args_list to assert on all emitted events, not just the first.

  • Workers' run() is safe to call in tests — it calls do(), catches exceptions, and sends WorkerQuit. Test do() 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) (from ManagerTestHarness) to assert on directive types — avoids accessing private _directives.