"""
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
from django.conf import settings
from django.test import TestCase, override_settings
from mock import MagicMock, Mock, patch
from twisted.internet.defer import Deferred
import evennia
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
_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_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"], DEFAULT_HOME="#1"
)
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] 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]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:
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)
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.
"""