"""
Various helper resources for writing unittests.
Classes for testing Evennia core:
- `BaseEvenniaTestCase` - no default objects, only enforced default settings
- `BaseEvenniaTest` - all default objects, enforced default settings
- `BaseEvenniaCommandTest` - for testing Commands, enforced default settings
Classes for testing game folder content:
- `EvenniaTestCase` - no default objects, using gamedir settings (identical to
standard Python TestCase)
- `EvenniaTest` - all default objects, using gamedir settings
- `EvenniaCommandTest` - for testing game folder commands, using gamedir settings
Other:
- `EvenniaTestMixin` - A class mixin for creating the test environment objects, for
making custom tests.
- `EvenniaCommandMixin` - A class mixin that adds support for command testing with the .call()
helper. Used by the command-test classes, but can be used for making a customt test class.
"""
import re
import sys
import types
import evennia
from django.conf import settings
from django.test import TestCase, override_settings
from evennia import settings_default
from evennia.accounts.accounts import DefaultAccount
from evennia.commands.command import InterruptCommand
from evennia.commands.default.muxcommand import MuxCommand
from evennia.objects.objects import DefaultCharacter, DefaultExit, DefaultObject, DefaultRoom
from evennia.scripts.scripts import DefaultScript
from evennia.server.serversession import ServerSession
from evennia.utils import ansi, create
from evennia.utils.idmapper.models import flush_cache
from evennia.utils.utils import all_from_module, to_str
from mock import MagicMock, Mock, patch
from twisted.internet.defer import Deferred
_RE_STRIP_EVMENU = re.compile(r"^\+|-+\+|\+-+|--+|\|(?:\s|$)", re.MULTILINE)
# set up a 'pristine' setting, unaffected by any changes in mygame
DEFAULT_SETTING_RESETS = dict(
CONNECTION_SCREEN_MODULE="evennia.game_template.server.conf.connection_screens",
AT_SERVER_STARTSTOP_MODULE="evennia.game_template.server.conf.at_server_startstop",
AT_SERVICES_PLUGINS_MODULES=["evennia.game_template.server.conf.server_services_plugins"],
PORTAL_SERVICES_PLUGIN_MODULES=["evennia.game_template.server.conf.portal_services_plugins"],
MSSP_META_MODULE="evennia.game_template.server.conf.mssp",
WEB_PLUGINS_MODULE="server.conf.web_plugins",
LOCK_FUNC_MODULES=("evennia.locks.lockfuncs", "evennia.game_template.server.conf.lockfuncs"),
INPUT_FUNC_MODULES=[
"evennia.server.inputfuncs",
"evennia.game_template.server.conf.inputfuncs",
],
PROTOTYPE_MODULES=["evennia.game_template.world.prototypes"],
CMDSET_UNLOGGEDIN="evennia.game_template.commands.default_cmdsets.UnloggedinCmdSet",
CMDSET_SESSION="evennia.game_template.commands.default_cmdsets.SessionCmdSet",
CMDSET_CHARACTER="evennia.game_template.commands.default_cmdsets.CharacterCmdSet",
CMDSET_ACCOUNT="evennia.game_template.commands.default_cmdsets.AccountCmdSet",
CMDSET_PATHS=["evennia.game_template.commands", "evennia", "evennia.contrib"],
TYPECLASS_PATHS=[
"evennia",
"evennia.contrib",
"evennia.contrib.game_systems",
"evennia.contrib.base_systems",
"evennia.contrib.full_systems",
"evennia.contrib.tutorials",
"evennia.contrib.utils",
],
BASE_ACCOUNT_TYPECLASS="evennia.accounts.accounts.DefaultAccount",
BASE_OBJECT_TYPECLASS="evennia.objects.objects.DefaultObject",
BASE_CHARACTER_TYPECLASS="evennia.objects.objects.DefaultCharacter",
BASE_ROOM_TYPECLASS="evennia.objects.objects.DefaultRoom",
BASE_EXIT_TYPECLASS="evennia.objects.objects.DefaultExit",
BASE_CHANNEL_TYPECLASS="evennia.comms.comms.DefaultChannel",
BASE_SCRIPT_TYPECLASS="evennia.scripts.scripts.DefaultScript",
BASE_BATCHPROCESS_PATHS=[
"evennia.game_template.world",
"evennia.contrib",
"evennia.contrib.tutorials",
],
FILE_HELP_ENTRY_MODULES=["evennia.game_template.world.help_entries"],
FUNCPARSER_OUTGOING_MESSAGES_MODULES=[
"evennia.utils.funcparser",
"evennia.game_template.server.conf.inlinefuncs",
],
FUNCPARSER_PROTOTYPE_PARSING_MODULES=[
"evennia.prototypes.protfuncs",
"evennia.game_template.server.conf.prototypefuncs",
],
BASE_GUEST_TYPECLASS="evennia.accounts.accounts.DefaultGuest",
# a special setting boolean TEST_ENVIRONMENT is set by the test runner
# while the test suite is running.
DEFAULT_HOME="#1",
TEST_ENVIRONMENT=True,
)
DEFAULT_SETTINGS = {**all_from_module(settings_default), **DEFAULT_SETTING_RESETS}
DEFAULT_SETTINGS.pop("DATABASES") # we want different dbs tested in CI
# mocking of evennia.utils.utils.delay
[docs]def mockdelay(timedelay, callback, *args, **kwargs):
callback(*args, **kwargs)
return Deferred()
# mocking of twisted's deferLater
[docs]def mockdeferLater(reactor, timedelay, callback, *args, **kwargs):
callback(*args, **kwargs)
return Deferred()
[docs]def unload_module(module):
"""
Reset import so one can mock global constants.
Args:
module (module, object or str): The module will
be removed so it will have to be imported again. If given
an object, the module in which that object sits will be unloaded. A string
should directly give the module pathname to unload.
Example:
```python
# (in a test method)
unload_module(foo)
with mock.patch("foo.GLOBALTHING", "mockval"):
import foo
... # test code using foo.GLOBALTHING, now set to 'mockval'
```
This allows for mocking constants global to the module, since
otherwise those would not be mocked (since a module is only
loaded once).
"""
if isinstance(module, str):
modulename = module
elif hasattr(module, "__module__"):
modulename = module.__module__
else:
modulename = module.__name__
if modulename in sys.modules:
del sys.modules[modulename]
def _mock_deferlater(reactor, timedelay, callback, *args, **kwargs):
callback(*args, **kwargs)
return Deferred()
[docs]class EvenniaTestMixin:
"""
Evennia test environment mixin
"""
account_typeclass = DefaultAccount
object_typeclass = DefaultObject
character_typeclass = DefaultCharacter
exit_typeclass = DefaultExit
room_typeclass = DefaultRoom
script_typeclass = DefaultScript
[docs] def create_accounts(self):
self.account = create.create_account(
"TestAccount",
email="test@test.com",
password="testpassword",
typeclass=self.account_typeclass,
)
self.account2 = create.create_account(
"TestAccount2",
email="test@test.com",
password="testpassword",
typeclass=self.account_typeclass,
)
self.account.permissions.add("Developer")
[docs] def teardown_accounts(self):
if hasattr(self, "account"):
self.account.delete()
if hasattr(self, "account2"):
self.account2.delete()
# Set up fake prototype module for allowing tests to use named prototypes.
[docs] @override_settings(PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"])
def create_rooms(self):
self.room1 = create.create_object(self.room_typeclass, key="Room", nohome=True)
self.room1.db.desc = "room_desc"
self.room2 = create.create_object(self.room_typeclass, key="Room2")
self.exit = create.create_object(
self.exit_typeclass, key="out", location=self.room1, destination=self.room2
)
[docs] def create_objs(self):
self.obj1 = create.create_object(
self.object_typeclass, key="Obj", location=self.room1, home=self.room1
)
self.obj2 = create.create_object(
self.object_typeclass, key="Obj2", location=self.room1, home=self.room1
)
[docs] def create_chars(self):
self.char1 = create.create_object(
self.character_typeclass, key="Char", location=self.room1, home=self.room1
)
self.char1.permissions.add("Developer")
self.char2 = create.create_object(
self.character_typeclass, key="Char2", location=self.room1, home=self.room1
)
self.char1.account = self.account
self.account.db._last_puppet = self.char1
self.char2.account = self.account2
self.account2.db._last_puppet = self.char2
[docs] def create_script(self):
self.script = create.create_script(self.script_typeclass, key="Script")
[docs] def setup_session(self):
dummysession = ServerSession()
dummysession.init_session("telnet", ("localhost", "testmode"), evennia.SESSION_HANDLER)
dummysession.sessid = 1
evennia.SESSION_HANDLER.portal_connect(
dummysession.get_sync_data()
) # note that this creates a new Session!
session = evennia.SESSION_HANDLER.session_from_sessid(1) # the real session
evennia.SESSION_HANDLER.login(session, self.account, testmode=True)
self.session = session
[docs] def teardown_session(self):
if hasattr(self, "sessions"):
del evennia.SESSION_HANDLER[self.session.sessid]
[docs] @patch("evennia.scripts.taskhandler.deferLater", _mock_deferlater)
def setUp(self):
"""
Sets up testing environment
"""
self.backups = (
evennia.SESSION_HANDLER.data_out,
evennia.SESSION_HANDLER.disconnect,
settings.DEFAULT_HOME,
settings.PROTOTYPE_MODULES,
)
evennia.SESSION_HANDLER.data_out = Mock()
evennia.SESSION_HANDLER.disconnect = Mock()
self.create_accounts()
self.create_rooms()
self.create_objs()
self.create_chars()
self.create_script()
self.setup_session()
[docs] @override_settings(PROTOTYPE_MODULES=["evennia.utils.tests.data.prototypes_example"])
def tearDown(self):
flush_cache()
try:
evennia.SESSION_HANDLER.data_out = self.backups[0]
evennia.SESSION_HANDLER.disconnect = self.backups[1]
settings.DEFAULT_HOME = self.backups[2]
settings.PROTOTYPE_MODULES = self.backups[3]
except AttributeError as err:
raise AttributeError(
f"{err}: Teardown error. If you overrode the `setUp()` method "
"in your test, make sure you also added `super().setUp()`!"
)
del evennia.SESSION_HANDLER[self.session.sessid]
self.teardown_accounts()
super().tearDown()
[docs]@patch("evennia.server.portal.portal.LoopingCall", new=MagicMock())
class EvenniaCommandTestMixin:
"""
Mixin to add to a test in order to provide the `.call` helper for
testing the execution and returns of a command.
Tests a Command by running it and comparing what messages it sends with
expected values. This tests without actually spinning up the cmdhandler
for every test, which is more controlled.
Example:
::
from commands.echo import CmdEcho
class MyCommandTest(EvenniaTest, CommandTestMixin):
def test_echo(self):
'''
Test that the echo command really returns
what you pass into it.
'''
self.call(MyCommand(), "hello world!",
"You hear your echo: 'Hello world!'")
"""
# formatting for .call's error message
_ERROR_FORMAT = """
=========================== Wanted message ===================================
{expected_msg}
=========================== Returned message =================================
{returned_msg}
==============================================================================
""".rstrip()
[docs] def call(
self,
cmdobj,
input_args,
msg=None,
cmdset=None,
noansi=True,
caller=None,
receiver=None,
cmdstring=None,
obj=None,
inputs=None,
raw_string=None,
):
"""
Test a command by assigning all the needed properties to a cmdobj and
running the sequence. The resulting `.msg` calls will be mocked and
the text= calls to them compared to a expected output.
Args:
cmdobj (Command): The command object to use.
input_args (str): This should be the full input the Command should
see, such as 'look here'. This will become `.args` for the Command
instance to parse.
msg (str or dict, optional): This is the expected return value(s)
returned through `caller.msg(text=...)` calls in the command. If a string, the
receiver is controlled with the `receiver` kwarg (defaults to `caller`).
If this is a `dict`, it is a mapping
`{receiver1: "expected1", receiver2: "expected2",...}` and `receiver` is
ignored. The message(s) are compared with the actual messages returned
to the receiver(s) as the Command runs. Each check uses `.startswith`,
so you can choose to only include the first part of the
returned message if that's enough to verify a correct result. EvMenu
decorations (like borders) are stripped and should not be included. This
should also not include color tags unless `noansi=False`.
If the command returns texts in multiple separate `.msg`-
calls to a receiver, separate these with `|` if `noansi=True`
(default) and `||` if `noansi=False`. If no `msg` is given (`None`),
then no automatic comparison will be done.
cmdset (str, optional): If given, make `.cmdset` available on the Command
instance as it runs. While `.cmdset` is normally available on the
Command instance by default, this is usually only used by
commands that explicitly operates/displays cmdsets, like
`examine`.
noansi (str, optional): By default the color tags of the `msg` is
ignored, this makes them significant. If unset, `msg` must contain
the same color tags as the actual return message.
caller (Object or Account, optional): By default `self.char1` is used as the
command-caller (the `.caller` property on the Command). This allows to
execute with another caller, most commonly an Account.
receiver (Object or Account, optional): This is the object to receive the
return messages we want to test. By default this is the same as `caller`
(which in turn defaults to is `self.char1`). Note that if `msg` is
a `dict`, this is ignored since the receiver is already specified there.
cmdstring (str, optional): Normally this is the Command's `key`.
This allows for tweaking the `.cmdname` property of the
Command`. This isb used for commands with multiple aliases,
where the command explicitly checs which alias was used to
determine its functionality.
obj (str, optional): This sets the `.obj` property of the Command - the
object on which the Command 'sits'. By default this is the same as `caller`.
This can be used for testing on-object Command interactions.
inputs (list, optional): A list of strings to pass to functions that pause to
take input from the user (normally using `@interactive` and
`ret = yield(question)` or `evmenu.get_input`). Each element of the
list will be passed into the command as if the user answered each prompt
in that order.
raw_string (str, optional): Normally the `.raw_string` property is set as
a combination of your `key/cmdname` and `input_args`. This allows
direct control of what this is, for example for testing edge cases
or malformed inputs.
Returns:
str or dict: The message sent to `receiver`, or a dict of
`{receiver: "msg", ...}` if multiple are given. This is usually
only used with `msg=None` to do the validation externally.
Raises:
AssertionError: If the returns of `.msg` calls (tested with `.startswith`) does not
match `expected_input`.
Notes:
As part of the tests, all methods of the Command will be called in
the proper order:
- cmdobj.at_pre_cmd()
- cmdobj.parse()
- cmdobj.func()
- cmdobj.at_post_cmd()
"""
# The `self.char1` is created in the `EvenniaTest` base along with
# other helper objects like self.room and self.obj
caller = caller if caller else self.char1
cmdobj.caller = caller
cmdobj.cmdname = cmdstring if cmdstring else cmdobj.key
cmdobj.raw_cmdname = cmdobj.cmdname
cmdobj.cmdstring = cmdobj.cmdname # deprecated
cmdobj.args = input_args
cmdobj.cmdset = cmdset
cmdobj.session = evennia.SESSION_HANDLER.session_from_sessid(1)
cmdobj.account = self.account
cmdobj.raw_string = raw_string if raw_string is not None else cmdobj.key + " " + input_args
cmdobj.obj = obj or (caller if caller else self.char1)
inputs = inputs or []
# set up receivers
receiver_mapping = {}
if isinstance(msg, dict):
# a mapping {receiver: msg, ...}
receiver_mapping = {
recv: str(msg).strip() if msg else None for recv, msg in msg.items()
}
else:
# a single expected string and thus a single receiver (defaults to caller)
receiver = receiver if receiver else caller
receiver_mapping[receiver] = str(msg).strip() if msg is not None else None
unmocked_msg_methods = {}
for receiver in receiver_mapping:
# save the old .msg method so we can get it back
# cleanly after the test
unmocked_msg_methods[receiver] = receiver.msg
# replace normal `.msg` with a mock
receiver.msg = Mock()
# Run the methods of the Command. This mimics what happens in the
# cmdhandler. This will have the mocked .msg be called as part of the
# execution. Mocks remembers what was sent to them so we will be able
# to retrieve what was sent later.
try:
if cmdobj.at_pre_cmd():
return
cmdobj.parse()
ret = cmdobj.func()
# handle func's with yield in them (making them generators)
if isinstance(ret, types.GeneratorType):
while True:
try:
inp = inputs.pop() if inputs else None
if inp:
try:
# this mimics a user's reply to a prompt
ret.send(inp)
except TypeError:
next(ret)
ret = ret.send(inp)
else:
# non-input yield, like yield(10). We don't pause
# but fire it immediately.
next(ret)
except StopIteration:
break
cmdobj.at_post_cmd()
except StopIteration:
pass
except InterruptCommand:
pass
for inp in inputs:
# if there are any inputs left, we may have a non-generator
# input to handle (get_input/ask_yes_no that uses a separate
# cmdset rather than a yield
caller.execute_cmd(inp)
# At this point the mocked .msg methods on each receiver will have
# stored all calls made to them (that's a basic function of the Mock
# class). We will not extract them and compare to what we expected to
# go to each receiver.
returned_msgs = {}
for receiver, expected_msg in receiver_mapping.items():
# get the stored messages from the Mock with Mock.mock_calls.
stored_msg = [
args[0] if args and args[0] else kwargs.get("text", to_str(kwargs))
for name, args, kwargs in receiver.msg.mock_calls
]
# we can return this now, we are done using the mock
receiver.msg = unmocked_msg_methods[receiver]
# Get the first element of a tuple if msg received a tuple instead of a string
stored_msg = [
str(smsg[0]) if isinstance(smsg, tuple) else str(smsg) for smsg in stored_msg
]
if expected_msg is None:
# no expected_msg; just build the returned_msgs dict
returned_msg = "\n".join(str(msg) for msg in stored_msg)
returned_msgs[receiver] = ansi.parse_ansi(returned_msg, strip_ansi=noansi).strip()
else:
# compare messages to expected
# set our separator for returned messages based on parsing ansi or not
msg_sep = "|" if noansi else "||"
# We remove Evmenu decorations since that just makes it harder
# to write the comparison string. We also strip ansi before this
# comparison since otherwise it would mess with the regex.
returned_msg = msg_sep.join(
_RE_STRIP_EVMENU.sub("", ansi.parse_ansi(mess, strip_ansi=noansi))
for mess in stored_msg
).strip()
# this is the actual test
if expected_msg == "" and returned_msg or not returned_msg.startswith(expected_msg):
# failed the test
raise AssertionError(
self._ERROR_FORMAT.format(
expected_msg=expected_msg, returned_msg=returned_msg
)
)
# passed!
returned_msgs[receiver] = returned_msg
if len(returned_msgs) == 1:
return list(returned_msgs.values())[0]
return returned_msgs
# Base testing classes
[docs]@override_settings(**DEFAULT_SETTINGS)
class BaseEvenniaTestCase(TestCase):
"""
Base test (with no default objects) but with enforced default settings.
"""
[docs] def tearDown(self) -> None:
super().tearDown()
flush_cache()
[docs]class EvenniaTestCase(TestCase):
"""
For use with gamedir settings; Just like the normal test case, only for naming consistency.
Notes:
- Inheriting from this class will bypass EvenniaTestMixin, and therefore
not setup some default objects. This can result in faster tests.
- If you do inherit from this class for your unit tests, and have
overridden the tearDown() method, please also call flush_cache(). Not
doing so will result in flakey and order-dependent tests due to the
Django ID cache not being flushed.
"""
[docs] def tearDown(self) -> None:
super().tearDown()
flush_cache()
[docs]@override_settings(**DEFAULT_SETTINGS)
class BaseEvenniaTest(EvenniaTestMixin, TestCase):
"""
This class parent has all default objects and uses only default settings.
"""
[docs]class EvenniaTest(EvenniaTestMixin, TestCase):
"""
This test class is intended for inheriting in mygame tests.
It helps ensure your tests are run with your own objects
and settings from your game folder.
"""
account_typeclass = settings.BASE_ACCOUNT_TYPECLASS
object_typeclass = settings.BASE_OBJECT_TYPECLASS
character_typeclass = settings.BASE_CHARACTER_TYPECLASS
exit_typeclass = settings.BASE_EXIT_TYPECLASS
room_typeclass = settings.BASE_ROOM_TYPECLASS
script_typeclass = settings.BASE_SCRIPT_TYPECLASS
[docs]@patch("evennia.commands.account.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.admin.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.batchprocess.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.building.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.comms.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.general.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.help.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.syscommands.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.system.COMMAND_DEFAULT_CLASS", MuxCommand)
@patch("evennia.commands.unloggedin.COMMAND_DEFAULT_CLASS", MuxCommand)
@override_settings(**DEFAULT_SETTINGS)
class BaseEvenniaCommandTest(BaseEvenniaTest, EvenniaCommandTestMixin):
"""
Commands only using the default settings.
"""
[docs]class EvenniaCommandTest(EvenniaTest, EvenniaCommandTestMixin):
"""
Parent class to inherit from - makes tests use your own
classes and settings in mygame.
"""