Writing Tests for Your Verbs
This tutorial shows you how to write automated tests for MOO verbs. By the end you’ll know how to capture player output, verify database side effects, call verbs directly without the command parser, and test lock behavior — all using pytest and the fixtures that ship with the project.
Prerequisites
Before you start:
Familiarity with Your First MOO Verb — the tutorial where the
greetverb is introducedA working development environment (see Development)
Basic familiarity with pytest
The verb we’re testing
We’ll test a greet verb on $thing. Save this as moo/bootstrap/default/verbs/thing/greet.py:
#!moo verb greet --on $thing --dspec this
print(f"You greet {this.name}. It doesn't respond.")
this.location.announce_all_but(context.player, f"{context.player.name} greets {this.name}.")
This verb:
Matches
greet widgetwhenwidgetis a$thing(--dspec thismeans the direct object must resolve to the object the verb is defined on — the parser matcheswidgettothis)Prints a message to the caller via
print()Announces to everyone else in the room via
announce_all_but()
Step 1: Create the test file
Create moo/bootstrap/default/tests/test_greet.py:
import pytest
from moo.core import code, parse
from moo.sdk import create, lookup
from moo.core.models import Object
These are the standard imports for default-verb tests. code provides ContextManager. parse provides interpret. create and lookup are SDK helpers for building test fixtures.
Step 2: Add the fixtures and markers
Every default-verb test uses two markers and two fixtures:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_greet_prints_to_caller(t_init: Object, t_wizard: Object):
...
@pytest.mark.django_db(transaction=True, reset_sequences=True)— wraps each test in a real database transaction that is rolled back on exit;reset_sequences=Trueensures auto-increment IDs reset so tests don’t depend on specific object IDs@pytest.mark.parametrize("t_init", ["default"], indirect=True)— runs thet_initfixture with"default"as its argument, which bootstraps the full default game worldt_init: Object— the system object (#1) after bootstrap; not used directly here but must be listed for the fixture to runt_wizard: Object— the Wizard player object; used as the actor for all commands
Step 3: Capture print() output
print() inside verb code sends output to the player who issued the command. In tests, you capture it by passing a callback to code.ContextManager:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_greet_prints_to_caller(t_init: Object, t_wizard: Object):
printed = []
with code.ContextManager(t_wizard, printed.append) as ctx:
system = lookup(1)
widget = create("widget", parents=[system.thing], location=t_wizard.location)
parse.interpret(ctx, "greet widget")
assert printed == ["You greet widget. It doesn't respond."]
code.ContextManager(t_wizard, printed.append) sets the current player to t_wizard for the duration of the with block. Every print() call inside verb code calls printed.append. parse.interpret(ctx, "greet widget") dispatches the command through the full verb search pipeline — the same path a real player command takes.
Step 4: Capture tell() and write() output
tell() and write() target a specific player. There’s no live SSH connection in tests, so write() raises ConnectionError — pytest surfaces it as a RuntimeWarning.
The greet verb calls announce_all_but(), which calls tell() on every player in the room except the caller. To capture those messages, wrap the parse.interpret call in pytest.warns:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_greet_announces_to_room(t_init: Object, t_wizard: Object):
printed = []
with code.ContextManager(t_wizard, printed.append) as ctx:
system = lookup(1)
player = lookup("Player")
widget = create("widget", parents=[system.thing], location=t_wizard.location)
with pytest.warns(RuntimeWarning, match="ConnectionError") as w:
parse.interpret(ctx, "greet widget")
messages = [str(x.message) for x in w.list]
assert any("Wizard greets widget." in m for m in messages)
pytest.warns(RuntimeWarning) catches all ConnectionError warnings. Each one has the form ConnectionError(#N (Name)): <message text>. Extract message text by converting to string and checking for a substring.
Note that mock_player_connected in conftest.py patches is_connected to return True for all objects during tests. This means tell() fires normally — without it, no write() calls would happen at all.
Step 5: Test database side effects
When a verb moves an object, modifies a property, or changes ownership, you need to call refresh_from_db() before asserting — the in-memory object won’t reflect database changes made inside the Celery task:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_drop_moves_object_to_room(t_init: Object, t_wizard: Object):
printed = []
with code.ContextManager(t_wizard, printed.append) as ctx:
system = lookup(1)
lab = t_wizard.location
widget = create("widget", parents=[system.thing], location=t_wizard)
with pytest.warns(RuntimeWarning, match="ConnectionError"):
parse.interpret(ctx, "drop widget")
widget.refresh_from_db()
assert widget.location == lab
assert printed == ["You drop widget."]
Without refresh_from_db(), widget.location still shows the pre-command value.
Step 6: Call verbs directly (skip the parser)
For helper verbs — message formatters, lock checks, anything called as a method rather than a player command — call them directly on the object inside a code.ContextManager block:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_message_verbs(t_init: Object, t_wizard: Object):
with code.ContextManager(t_wizard, lambda _: None):
system = lookup(1)
widget = create("widget", parents=[system.thing], location=t_wizard)
assert widget.take_succeeded_msg() == f"You take {widget.title()}."
assert widget.drop_succeeded_msg() == f"You drop {widget.title()}."
assert widget.odrop_succeeded_msg() == f"{t_wizard.name} drops {widget.title()}."
This calls the verb as a Python method — widget.take_succeeded_msg() — directly on the object, without going through the command parser. It runs in the context of t_wizard as the current player.
Step 7: Test a lock guard
The moveto() method checks the destination’s key property before moving an object. Set a key expression to block movement and verify the object stays put:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_moveto_blocked_by_lock(t_init: Object, t_wizard: Object):
with code.ContextManager(t_wizard, lambda _: None):
system = lookup(1)
rooms = lookup("Generic Room")
lab = t_wizard.location
destination = create("Locked Room", parents=[rooms], location=None)
widget = create("widget", parents=[system.thing], location=lab)
# Block the widget by ID: lock expression ["!", id] means "not this object"
destination.set_property("key", ["!", widget.id])
widget.moveto(destination)
widget.refresh_from_db()
assert widget.location == lab # move was blocked
Step 8: Using the setup_item fixture
The setup_item fixture from moo/bootstrap/default/tests/conftest.py creates a $thing in any location, reducing boilerplate:
@pytest.mark.django_db(transaction=True, reset_sequences=True)
@pytest.mark.parametrize("t_init", ["default"], indirect=True)
def test_give_to_player(t_init: Object, t_wizard: Object, setup_item):
with code.ContextManager(t_wizard, lambda _: None) as ctx:
player_obj = lookup("Player")
widget = setup_item(t_wizard, "widget") # creates $thing in t_wizard's inventory
parse.interpret(ctx, "give widget to Player")
widget.refresh_from_db()
assert widget.location == player_obj
setup_item(location, name) creates a $thing child at the given location. The name argument defaults to "red ball".
Step 9: Run the tests
uv run pytest -n auto moo/bootstrap/default/tests/test_greet.py
-n auto runs tests in parallel using all available cores. To run a single test, append ::test_name:
uv run pytest -n auto moo/bootstrap/default/tests/test_greet.py::test_greet_prints_to_caller
What just happened
code.ContextManager establishes the context proxy for a given player. Every piece of verb code that reads context.player, context.caller, or context.parser is reading state set by this context manager. parse.interpret dispatches a command string through the same verb search pipeline that live player commands use — object resolution, preposition matching, dspec checking — so tests cover the full dispatch path.
tell() and write() raise ConnectionError for players without a live session because there is no SSH connection to send output to. The mock_player_connected autouse fixture (in moo/conftest.py) patches is_connected() to return True so these calls proceed normally in test. The ConnectionError that arrives is therefore a synthetic stand-in for the actual message delivery.
Where to go next
Development — The full test toolchain: running tests with coverage, pylint, and the VSCode test runner
The DjangoMOO Runtime — The complete
contextvariable referenceCreating MOO Verbs — The full verb code format including shebang syntax, output mechanisms, and error handling