From 289f9cec974b7460e54fb0590619e0c6c3905019 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 14 Dec 2020 17:58:20 +0100 Subject: [PATCH 1/3] tests: deprecate test_tools; replace with tests.pytest_plugin --- sopel/plugin.py | 15 +-- sopel/test_tools.py | 172 ++++++++++------------------------- sopel/tests/pytest_plugin.py | 153 +++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+), 132 deletions(-) diff --git a/sopel/plugin.py b/sopel/plugin.py index 4f3fe78f20..3ea4ee10a1 100644 --- a/sopel/plugin.py +++ b/sopel/plugin.py @@ -1134,16 +1134,16 @@ def __call__(self, func): import sys - import sopel.test_tools # TODO: fix circular import with sopel.bot and sopel.test_tools - # only inject test-related stuff if we're running tests # see https://stackoverflow.com/a/44595269/5991 if 'pytest' in sys.modules and self.result: + from sopel.tests import pytest_plugin + # avoids doing `import pytest` and causing errors when # dev-dependencies aren't installed pytest = sys.modules['pytest'] - test = sopel.test_tools.get_example_test( + test = pytest_plugin.get_example_test( func, self.msg, self.result, self.privmsg, self.admin, self.owner, self.repeat, self.use_re, self.ignore ) @@ -1154,11 +1154,14 @@ def __call__(self, func): if self.vcr: test = pytest.mark.vcr(test) - sopel.test_tools.insert_into_module( + pytest_plugin.insert_into_module( test, func.__module__, func.__name__, 'test_example' ) - sopel.test_tools.insert_into_module( - sopel.test_tools.get_disable_setup(), func.__module__, func.__name__, 'disable_setup' + pytest_plugin.insert_into_module( + pytest_plugin.get_disable_setup(), + func.__module__, + func.__name__, + 'disable_setup', ) record = { diff --git a/sopel/test_tools.py b/sopel/test_tools.py index a9ec2c8ca2..8e25c6fd4b 100644 --- a/sopel/test_tools.py +++ b/sopel/test_tools.py @@ -1,12 +1,17 @@ # coding=utf-8 -"""This module has classes and functions that can help in writing tests. +"""This module provided tools that helped to write tests. -.. note:: +.. deprecated:: 7.1 - This module formerly contained mock classes for bot, bot wrapper, and config - objects. Those are deprecated, and will be removed in Sopel 8.0. New code - should use the new :mod:`.mocks`, :mod:`.factories`, and - :mod:`.pytest_plugin` added in Sopel 7.0. + This module will be **removed in Sopel 8**. + + It formerly contained mock classes for the bot, its wrapper, and its config + object. As the module is deprecated, so are they, and they will be removed + as well. + + New code should use the :mod:`pytest plugin ` + for Sopel; or should take advantage of the :mod:`~sopel.tests.mocks` and + :mod:`~sopel.tests.factories` modules, both added in Sopel 7.0. """ # Copyright 2013, Ari Koivula, @@ -24,7 +29,7 @@ except ImportError: import configparser as ConfigParser -from sopel import bot, config, loader, plugins, tools, trigger +from sopel import bot, config, tools __all__ = [ @@ -133,131 +138,46 @@ def __init__(self, *args, **kwargs): """ -def get_example_test(tested_func, msg, results, privmsg, admin, - owner, repeat, use_regexp, ignore=[]): +@tools.deprecated('this is now part of sopel.tests.pytest_plugin', '7.1', '8.0') +def get_example_test(*args, **kwargs): """Get a function that calls ``tested_func`` with fake wrapper and trigger. - :param callable tested_func: a Sopel callable that accepts a - :class:`~.bot.SopelWrapper` and a :class:`~.trigger.Trigger` - :param str msg: message that is supposed to trigger the command - :param list results: expected output from the callable - :param bool privmsg: if ``True``, make the message appear to have arrived - in a private message to the bot; otherwise make it - appear to have come from a channel - :param bool admin: make the message appear to have come from an admin - :param bool owner: make the message appear to have come from an owner - :param int repeat: how many times to repeat the test; useful for tests that - return random stuff - :param bool use_regexp: pass ``True`` if ``results`` are in regexp format - :param list ignore: strings to ignore - :return: a test function for ``tested_func`` - :rtype: :term:`function` + .. deprecated:: 7.1 + + This is now part of the Sopel pytest plugin at + :mod:`sopel.tests.pytest_plugin`. + """ - def test(configfactory, botfactory, ircfactory): - test_config = TEST_CONFIG.format( - name='NickName', - admin=admin, - owner=owner, - ) - settings = configfactory('default.cfg', test_config) - url_schemes = settings.core.auto_url_schemes - mockbot = botfactory(settings) - server = ircfactory(mockbot) - server.channel_joined('#Sopel') - - if not hasattr(tested_func, 'commands'): - raise AssertionError('Function is not a command.') - - loader.clean_callable(tested_func, settings) - test_rule = plugins.rules.Command.from_callable(settings, tested_func) - parse_results = list(test_rule.parse(msg)) - assert parse_results, "Example did not match any command." - - match = parse_results[0] - sender = mockbot.nick if privmsg else "#channel" - hostmask = "%s!%s@%s" % (mockbot.nick, "UserName", "example.com") - - # TODO enable message tags - full_message = ':{} PRIVMSG {} :{}'.format(hostmask, sender, msg) - pretrigger = trigger.PreTrigger( - mockbot.nick, full_message, url_schemes=url_schemes) - test_trigger = trigger.Trigger(mockbot.settings, pretrigger, match) - pattern = re.compile(r'^%s: ' % re.escape(mockbot.nick)) - - # setup module - module = sys.modules[tested_func.__module__] - if hasattr(module, 'setup'): - module.setup(mockbot) - - def isnt_ignored(value): - """Return True if value doesn't match any re in ignore list.""" - return not any( - re.match(ignored_line, value) - for ignored_line in ignore) - - expected_output_count = 0 - for _i in range(repeat): - expected_output_count += len(results) - wrapper = bot.SopelWrapper(mockbot, test_trigger) - tested_func(wrapper, test_trigger) - - output_triggers = ( - trigger.PreTrigger( - mockbot.nick, - message.decode('utf-8'), - url_schemes=url_schemes, - ) - for message in wrapper.backend.message_sent - ) - output_texts = ( - # subtract "Sopel: " when necessary - pattern.sub('', output_trigger.args[-1]) - for output_trigger in output_triggers - ) - outputs = [text for text in output_texts if isnt_ignored(text)] - - # output length - assert len(outputs) == expected_output_count - - # output content - for expected, output in zip(results, outputs): - if use_regexp: - message = ( - "Output does not match the regex:\n" - "Pattern: %s\n" - "Output: %s" - ) % (expected, output) - if not re.match(expected, output): - raise AssertionError(message) - else: - assert expected == output - - return test + from sopel.tests import pytest_plugin + return pytest_plugin.get_example_test(*args, **kwargs) +@tools.deprecated('this is now part of sopel.tests.pytest_plugin', '7.1', '8.0') def get_disable_setup(): - import pytest - import py - - @pytest.fixture(autouse=True) - def disable_setup(request, monkeypatch): - setup = getattr(request.module, "setup", None) - isfixture = hasattr(setup, "_pytestfixturefunction") - if setup is not None and not isfixture and py.builtin.callable(setup): - monkeypatch.setattr(setup, "_pytestfixturefunction", pytest.fixture(), raising=False) - return disable_setup - - -def insert_into_module(func, module_name, base_name, prefix): - """Add a function into a module.""" - func.__module__ = module_name - module = sys.modules[module_name] - # Make sure the func method does not overwrite anything. - for i in range(1000): - func.__name__ = str("%s_%s_%s" % (prefix, base_name, i)) - if not hasattr(module, func.__name__): - break - setattr(module, func.__name__, func) + """Get a function to prevent conflict between pytest and plugin's setup. + + .. deprecated:: 7.1 + + This is now part of the Sopel pytest plugin at + :mod:`sopel.tests.pytest_plugin`. + + """ + from sopel.tests import pytest_plugin + return pytest_plugin.get_disable_setup() + + +@tools.deprecated('this is now part of sopel.tests.pytest_plugin', '7.1', '8.0') +def insert_into_module(*args, **kwargs): + """Add a function into a module. + + .. deprecated:: 7.1 + + This is now part of the Sopel pytest plugin at + :mod:`sopel.tests.pytest_plugin`. + + """ + from sopel.tests import pytest_plugin + return pytest_plugin.insert_into_module(*args, **kwargs) @tools.deprecated('pytest now runs @plugin.example tests directly', '7.1', '8.0') diff --git a/sopel/tests/pytest_plugin.py b/sopel/tests/pytest_plugin.py index a33f34eb57..bbb3a148fa 100644 --- a/sopel/tests/pytest_plugin.py +++ b/sopel/tests/pytest_plugin.py @@ -5,11 +5,164 @@ """ from __future__ import absolute_import, division, print_function, unicode_literals +import re +import sys + +import py import pytest +from sopel import bot, loader, plugins, trigger from .factories import BotFactory, ConfigFactory, IRCFactory, TriggerFactory, UserFactory +TEMPLATE_TEST_CONFIG = """ +[core] +nick = {name} +owner = {owner} +admin = {admin} +""" + + +def get_disable_setup(): + """Generate a pytest fixture to setup the plugin before running its tests. + + When using ``@example`` for a plugin callable with an expected output, + pytest will be used to run it as a test. In order to work, this fixture + must be added to the plugin to set up the plugin before running the test. + """ + @pytest.fixture(autouse=True) + def disable_setup(request, monkeypatch): + setup = getattr(request.module, "setup", None) + isfixture = hasattr(setup, "_pytestfixturefunction") + if setup is not None and not isfixture and py.builtin.callable(setup): + monkeypatch.setattr( + setup, + "_pytestfixturefunction", + pytest.fixture(), + raising=False, + ) + return disable_setup + + +def get_example_test(tested_func, msg, results, privmsg, admin, + owner, repeat, use_regexp, ignore=[]): + """Get a function that calls ``tested_func`` with fake wrapper and trigger. + + :param callable tested_func: a Sopel callable that accepts a + :class:`~.bot.SopelWrapper` and a + :class:`~.trigger.Trigger` + :param str msg: message that is supposed to trigger the command + :param list results: expected output from the callable + :param bool privmsg: if ``True``, make the message appear to have arrived + in a private message to the bot; otherwise make it + appear to have come from a channel + :param bool admin: make the message appear to have come from an admin + :param bool owner: make the message appear to have come from an owner + :param int repeat: how many times to repeat the test; useful for tests that + return random stuff + :param bool use_regexp: pass ``True`` if ``results`` are in regexp format + :param list ignore: strings to ignore + :return: a test function for ``tested_func`` + :rtype: :term:`function` + """ + def test(configfactory, botfactory, ircfactory): + test_config = TEMPLATE_TEST_CONFIG.format( + name='NickName', + admin=admin, + owner=owner, + ) + settings = configfactory('default.cfg', test_config) + url_schemes = settings.core.auto_url_schemes + mockbot = botfactory(settings) + server = ircfactory(mockbot) + server.channel_joined('#Sopel') + + if not hasattr(tested_func, 'commands'): + raise AssertionError('Function is not a command.') + + loader.clean_callable(tested_func, settings) + test_rule = plugins.rules.Command.from_callable(settings, tested_func) + parse_results = list(test_rule.parse(msg)) + assert parse_results, "Example did not match any command." + + match = parse_results[0] + sender = mockbot.nick if privmsg else "#channel" + hostmask = "%s!%s@%s" % (mockbot.nick, "UserName", "example.com") + + # TODO enable message tags + full_message = ':{} PRIVMSG {} :{}'.format(hostmask, sender, msg) + pretrigger = trigger.PreTrigger( + mockbot.nick, full_message, url_schemes=url_schemes) + test_trigger = trigger.Trigger(mockbot.settings, pretrigger, match) + pattern = re.compile(r'^%s: ' % re.escape(mockbot.nick)) + + # setup module + module = sys.modules[tested_func.__module__] + if hasattr(module, 'setup'): + module.setup(mockbot) + + def isnt_ignored(value): + """Return True if value doesn't match any re in ignore list.""" + return not any( + re.match(ignored_line, value) + for ignored_line in ignore) + + expected_output_count = 0 + for _i in range(repeat): + expected_output_count += len(results) + wrapper = bot.SopelWrapper(mockbot, test_trigger) + tested_func(wrapper, test_trigger) + + output_triggers = ( + trigger.PreTrigger( + mockbot.nick, + message.decode('utf-8'), + url_schemes=url_schemes, + ) + for message in wrapper.backend.message_sent + ) + output_texts = ( + # subtract "Sopel: " when necessary + pattern.sub('', output_trigger.args[-1]) + for output_trigger in output_triggers + ) + outputs = [text for text in output_texts if isnt_ignored(text)] + + # output length + assert len(outputs) == expected_output_count + + # output content + for expected, output in zip(results, outputs): + if use_regexp: + message = ( + "Output does not match the regex:\n" + "Pattern: %s\n" + "Output: %s" + ) % (expected, output) + if not re.match(expected, output): + raise AssertionError(message) + else: + assert expected == output + + return test + + +def insert_into_module(func, module_name, base_name, prefix): + """Add a function into a module. + + This can be used to add a test function, a setup function, or a fixture + to an existing module to be used with pytest. + """ + func.__module__ = module_name + module = sys.modules[module_name] + # Make sure the func method does not overwrite anything. + for i in range(1000): + func.__name__ = str("%s_%s_%s" % (prefix, base_name, i)) + if not hasattr(module, func.__name__): + break + setattr(module, func.__name__, func) + + @pytest.fixture def botfactory(): """Fixture to get a Bot factory. From 3166e1ff4f36437d6f816edf6067fcd0d23ff21c Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 14 Dec 2020 18:56:54 +0100 Subject: [PATCH 2/3] tests: raise if a function cannot be included in a test run --- sopel/tests/pytest_plugin.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sopel/tests/pytest_plugin.py b/sopel/tests/pytest_plugin.py index bbb3a148fa..87303a9468 100644 --- a/sopel/tests/pytest_plugin.py +++ b/sopel/tests/pytest_plugin.py @@ -160,6 +160,11 @@ def insert_into_module(func, module_name, base_name, prefix): func.__name__ = str("%s_%s_%s" % (prefix, base_name, i)) if not hasattr(module, func.__name__): break + else: + # 1000 variations of this function's name already exist + raise RuntimeError('Unable to insert function %s into module %s' % ( + func.__name__, func.__module__ + )) setattr(module, func.__name__, func) From b8d5144b499cd6b82e52e6dfd4b81d66deef3181 Mon Sep 17 00:00:00 2001 From: Florian Strzelecki Date: Mon, 14 Dec 2020 19:06:38 +0100 Subject: [PATCH 3/3] tests: replace py.builtin function by actual builtin --- sopel/tests/pytest_plugin.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/sopel/tests/pytest_plugin.py b/sopel/tests/pytest_plugin.py index 87303a9468..d4f6ddb3a1 100644 --- a/sopel/tests/pytest_plugin.py +++ b/sopel/tests/pytest_plugin.py @@ -8,7 +8,6 @@ import re import sys -import py import pytest from sopel import bot, loader, plugins, trigger @@ -34,7 +33,7 @@ def get_disable_setup(): def disable_setup(request, monkeypatch): setup = getattr(request.module, "setup", None) isfixture = hasattr(setup, "_pytestfixturefunction") - if setup is not None and not isfixture and py.builtin.callable(setup): + if setup is not None and not isfixture and callable(setup): monkeypatch.setattr( setup, "_pytestfixturefunction",