From bd8041c55fd925fbcc62f0617292c04d2f247708 Mon Sep 17 00:00:00 2001 From: Vladas Tamoshaitis Date: Sat, 4 Apr 2020 19:38:22 +0300 Subject: [PATCH] Allow override_config for pytest (#338) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * provides: base override class; unittest and pytest overrides * raise invalid config error earlier * update AUTHORS * avoid AttributeError * fix comment * add tests * fix tests, update docstring * update docs, improve tests * fix docs * fix markdown * refactor pytest override, use hidden fixture, refactor base and unittest classes * improve docstring and error * refactor pytest override to use hooks * set minimum pytest version * revert empty lines removal * introduce pytest test runner for package, refactoring * WIP * Finalize tox config, refactor docs, add global fixture * skip py35 * pytest command: remove unnecessary ignore * address comments * Update constance/test/pytest.py * address comments * add test for checking nested markers Co-authored-by: Camilo Nova Co-authored-by: Paweł Zarębski --- .coveragerc | 4 +- AUTHORS | 1 + constance/test/__init__.py | 2 +- constance/test/pytest.py | 79 +++++++++++++++++++ constance/test/{utils.py => unittest.py} | 0 docs/testing.rst | 70 ++++++++++++++++ setup.py | 7 +- tests/test_pytest_overrides.py | 78 ++++++++++++++++++ ...t_test_utils.py => test_test_overrides.py} | 0 tox.ini | 14 ++-- 10 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 constance/test/pytest.py rename constance/test/{utils.py => unittest.py} (100%) create mode 100644 tests/test_pytest_overrides.py rename tests/{test_test_utils.py => test_test_overrides.py} (100%) diff --git a/.coveragerc b/.coveragerc index 225c0c7a..72b67fd2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,8 @@ [run] source = constance branch = 1 +omit = + */pytest.py [report] -omit = *tests*,*migrations* +omit = *tests*,*migrations*,.tox/*,setup.py,*settings.py diff --git a/AUTHORS b/AUTHORS index 27ef03cb..738055a6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,6 +40,7 @@ saw2th trbs vl <1844144@gmail.com> vl +Vladas Tamoshaitis Dmitriy Tatarkin Alexandr Artemyev Elisey Zanko diff --git a/constance/test/__init__.py b/constance/test/__init__.py index 0fb5524c..222fef9f 100644 --- a/constance/test/__init__.py +++ b/constance/test/__init__.py @@ -1 +1 @@ -from .utils import override_config +from .unittest import override_config # pragma: no cover diff --git a/constance/test/pytest.py b/constance/test/pytest.py new file mode 100644 index 00000000..bd878d46 --- /dev/null +++ b/constance/test/pytest.py @@ -0,0 +1,79 @@ +""" +Pytest constance override config plugin. + +Inspired by https://github.com/pytest-dev/pytest-django/. +""" +import pytest +from contextlib import ContextDecorator +from constance import config as constance_config + + +@pytest.hookimpl(trylast=True) +def pytest_configure(config): # pragma: no cover + """ + Register override_config marker. + """ + config.addinivalue_line( + "markers", + ( + "override_config(**kwargs): " + "mark test to override django-constance config" + ) + ) + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_call(item): # pragma: no cover + """ + Validate constance override marker params. Run test with overrided config. + """ + marker = item.get_closest_marker("override_config") + if marker is not None: + if marker.args: + pytest.fail( + "Constance override can not not accept positional args" + ) + with override_config(**marker.kwargs): + yield + else: + yield + + +class override_config(ContextDecorator): + """ + Override config while running test function. + + Act as context manager and decorator. + """ + def enable(self): + """ + Store original config values and set overridden values. + """ + for key, value in self._to_override.items(): + self._original_values[key] = getattr(constance_config, key) + setattr(constance_config, key, value) + + def disable(self): + """ + Set original values to the config. + """ + for key, value in self._original_values.items(): + setattr(constance_config, key, value) + + def __init__(self, **kwargs): + self._to_override = kwargs.copy() + self._original_values = {} + + def __enter__(self): + self.enable() + + def __exit__(self, exc_type, exc_val, exc_tb): + self.disable() + + +@pytest.fixture(name="override_config") +def _override_config(): + """ + Make override_config available as a function fixture. + """ + return override_config diff --git a/constance/test/utils.py b/constance/test/unittest.py similarity index 100% rename from constance/test/utils.py rename to constance/test/unittest.py diff --git a/docs/testing.rst b/docs/testing.rst index 8d092f12..fb1ebace 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -38,3 +38,73 @@ method level and also as a def test_what_is_your_favourite_color(self): with override_config(YOUR_FAVOURITE_COLOR="Blue?"): self.assertEqual(config.YOUR_FAVOURITE_COLOR, "Blue?") + + +Pytest usage +~~~~~ + +Django-constance provides pytest plugin that adds marker +:class:`@pytest.mark.override_config()`. It handles config override for +module/class/function, and automatically revert any changes made to the +constance config values when test is completed. + +.. py:function:: pytest.mark.override_config(**kwargs) + + Specify different config values for the marked tests in kwargs. + +Module scope override + +.. code-block:: python + + pytestmark = pytest.mark.override_config(API_URL="/awesome/url/") + + def test_api_url_is_awesome(): + ... + +Class/function scope + +.. code-block:: python + + from constance import config + + @pytest.mark.override_config(API_URL="/awesome/url/") + class SomeClassTest: + def test_is_awesome_url(self): + assert config.API_URL == "/awesome/url/" + + @pytest.mark.override_config(API_URL="/another/awesome/url/") + def test_another_awesome_url(self): + assert config.API_URL == "/another/awesome/url/" + +If you want to use override as a context manager or decorator, consider using + +.. code-block:: python + + from constance.test.pytest import override_config + + def test_override_context_manager(): + with override_config(BOOL_VALUE=False): + ... + # or + @override_config(BOOL_VALUE=False) + def test_override_context_manager(): + ... + +Pytest fixture as function or method parameter ( +NOTE: no import needed as fixture is available globally) + +.. code-block:: python + + def test_api_url_is_awesome(override_config): + with override_config(API_URL="/awesome/url/"): + ... + +Any scope, auto-used fixture alternative can also be implemented like this + +.. code-block:: python + + @pytest.fixture(scope='module', autouse=True) # e.g. module scope + def api_url(override_config): + with override_config(API_URL="/awesome/url/"): + yield + diff --git a/setup.py b/setup.py index 831b374f..8b6449ea 100644 --- a/setup.py +++ b/setup.py @@ -56,5 +56,10 @@ def find_version(*file_paths): extras_require={ 'database': ['django-picklefield'], 'redis': ['redis'], - } + }, + entry_points={ + 'pytest11': [ + 'pytest-django-constance = constance.test.pytest', + ], + }, ) diff --git a/tests/test_pytest_overrides.py b/tests/test_pytest_overrides.py new file mode 100644 index 00000000..43734dc7 --- /dev/null +++ b/tests/test_pytest_overrides.py @@ -0,0 +1,78 @@ +import unittest + + +try: + import pytest + + from constance import config + from constance.test.pytest import override_config + + + class TestPytestOverrideConfigFunctionDecorator: + """Test that the override_config decorator works correctly for Pytest classes. + + Test usage of override_config on test method and as context manager. + """ + + def test_default_value_is_true(self): + """Assert that the default value of config.BOOL_VALUE is True.""" + assert config.BOOL_VALUE + + @pytest.mark.override_config(BOOL_VALUE=False) + def test_override_config_on_method_changes_config_value(self): + """Assert that the pytest mark decorator changes config.BOOL_VALUE.""" + assert not config.BOOL_VALUE + + def test_override_config_as_context_manager_changes_config_value(self): + """Assert that the context manager changes config.BOOL_VALUE.""" + with override_config(BOOL_VALUE=False): + assert not config.BOOL_VALUE + + assert config.BOOL_VALUE + + @override_config(BOOL_VALUE=False) + def test_method_decorator(self): + """Ensure `override_config` can be used as test method decorator.""" + assert not config.BOOL_VALUE + + + @pytest.mark.override_config(BOOL_VALUE=False) + class TestPytestOverrideConfigDecorator: + """Test that the override_config decorator works on classes.""" + + def test_override_config_on_class_changes_config_value(self): + """Asser that the class decorator changes config.BOOL_VALUE.""" + assert not config.BOOL_VALUE + + @pytest.mark.override_config(BOOL_VALUE='True') + def test_override_config_on_overrided_value(self): + """Ensure that method mark decorator changes already overrided value for class.""" + assert config.BOOL_VALUE == 'True' + + + def test_fixture_override_config(override_config): + """ + Ensure `override_config` fixture is available globally + and can be used in test functions. + """ + with override_config(BOOL_VALUE=False): + assert not config.BOOL_VALUE + + @override_config(BOOL_VALUE=False) + def test_func_decorator(): + """Ensure `override_config` can be used as test function decorator.""" + assert not config.BOOL_VALUE + +except ImportError: + pass + + +class PytestTests(unittest.TestCase): + def setUp(self): + self.skipTest('Skip all pytest tests when using unittest') + + def test_do_not_skip_silently(self): + """ + If no at least one test present, unittest silently skips module. + """ + pass diff --git a/tests/test_test_utils.py b/tests/test_test_overrides.py similarity index 100% rename from tests/test_test_utils.py rename to tests/test_test_overrides.py diff --git a/tox.ini b/tox.ini index ce609f7d..abbe6132 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - py{35,36,37,pypy3}-django{22} - py{36,37,38}-django{30} - py{36,37,38}-django-master + py{35,36,37,pypy3}-django{22}-unittest + py{36,37,38}-django{30,-master}-unittest + py{36,37,38,pypy3}-django{22,30,-master}-pytest [testenv] deps = @@ -13,12 +13,16 @@ deps = django-22: Django>=2.2,<3.0 django-30: Django>=3.0,<3.1 django-master: https://github.com/django/django/archive/master.tar.gz + pytest: pytest + pytest: pytest-cov + pytest: pytest-django usedevelop = True ignore_outcome = django-master: True commands = - coverage run {envbindir}/django-admin test -v2 - coverage report + unittest: coverage run {envbindir}/django-admin test -v2 + unittest: coverage report + pytest: pytest --cov=. --ignore=.tox --disable-pytest-warnings {toxinidir} setenv = PYTHONDONTWRITEBYTECODE=1 DJANGO_SETTINGS_MODULE=tests.settings