├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── pytest_describe ├── __init__.py ├── plugin.py └── shared.py ├── setup.cfg ├── setup.py ├── test ├── conftest.py ├── test_class.py ├── test_collect.py ├── test_fixtures.py ├── test_marks.py ├── test_output.py ├── test_prefix.py ├── test_shared.py └── test_simple.py └── tox.ini /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | 10 | pull_request: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | python: ['3.7', '3.8', '3.9', '3.10', '3.11', '3.12', 'pypy3.9', 'pypy3.10'] 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | with: 25 | fetch-depth: 0 26 | 27 | - name: Set up Python ${{ matrix.python }} 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: ${{ matrix.python }} 31 | 32 | - name: Install dependencies 33 | run: | 34 | python -m pip install --upgrade pip setuptools 35 | python -m pip install 'tox>=4.4,<5' 36 | 37 | - name: Test with Python 3.7 38 | if: matrix.python == '3.7' 39 | run: tox run -x "tox.envlist=py37-pytest{4,5,60,61,62,70,71,72,73,74}" 40 | 41 | - name: Test with Python 3.8 42 | if: matrix.python == '3.8' 43 | run: tox run -x "tox.envlist=py38-pytest{4,5,60,61,62,70,71,72,73,74,80}" 44 | 45 | - name: Test with Python 3.9 46 | if: matrix.python == '3.9' 47 | run: tox run -x "tox.envlist=py39-pytest{4,5,60,61,62,70,71,72,73,74,80}" 48 | 49 | - name: Test with Python 3.10 50 | if: matrix.python == '3.10' 51 | run: tox run -x "tox.envlist=py310-pytest{70,71,72,73,74,80}" 52 | 53 | - name: Test with Python 3.11 54 | if: matrix.python == '3.11' 55 | run: tox run -x "tox.envlist=spy311-pytest{73,74,80}" 56 | 57 | - name: Test with Python 3.12 58 | if: matrix.python == '3.12' 59 | run: tox run -x "tox.envlist=spy312-pytest{74,80}" 60 | 61 | - name: Test with PyPy 3.9 62 | if: matrix.python == 'pypy3.9' 63 | run: tox run -x "tox.envlist=pypy39-pytest{4,5,60,61,62,70,71,72,73,74}" 64 | 65 | - name: Test with PyPy 3.10 66 | if: matrix.python == 'pypy3.10' 67 | run: tox run -x "tox.envlist=pypy310-pytest{70,71,72,73,74,80}" 68 | 69 | - name: Linting with Flake8 70 | if: matrix.python == '3.11' 71 | run: tox run -e flake8 72 | 73 | - name: Ensure full coverage 74 | if: matrix.python == '3.11' 75 | run: tox run -e coverage 76 | 77 | deploy: 78 | if: | 79 | github.event_name == 'push' && 80 | startsWith(github.event.ref, 'refs/tags') && 81 | github.repository == 'pytest-dev/pytest-describe' 82 | runs-on: ubuntu-latest 83 | 84 | steps: 85 | - uses: actions/checkout@v3 86 | with: 87 | fetch-depth: 0 88 | 89 | - uses: actions/setup-python@v4 90 | with: 91 | python-version: '3.10' 92 | 93 | - name: Install dependencies 94 | run: | 95 | python -m pip install --upgrade pip 96 | pip install --upgrade wheel setuptools setuptools_scm 97 | 98 | - name: Build package 99 | run: python setup.py sdist bdist_wheel 100 | 101 | - name: Publish package 102 | uses: pypa/gh-action-pypi-publish@release/v1 103 | with: 104 | user: __token__ 105 | password: ${{ secrets.pypi_token }} 106 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # Distribution / packaging 7 | .Python 8 | build/ 9 | develop-eggs/ 10 | dist/ 11 | downloads/ 12 | eggs/ 13 | .eggs/ 14 | lib/ 15 | lib64/ 16 | parts/ 17 | sdist/ 18 | var/ 19 | wheels/ 20 | share/python-wheels/ 21 | *.egg-info/ 22 | .installed.cfg 23 | *.egg 24 | MANIFEST 25 | 26 | # Installer logs 27 | pip-log.txt 28 | pip-delete-this-directory.txt 29 | 30 | # Unit test / coverage reports 31 | htmlcov/ 32 | .tox/ 33 | .nox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *.cover 40 | .hypothesis/ 41 | .pytest_cache/ 42 | 43 | # pyenv 44 | .python-version 45 | 46 | # Environments 47 | .env 48 | .venv 49 | env/ 50 | venv/ 51 | ENV/ 52 | env.bak/ 53 | venv.bak/ 54 | 55 | # IDEs 56 | .idea 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Permission is hereby granted, free of charge, to any person obtaining a copy 3 | of this software and associated documentation files (the "Software"), to deal 4 | in the Software without restriction, including without limitation the rights 5 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 6 | copies of the Software, and to permit persons to whom the Software is 7 | furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | 12 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 13 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 14 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 15 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 16 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 17 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 18 | SOFTWARE. 19 | 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include setup.py 4 | include setup.cfg 5 | include tox.ini 6 | include test/*.py 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI version](https://badge.fury.io/py/pytest-describe.svg)](https://pypi.org/project/pytest-describe/) 2 | [![Workflow status](https://github.com/pytest-dev/pytest-describe/actions/workflows/main.yml/badge.svg)](https://github.com/pytest-dev/pytest-describe/actions) 3 | 4 | # Describe-style plugin for pytest 5 | 6 | **pytest-describe** is a plugin for [pytest](https://docs.pytest.org/) 7 | that allows tests to be written in arbitrary nested describe-blocks, 8 | similar to RSpec (Ruby) and Jasmine (JavaScript). 9 | 10 | The main inspiration for this was 11 | a [video](https://www.youtube.com/watch?v=JJle8L8FRy0>) by Gary Bernhardt. 12 | 13 | ## Installation 14 | 15 | You guessed it: 16 | 17 | ```sh 18 | pip install pytest-describe 19 | ``` 20 | 21 | ## Usage 22 | 23 | Pytest will automatically find the plugin and use it when you run pytest. 24 | Running pytest will show that the plugin is loaded: 25 | 26 | ```sh 27 | $ pytest 28 | ... 29 | plugins: describe-2.2.0 30 | ... 31 | ``` 32 | 33 | Tests can now be written in describe-blocks. 34 | Here is an example for testing a Wallet class: 35 | 36 | ```python 37 | import pytest 38 | 39 | 40 | class Wallet: 41 | 42 | def __init__(self, initial_amount=0): 43 | self.balance = initial_amount 44 | 45 | def spend_cash(self, amount): 46 | if self.balance < amount: 47 | raise ValueError(f'Not enough available to spend {amount}') 48 | self.balance -= amount 49 | 50 | def add_cash(self, amount): 51 | self.balance += amount 52 | 53 | 54 | def describe_wallet(): 55 | 56 | def describe_start_empty(): 57 | 58 | @pytest.fixture 59 | def wallet(): 60 | return Wallet() 61 | 62 | def initial_amount(wallet): 63 | assert wallet.balance == 0 64 | 65 | def add_cash(wallet): 66 | wallet.add_cash(80) 67 | assert wallet.balance == 80 68 | 69 | def spend_cash(wallet): 70 | with pytest.raises(ValueError): 71 | wallet.spend_cash(10) 72 | 73 | def describe_with_starting_balance(): 74 | 75 | @pytest.fixture 76 | def wallet(): 77 | return Wallet(20) 78 | 79 | def initial_amount(wallet): 80 | assert wallet.balance == 20 81 | 82 | def describe_adding(): 83 | 84 | def add_little_cash(wallet): 85 | wallet.add_cash(5) 86 | assert wallet.balance == 25 87 | 88 | def add_much_cash(wallet): 89 | wallet.add_cash(980) 90 | assert wallet.balance == 1000 91 | 92 | def describe_spending(): 93 | 94 | def spend_cash(wallet): 95 | wallet.spend_cash(15) 96 | assert wallet.balance == 5 97 | 98 | def spend_too_much_cash(wallet): 99 | with pytest.raises(ValueError): 100 | wallet.spend_cash(25) 101 | ``` 102 | 103 | The default prefix for describe-blocks is `describe_`, but you can configure it 104 | in the pytest/python configuration file via `describe_prefixes` or 105 | via the command line option `--describe-prefixes`. 106 | 107 | For example in your `pyproject.toml`: 108 | 109 | ```toml 110 | [tool.pytest.ini_options] 111 | describe_prefixes = ["custom_prefix_"] 112 | ``` 113 | 114 | Functions prefixed with `_` in the describe-block are not collected as tests. 115 | This can be used to group helper functions. Otherwise, functions inside the 116 | describe-blocks need not follow any special naming convention. 117 | 118 | ```python 119 | def describe_function(): 120 | 121 | def _helper(): 122 | return "something" 123 | 124 | def it_does_something(): 125 | value = _helper() 126 | ... 127 | ``` 128 | 129 | 130 | ## Why bother? 131 | 132 | I've found that quite often my tests have one "dimension" more than my production 133 | code. The production code is organized into packages, modules, classes 134 | (sometimes), and functions. I like to organize my tests in the same way, but 135 | tests also have different *cases* for each function. This tends to end up with 136 | a set of tests for each module (or class), where each test has to name both a 137 | function and a *case*. For instance: 138 | 139 | ```python 140 | def test_my_function_with_default_arguments(): 141 | def test_my_function_with_some_other_arguments(): 142 | def test_my_function_throws_exception(): 143 | def test_my_function_handles_exception(): 144 | def test_some_other_function_returns_true(): 145 | def test_some_other_function_returns_false(): 146 | ``` 147 | 148 | It's much nicer to do this: 149 | 150 | ```python 151 | def describe_my_function(): 152 | def with_default_arguments(): 153 | def with_some_other_arguments(): 154 | def it_throws_exception(): 155 | def it_handles_exception(): 156 | 157 | def describe_some_other_function(): 158 | def it_returns_true(): 159 | def it_returns_false(): 160 | ``` 161 | 162 | It has the additional advantage that you can have marks and fixtures that apply 163 | locally to each group of test function. 164 | 165 | With pytest, it's possible to organize tests in a similar way with classes. 166 | However, I think classes are awkward. I don't think the convention of using 167 | camel-case names for classes fit very well when testing functions in different 168 | cases. In addition, every test function must take a "self" argument that is 169 | never used. 170 | 171 | The pytest-describe plugin allows organizing your tests in the nicer way shown 172 | above using describe-blocks. 173 | 174 | ## Shared Behaviors 175 | 176 | If you've used rspec's shared examples or test class inheritance, then you may 177 | be familiar with the benefit of having the same tests apply to 178 | multiple "subjects" or "suts" (system under test). 179 | 180 | ```python 181 | from pytest import fixture 182 | from pytest_describe import behaves_like 183 | 184 | def a_duck(): 185 | def it_quacks(sound): 186 | assert sound == "quack" 187 | 188 | @behaves_like(a_duck) 189 | def describe_something_that_quacks(): 190 | @fixture 191 | def sound(): 192 | return "quack" 193 | 194 | # the it_quacks test in this describe will pass 195 | 196 | @behaves_like(a_duck) 197 | def describe_something_that_barks(): 198 | @fixture 199 | def sound(): 200 | return "bark" 201 | 202 | # the it_quacks test in this describe will fail (as expected) 203 | ``` 204 | 205 | Fixtures defined in the block that includes the shared behavior take precedence 206 | over fixtures defined in the shared behavior. This rule only applies to 207 | fixtures, not to other functions (nested describe blocks and tests). Instead, 208 | they are all collected as separate tests. 209 | -------------------------------------------------------------------------------- /pytest_describe/__init__.py: -------------------------------------------------------------------------------- 1 | from .shared import behaves_like 2 | 3 | __all__ = ['behaves_like'] 4 | 5 | __version__ = '2.2.0' 6 | -------------------------------------------------------------------------------- /pytest_describe/plugin.py: -------------------------------------------------------------------------------- 1 | """The pytest-describe plugin""" 2 | 3 | import sys 4 | import types 5 | import pytest 6 | 7 | 8 | PYTEST_GTE_7_0 = getattr(pytest, 'version_tuple', (0, 0)) >= (7, 0) 9 | PYTEST_GTE_5_4 = PYTEST_GTE_7_0 or hasattr(pytest.Collector, 'from_parent') 10 | 11 | 12 | def trace_function(func, *args, **kwargs): # pragma: no-cover 13 | """Call a function and return its locals.""" 14 | f_locals = {} 15 | 16 | def _trace_func(frame, event, arg): # pragma: no cover 17 | # Activate local trace for first call only 18 | if (frame.f_back.f_locals.get('_trace_func') == _trace_func 19 | and event == 'return'): 20 | f_locals.update(frame.f_locals) 21 | 22 | sys.setprofile(_trace_func) 23 | try: 24 | func(*args, **kwargs) 25 | finally: 26 | sys.setprofile(None) 27 | 28 | return f_locals 29 | 30 | 31 | def make_module_from_function(func): 32 | """Evaluate the local scope of a function as if it was a module.""" 33 | module = types.ModuleType(func.__name__) 34 | 35 | # Import shared behaviors into the generated module. We do this before 36 | # importing the direct children, so that fixtures in the block that's 37 | # importing the behavior take precedence. 38 | for shared_func in getattr(func, '_behaves_like', []): 39 | module.__dict__.update(evaluate_shared_behavior(shared_func)) 40 | 41 | # Import children 42 | module.__dict__.update(trace_function(func)) 43 | return module 44 | 45 | 46 | def evaluate_shared_behavior(func): 47 | """Evaluate the local scope of a function.""" 48 | try: 49 | shared_functions = func._shared_functions 50 | except AttributeError: 51 | shared_functions = {} 52 | for name, obj in trace_function(func).items(): 53 | # Only functions are relevant here 54 | if not isinstance(obj, types.FunctionType): 55 | continue 56 | 57 | # Mangle names of imported functions, except fixtures because we 58 | # want fixtures to be overridden in the block that's importing the 59 | # behavior. 60 | if not hasattr(obj, '_pytestfixturefunction'): 61 | name = obj._mangled_name = f"{func.__name__}::{name}" 62 | 63 | shared_functions[name] = obj 64 | func._shared_functions = shared_functions 65 | return shared_functions 66 | 67 | 68 | class DescribeBlock(pytest.Module): 69 | """Module-like object representing the scope of a describe block""" 70 | 71 | @classmethod 72 | def from_parent(cls, parent, obj): 73 | """Construct a new node for the describe block""" 74 | name = getattr(obj, '_mangled_name', obj.__name__) 75 | nodeid = parent.nodeid + '::' + name 76 | if PYTEST_GTE_7_0: 77 | self = super().from_parent( 78 | parent=parent, path=parent.path, nodeid=nodeid) 79 | elif PYTEST_GTE_5_4: # pragma: no cover 80 | self = super().from_parent( 81 | parent=parent, fspath=parent.fspath, nodeid=nodeid) 82 | else: # pragma: no cover 83 | self = cls(parent=parent, fspath=parent.fspath, nodeid=nodeid) 84 | self.name = name 85 | self.funcobj = obj 86 | return self 87 | 88 | def collect(self): 89 | """Get list of children""" 90 | self.session._fixturemanager.parsefactories(self) 91 | return super().collect() 92 | 93 | def _getobj(self): 94 | """Get the underlying Python object""" 95 | return self._importtestmodule() 96 | 97 | def _importtestmodule(self): 98 | """Import a describe block as if it was a module""" 99 | module = make_module_from_function(self.funcobj) 100 | self.own_markers = getattr(self.funcobj, 'pytestmark', []) 101 | return module 102 | 103 | def funcnamefilter(self, name): 104 | """Treat all nested functions as tests 105 | 106 | We do not require the 'test_' prefix for the specs. 107 | """ 108 | return not name.startswith('_') 109 | 110 | def classnamefilter(self, name): 111 | """Don't allow test classes inside describe""" 112 | return False 113 | 114 | def __repr__(self): 115 | return f"<{self.__class__.__name__} {self.name!r}>" 116 | 117 | 118 | def pytest_pycollect_makeitem(collector, name, obj): 119 | """Collector items from describe blocks.""" 120 | if isinstance(obj, types.FunctionType): 121 | for prefix in collector.config.getini('describe_prefixes'): 122 | if obj.__name__.startswith(prefix): 123 | return DescribeBlock.from_parent(collector, obj) 124 | 125 | 126 | def pytest_addoption(parser): 127 | """Add configuration option describe_prefixes.""" 128 | parser.addini("describe_prefixes", type="args", default=("describe",), 129 | help="prefixes for Python describe function discovery") 130 | -------------------------------------------------------------------------------- /pytest_describe/shared.py: -------------------------------------------------------------------------------- 1 | """Support for shared behaviors""" 2 | 3 | __all__ = ["behaves_like"] 4 | 5 | 6 | def behaves_like(*behavior_funcs): 7 | """Decorator for shared behaviors.""" 8 | 9 | def decorator(func): 10 | try: 11 | func._behaves_like.extend(behavior_funcs) 12 | except AttributeError: 13 | func._behaves_like = behavior_funcs[:] 14 | return func 15 | 16 | return decorator 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = pytest-describe 3 | version = attr: pytest_describe.__version__ 4 | description = Describe-style plugin for pytest 5 | long_description = file: README.md 6 | long_description_content_type = text/markdown 7 | url = https://github.com/pytest-dev/pytest-describe 8 | author = Robin Pedersen 9 | author_email = robinpeder@gmail.com 10 | maintainer = Christoph Zwerschke 11 | maintainer_email = cito@online.de 12 | license = MIT 13 | license_files = LICENSE 14 | platforms = unix, linux, osx, cygwin, win32 15 | classifiers = 16 | Development Status :: 5 - Production/Stable 17 | Intended Audience :: Developers 18 | License :: OSI Approved :: MIT License 19 | Operating System :: MacOS :: MacOS X 20 | Operating System :: Microsoft :: Windows 21 | Operating System :: POSIX 22 | Programming Language :: Python :: 3 23 | Programming Language :: Python :: 3 :: Only 24 | Programming Language :: Python :: 3.7 25 | Programming Language :: Python :: 3.8 26 | Programming Language :: Python :: 3.9 27 | Programming Language :: Python :: 3.10 28 | Programming Language :: Python :: 3.11 29 | Programming Language :: Python :: 3.12 30 | Topic :: Software Development :: Libraries 31 | Topic :: Software Development :: Testing 32 | Topic :: Utilities 33 | keywords = test, unittest, plugin, describe 34 | project_urls = 35 | Source=https://github.com/pytest-dev/pytest-describe 36 | Tracker=https://github.com/pytest-dev/pytest-describe/issues 37 | 38 | [options] 39 | python_requires = >=3.7 40 | packages = 41 | pytest_describe 42 | install_requires = 43 | pytest>=4.6,<9 44 | 45 | [options.entry_points] 46 | pytest11 = 47 | pytest-describe = pytest_describe.plugin 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup() 5 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | """Global fixtures for testing the plugin""" 2 | 3 | pytest_plugins = ["pytester"] 4 | -------------------------------------------------------------------------------- /test/test_class.py: -------------------------------------------------------------------------------- 1 | """Test that classes are ignored""" 2 | 3 | 4 | def test_skip_classes(testdir): 5 | testdir.makepyfile( 6 | """ 7 | def describe_something(): 8 | def fn(): 9 | assert True 10 | class cls: 11 | def __call__(self): 12 | assert True 13 | """) 14 | 15 | result = testdir.runpytest() 16 | result.assert_outcomes(passed=1) 17 | -------------------------------------------------------------------------------- /test/test_collect.py: -------------------------------------------------------------------------------- 1 | """Test collection of test functions""" 2 | 3 | from textwrap import dedent 4 | 5 | 6 | def test_collect_only(testdir): 7 | testdir.makepyfile( 8 | """ 9 | def describe_something(): 10 | def is_foo(): 11 | pass 12 | def can_bar(): 13 | pass 14 | def _not_a_test(): 15 | pass 16 | def describe_something_else(): 17 | def describe_nested(): 18 | def a_test(): 19 | pass 20 | def foo_not_collected(): 21 | pass 22 | def test_something(): 23 | pass 24 | """) 25 | 26 | result = testdir.runpytest('--collectonly') 27 | result.assert_outcomes() 28 | 29 | output = '\n'.join(line.lstrip() for line in result.outlines) 30 | assert "collected 4 items" in output 31 | assert dedent(""" 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | """) in output 41 | 42 | 43 | def test_describe_evaluated_once(testdir): 44 | testdir.makepyfile( 45 | """ 46 | count = 0 47 | def describe_is_evaluated_only_once(): 48 | global count 49 | count += 1 50 | def one(): 51 | assert count == 1 52 | def two(): 53 | assert count == 1 54 | def describe_nested(): 55 | def three(): 56 | assert count == 1 57 | """) 58 | 59 | result = testdir.runpytest('-v') 60 | result.assert_outcomes(passed=3) 61 | -------------------------------------------------------------------------------- /test/test_fixtures.py: -------------------------------------------------------------------------------- 1 | """Test with fixtures""" 2 | 3 | 4 | def test_can_access_local_fixture(testdir): 5 | testdir.makepyfile( 6 | """ 7 | import pytest 8 | 9 | def describe_something(): 10 | @pytest.fixture 11 | def thing(): 12 | return 42 13 | 14 | def thing_is_42(thing): 15 | assert thing == 42 16 | """) 17 | 18 | result = testdir.runpytest() 19 | result.assert_outcomes(passed=1) 20 | 21 | 22 | def test_can_access_fixture_from_nested_scope(testdir): 23 | testdir.makepyfile( 24 | """ 25 | import pytest 26 | 27 | def describe_something(): 28 | @pytest.fixture 29 | def thing(): 30 | return 42 31 | 32 | def describe_a_nested_scope(): 33 | def thing_is_42(thing): 34 | assert thing == 42 35 | """) 36 | 37 | result = testdir.runpytest() 38 | result.assert_outcomes(passed=1) 39 | 40 | 41 | def test_local_fixture_overrides(testdir): 42 | testdir.makepyfile( 43 | """ 44 | import pytest 45 | 46 | @pytest.fixture 47 | def thing(): 48 | return 12 49 | 50 | def describe_something(): 51 | def describe_a_nested_scope(): 52 | @pytest.fixture 53 | def thing(): 54 | return 42 55 | 56 | def thing_is_42(thing): 57 | assert thing == 42 58 | 59 | def thing_is_12(thing): 60 | assert thing == 12 61 | """) 62 | 63 | result = testdir.runpytest() 64 | result.assert_outcomes(passed=2) 65 | -------------------------------------------------------------------------------- /test/test_marks.py: -------------------------------------------------------------------------------- 1 | """Test mark decorator""" 2 | 3 | 4 | def assert_outcomes(result, **kwargs): 5 | """Get all relevant outcomes""" 6 | assert { 7 | key: value 8 | for key, value in result.parseoutcomes().items() 9 | if key != 'seconds' 10 | } == kwargs # pragma: no cover 11 | 12 | 13 | def test_special_marks(testdir): 14 | testdir.makepyfile( 15 | """ 16 | import pytest 17 | 18 | def describe_marks(): 19 | @pytest.mark.xfail 20 | def xfails(): 21 | assert False 22 | 23 | @pytest.mark.xfail 24 | def xpasses(): 25 | pass 26 | 27 | @pytest.mark.skipif("0 < 1") 28 | def skipped(): 29 | pass 30 | 31 | @pytest.mark.parametrize('foo', (1, 2, 3)) 32 | def isint(foo): 33 | assert foo == int(foo) 34 | """) 35 | 36 | result = testdir.runpytest() 37 | result.assert_outcomes(passed=3, xfailed=1, xpassed=1, skipped=1) 38 | 39 | 40 | def test_multiple_variables_parametrize(testdir): 41 | testdir.makepyfile( 42 | """ 43 | import pytest 44 | 45 | def describe_marks(): 46 | @pytest.mark.parametrize('foo,bar', [(1, 2), (3, 4)]) 47 | def isint_str_names(foo, bar): 48 | assert foo == int(foo) 49 | assert bar == int(bar) 50 | 51 | @pytest.mark.parametrize(['foo', 'bar'], [(1, 2), (3, 4)]) 52 | def isint_list_names(foo, bar): 53 | assert foo == int(foo) 54 | assert bar == int(bar) 55 | 56 | @pytest.mark.parametrize(('foo', 'bar'), [(1, 2), (3, 4)]) 57 | def isint_tuple_names(foo, bar): 58 | assert foo == int(foo) 59 | assert bar == int(bar) 60 | """) 61 | 62 | result = testdir.runpytest() 63 | result.assert_outcomes(passed=6) 64 | 65 | 66 | def test_cartesian_parametrize(testdir): 67 | testdir.makepyfile( 68 | """ 69 | import pytest 70 | 71 | def describe_marks(): 72 | 73 | @pytest.mark.parametrize('foo', (1, 2, 3)) 74 | @pytest.mark.parametrize('bar', (1, 2, 3)) 75 | def isint(foo, bar): 76 | assert foo == int(foo) 77 | assert bar == int(bar) 78 | """) 79 | 80 | result = testdir.runpytest() 81 | result.assert_outcomes(passed=9) 82 | 83 | 84 | def test_parametrize_applies_to_describe(testdir): 85 | testdir.makepyfile( 86 | """ 87 | import pytest 88 | 89 | @pytest.mark.parametrize('foo', (1, 2, 3)) 90 | def describe_marks(): 91 | 92 | @pytest.mark.parametrize('bar', (1, 2, 3)) 93 | def isint(foo, bar): 94 | assert foo == int(foo) 95 | assert bar == int(bar) 96 | 97 | def isint2(foo): 98 | assert foo == int(foo) 99 | 100 | def describe_nested(): 101 | def isint3(foo): 102 | assert foo == int(foo) 103 | """) 104 | 105 | result = testdir.runpytest() 106 | result.assert_outcomes(passed=15) 107 | 108 | 109 | def test_cartesian_parametrize_on_describe(testdir): 110 | testdir.makepyfile( 111 | """ 112 | import pytest 113 | 114 | @pytest.mark.parametrize('foo', (1, 2, 3)) 115 | @pytest.mark.parametrize('bar', (1, 2, 3)) 116 | def describe_marks(): 117 | 118 | def isint(foo, bar): 119 | assert foo == int(foo) 120 | assert bar == int(bar) 121 | """) 122 | 123 | result = testdir.runpytest() 124 | result.assert_outcomes(passed=9) 125 | 126 | 127 | def test_parametrize_with_shared(testdir): 128 | testdir.makepyfile( 129 | """ 130 | import pytest 131 | from pytest import fixture 132 | from pytest_describe import behaves_like 133 | 134 | def a_duck(): 135 | def it_quacks(sound): 136 | assert sound == int(sound) 137 | 138 | 139 | @pytest.mark.parametrize('foo', (1, 2, 3)) 140 | @behaves_like(a_duck) 141 | def describe_something_that_quacks(): 142 | @fixture 143 | def sound(foo): 144 | return foo 145 | 146 | @pytest.mark.parametrize('foo', (1, 2, 3)) 147 | @behaves_like(a_duck) 148 | def describe_something_that_barks(): 149 | @fixture 150 | def sound(foo): 151 | return foo 152 | """) 153 | 154 | result = testdir.runpytest() 155 | result.assert_outcomes(passed=6) 156 | 157 | 158 | def test_parametrize_with_shared_but_different_values(testdir): 159 | testdir.makepyfile( 160 | """ 161 | import pytest 162 | from pytest import fixture 163 | from pytest_describe import behaves_like 164 | 165 | def a_duck(): 166 | def it_quacks(sound): 167 | assert sound[1] == int(sound[1]) 168 | assert sound[0] == 'bark' or sound[1] <= 3 169 | assert sound[0] == 'quack' or sound[1] >= 4 170 | 171 | 172 | @pytest.mark.parametrize('foo', (1, 2, 3)) 173 | @behaves_like(a_duck) 174 | def describe_something_that_quacks(): 175 | @fixture 176 | def sound(foo): 177 | return ('quack', foo) 178 | 179 | @pytest.mark.parametrize('foo', (4, 5, 6)) 180 | @behaves_like(a_duck) 181 | def describe_something_that_barks(): 182 | @fixture 183 | def sound(foo): 184 | return ('bark', foo) 185 | """) 186 | 187 | result = testdir.runpytest() 188 | result.assert_outcomes(passed=6) 189 | 190 | 191 | def test_coincident_parametrize_at_top(testdir): 192 | testdir.makepyfile( 193 | """ 194 | import pytest 195 | 196 | @pytest.mark.parametrize('foo', (1, 2, 3)) 197 | def describe_marks(): 198 | 199 | @pytest.mark.parametrize('bar', (1, 2, 3)) 200 | def isint(foo, bar): 201 | assert foo == int(foo) 202 | assert bar == int(bar) 203 | 204 | @pytest.mark.parametrize('foo', (1, 2, 3)) 205 | def describe_marks2(): 206 | def isint2(foo): 207 | assert foo == int(foo) 208 | """) 209 | 210 | result = testdir.runpytest() 211 | result.assert_outcomes(passed=12) 212 | 213 | 214 | def test_keywords(testdir): 215 | testdir.makepyfile( 216 | """ 217 | import pytest 218 | def describe_a(): 219 | def foo_test(): 220 | pass 221 | def bar_test(): 222 | pass 223 | """) 224 | 225 | result = testdir.runpytest('-k', 'foo') 226 | try: 227 | result.assert_outcomes(passed=1, deselected=1) 228 | except TypeError: # pragma: no cover pytest < 7.0 229 | assert_outcomes(result, passed=1, deselected=1) 230 | 231 | 232 | def test_custom_markers(testdir): 233 | testdir.makeini( 234 | """ 235 | [pytest] 236 | markers = 237 | foo 238 | bar 239 | """) 240 | 241 | testdir.makepyfile( 242 | """ 243 | import pytest 244 | def describe_a(): 245 | @pytest.mark.foo 246 | def foo_test(): 247 | pass 248 | @pytest.mark.bar 249 | def bar_test(): 250 | pass 251 | """) 252 | 253 | result = testdir.runpytest('-m', 'foo') 254 | try: 255 | result.assert_outcomes(passed=1, deselected=1) 256 | except TypeError: # pragma: no cover pytest < 7.0 257 | assert_outcomes(result, passed=1, deselected=1) 258 | 259 | 260 | def test_module_marks(testdir): 261 | testdir.makepyfile( 262 | """ 263 | import pytest 264 | pytestmark = [ pytest.mark.foo ] 265 | def describe_a(): 266 | pytestmark = [ pytest.mark.bar ] 267 | def describe_b(): 268 | def a_test(): 269 | pass 270 | """) 271 | 272 | result = testdir.runpytest('-m', 'foo') 273 | result.assert_outcomes(passed=1) 274 | 275 | 276 | def test_mark_at_describe_function(testdir): 277 | testdir.makepyfile( 278 | """ 279 | import pytest 280 | @pytest.mark.foo 281 | def describe_foo(): 282 | def describe_a(): 283 | def a_test(): 284 | pass 285 | @pytest.mark.bar 286 | def b_test(): 287 | pass 288 | """) 289 | 290 | result = testdir.runpytest('-m', 'foo') 291 | result.assert_outcomes(passed=2) 292 | 293 | 294 | def test_mark_stacking(testdir): 295 | testdir.makepyfile( 296 | """ 297 | import pytest 298 | @pytest.fixture() 299 | def get_marks(request): 300 | return [(mark.args[0], node.name) for node, mark 301 | in request.node.iter_markers_with_node(name='my_mark')] 302 | 303 | @pytest.mark.my_mark('foo') 304 | def describe_marks(): 305 | def it_is_inherited_from_describe_block(get_marks): 306 | assert get_marks == [('foo', 'describe_marks')] 307 | 308 | @pytest.mark.my_mark('bar') 309 | @pytest.mark.my_mark('baz') 310 | def all_marks_are_chained(get_marks): 311 | assert get_marks == [ 312 | ('baz', 'all_marks_are_chained'), 313 | ('bar', 'all_marks_are_chained'), 314 | ('foo', 'describe_marks')] 315 | """) 316 | 317 | result = testdir.runpytest() 318 | result.assert_outcomes(passed=2) 319 | -------------------------------------------------------------------------------- /test/test_output.py: -------------------------------------------------------------------------------- 1 | """Test verbose output""" 2 | 3 | 4 | def test_verbose_output(testdir): 5 | testdir.makepyfile( 6 | """ 7 | def describe_something(): 8 | def describe_nested_ok(): 9 | def passes(): 10 | assert True 11 | def describe_nested_bad(): 12 | def fails(): 13 | assert False 14 | """ 15 | ) 16 | 17 | result = testdir.runpytest("-v") 18 | 19 | result.assert_outcomes(passed=1, failed=1) 20 | 21 | output = [ 22 | ' '.join(line.split('::', 2)[2].split()) 23 | for line in result.outlines 24 | if line.startswith('test_verbose_output.py::describe_something::') 25 | ] 26 | 27 | assert output == [ 28 | "describe_nested_ok::passes PASSED [ 50%]", 29 | "describe_nested_bad::fails FAILED [100%]", 30 | ] 31 | -------------------------------------------------------------------------------- /test/test_prefix.py: -------------------------------------------------------------------------------- 1 | """Test custom prefixes""" 2 | 3 | from textwrap import dedent 4 | 5 | 6 | def test_collect_custom_prefix(testdir): 7 | testdir.makeini( 8 | """ 9 | [pytest] 10 | describe_prefixes = foo bar 11 | """) 12 | 13 | testdir.makepyfile( 14 | """ 15 | def foo_scope(): 16 | def bar_context(): 17 | def passes(): 18 | pass 19 | """) 20 | 21 | result = testdir.runpytest('--collectonly') 22 | result.assert_outcomes() 23 | 24 | output = '\n'.join(line.lstrip() for line in result.outlines if line) 25 | assert "collected 1 item" in output 26 | assert dedent(""" 27 | 28 | 29 | 30 | 31 | """) in output 32 | -------------------------------------------------------------------------------- /test/test_shared.py: -------------------------------------------------------------------------------- 1 | """Test shared behaviors""" 2 | 3 | 4 | def test_shared_behaviors(testdir): 5 | testdir.makepyfile( 6 | """ 7 | from pytest import fixture 8 | from pytest_describe import behaves_like 9 | 10 | def a_duck(): 11 | def it_quacks(sound): 12 | assert sound == "quack" 13 | 14 | @behaves_like(a_duck) 15 | def describe_something_that_quacks(): 16 | @fixture 17 | def sound(): 18 | return "quack" 19 | 20 | @behaves_like(a_duck) 21 | def describe_something_that_barks(): 22 | @fixture 23 | def sound(): 24 | return "bark" 25 | """) 26 | 27 | result = testdir.runpytest() 28 | result.assert_outcomes(failed=1, passed=1) 29 | 30 | 31 | def test_multiple_shared_behaviors(testdir): 32 | testdir.makepyfile( 33 | """ 34 | from pytest import fixture 35 | from pytest_describe import behaves_like 36 | 37 | def a_duck(): 38 | def it_quacks(sound): 39 | assert sound == "quack" 40 | 41 | def a_bird(): 42 | def it_flies(medium): 43 | assert medium == "air" 44 | 45 | def describe_birds(): 46 | @fixture 47 | def medium(): 48 | return "air" 49 | 50 | @behaves_like(a_duck, a_bird) 51 | def describe_something_that_quacks(): 52 | @fixture 53 | def sound(): 54 | return "quack" 55 | 56 | @behaves_like(a_duck, a_bird) 57 | def describe_something_that_barks(): 58 | @fixture 59 | def sound(): 60 | return "bark" 61 | """) 62 | 63 | result = testdir.runpytest() 64 | result.assert_outcomes(failed=1, passed=3) 65 | 66 | 67 | def test_fixture(testdir): 68 | testdir.makepyfile( 69 | """ 70 | from pytest import fixture 71 | from pytest_describe import behaves_like 72 | 73 | def a_duck(): 74 | @fixture 75 | def sound(): 76 | return "quack" 77 | 78 | def it_quacks(sound): 79 | assert sound == "quack" 80 | 81 | @behaves_like(a_duck) 82 | def describe_a_normal_duck(): 83 | pass 84 | """) 85 | 86 | result = testdir.runpytest('-v') 87 | result.assert_outcomes(passed=1) 88 | 89 | 90 | def test_override_fixture(testdir): 91 | testdir.makepyfile( 92 | """ 93 | from pytest import fixture 94 | from pytest_describe import behaves_like 95 | 96 | def a_duck(): 97 | @fixture 98 | def sound(): 99 | return "quack" 100 | 101 | def it_quacks(sound): 102 | assert sound == "quack" 103 | 104 | @behaves_like(a_duck) 105 | def describe_something_that_barks(): 106 | @fixture 107 | def sound(): 108 | return "bark" 109 | """) 110 | 111 | result = testdir.runpytest('-v') 112 | result.assert_outcomes(failed=1) 113 | 114 | 115 | def test_name_mangling(testdir): 116 | testdir.makepyfile( 117 | """ 118 | from pytest import fixture 119 | from pytest_describe import behaves_like 120 | 121 | def thing(): 122 | foo = 42 123 | def it_does_something(): 124 | assert foo == 42 125 | 126 | @behaves_like(thing) 127 | def describe_something(): 128 | foo = 4242 129 | def it_does_something(): 130 | assert foo == 4242 131 | """) 132 | 133 | result = testdir.runpytest('-v') 134 | result.assert_outcomes(passed=2) 135 | 136 | 137 | def test_nested_name_mangling(testdir): 138 | testdir.makepyfile( 139 | """ 140 | from pytest import fixture 141 | from pytest_describe import behaves_like 142 | 143 | def thing(): 144 | def it_does_something(): 145 | pass 146 | def describe_thing(): 147 | def it_does_something(): 148 | pass 149 | def describe_thing(): 150 | def it_does_something(): 151 | pass 152 | 153 | @behaves_like(thing) 154 | def describe_thing(): 155 | def it_does_something(): 156 | pass 157 | def describe_thing(): 158 | def it_does_something(): 159 | pass 160 | """) 161 | 162 | result = testdir.runpytest('-v') 163 | result.assert_outcomes(passed=5) 164 | 165 | 166 | def test_evaluated_once(testdir): 167 | testdir.makepyfile( 168 | """ 169 | from pytest import fixture 170 | from pytest_describe import behaves_like 171 | 172 | count = 0 173 | def thing(): 174 | global count 175 | count += 1 176 | def is_evaluated_once(): 177 | assert count == 1 178 | 179 | @behaves_like(thing) 180 | def describe_something(): 181 | pass 182 | @behaves_like(thing) 183 | def describe_something_else(): 184 | pass 185 | """) 186 | 187 | result = testdir.runpytest('-v') 188 | result.assert_outcomes(passed=2) 189 | -------------------------------------------------------------------------------- /test/test_simple.py: -------------------------------------------------------------------------------- 1 | """Test simple execution""" 2 | 3 | 4 | def test_can_pass(testdir): 5 | testdir.makepyfile( 6 | """ 7 | def describe_something(): 8 | def passes(): 9 | assert True 10 | def describe_nested(): 11 | def passes_too(): 12 | assert True 13 | """) 14 | 15 | result = testdir.runpytest() 16 | result.assert_outcomes(passed=2) 17 | 18 | 19 | def test_can_fail(testdir): 20 | testdir.makepyfile( 21 | """ 22 | def describe_something(): 23 | def fails(): 24 | assert False 25 | def describe_nested(): 26 | def fails_too(): 27 | assert False 28 | """) 29 | 30 | result = testdir.runpytest() 31 | result.assert_outcomes(failed=2) 32 | 33 | 34 | def test_can_fail_and_pass(testdir): 35 | testdir.makepyfile( 36 | """ 37 | def describe_something(): 38 | def describe_nested_ok(): 39 | def passes(): 40 | assert True 41 | def describe_nested_bad(): 42 | def fails(): 43 | assert False 44 | """) 45 | 46 | result = testdir.runpytest() 47 | result.assert_outcomes(passed=1, failed=1) 48 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37-pytest{4,5,60,61,62,70,71,72,73,74},py{38,39,py39}-pytest{4,5,60,61,62,70,71,72,73,74,80},py{310,py310}-pytest{70,71,72,73,74,80},py311-pytest{73,74,80,-latest},py312-pytest{74,80,-latest},flake8,coverage 3 | 4 | [testenv] 5 | basepython = 6 | py37: python3.7 7 | py38: python3.8 8 | py39: python3.9 9 | py310: python3.10 10 | py311: python3.11 11 | py312: python3.12 12 | pypy39: pypy3.9 13 | pypy310: pypy3.10 14 | deps = 15 | pytest4: pytest>=4.6,<5.0 16 | pytest5: pytest>=5.4,<5.5 17 | pytest60: pytest>=6.0,<6.1 18 | pytest61: pytest>=6.1,<6.2 19 | pytest62: pytest>=6.2,<6.3 20 | pytest70: pytest>=7.0,<7.1 21 | pytest71: pytest>=7.1,<7.2 22 | pytest72: pytest>=7.2,<7.3 23 | pytest73: pytest>=7.3,<7.4 24 | pytest74: pytest>=7.4,<7.5 25 | pytest80: pytest>=8.0,<8.1 26 | pytest-latest: pytest 27 | pytest-main: git+https://github.com/pytest-dev/pytest.git@main 28 | commands = pytest test {posargs} 29 | 30 | [testenv:flake8] 31 | basepython = python3.11 32 | deps = flake8>=7,<8 33 | commands = 34 | flake8 pytest_describe test setup.py 35 | 36 | [testenv:coverage] 37 | basepython = python3.11 38 | deps = 39 | coverage 40 | pytest 41 | commands = 42 | coverage run --source=pytest_describe,test -m pytest test {posargs} 43 | coverage report -m --fail-under=100 44 | 45 | [pytest] 46 | minversion = 4.6 47 | filterwarnings = 48 | ignore:The TerminalReporter\.writer attribute is deprecated, use TerminalReporter\._tw instead at your own risk\.:DeprecationWarning 49 | --------------------------------------------------------------------------------