Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/_newsfragments/2156.breakingchange.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
The ``testtools`` integration for :class:`falcon.testing.TestCase` is
deprecated and will be removed in Falcon 5.0.

If ``testtools`` is installed, :class:`~falcon.testing.TestCase` still
rebases onto :class:`testtools.TestCase` for backwards compatibility, but
doing so now emits a :class:`~falcon.util.deprecation.DeprecatedWarning`.
Prefer the standard library :mod:`unittest` base (used when ``testtools`` is
not installed) or :mod:`pytest` as shown in the testing tutorial.

An escape hatch is available: set the environment variable
``FALCON_TESTING_TESTCASE_BASE`` to an import path
(``module:attribute`` or ``module.attribute``), for example
``testtools:TestCase`` or a project-specific base class.
12 changes: 10 additions & 2 deletions docs/user/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1273,10 +1273,18 @@ config object on demand.
How do I test my Falcon app? Can I use pytest?
----------------------------------------------

Falcon's testing framework supports both ``unittest`` and ``pytest``. In fact,
the tutorial in the docs provides an excellent introduction to
Falcon's testing framework supports both ``unittest`` and ``pytest``. Prefer
those over third-party unittest runners: the tutorial provides an excellent
introduction to
`testing Falcon apps with pytest <http://falcon.readthedocs.io/en/stable/user/tutorial.html#testing-your-application>`_.

If you use :class:`falcon.testing.TestCase` and ``testtools`` happens to be
installed, Falcon may still rebase the helper on ``testtools.TestCase`` for
backwards compatibility. That path is **deprecated** and will be removed in
Falcon 5.0; a deprecation warning is emitted when it is used. To keep a custom
base class until then, set ``FALCON_TESTING_TESTCASE_BASE`` (for example
``testtools:TestCase``).

(See also: `Testing <http://falcon.readthedocs.io/en/stable/api/testing.html>`_)

Can I shut my server down cleanly from the app?
Expand Down
2 changes: 2 additions & 0 deletions falcon/testing/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ def test_get_message(client):
from falcon.testing.client import simulate_request
from falcon.testing.client import StreamedResult
from falcon.testing.client import TestClient
import falcon.testing.helpers as helpers # public submodule for tests/docs
from falcon.testing.helpers import ASGILifespanEventEmitter
from falcon.testing.helpers import ASGIRequestEventEmitter
from falcon.testing.helpers import ASGIResponseEventCollector
Expand Down Expand Up @@ -158,6 +159,7 @@ def test_get_message(client):
'StreamedResult',
'TestClient',
# helpers
'helpers',
'ASGILifespanEventEmitter',
'ASGIRequestEventEmitter',
'ASGIResponseEventCollector',
Expand Down
109 changes: 100 additions & 9 deletions falcon/testing/test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,35 +18,117 @@
utilities for simulating and validating HTTP requests.
"""

from typing import Any
from __future__ import annotations

try:
import testtools as unittest
except ImportError: # pragma: nocover
import unittest
import importlib
import os
from types import ModuleType
from typing import Any
import unittest
import warnings

import falcon
import falcon.request

# TODO: Hoist for backwards compat. Remove in Falcon 5.0.
from falcon.testing.client import Result # NOQA
from falcon.testing.client import TestClient
from falcon.util.deprecation import DeprecatedWarning

_TESTTOOLS_WARNING = (
'falcon.testing.TestCase is rebased on testtools.TestCase. '
'This integration is deprecated and will be removed in Falcon 5.0. '
'Prefer unittest.TestCase (default) or pytest; see the testing tutorial. '
'To keep using a custom TestCase base, set the FALCON_TESTING_TESTCASE_BASE '
'environment variable to an import path (module:attribute or module.attribute).'
)


def _load_custom_testcase_base() -> type[unittest.TestCase] | None:
"""Load a custom TestCase base from FALCON_TESTING_TESTCASE_BASE, if set.

The value may be ``module:attribute`` (preferred) or ``module.attribute``.
"""
spec = os.environ.get('FALCON_TESTING_TESTCASE_BASE', '').strip()
if not spec:
return None

if ':' in spec:
module_name, _, attr_path = spec.partition(':')
else:
module_name, _, attr_path = spec.rpartition('.')
if not module_name or not attr_path:
raise ImportError(
'FALCON_TESTING_TESTCASE_BASE must be module:attribute or '
f'module.attribute, got {spec!r}'
)

module: ModuleType = importlib.import_module(module_name)
obj: Any = module
for part in attr_path.split('.'):
obj = getattr(obj, part)

if not isinstance(obj, type) or not issubclass(obj, unittest.TestCase):
raise TypeError(
'FALCON_TESTING_TESTCASE_BASE must resolve to a unittest.TestCase '
f'subclass, got {obj!r}'
)
return obj


def _resolve_unittest_base() -> tuple[type[unittest.TestCase], bool]:
"""Return (base_class, used_testtools).

Order:
1. Custom base via FALCON_TESTING_TESTCASE_BASE
2. testtools.TestCase if installed (deprecated path)
3. unittest.TestCase
"""
custom = _load_custom_testcase_base()
if custom is not None:
return custom, False

class TestCase(unittest.TestCase, TestClient): # type: ignore[misc]
try:
import testtools as testtools_mod
except ImportError: # pragma: nocover
return unittest.TestCase, False

return testtools_mod.TestCase, True


_UnittestBase, _USED_TESTTOOLS = _resolve_unittest_base()
_testtools_deprecation_emitted = False


class TestCase(_UnittestBase, TestClient): # type: ignore[misc, valid-type]
"""Extends :mod:`unittest` to support WSGI/ASGI functional testing.

Note:
If available, uses :mod:`testtools` in lieu of
:mod:`unittest`.
If :mod:`testtools` is installed and no custom base is configured,
:class:`falcon.testing.TestCase` is rebased on
:class:`testtools.TestCase` for backwards compatibility. That path is
**deprecated** and will be removed in Falcon 5.0. Prefer the standard
library :mod:`unittest` (default when ``testtools`` is absent) or
:mod:`pytest` (see the testing tutorial).

To keep a custom base class (including ``testtools``), set the
environment variable ``FALCON_TESTING_TESTCASE_BASE`` to an import path
such as ``testtools:TestCase`` or ``mypkg.tests:MyBase``.

.. versionchanged:: 4.4
Automatic rebase onto :class:`testtools.TestCase` is deprecated
and will be removed in Falcon 5.0. A
:class:`~falcon.util.deprecation.DeprecatedWarning` is emitted when
that path is used. Prefer :mod:`unittest` or :mod:`pytest`, or set
``FALCON_TESTING_TESTCASE_BASE`` for a custom base until 5.0.

This base class provides some extra plumbing for unittest-style
test cases, to help simulate WSGI or ASGI requests without having
to spin up an actual web server. Various simulation methods are
derived from :class:`falcon.testing.TestClient`.

Simply inherit from this class in your test case classes instead of
:class:`unittest.TestCase` or :class:`testtools.TestCase`.
:class:`unittest.TestCase`.
"""

# NOTE(vytas): Here we have to restore __test__ to allow collecting tests!
Expand Down Expand Up @@ -86,6 +168,15 @@ def test_get_message(self):
"""

def setUp(self) -> None:
global _testtools_deprecation_emitted
if _USED_TESTTOOLS and not _testtools_deprecation_emitted:
warnings.warn(
_TESTTOOLS_WARNING,
category=DeprecatedWarning,
stacklevel=2,
)
_testtools_deprecation_emitted = True

super().setUp()

app = falcon.App()
Expand Down
153 changes: 153 additions & 0 deletions tests/test_testcase_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Tests for falcon.testing.TestCase base resolution and testtools deprecation."""

from __future__ import annotations

import importlib
import sys
import unittest
import warnings

import pytest

from falcon.util.deprecation import DeprecatedWarning


def _fresh_test_case_module(
monkeypatch, *, env: str | None = None, block_testtools: bool = False
):
"""Import falcon.testing.test_case under controlled env/import conditions."""
if env is None:
monkeypatch.delenv('FALCON_TESTING_TESTCASE_BASE', raising=False)
else:
monkeypatch.setenv('FALCON_TESTING_TESTCASE_BASE', env)

if block_testtools:
real_import = __import__

def fake_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == 'testtools' or (
isinstance(name, str) and name.startswith('testtools.')
):
raise ImportError('testtools blocked for unit test')
return real_import(name, globals, locals, fromlist, level)

monkeypatch.setattr('builtins.__import__', fake_import)
# Prevent a previously imported testtools module from satisfying imports.
monkeypatch.setitem(sys.modules, 'testtools', None)

# Drop modules that capture base class at import time.
for name in list(sys.modules):
if name == 'falcon.testing.test_case' or name == 'falcon.testing':
sys.modules.pop(name, None)

import falcon.testing.test_case as tc

return tc


def test_resolve_helpers_default_unittest_when_testtools_missing(monkeypatch):
tc = _fresh_test_case_module(monkeypatch, block_testtools=True)

base, used = tc._resolve_unittest_base()
assert used is False
assert base is unittest.TestCase
assert tc._USED_TESTTOOLS is False
assert issubclass(tc.TestCase, unittest.TestCase)


def test_resolve_helpers_testtools_when_installed(monkeypatch):
pytest.importorskip('testtools')
import testtools

tc = _fresh_test_case_module(monkeypatch)

assert tc._USED_TESTTOOLS is True
assert tc._UnittestBase is testtools.TestCase
assert issubclass(tc.TestCase, testtools.TestCase)


def test_testtools_path_emits_deprecation_once(monkeypatch):
pytest.importorskip('testtools')
tc = _fresh_test_case_module(monkeypatch)
assert tc._USED_TESTTOOLS is True

class Sample(tc.TestCase):
def test_ok(self):
self.assertTrue(True)

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
Sample('test_ok').setUp()
Sample('test_ok').setUp()

dep = [w for w in caught if isinstance(w.message, DeprecatedWarning)]
assert len(dep) == 1
text = str(dep[0].message)
assert 'deprecated' in text.lower()
assert 'FALCON_TESTING_TESTCASE_BASE' in text
assert '5.0' in text


def test_custom_base_via_env_uses_unittest_without_deprecation(monkeypatch):
pytest.importorskip('testtools')
tc = _fresh_test_case_module(monkeypatch, env='unittest:TestCase')

assert tc._USED_TESTTOOLS is False
assert tc._UnittestBase is unittest.TestCase

class Sample(tc.TestCase):
def test_ok(self):
self.assertTrue(True)

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
Sample('test_ok').setUp()

assert not any(isinstance(w.message, DeprecatedWarning) for w in caught)


def test_custom_base_dotted_form(monkeypatch):
tc = _fresh_test_case_module(
monkeypatch, env='unittest.TestCase', block_testtools=True
)
assert tc._UnittestBase is unittest.TestCase
assert tc._USED_TESTTOOLS is False


def test_custom_base_explicit_testtools_is_escape_hatch(monkeypatch):
"""Explicit env opt-in keeps testtools without the auto-rebase deprecation flag."""
pytest.importorskip('testtools')
import testtools

tc = _fresh_test_case_module(monkeypatch, env='testtools:TestCase')

assert tc._USED_TESTTOOLS is False
assert tc._UnittestBase is testtools.TestCase

class Sample(tc.TestCase):
def test_ok(self):
self.assertTrue(True)

with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter('always')
Sample('test_ok').setUp()

assert not any(isinstance(w.message, DeprecatedWarning) for w in caught)


def test_invalid_custom_base_env_raises(monkeypatch):
monkeypatch.setenv('FALCON_TESTING_TESTCASE_BASE', 'not_a_valid_spec')
for name in ('falcon.testing.test_case', 'falcon.testing'):
sys.modules.pop(name, None)

with pytest.raises((ImportError, ModuleNotFoundError, AttributeError, TypeError)):
importlib.import_module('falcon.testing.test_case')


def test_custom_base_must_be_testcase_subclass(monkeypatch):
monkeypatch.setenv('FALCON_TESTING_TESTCASE_BASE', 'typing:Any')
for name in ('falcon.testing.test_case', 'falcon.testing'):
sys.modules.pop(name, None)

with pytest.raises(TypeError, match='unittest.TestCase'):
importlib.import_module('falcon.testing.test_case')
Loading