ollim-bot has a pytest-based test suite covering data structures, storage I/O,
scheduling, permissions, forks, and more. Tests run against real files in temp
directories rather than mocking internal behavior.
Running tests
To run with coverage reporting:
To run a single test file:
uv run pytest tests/test_ping_budget.py
No extra configuration is needed. The conftest.py sets default environment
variables (OLLIM_USER_NAME=TestUser, OLLIM_BOT_NAME=test-bot) so tests
run without a .env file.
Test philosophy
Tests verify real behavior against real data. The guiding principle: mock only what you cannot control.
What gets tested with real instances:
- Dataclass construction and field defaults (
Routine, Reminder, BudgetState)
- File I/O — JSONL reading/writing, markdown parsing, roundtrip serialization
- State transitions — ping budget refill, session compaction, permission approval
- Configuration loading and validation
What gets mocked:
- Discord API calls (
channel.send(), message objects) — these require an active gateway connection
- Agent/Client creation in fork execution — these start real Claude API calls
This is not “no mocks.” It is no gratuitous mocks. If the code under test
can run with real objects and temp files, it does.
Test structure
Dependencies
| Package | Version | Purpose |
|---|
pytest | >=9.0.2 | Test framework |
pytest-asyncio | >=1.3.0 | Async test support |
pytest-cov | >=7.0.0 | Coverage reporting |
All three are in the dev dependency group and installed by uv sync.
The data_dir fixture
Every test that touches the filesystem uses the data_dir fixture from
conftest.py. It redirects all module-level path constants to a
tmp_path directory:
@pytest.fixture()
def data_dir(tmp_path, monkeypatch):
"""Redirect all data file paths to a temp directory."""
import ollim_bot.forks as forks_mod
import ollim_bot.inquiries as inquiries_mod
import ollim_bot.ping_budget as ping_budget_mod
import ollim_bot.scheduling.reminders as reminders_mod
import ollim_bot.scheduling.routines as routines_mod
import ollim_bot.sessions as sessions_mod
import ollim_bot.storage as storage_mod
state_dir = tmp_path / "state"
monkeypatch.setattr(storage_mod, "DATA_DIR", tmp_path)
monkeypatch.setattr(storage_mod, "STATE_DIR", state_dir)
monkeypatch.setattr(routines_mod, "ROUTINES_DIR", tmp_path / "routines")
monkeypatch.setattr(reminders_mod, "REMINDERS_DIR", tmp_path / "reminders")
monkeypatch.setattr(inquiries_mod, "INQUIRIES_FILE", state_dir / "inquiries.json")
monkeypatch.setattr(ping_budget_mod, "BUDGET_FILE", state_dir / "ping_budget.json")
monkeypatch.setattr(sessions_mod, "SESSIONS_FILE", state_dir / "sessions.json")
monkeypatch.setattr(
sessions_mod, "HISTORY_FILE", state_dir / "session_history.jsonl"
)
monkeypatch.setattr(
sessions_mod, "FORK_MESSAGES_FILE", state_dir / "fork_messages.json"
)
monkeypatch.setattr(forks_mod, "_UPDATES_FILE", state_dir / "pending_updates.json")
import ollim_bot.webhook as webhook_mod
monkeypatch.setattr(webhook_mod, "WEBHOOKS_DIR", tmp_path / "webhooks")
return tmp_path
This means tests never touch ~/.ollim-bot/. Each test gets an isolated temp
directory that is cleaned up automatically.
File organization
All tests live in tests/ as module-level functions — no test classes. Each file maps to a source module:
| Test file | Source module |
|---|
test_agent_tools.py | agent_tools.py |
test_bot.py | bot.py |
test_cli.py | main.py, routine_cmd.py, reminder_cmd.py |
test_config.py | config.py |
test_embeds.py | embeds.py, views.py |
test_forks.py | forks.py |
test_formatting.py | formatting.py |
test_inquiries.py | inquiries.py |
test_permissions.py | permissions.py |
test_ping_budget.py | ping_budget.py |
test_reminders.py | scheduling/reminders.py |
test_routines.py | scheduling/routines.py |
test_scheduler_prompts.py | scheduling/preamble.py, prompts.py |
test_sessions.py | sessions.py |
test_storage.py | storage.py |
test_tool_restrictions.py | agent_tools.py |
test_webhook.py | webhook.py |
Writing tests
Basic pattern
Tests follow a three-part structure: set up state, call the function, assert the result.
def test_load_returns_defaults_when_no_file(data_dir):
state = ping_budget.load()
assert state.capacity == 5
assert state.available == 5.0
assert state.refill_rate_minutes == 90
assert state.critical_used == 0
assert state.daily_used == 0
Use data_dir as a fixture parameter whenever the test involves file I/O.
For tests that only need a temp directory without path redirection, use
pytest’s built-in tmp_path.
Async tests
For test functions that are natively async:@pytest.mark.asyncio
async def test_concurrent_appends(data_dir):
async def append_one(i):
await append_update(f"update-{i}")
async with anyio.create_task_group() as tg:
for i in range(10):
tg.start_soon(append_one, i)
updates = await pop_pending_updates()
assert len(updates) == 10
For sync test functions that work with synchronous APIs:def test_append_and_list_routines(data_dir):
r1 = Routine.new(message="morning", cron="0 8 * * *")
r2 = Routine.new(message="evening", cron="0 18 * * *")
append_routine(r1)
append_routine(r2)
result = list_routines()
assert len(result) == 2
Assertions
Use direct assertions rather than assertion helpers:
# Equality
assert loaded.capacity == 5
# Membership
assert "5/5 available" in status
# Boolean
assert result is True
# Approximate (for floats)
from pytest import approx
assert loaded.available == approx(3.0, abs=0.01)
# Exceptions
with pytest.raises(ValueError):
parse_cron("not a cron")
Next steps