2 # -*- coding: utf-8 -*-
3 # Extends unittest with support for pytest-style fixtures.
5 # Copyright (c) 2018 Peter Wu <peter@lekensteyn.nl>
7 # SPDX-License-Identifier: (GPL-2.0-or-later OR MIT)
15 _use_native_pytest = False
19 global _use_native_pytest, pytest
22 _use_native_pytest = True
25 def fixture(scope="function", params=None, autouse=False, ids=None, name=None):
27 When running under pytest, this is the same as the pytest.fixture decorator.
28 See https://docs.pytest.org/en/latest/reference.html#pytest-fixture
30 if _use_native_pytest:
31 # XXX sorting of fixtures based on scope does not work, see
32 # https://github.com/pytest-dev/pytest/issues/4143#issuecomment-431794076
33 # When ran under pytest, use native functionality.
34 return pytest.fixture(scope, params, autouse, ids, name)
35 init_fallback_fixtures_once()
36 return _fallback.fixture(scope, params, autouse, ids, name)
39 def _fixture_wrapper(test_fn, params):
40 @functools.wraps(test_fn)
42 if not _use_native_pytest:
43 self._fixture_request.function = getattr(self, test_fn.__name__)
44 self._fixture_request.fillfixtures(params)
45 fixtures = [self._fixture_request.getfixturevalue(n) for n in params]
46 test_fn(self, *fixtures)
50 def uses_fixtures(cls):
51 """Enables use of fixtures within test methods of unittest.TestCase."""
52 assert issubclass(cls, unittest.TestCase)
55 func = getattr(cls, name)
56 if not name.startswith('test') or not callable(func):
58 params = inspect.getfullargspec(func).args[1:]
59 # Unconditionally overwrite methods in case usefixtures marks exist.
60 setattr(cls, name, _fixture_wrapper(func, params))
62 if _use_native_pytest:
63 # Make request object to _fixture_wrapper
64 @pytest.fixture(autouse=True)
65 def __inject_request(self, request):
66 self._fixture_request = request
67 cls.__inject_request = __inject_request
69 _patch_unittest_testcase_class(cls)
74 def mark_usefixtures(*args):
75 """Add the given fixtures to every test method."""
76 if _use_native_pytest:
77 return pytest.mark.usefixtures(*args)
80 cls._fixtures_prepend = list(args)
85 # Begin fallback functionality when pytest is not available.
87 # - session-scoped fixtures (for cmd_tshark)
88 # - function-scoped fixtures (for tmpfile)
89 # - teardown (via yield keyword in fixture)
90 # - sorting of scopes (session before function)
91 # - fixtures that depend on other fixtures (requires sorting)
92 # - marking classes with @pytest.mark.usefixtures("fixture")
93 # Not supported (yet) due to lack of need for it:
95 # - parameterized fixtures (@pytest.fixture(params=...))
96 # - class-scoped fixtures
97 # - (overriding) fixtures on various levels (e.g. conftest, module, class)
100 class _FixtureSpec(object):
101 def __init__(self, name, scope, func):
105 self.params = inspect.getfullargspec(func).args
106 if inspect.ismethod(self.params):
107 self.params = self.params[1:] # skip self
110 return '<_FixtureSpec name=%s scope=%s params=%r>' % \
111 (self.name, self.scope, self.params)
114 class _FixturesManager(object):
115 '''Records collected fixtures when pytest is unavailable.'''
117 # supported scopes, in execution order.
118 SCOPES = ('session', 'function')
120 def _add_fixture(self, scope, autouse, name, func):
121 name = name or func.__name__
122 if name in self.fixtures:
123 raise NotImplementedError('overriding fixtures is not supported')
124 self.fixtures[name] = _FixtureSpec(name, scope, func)
127 def fixture(self, scope, params, autouse, ids, name):
129 raise NotImplementedError('params is not supported')
131 raise NotImplementedError('ids is not supported')
133 raise NotImplementedError('autouse is not supported yet')
136 # used as decorator, pass through the original function
137 self._add_fixture('function', autouse, name, scope)
139 assert scope in self.SCOPES, 'unsupported scope'
140 # invoked with arguments, should return a decorator
141 return lambda func: self._add_fixture(scope, autouse, name, func)
143 def lookup(self, name):
144 return self.fixtures.get(name)
146 def resolve_fixtures(self, fixtures):
147 '''Find all dependencies for the requested list of fixtures.'''
148 unresolved = fixtures.copy()
149 resolved_keys, resolved = [], []
151 param = unresolved.pop(0)
152 if param in resolved:
154 spec = self.lookup(param)
156 if param == 'request':
158 raise RuntimeError("Fixture '%s' not found" % (param,))
159 unresolved += spec.params
160 resolved_keys.append(param)
161 resolved.append(spec)
162 # Return fixtures, sorted by their scope
163 resolved.sort(key=lambda spec: self.SCOPES.index(spec.scope))
167 class _ExecutionScope(object):
168 '''Store execution/teardown state for a scope.'''
170 def __init__(self, scope, parent):
176 def _find_scope(self, scope):
178 while context.scope != scope:
179 context = context.parent
182 def execute(self, spec, test_fn):
183 '''Execute a fixture and cache the result.'''
184 context = self._find_scope(spec.scope)
185 if spec.name in context.cache:
188 value, cleanup = self._execute_one(spec, test_fn)
191 value, cleanup, exc = None, None, sys.exc_info()[1]
192 context.cache[spec.name] = value, exc
194 context.finalizers.append(cleanup)
198 def cached_result(self, spec):
199 '''Obtain the cached result for a previously executed fixture.'''
200 value, exc = self._find_scope(spec.scope).cache[spec.name]
205 def _execute_one(self, spec, test_fn):
206 # A fixture can only execute in the same or earlier scopes
207 context_scope_index = _FixturesManager.SCOPES.index(self.scope)
208 fixture_scope_index = _FixturesManager.SCOPES.index(spec.scope)
209 assert fixture_scope_index <= context_scope_index
211 # Do not invoke destroy, it is taken care of by the main request.
212 subrequest = _FixtureRequest(self)
213 subrequest.function = test_fn
214 subrequest.fillfixtures(spec.params)
215 fixtures = (subrequest.getfixturevalue(n) for n in spec.params)
216 value = spec.func(*fixtures) # Execute fixture
218 value = spec.func() # Execute fixture
219 if not inspect.isgenerator(value):
222 @functools.wraps(value)
226 except StopIteration:
229 raise RuntimeError('%s yielded more than once!' % (spec.name,))
230 return next(value), cleanup
234 for cleanup in self.finalizers:
238 exceptions.append(sys.exc_info()[1])
240 self.finalizers.clear()
245 class _FixtureRequest(object):
247 Holds state during a single test execution. See
248 https://docs.pytest.org/en/latest/reference.html#request
251 def __init__(self, context):
252 self._context = context
253 self._fixtures_prepend = [] # fixtures added via usefixtures
254 # XXX is there any need for .module or .cls?
255 self.function = None # test function, set before execution.
257 def fillfixtures(self, params):
258 params = self._fixtures_prepend + params
259 specs = _fallback.resolve_fixtures(params)
261 self._context.execute(spec, self.function)
263 def getfixturevalue(self, argname):
264 spec = _fallback.lookup(argname)
266 assert argname == 'request'
268 return self._context.cached_result(spec)
271 self._context.destroy()
273 def addfinalizer(self, finalizer):
274 self._context.finalizers.append(finalizer)
278 return self.function.__self__
281 def _patch_unittest_testcase_class(cls):
283 Patch the setUp and tearDown methods of the unittest.TestCase such that the
284 fixtures are properly setup and destroyed.
288 assert _session_context, 'must call create_session() first!'
289 function_context = _ExecutionScope('function', _session_context)
290 req = _FixtureRequest(function_context)
291 req._fixtures_prepend = getattr(self, '_fixtures_prepend', [])
292 self._fixture_request = req
297 self._orig_tearDown()
299 self._fixture_request.destroy()
300 # Only the leaf test case class should be decorated!
301 assert not hasattr(cls, '_orig_setUp')
302 assert not hasattr(cls, '_orig_tearDown')
303 cls._orig_setUp, cls.setUp = cls.setUp, setUp
304 cls._orig_tearDown, cls.tearDown = cls.tearDown, tearDown
308 _session_context = None
311 def init_fallback_fixtures_once():
313 assert not _use_native_pytest
316 _fallback = _FixturesManager()
317 # Register standard fixtures here as needed
320 def create_session():
321 global _session_context
322 assert not _use_native_pytest
323 _session_context = _ExecutionScope('session', None)
326 def destroy_session():
327 global _session_context
328 assert not _use_native_pytest
329 _session_context = None
333 '''Skip the executing test with the given message.'''
334 if _use_native_pytest:
337 raise unittest.SkipTest(msg)