├── .gitignore ├── tests ├── conftest.py └── test_pytest_it.py ├── setup.cfg ├── dev-requirements.txt ├── img └── output-example.png ├── src └── pytest_it │ ├── __init__.py │ └── plugin.py ├── LICENSE ├── tox.ini ├── Makefile ├── setup.py ├── .travis.yml └── README.rst /.gitignore: -------------------------------------------------------------------------------- 1 | _examples 2 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = "pytester" 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | ignore = E501 -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | pytest>=3.6 3 | flake8 4 | twine 5 | requests 6 | tox 7 | -------------------------------------------------------------------------------- /img/output-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattduck/pytest-it/HEAD/img/output-example.png -------------------------------------------------------------------------------- /src/pytest_it/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.1.5" 2 | 3 | __title__ = "pytest-it" 4 | __description__ = "Pytest plugin to display test reports as a plaintext spec, inspired by Rspec: https://github.com/mattduck/pytest-it." 5 | __doc__ = __description__ 6 | __uri__ = "https://github.com/mattduck/pytest-it" 7 | 8 | __author__ = "Matt Duck" 9 | __email__ = "matt@mattduck.com" 10 | 11 | __license__ = "MIT" 12 | __copyright__ = "Copyright (c) 2019 Matthew Duck" 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Matthew Duck 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py36,py310,py311,coverage-report 3 | 4 | # [testenv] 5 | # deps = -rdev-requirements.txt 6 | # commands = python -m pytest tests {posargs} 7 | 8 | # [testenv:py27] 9 | # deps = -rdev-requirements.txt 10 | # commands = coverage run --parallel -m pytest {posargs} 11 | 12 | [testenv:py27] 13 | deps = -rdev-requirements.txt 14 | commands = coverage run --parallel --source=pytest_it -m pytest tests {posargs} 15 | 16 | [testenv:py36] 17 | deps = -rdev-requirements.txt 18 | commands = coverage run --parallel --source=pytest_it -m pytest tests {posargs} 19 | 20 | [testenv:py39] 21 | deps = -rdev-requirements.txt 22 | commands = coverage run --parallel --source=pytest_it -m pytest tests {posargs} 23 | 24 | [testenv:py310] 25 | deps = -rdev-requirements.txt 26 | commands = coverage run --parallel --source=pytest_it -m pytest tests {posargs} 27 | 28 | [testenv:py311] 29 | deps = -rdev-requirements.txt 30 | commands = coverage run --parallel --source=pytest_it -m pytest tests {posargs} 31 | 32 | [testenv:coverage-report] 33 | deps = coverage 34 | skip_install = true 35 | commands = 36 | coverage combine 37 | coverage report 38 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: bootstrap install test lint _lintblack _lintflake8 build clean assert_new_pypi_version assert_clean_git 2 | 3 | SHELL := /bin/bash 4 | 5 | bootstrap: 6 | pip install flake8 tox requests 7 | pip install black || echo "Error installing black" 8 | 9 | build: 10 | python setup.py sdist bdist_wheel 11 | 12 | install: 13 | python setup.py install 14 | 15 | clean: 16 | rm -rf build dist pytest_it.egg-info 17 | 18 | test: 19 | tox 20 | 21 | lint: _lintblack _lintflake8 22 | 23 | _lintblack: 24 | set -o pipefail && which black && black src --check 2>&1 | sed "s/^/[black] /" 25 | 26 | _lintflake8: 27 | set -o pipefail && flake8 src tests | sed "s/^/[flake8] /" 28 | 29 | format: 30 | black . 31 | 32 | release: clean lint test build assert_clean_git assert_new_pypi_version 33 | twine upload --repository pytest-it dist/"$$(python setup.py --name)"* 34 | git tag "$$(python setup.py --version)" 35 | echo "Release successful. You probably want to push the new git tag." 36 | 37 | assert_new_pypi_version: 38 | python3 -c "$$PYSCRIPT_ASSERT_NEW_VERSION" 39 | 40 | assert_clean_git: 41 | if [ "$$TRAVIS" = "true" ]; then exit 0; elif [ "$$(git status --porcelain)" != "" ]; then echo "Dirty git index, exiting." && exit 1; fi 42 | 43 | 44 | # PYSCRIPT_ASSERT_NEW_VERSION ------------------------------ 45 | # Confirm that there isn't an existing PyPI build for this version. 46 | define PYSCRIPT_ASSERT_NEW_VERSION 47 | 48 | import subprocess, sys, requests 49 | 50 | NAME = subprocess.check_output(['python', 'setup.py', '--name']).decode().strip() 51 | LOCAL_VERSION = subprocess.check_output(['python', 'setup.py', '--version']).decode().strip() 52 | 53 | pypi_data_url = 'https://pypi.python.org/pypi/{name}/json'.format(name=NAME) 54 | pypi_resp = requests.get(pypi_data_url) 55 | pypi_resp.raise_for_status() # If not registered this will raise 56 | pypi_data = pypi_resp.json() 57 | for pypi_version, upload_data in pypi_data['releases'].items(): 58 | if upload_data and LOCAL_VERSION == pypi_version: 59 | print('Found existing PyPI release to match local version {}, exiting'.format(LOCAL_VERSION)) 60 | sys.exit(1) 61 | 62 | endef 63 | export PYSCRIPT_ASSERT_NEW_VERSION 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import codecs 4 | import os 5 | import re 6 | 7 | from setuptools import setup, find_packages 8 | 9 | 10 | ################################################################### 11 | 12 | NAME = "pytest-it" 13 | PACKAGES = find_packages(where="src") 14 | ENTRY_POINTS = {"pytest11": ["it = pytest_it.plugin"]} 15 | META_PATH = os.path.join("src", "pytest_it", "__init__.py") 16 | KEYWORDS = ["pytest", "pytest-it", "test", "bdd", "rspec", "org-mode", "markdown"] 17 | CLASSIFIERS = [ 18 | "Development Status :: 4 - Beta", 19 | "Framework :: Pytest", 20 | "Intended Audience :: Developers", 21 | "Topic :: Software Development :: Testing", 22 | "Natural Language :: English", 23 | "Programming Language :: Python", 24 | "Programming Language :: Python :: 2.7", 25 | "Programming Language :: Python :: 3.6", 26 | "Programming Language :: Python :: 3.7", 27 | "License :: OSI Approved :: MIT License", 28 | ] 29 | INSTALL_REQUIRES = [] 30 | 31 | ################################################################### 32 | 33 | HERE = os.path.abspath(os.path.dirname(__file__)) 34 | 35 | 36 | def read(*parts): 37 | """ 38 | Build an absolute path from *parts* and and return the contents of the 39 | resulting file. Assume UTF-8 encoding. 40 | """ 41 | with codecs.open(os.path.join(HERE, *parts), "rb", "utf-8") as f: 42 | return f.read() 43 | 44 | 45 | META_FILE = read(META_PATH) 46 | 47 | 48 | def find_meta(meta): 49 | """ 50 | Extract __*meta*__ from META_FILE. 51 | """ 52 | meta_match = re.search( 53 | r"^__{meta}__ = ['\"]([^'\"]*)['\"]".format(meta=meta), META_FILE, re.M 54 | ) 55 | if meta_match: 56 | return meta_match.group(1) 57 | raise RuntimeError("Unable to find __{meta}__ string.".format(meta=meta)) 58 | 59 | 60 | if __name__ == "__main__": 61 | setup( 62 | name=NAME, 63 | description=find_meta("description"), 64 | license=find_meta("license"), 65 | url=find_meta("uri"), 66 | version=find_meta("version"), 67 | author=find_meta("author"), 68 | author_email=find_meta("email"), 69 | maintainer=find_meta("author"), 70 | maintainer_email=find_meta("email"), 71 | keywords=KEYWORDS, 72 | long_description=read("README.rst"), 73 | packages=PACKAGES, 74 | package_dir={"": "src"}, 75 | zip_safe=False, 76 | classifiers=CLASSIFIERS, 77 | install_requires=INSTALL_REQUIRES, 78 | entry_points=ENTRY_POINTS, 79 | ) 80 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | notifications: 2 | email: 3 | on_success: never 4 | on_failure: change 5 | sudo: false 6 | language: python 7 | cache: pip 8 | before_cache: 9 | - rm -f $HOME/.cache/pip/log/debug.log 10 | install: 11 | - make bootstrap 12 | - pip install -r dev-requirements.txt 13 | after_success: 14 | - tox -e coverage-report 15 | jobs: 16 | include: 17 | - stage: lint 18 | python: '3.6' 19 | script: make lint 20 | # - stage: test 21 | # python: '2.7' 22 | # env: TOXENV=py27 23 | # script: make test 24 | - stage: test 25 | python: '3.6' 26 | env: TOXENV=py36 27 | script: make test 28 | - stage: release 29 | python: 3.6 30 | if: branch = master AND tag IS blank 31 | before_script: 32 | - git status # For debugging 33 | - if [ -z "$TWINE_USERNAME" ]; then echo "TWINE_USERNAME not set, exiting." && exit 1; fi 34 | - if [ -z "$TWINE_PASSWORD" ]; then echo "TWINE_PASSWORD not set, exiting." && exit 1; fi 35 | - if [ -z "$GITHUB_TAG_TOKEN" ]; then echo "GITHUB_TAG_TOKEN not set, exiting." && exit 1; fi 36 | - make assert_new_pypi_version || exit 0 37 | - git config --global user.email "builds@travis-ci.com" 38 | - git config --global user.name "Travis CI" 39 | - pip install -r dev-requirements.txt 40 | script: make release && git push --quiet https://mattduck:$GITHUB_TAG_TOKEN@github.com/mattduck/pytest-it.git --tags 41 | after_success: pip install pytest-it --upgrade 42 | env: 43 | - secure: "Gc4kUgf70OiKTvTOr0rNi0AyOjKps3JUVpHcDxQXFtRPZjsFhOu4qGWxUo41Ro69kPeuQplqtn8U6FpEy+pICdN28WqDjuGyP8rKvpK4IQwBaZnCei3eAf6s/zZ8k1vDE0bRcWJjAXqkpt86tr9Z0tSJFJAQp+NK9iEqJGmOpmV9Svyy3AjueaRE5HnbgF/X3NrRBS+BqkpM1Gdg4+sawTearH358N6Y9Y60+cIvVwfomoV9LpElvPG+RZ0QnZa8zm6LSijC89W95gSY13Cxhr1eLa8p8/awrm8Usw34AvjnKBr9qy4ijCczIJbW0q9pFD4NLSRfyQxl4DPgXI7gUbkOrnuJZGBzjpFtOSYSPOTa4KDJKmOHm09FPbviETeoS7WkyJg6wr7BlgCCpTRSrXKRO9krSg8M5XOV6JgsgUkJlt9b8PRtjyOvmGX+HjlGK3/jmZqdaH/iH05fb+YIkQicwwe31nF/B7s/0A6ukEqsFf8n5FsvH9SIN19fxbhUxp3xE33Pk9kM6xEVfwv0C9caYYImTQeX3cjPDa0FtI/qRM13wFCXCJxl0uVNqNjf9zhDeJSMMAHXria3B+IMON+nsguZZI12yg5HS/LA5Pp13BoaH0AeKqs6Q6yGAz5kcPGKHqDmOC5Wmzk6oKZrNIgiUMShcgOIk6y1zSPc6cg=" 44 | - secure: "Yrwykc4Ca8J1R3kjAOZ7YMzsfkz8WOTu1K/BG6WnjJDZGyep0Bh5YXUXGKApyz0EuqXjX+ovVJPBBFo7BqL6NhNjDjtl5rKXAnwNvll4mQ/Xe0k607yHF9LZrGZEBkbAaUKvf9t6x/6rB1X7G+tSx7VS7RscYRH4FBXOxHLhgRAI3J46BvqP5IIyd1XROIhb1zofbkRkxXKBf2lLkQV/z4NODWxkCeHyjrRVFZcuy74eS2adx7xUAoUmKYSmlIf6BLSWJap0+lPd/ciD/3UiVnu76aQqHQmz3tJGrAjSdH9K06GJDIpLHF+KAqHBqXKstQAz521E4TzzuWprzOOYzJmq5nz7NagpvrumEEkaJg5JKErDFMETlSib5XOOMmwwF8BBQva/xsH+lkxNq1FQ1gjBRYw7oV/IMussKgZOeGfaJ/OidFh0sYrIZJZoNbrrq7+dH6blsXm/3Xe31hFETcFVzlJA9T8JGJ3AjWORmUw8FGuLW+6BZ9nzcfi3aPU+iakzD7wgX6xILZtXAolBE5OygUaAjFimu0kEDF++fRAlJAyP3tv34q5lQ3QIBiDA8Ij6r9mtuwbYw3NO1mmYJ1Nk64+uqRsnGm9PEx82+mvHXqydrTVomDNSPTCPx3UokRDS2YR5u/v67VNb4Q5KBT+2GiWaoqd7xHF1sQ5ZRsQ=" 45 | - secure: "nWSghMnPhrr1W+uDbyv7baGx9tDpEGdXEDrsGKFhziksFSRkb3aBD6iUJLAeurK6cqo9d5qsc1ZxYIx3OIGh24hCHwKuYWrjY3V30h0pFpthlI/sn7TAui0k4Vvkzg0u3G2u4XqhJ44HyalNnTrINM0ZjBWch0wnSoWiWQXSQcaGehgoLlrA91tXrIukq0dOIdv4DkRVCxaXvUKv6xKIFX0lT+uKSRF0ZlILHDndXXpJ8JLAquk36m8Ah0DS0uXtVIy/WQm3jrodnr51kHWCIl3rLoAlivw+nkeyPkikgCkcU7TgZVOXN5XRf7isWXyxOscj2/n1EwsFULPHrt5jR2KhNfYrKUlNxb204+zlOwY6Y+bLSOGllUf69l3Cdmu4BakApFj8NlxNdCsEzZ0YqayF4OFN/YND84lerZ4Dq5hGqlYkd+/g3ZG7+PuAK1Q52iwYoTjNczE3SgdA0uBaUD8yOaMUTU8CuJA7I+JrgTFrs3BPV/ru3bYSWx0GxPM1IXgG4O+NfOBYmyOxJQbTWKWmOA3cFu59+NJ9jSnAscnzgnWwgJThILNNG94ceGyodzcAtOJZyRmCULrvugwtXXGOYUpDKUo2piiZADrkXIcjjQFoY23gikYnzVW4KXdyBpRhV0vuEvC8JYac3go8icrOmHXOl0/V1x/FTVPBPL4=" 46 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pytest-it 2 | ========= 3 | 4 | |PyPI| |Travis| 5 | 6 | Decorate your pytest suite with `RSpec-inspired `_ markers ``describe``, ``context`` and 7 | ``it``. Then run ``pytest --it`` to see a plaintext, org-mode compatible spec of the 8 | test structure. 9 | 10 | .. image:: img/output-example.png 11 | 12 | 13 | 14 | Install 15 | ------- 16 | 17 | ``pytest-it`` is `available on PyPi `_: ``pip install pytest-it``. 18 | 19 | 20 | Examples 21 | -------- 22 | 23 | A basic example that uses ``pytest.mark.describe``, ``pytest.mark.context`` and ``pytest.mark.it``: 24 | 25 | .. code-block:: python 26 | 27 | from pytest import mark as m 28 | 29 | @m.describe("The test function report format") 30 | class TestPytestItExample(object): 31 | 32 | @m.context("When @pytest.mark.it is used") 33 | @m.it("Displays an '- It: ' block matching the decorator") 34 | def test_it_decorator(self, testdir): 35 | pass 36 | 37 | This produces:: 38 | 39 | - Describe: The test function report format... 40 | 41 | - Context: When @pytest.mark.it is used... 42 | - ✓ It: Displays an '- It: ' block matching the decorator 43 | 44 | 45 | ``Describe`` and ``Context`` blocks can be nested arbitrarily by using multiple 46 | markers, eg: 47 | 48 | .. code-block:: python 49 | 50 | from pytest import mark as m 51 | 52 | @m.describe("The test function report format") 53 | class TestPytestItExample(object): 54 | 55 | @m.context("When @pytest.mark.it is not used") 56 | @m.it("Displays the test function name") 57 | def test_no_argument(self, testdir): 58 | pass 59 | 60 | @m.context("When @pytest.mark.it is not used") 61 | @m.context("but the test name starts with 'test_it_'") 62 | @m.it("Prettifies the test name into the 'It: ' value") 63 | def test_populates_the_it_marker_using_function_name(self, testdir): 64 | pass 65 | 66 | This produces:: 67 | 68 | - Describe: The test function report format... 69 | 70 | - Context: When @pytest.mark.it is not used... 71 | - ✓ It: Displays the test function name 72 | 73 | - ...but the test name starts with 'test_it_'... 74 | - ✓ It: Prettifies the test name into the 'It: ' value 75 | 76 | 77 | Behaviour 78 | --------- 79 | 80 | - Pytest markers are used to specify the ``Describe:``, ``Context:`` and ``It:`` 81 | sections. You can set these in all the usual ways that you specify pytest 82 | markers. 83 | 84 | - ``Describe`` and ``Context`` can be nested arbitrarily. 85 | 86 | - If ``--collect-only`` is used, it displays the same ``pytest-it`` spec as usual, but 87 | without the test result (✓/F/s). 88 | 89 | - If ``-v`` is higher than 0, the full path to the test function is include in the 90 | test name. 91 | 92 | - If ``pytest.mark.it`` is not used on a test, the test name is displayed instead 93 | of the ``It: does something`` output. 94 | 95 | - If ``pytest.mark.it`` is not used but the test name starts with ``test_it``, 96 | ``pytest-it`` will prettify the test name into an ``It: does something`` value. 97 | 98 | - The test output should be able to be copied directly into an `org-mode `_ file. 99 | 100 | 101 | Background 102 | ----------- 103 | 104 | Pytest provides a lot of useful features for testing in Python, but when testing 105 | complex systems, it can be hard to clearly communicate the intent of a test 106 | using the standard ``test_module.py::TestClass::test_function`` structure. 107 | 108 | One way to improve clarity is to use a BDD testing framework 109 | (eg. `Behave `_, 110 | `Mamba `_, `Rspec `_), but 111 | it's not always desirable to restructure existing test and program code. 112 | 113 | There are some pytest plugins that attempt to bridge this gap, by providing 114 | alternative ways to structure the tests (eg. `pytest-describe 115 | `_, `pytest-bdd `_), or 116 | altering the test report output (eg. `pytest-testdox `_, `pytest-pspec `_). 117 | 118 | ``pytest-it`` takes a similar approach to ``pytest-testdox``, by providing pytest 119 | markers that can describe the test output. ``pytest-it`` supports a few other 120 | features, such as: 121 | 122 | - A plaintext test structure that can easily be copied to markdown/org-mode documents. 123 | - Arbitrary nesting of ``describe`` and ``context`` markers. 124 | - Supporting the ``--collect-only`` pytest flag to display test structure. 125 | - Displaying the full path to each test if ``-v`` is used. 126 | - Neatly integrating tests in the output if they don't use the ``pytest-it`` 127 | markers. 128 | 129 | Although ``pytest-it`` does not change the behaviour of pytest tests, you may find it 130 | a useful tool for thinking about test structure, and communicating the intention 131 | of both the test code and the system under test. 132 | 133 | .. |PyPI| image:: https://img.shields.io/pypi/v/pytest-it.svg 134 | :target: https://pypi.python.org/pypi/pytest-it 135 | 136 | .. |Travis| image:: https://travis-ci.org/mattduck/pytest-it.svg?branch=master 137 | :target: https://travis-ci.org/mattduck/pytest-it 138 | -------------------------------------------------------------------------------- /src/pytest_it/plugin.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from collections import defaultdict 5 | 6 | import pytest 7 | from _pytest.pathlib import bestrelpath 8 | from _pytest.terminal import TerminalReporter 9 | 10 | REGISTERED = False 11 | 12 | 13 | def pytest_addoption(parser): 14 | group = parser.getgroup("terminal reporting", "reporting", after="general") 15 | group.addoption( 16 | "--it", 17 | action="store_true", 18 | dest="it", 19 | default=False, 20 | help="Display test reports as a plaintext spec, inspired by RSpec", 21 | ) 22 | group.addoption( 23 | "--it-no-color", 24 | action="store_false", 25 | dest="it_color", 26 | default=None, 27 | help="Disable coloured output when using --it", 28 | ) 29 | 30 | 31 | @pytest.hookimpl(trylast=True) 32 | def pytest_configure(config): 33 | global REGISTERED 34 | if (config.option.it or (config.option.it_color is False)) and not REGISTERED: 35 | default = config.pluginmanager.getplugin("terminalreporter") 36 | config.pluginmanager.unregister(default) 37 | config.pluginmanager.register( 38 | ItTerminalReporter(default.config), "terminalreporter" 39 | ) 40 | 41 | config.addinivalue_line( 42 | "markers", 43 | "describe(arg): pytest-it marker to apply a 'Describe: ' block to the report.", 44 | ) 45 | config.addinivalue_line( 46 | "markers", 47 | "context(arg): pytest-it marker to apply a 'Context: ' block to the report.", 48 | ) 49 | config.addinivalue_line( 50 | "markers", 51 | """it(arg): pytest-it marker to specify the 'It: ' output for the report. If not provided, pytest-it will automatically add an 'It :' marker to any test function starting with `test_it_`.""", # noqa 52 | ) 53 | 54 | 55 | class ItItem(object): 56 | 57 | INDENT = " " 58 | COLORS = { 59 | "reset": "\033[0m", 60 | "passed": "\033[92m", 61 | "failed": "\033[91m", 62 | "skipped": "\033[93m", 63 | } 64 | 65 | def __init__(self, item): 66 | assert item 67 | self._item = item 68 | 69 | @property 70 | def path(self): 71 | """Path to the test""" 72 | 73 | # TODO: docstrings show up in the path. This is used for verbose mode. Should only show 74 | # the actual path without docstrings. 75 | return "::".join(self._item.nodeid.split("::")[:-1]) 76 | 77 | def formatted_result(self, outcome): 78 | icons = {"passed": "- ✓", "failed": "- F", "skipped": "- s"} 79 | title = "" 80 | for mark in self._item.iter_markers(name="it"): 81 | try: 82 | title = mark.args[0] 83 | break 84 | except IndexError: 85 | pass 86 | if title: 87 | prefix = "It:" 88 | else: 89 | prefix = "" 90 | title = self._item.name 91 | try: 92 | if "[doctest]" in self._item.location[-1]: 93 | title = self._item.name + " - [doctest]" 94 | except IndexError: 95 | pass 96 | if self._item.config.option.verbose > 0: 97 | title = self.path + "::{} - {}".format(self._item.name, title) 98 | if "[" in self._item.name: # Parametrised test 99 | title = title + " - [{}".format(self._item.name.split("[")[1]) 100 | return "{color}{icon}{prefix}{reset} {title}".format( 101 | color=self.color(outcome), 102 | reset=self.color("reset"), 103 | icon=icons.get(outcome, "-"), 104 | prefix=" " + prefix if prefix else "", 105 | title=title, 106 | ) 107 | 108 | @property 109 | def parent_marks(self): 110 | markers = [] 111 | for m in self._item.iter_markers(): 112 | if m.name in ("describe", "context"): 113 | try: 114 | markers.append((m.name, m.args[0])) 115 | except IndexError: 116 | pass 117 | return list(reversed(markers)) 118 | 119 | def color(self, s): 120 | if self._item.config.option.it_color in (True, None): 121 | return self.COLORS.get(s, self.COLORS["skipped"]) 122 | return "" 123 | 124 | def _print_marker(self, value, value_type, value_depth, type_depth, tw): 125 | if value_type == "describe": 126 | value = "- Describe: {}...".format(value.capitalize()) 127 | elif value_type == "context": 128 | if type_depth > 1: 129 | value = "- ...{}...".format(self._uncapitalize(value)) 130 | else: 131 | value = "- Context: {}...".format(value.capitalize()) 132 | 133 | if value_depth <= 3: 134 | tw.sep(" ") 135 | if value_depth <= 1: 136 | tw.line(value) 137 | else: 138 | tw.line((self.INDENT * (value_depth - 1)) + value) 139 | 140 | @property 141 | def module(self): 142 | try: 143 | return self._item.module 144 | except AttributeError: 145 | # a DocTestItem doesn't have a module attribute, but the parent 146 | # does 147 | return self._item.parent.module 148 | 149 | def reconcile_and_print(self, prev, tw, outcome, is_first_test=False): 150 | if prev: 151 | prev_marks = prev.parent_marks 152 | is_first_module_test = self.module != prev.module 153 | else: 154 | prev_marks, is_first_module_test = [], True 155 | depth = defaultdict(int) 156 | diff_broken = False 157 | self_depth = 0 158 | for self_depth, self_marker_info in enumerate(self.parent_marks, 1): 159 | self_type, self_value = self_marker_info 160 | depth[self_type] += 1 161 | 162 | prev_marker_info = None 163 | try: 164 | prev_marker_info = prev_marks[self_depth - 1] 165 | except IndexError: 166 | diff_broken = True 167 | if prev_marker_info != self_marker_info: 168 | diff_broken = True 169 | 170 | if not diff_broken: 171 | continue 172 | 173 | # Start printing Describe/Context markers at the point where the 174 | # marker hierarchy differs from the previous test. 175 | self._print_marker( 176 | value=self_value, 177 | value_type=self_type, 178 | value_depth=self_depth, 179 | type_depth=depth[self_type], 180 | tw=tw, 181 | ) 182 | 183 | # Print a separator before the test if this test is displayed after a deeper block. 184 | prev_depth = max(0, (len(prev_marks))) 185 | if self_depth < prev_depth and not diff_broken: 186 | tw.sep(" ") 187 | # If this is a new module, add a separate to avoid printing "* my_module.py... It: does something" 188 | # on a single line. 189 | elif self_depth == 0 and is_first_module_test: 190 | tw.sep(" ") 191 | 192 | # Print the test with appropriate indent 193 | if self_depth: 194 | tw.line((self.INDENT * self_depth) + self.formatted_result(outcome)) 195 | else: 196 | tw.line(self.formatted_result(outcome)) 197 | 198 | def _uncapitalize(self, s): 199 | return s[0].lower() + s[1:] 200 | 201 | 202 | @pytest.hookimpl(hookwrapper=True) 203 | def pytest_runtest_makereport(item, call): 204 | result = yield 205 | report = result.get_result() 206 | report._item = item 207 | 208 | 209 | @pytest.hookimpl(hookwrapper=True) 210 | def pytest_collection_modifyitems(items): 211 | """ 212 | Allow a test to use the naming convention `test_it_does_something`. Interpret this 213 | the same as if @pytest.mark.it was used. 214 | """ 215 | for item in items: 216 | if item.name.startswith("test_it_"): 217 | if len(list(item.iter_markers(name="it"))) == 0: 218 | name = item.name.split("test_it_")[1].replace("_", " ").capitalize() 219 | item.add_marker(pytest.mark.it(name)) 220 | yield items 221 | 222 | 223 | class ItTerminalReporter(TerminalReporter): 224 | 225 | _current_pytest_it_fspath = None 226 | 227 | def __init__(self, config, file=None): 228 | TerminalReporter.__init__(self, config, file) 229 | self._prev_item = None 230 | self._showfs_path = False 231 | 232 | def _register_stats(self, report): 233 | res = self.config.hook.pytest_report_teststatus( 234 | report=report, config=self.config 235 | ) 236 | self.stats.setdefault(res[0], []).append(report) 237 | self._tests_ran = True 238 | 239 | def pytest_runtest_logreport(self, report): 240 | self._register_stats(report) 241 | if report.when != "call" and not report.skipped: 242 | return 243 | item = ItItem(report._item) 244 | item.reconcile_and_print(self._prev_item, self._tw, report.outcome) 245 | self._prev_item = item 246 | 247 | # This is probably the best function to override. _print_collecteditems() is also a candidate, but 248 | # I think it's more liable to change because it's a private method. 249 | def pytest_collection_finish(self, session): 250 | if self.config.getoption("collectonly"): 251 | prev_it_item = None 252 | for item in session.items: 253 | it_item = ItItem(item) 254 | it_item.reconcile_and_print(prev_it_item, self._tw, outcome=None) 255 | prev_it_item = it_item 256 | 257 | # NOTE: this logic is copied from TerminalReporter.pytest_collection_finish 258 | lines = self.config.hook.pytest_report_collectionfinish( 259 | config=self.config, start_path=self.startpath, items=session.items 260 | ) 261 | self._write_report_lines_from_hooks(lines) 262 | if self.config.getoption("collectonly"): 263 | if self.stats.get("failed"): 264 | self._tw.sep("!", "collection failures") 265 | for rep in self.stats.get("failed"): 266 | rep.toterminal(self._tw) 267 | 268 | def pytest_runtest_logstart(self, nodeid, location): 269 | """ 270 | Disable the normal running test output 271 | """ 272 | if self.showfspath: 273 | fsid = nodeid.split("::")[0] 274 | # below logic is very similar to self.write_fspath_result(). Ideally we would 275 | # call it directly, but there's no way to inject the "*" character before the 276 | # path. 277 | fspath = self.config.rootpath / fsid 278 | if fspath != self.currentfspath: 279 | if self.currentfspath is not None and self._show_progress_info: 280 | self._write_progress_information_filling_space() 281 | self.currentfspath = fspath 282 | fspath = bestrelpath(self.startpath, fspath) 283 | self._tw.line() 284 | self._tw.line() 285 | self._tw.write("* " + fspath + "... ") 286 | -------------------------------------------------------------------------------- /tests/test_pytest_it.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import pytest # noqa 5 | from pytest import mark as m 6 | 7 | 8 | @m.describe("The plugin integration with pytest") 9 | class TestSanity(object): 10 | 11 | BASIC_PYTEST_TEST_CODE = """ 12 | import pytest 13 | 14 | @pytest.mark.describe("A basic foo") 15 | @pytest.mark.context("When called with no arguments") 16 | @pytest.mark.context("and also in another circumstance") 17 | @pytest.mark.it("Does something") 18 | def test_foo(): 19 | assert True 20 | """ 21 | 22 | @m.context("When pytest is called with the --it flag") 23 | @m.it("Makes a pytest-it test run without error") 24 | def test_with_flag(self, testdir): 25 | """ 26 | Quick catch-all test for the main features 27 | """ 28 | testdir.makepyfile(self.BASIC_PYTEST_TEST_CODE) 29 | result = testdir.runpytest("--it", "--it-no-color") 30 | result.stdout.fnmatch_lines( 31 | [ 32 | "*- Describe: A basic foo*", 33 | "*- Context: When called with no arguments*", 34 | "*- ...and also in another circumstance...*", 35 | "*- ✓ It: Does something*", 36 | ] 37 | ) 38 | assert result.ret == 0 # 0 exit code for the test suite 39 | 40 | @m.context("When pytest is called with the --it flag") 41 | @m.it("Does not error when processing a marker that has no arg value") 42 | def test_with_flag_but_no_args(self, testdir): 43 | testdir.makepyfile( 44 | """ 45 | import pytest 46 | 47 | @pytest.mark.describe 48 | @pytest.mark.context 49 | @pytest.mark.context 50 | @pytest.mark.it 51 | def test_foo(): 52 | assert True 53 | """ 54 | ) 55 | result = testdir.runpytest("--it", "--it-no-color") 56 | assert result.ret == 0 # 0 exit code for the test suite 57 | 58 | @m.context("When pytest is called without the --it flag") 59 | @m.it("Does not cause pytest to error") 60 | def test_without_flag(self, testdir): 61 | testdir.makepyfile(self.BASIC_PYTEST_TEST_CODE) 62 | result = testdir.runpytest() 63 | assert result.ret == 0 64 | 65 | 66 | @m.describe("The --collect-only behaviour") 67 | class TestCollection(object): 68 | @m.it("Displays all tests without the result status") 69 | def test_no_result_status_is_used(self, testdir): 70 | testdir.makepyfile( 71 | """ 72 | import pytest 73 | 74 | @pytest.mark.it("Does something") 75 | def test_it_does_something_a(): 76 | assert True 77 | 78 | @pytest.mark.it("Does something else") 79 | def test_it_does_something_b(): 80 | assert True 81 | """ 82 | ) 83 | result = testdir.runpytest("--it-no-color", "--collect-only") 84 | result.stdout.fnmatch_lines( 85 | ["*- It: Does something*", "*- It: Does something else*"] 86 | ) 87 | 88 | 89 | @m.describe("The @pytest.mark.describe marker") 90 | class TestDescribe(object): 91 | @m.it("Displays a '- Describe: ' block matching the decorator") 92 | def test_one_describe(self, testdir): 93 | testdir.makepyfile( 94 | """ 95 | import pytest 96 | 97 | @pytest.mark.describe("A foo") 98 | def test_something(): 99 | assert True 100 | """ 101 | ) 102 | result = testdir.runpytest("--it-no-color") 103 | result.stdout.fnmatch_lines(["*- Describe: A foo*"]) 104 | 105 | @m.it("Displays a nested, indented '- Describe: ' block") 106 | def test_nested_describe(self, testdir): 107 | testdir.makepyfile( 108 | """ 109 | import pytest 110 | 111 | @pytest.mark.describe("A foo") 112 | @pytest.mark.describe("A nested foo") 113 | def test_something(): 114 | assert True 115 | """ 116 | ) 117 | result = testdir.runpytest("--it-no-color") 118 | result.stdout.fnmatch_lines( 119 | ["*- Describe: A foo*", "* - Describe: A nested foo*"] 120 | ) 121 | 122 | 123 | @m.describe("The @pytest.mark.context marker") 124 | class TestContext(object): 125 | @m.it("Displays a '- Context: ' block matching the decorator") 126 | def test_one_context(self, testdir): 127 | testdir.makepyfile( 128 | """ 129 | import pytest 130 | 131 | @pytest.mark.context("When something") 132 | def test_something(): 133 | assert True 134 | """ 135 | ) 136 | result = testdir.runpytest("--it-no-color") 137 | result.stdout.fnmatch_lines(["*- Context: When something...*"]) 138 | 139 | @m.it("Displays a nested, indented '..$context..' block") 140 | def test_nested_context(self, testdir): 141 | testdir.makepyfile( 142 | """ 143 | import pytest 144 | 145 | @pytest.mark.context("When something") 146 | @pytest.mark.context("And when another thing") 147 | def test_something(): 148 | assert True 149 | """ 150 | ) 151 | result = testdir.runpytest("--it-no-color") 152 | result.stdout.fnmatch_lines( 153 | ["*- Context: When something...*", "* - ...and when another thing...*"] 154 | ) 155 | 156 | @m.it("Ignores a @pytest.mark.context decorator that has no argument") 157 | def test_no_argument(self, testdir): 158 | testdir.makepyfile( 159 | """ 160 | import pytest 161 | 162 | @pytest.mark.context 163 | def test_something(): 164 | assert True 165 | """ 166 | ) 167 | result = testdir.runpytest("--it-no-color") 168 | assert "context" not in str(result.stdout).lower() 169 | 170 | 171 | @m.it("Handles indentation for arbitrary Describe and Context nesting") 172 | def test_deep_nesting_of_context_and_describe(testdir): 173 | testdir.makepyfile( 174 | """ 175 | import pytest 176 | 177 | @pytest.mark.describe("A thing") 178 | @pytest.mark.context("When something") 179 | @pytest.mark.context("And when another thing") 180 | @pytest.mark.describe("A nested thing") 181 | def test_something(): 182 | assert True 183 | """ 184 | ) 185 | result = testdir.runpytest("--it-no-color") 186 | result.stdout.fnmatch_lines( 187 | [ 188 | "*- Describe: A thing...*", 189 | "* - Context: When something...*", 190 | "* - ...and when another thing...*", 191 | "* - Describe: A nested thing*", 192 | ] 193 | ) 194 | 195 | 196 | @m.describe("The test function report format") 197 | class TestTestFormat(object): 198 | @m.it("Displays a test pass using '- ✓ '") 199 | def test_pytest_pass(self, testdir): 200 | testdir.makepyfile( 201 | """ 202 | import pytest 203 | 204 | def test_something(): 205 | assert True 206 | """ 207 | ) 208 | result = testdir.runpytest("--it-no-color") 209 | result.stdout.fnmatch_lines(["*- ✓ *"]) 210 | 211 | @m.it("Displays a test fail using '- F '") 212 | def test_fail(self, testdir): 213 | testdir.makepyfile( 214 | """ 215 | import pytest 216 | 217 | def test_something(): 218 | assert False 219 | """ 220 | ) 221 | result = testdir.runpytest("--it-no-color") 222 | result.stdout.fnmatch_lines(["*- F *"]) 223 | 224 | # TODO: would be nice for this to show the skip message 225 | @m.it("Displays a test skip using '- s '") 226 | def test_skip(self, testdir): 227 | testdir.makepyfile( 228 | """ 229 | import pytest 230 | 231 | def test_something(): 232 | pytest.skip("Skip reason") 233 | """ 234 | ) 235 | result = testdir.runpytest("--it-no-color") 236 | result.stdout.fnmatch_lines(["*- s *"]) 237 | 238 | @m.it("Displays the pytest ID for parameterised tests") 239 | def test_param(self, testdir): 240 | testdir.makepyfile( 241 | """ 242 | import pytest 243 | 244 | @pytest.mark.parametrize("param", ["a", "b", "c"]) 245 | @pytest.mark.it("Does something") 246 | def test_something(param): 247 | assert True 248 | """ 249 | ) 250 | result = testdir.runpytest("--it-no-color") 251 | result.stdout.fnmatch_lines( 252 | [ 253 | "*- ✓ It: Does something - [a*", 254 | "*- ✓ It: Does something - [b*", 255 | "*- ✓ It: Does something - [c*", 256 | ] 257 | ) 258 | 259 | @m.it("Does not use the docstring in the test name") 260 | @m.parametrize("v", [1, 2, 3]) 261 | def test_no_docstring(self, testdir, v): 262 | testdir.makepyfile( 263 | """ 264 | import pytest 265 | 266 | def test_it_does_something(): 267 | "A should not appear" 268 | assert True 269 | 270 | @pytest.mark.it("Does something else") 271 | def test_it_does_something_else(): 272 | "B should not appear" 273 | assert True 274 | """ 275 | ) 276 | result = testdir.runpytest("--it-no-color", "-" + ("v" * v)) 277 | assert "should not appear" not in str(result.stdout) 278 | 279 | @m.context("When @pytest.mark.it is used") 280 | @m.it("Displays an '- It: ' block matching the decorator") 281 | def test_it_decorator(self, testdir): 282 | testdir.makepyfile( 283 | """ 284 | import pytest 285 | 286 | @pytest.mark.it("Does something") 287 | def test_something(): 288 | assert True 289 | """ 290 | ) 291 | result = testdir.runpytest("--it-no-color") 292 | result.stdout.fnmatch_lines(["*- ✓ It: Does something*"]) 293 | 294 | @m.context("When @pytest.mark.it is used") 295 | @m.context("When -v is higher than 0") 296 | @m.parametrize("v", [1, 2, 3]) 297 | @m.it("Displays the full module::class::function prefix to the test") 298 | def test_verbose(self, testdir, v): 299 | testdir.makepyfile( 300 | """ 301 | import pytest 302 | 303 | @pytest.mark.it("Does something") 304 | def test_something(): 305 | assert True 306 | """ 307 | ) 308 | result = testdir.runpytest("--it-no-color", "-" + ("v" * v)) 309 | result.stdout.fnmatch_lines( 310 | ["*- ✓ It: test_verbose.py::test_something - Does something*"] 311 | ) 312 | 313 | @m.context("When @pytest.mark.it is not used") 314 | @m.it("Displays the test function name") 315 | def test_no_argument(self, testdir): 316 | testdir.makepyfile( 317 | """ 318 | import pytest 319 | 320 | def test_something(): 321 | assert True 322 | """ 323 | ) 324 | result = testdir.runpytest("--it-no-color") 325 | result.stdout.fnmatch_lines(["*- ✓ test_something*"]) 326 | 327 | @m.context("When @pytest.mark.it is not used") 328 | @m.context("but the test name starts with 'test_it_'") 329 | @m.it("Prettifies the test name into the 'It: ' value") 330 | def test_populates_the_it_marker_using_function_name(self, testdir): 331 | testdir.makepyfile( 332 | """ 333 | import pytest 334 | 335 | def test_it_does_something(): 336 | assert True 337 | """ 338 | ) 339 | result = testdir.runpytest("--it-no-color") 340 | result.stdout.fnmatch_lines(["*- ✓ It: Does something*"]) 341 | 342 | @m.context("When multiple @pytest.mark.it markers are used") 343 | @m.it("Uses the lowest decorator for the 'It : ' value") 344 | def test_uses_the_closest_it_decorator_if_there_are_many(self, testdir): 345 | testdir.makepyfile( 346 | """ 347 | import pytest 348 | 349 | pytestmark = [pytest.mark.it("Does something C")] 350 | 351 | @pytest.mark.it("Does something B") 352 | @pytest.mark.it("Does something A") 353 | def test_one(): 354 | assert True 355 | """ 356 | ) 357 | result = testdir.runpytest("--it-no-color") 358 | result.stdout.fnmatch_lines(["*- ✓ It: Does something A*"]) 359 | --------------------------------------------------------------------------------