Test an application¶
Every timed behaviour is observable without waiting, because the clock is injected. What those behaviours are is the run; this is how to drive them.
Which store a test uses¶
| Test | Store |
|---|---|
| A unit test of your agents, rules, or timing | InMemoryStore() |
| A test that has to see the storage semantics a deployment has | SqliteStore(":memory:") |
| A test that closes a store and opens the record again | SqliteStore(str(tmp_path / "board.sqlite3")) |
A test of your own BoardStore implementation |
Your adapter, through the conformance suite |
SqliteStore takes its path and has no default. ":memory:" is private to the
store that opened it, so a second SqliteStore(":memory:") in the same process
reads an empty board; a test that opens the same record twice gives both stores
a file.
Content crosses every store as JSON, the in-memory one included, so a test cannot pass against content that a deployment would refuse.
The manual clock¶
from datetime import UTC, datetime, timedelta
from blackboard import InMemoryStore, ManualClock, Settled, create_model
clock = ManualClock(start=datetime(2026, 8, 21, 12, 0, tzinfo=UTC))
model = create_model(..., clock=clock, store=InMemoryStore())
advance moves time and fires every due call synchronously, on the calling
thread, in the order their instants fall. A call armed during an advance fires
inside it when its instant falls at or before the instant the advance moves to.
clock.advance(timedelta(minutes=10))
assert model.control.outcome() == Settled()
SystemClock is the default and the only code in the library that reads the
operating system clock.
A whole scenario on one thread¶
Acknowledge inside the callback, and the entire cycle runs inline, so a test that drives a cycle this way needs no threads.
def agent_cycle(notification):
model.control.write("platform", "a finding", writer="ocp")
model.control.ack(notification.notification_id, agent="ocp")
model.control.register_agent(
Agent(name="ocp", notify=agent_cycle, subscribes_to={"signals"})
)
Name what wakes the agent. An agent registered without subscribes_to is woken
by every premise; no level wakes it, so a cycle driven by a level write never
runs.
Chained notifications are drained from a queue rather than the call stack, so agents that wake each other do not grow the call stack. The wall clock is the only bound the library puts on such an exchange, so a test that drives such an exchange bounds it itself.
One agent body, tested in process¶
Control.as_agent(name) returns the board as that agent sees it. It satisfies
AgentBoard, and so does the BoardClient that an agent deployed on its own
holds,
so a body written against that protocol is tested with no HTTP and no client.
from blackboard import AgentBoard
LIMITS = RunLimits(wall_clock=timedelta(hours=1), idle=timedelta(minutes=5))
def triage(board: AgentBoard, from_sequence: int) -> None:
for signal in board.read_level("signals", from_sequence):
board.write("findings", {"from": signal.sequence})
def test_the_agent_body_in_process():
model = create_model(
board_id="incident-3391",
store=InMemoryStore(),
regions=[Level("signals"), Level("findings"), Premise("severity")],
premises={"severity": "high"},
limits=LIMITS,
clock=ManualClock(),
)
model.control.write("signals", "disk full", writer="watchdog")
triage(model.control.as_agent("triage"), 0)
assert [c.content for c in model.reader.read_level("findings")] == [{"from": 2}]
The finding is at sequence 2 rather than 1, because the opening value of
severity is a write and took sequence 1.
Driving a batch window¶
model = create_model(
regions=[Premise("namespace", batch_window=timedelta(seconds=5))], ...
)
model.control.set_premise("namespace", ["ns1"], expected_version=1, writer="operator")
clock.advance(timedelta(seconds=2))
model.control.set_premise(
"namespace", ["ns1", "ns2"], expected_version=2, writer="operator"
)
assert notifications == [] # still inside the window
clock.advance(timedelta(seconds=3))
assert len(notifications) == 1 # one notification, covering the window
The window opens at the first change that the region takes, which is the
opening value that create_model writes, so the one notification covers that
write and both premise
writes after it. A level carries a window on the same terms, so
Level("signals", batch_window=timedelta(seconds=5)) is driven by the same two
advances.
A replacement replica, without a second process¶
create_model converges on a board the store already holds, so a test of a
replica taking over needs no second process: call it again with the same
arguments. Give the store before the restart and the store after it one file,
because a second SqliteStore(":memory:") reads an empty board.
def test_a_run_that_opens_over_an_existing_record(tmp_path):
file = str(tmp_path / "board.sqlite3")
regions = [Level("signals"), Level("findings"), Premise("severity")]
first = SqliteStore(file)
opened = create_model(
board_id="incident-3391",
store=first,
regions=regions,
premises={"severity": "high"},
limits=LIMITS,
clock=ManualClock(),
)
opened.control.write("signals", "disk full", writer="watchdog")
opened.control.abort("the process is going away")
first.close()
second = SqliteStore(file)
woken = []
resumed = create_model(
board_id="incident-3391",
store=second,
regions=regions,
premises={"severity": "high"},
agents=[Agent(name="triage", notify=woken.append, subscribes_to={"signals"})],
limits=LIMITS,
clock=ManualClock(),
)
assert resumed.reader.read_premise("severity").version == 1
assert woken[0].from_sequence == 1
assert resumed.control.write("findings", "x", writer="triage").sequence == 3
resumed.control.abort("done")
second.close()
The sequence continues from the record. This agent is woken from sequence 1 because it has answered nothing: a cursor is on the record, so an agent that had acknowledged would resume past what it answered.
The opening premise is not written again, which is why severity is still at
version 1 rather than 2. A region named in regions that the record holds as
the other kind raises RegionKindError here rather than at the first write.
Assert on the record, not on timing¶
A refusal is returned to its caller, so assert on what the call answered:
from blackboard import Rejected, RejectionCause
result = model.control.write("findings", {"n": 1}, writer="triage")
assert result == Rejected(
cause=RejectionCause.ADMISSION,
reason="a duplicate of a contribution already on the board",
)
assert model.reader.read_level("findings") == []
A write that landed is on the board with its writer and its instant, and how
far each agent has been told and has answered is store.read_agents. Both are
ordered by the sequence, which does not depend on when anything ran.
Testing your own adapter¶
blackboard.conformance is the suite that every store implementation owes, and
it ships with the package:
pip install 'blackboardx[conformance]'
Subclass BoardConformance, and give it a store fixture returning a fresh
store, and its fifty-eight cases run against your store. Subclass
SharedStoreConformance as well, with a store fixture of its own, for the
eleven cases about one store holding many boards.
import pytest
from blackboard.conformance import BoardConformance, SharedStoreConformance
from myapp.storage import CassandraStore
class TestCassandraStore(BoardConformance):
@pytest.fixture
def store(self):
return CassandraStore(session)
class TestCassandraHoldsManyBoards(SharedStoreConformance):
@pytest.fixture
def store(self):
return CassandraStore(session)
That is how InMemoryStore, SqliteStore, PostgresStore, and MongoStore
are held to the same behaviour, with PostgresStore and MongoStore run
against real servers. They are held to that module rather than a copy of it.
Reading the rules and reimplementing them is not the same as running the suite. A store that gives each region its own counter fails six cases here.
ManualClock and SystemClock both satisfy the Clock protocol, whose
call_at returns a ScheduledCall that the control component cancels when a
deadline moves. A clock of your own satisfies the same two methods, now and
call_at.