├── tests ├── __init__.py ├── conftest.py ├── test_markers.py ├── test_data_structures.py ├── test_formatters.py └── test_plugin.py ├── pytest_testdox ├── __init__.py ├── constants.py ├── wrappers.py ├── terminal.py ├── formatters.py ├── plugin.py └── data_structures.py ├── .gitignore ├── requirements-dev.txt ├── pyproject.toml ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── setup.cfg ├── Makefile ├── LICENSE ├── setup.py └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest_testdox/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest_testdox/constants.py: -------------------------------------------------------------------------------- 1 | TITLE_MARK = 'it' 2 | CLASS_NAME_MARK = 'describe' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | __pycache__/ 3 | *.pyc 4 | *.egg-info/ 5 | 6 | /build 7 | /dist 8 | 9 | .coverage 10 | coverage.xml 11 | junit.xml 12 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black[typed-ast]==25.9.0 2 | bumpversion~=0.6.0 3 | flake8~=7.3.0 4 | isort~=7.0.0 5 | mypy==1.18.2 6 | pytest-cov~=7.0.0 7 | pytest>=4.6.0 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | skip-string-normalization = true 3 | line-length = 79 4 | 5 | [tool.mypy] 6 | ignore_missing_imports = true 7 | 8 | [tool.isort] 9 | known_first_party = "pytest_testdox" 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: pip 5 | directory: "/" 6 | schedule: 7 | interval: monthly 8 | groups: 9 | python_dependencies: 10 | patterns: 11 | - "*" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: monthly 16 | groups: 17 | gh_actions_dependencies: 18 | patterns: 19 | - "*" 20 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 3.1.0 3 | commit = True 4 | tag = True 5 | tag_name = {new_version} 6 | 7 | [bdist_wheel] 8 | universal = 1 9 | 10 | [bumpversion:file:setup.py] 11 | 12 | [coverage:run] 13 | omit = */*/tests/* 14 | 15 | [isort] 16 | profile = black 17 | known_first_party = pytest_testdox 18 | atomic = true 19 | line_length = 79 20 | multi_line_output = 3 21 | use_parentheses = true 22 | 23 | [flake8] 24 | extend-ignore = E203 25 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help 2 | 3 | help: ## This help 4 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) 5 | 6 | install: ## Install package for development 7 | @pip install -r requirements-dev.txt 8 | 9 | test: 10 | @pytest tests/ --cov pytest_testdox --cov-report=xml --junitxml=junit.xml -o junit_family=legacy 11 | 12 | check: ## Run static code checks 13 | @mypy . 14 | @black --check . 15 | @isort --check . 16 | @flake8 . 17 | 18 | clean: ## Clean cache and temporary files 19 | @find . -name "*.pyc" | xargs rm -rf 20 | @find . -name "*.pyo" | xargs rm -rf 21 | @find . -name "__pycache__" -type d | xargs rm -rf 22 | @rm -rf *.egg-info 23 | @rm -rf dist/ 24 | @rm -rf build/ 25 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | 4 | import pytest 5 | 6 | pytest_plugins = 'pytester' 7 | 8 | 9 | @pytest.fixture(scope='session', autouse=True) 10 | def verify_target_path(): 11 | import pytest_testdox 12 | 13 | current_path_root = os.path.dirname( 14 | os.path.dirname(os.path.realpath(__file__)) 15 | ) 16 | if current_path_root not in pytest_testdox.__file__: 17 | warnings.warn( 18 | 'pytest-testdox was not imported from your repository. ' 19 | 'You might be testing the wrong code. ' 20 | 'Uninstall pytest-testdox to be able to run all test cases ' 21 | '-- More: https://github.com/renanivo/pytest-testdox/issues/13', 22 | UserWarning, 23 | ) 24 | -------------------------------------------------------------------------------- /tests/test_markers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | class TestMarkers: 5 | @pytest.fixture 6 | def testdir(self, testdir): 7 | testdir.makeconftest( 8 | """ 9 | pytest_plugins = 'pytest_testdox.plugin' 10 | """ 11 | ) 12 | return testdir 13 | 14 | def test_should_not_raise_warning_without_plugin_call(self, testdir): 15 | testdir.makepyfile( 16 | """ 17 | import pytest 18 | 19 | @pytest.mark.it('Should not raise warning') 20 | def test_with_plugin_mark(): 21 | assert True 22 | """ 23 | ) 24 | 25 | result = testdir.runpytest() 26 | 27 | unknown_mark_warning = 'PytestUnknownMarkWarning' 28 | 29 | assert unknown_mark_warning not in result.stdout.str() 30 | assert '1 passed' in result.stdout.str() 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2022 Renan Ivo Martins 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build-n-publish: 10 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | 15 | - name: Set up Python 3.9 16 | uses: actions/setup-python@v6 17 | with: 18 | python-version: '3.9' 19 | 20 | - name: Get pip cache dir 21 | id: pip-cache 22 | run: | 23 | echo "::set-output name=dir::$(pip cache dir)" 24 | 25 | - name: pip cache 26 | uses: actions/cache@v4 27 | with: 28 | path: ${{ steps.pip-cache.outputs.dir }} 29 | key: 30 | release-v2-${{ hashFiles('**/setup.py') }} 31 | restore-keys: | 32 | release-v2- 33 | 34 | - name: Install dependencies 35 | run: | 36 | python -m pip install -U pip 37 | python -m pip install setuptools twine wheel 38 | make install 39 | 40 | - name: Build package 41 | run: | 42 | python setup.py --version 43 | python setup.py sdist --format=gztar bdist_wheel 44 | twine check dist/* 45 | 46 | - name: Publish distribution 📦 to PyPI 47 | uses: pypa/gh-action-pypi-publish@master 48 | with: 49 | password: ${{ secrets.pypi_password }} 50 | -------------------------------------------------------------------------------- /pytest_testdox/wrappers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Union 4 | 5 | from pytest_testdox import formatters 6 | from pytest_testdox.data_structures import Result 7 | 8 | 9 | class Wrapper: 10 | def __init__(self, wrapped: Union[Result, Wrapper]): 11 | self.wrapped = wrapped 12 | 13 | def __getattr__(self, name): 14 | return getattr(self.wrapped, name) 15 | 16 | 17 | class ColorWrapper(Wrapper): 18 | _COLOR_BY_OUTCOME = { 19 | 'passed': '\033[92m', 20 | 'failed': '\033[91m', 21 | 'skipped': '\033[93m', 22 | } 23 | _color_reset = '\033[0m' 24 | 25 | def __str__(self) -> str: 26 | color = self._COLOR_BY_OUTCOME.get(self.wrapped.outcome, '') 27 | reset = self._color_reset if color else '' 28 | 29 | return '{color}{result}{reset}'.format( 30 | color=color, result=self.wrapped, reset=reset 31 | ) 32 | 33 | 34 | class UTF8Wrapper(Wrapper): 35 | _CHARACTER_BY_OUTCOME = { 36 | 'passed': ' ✓ ', 37 | 'failed': ' ✗ ', 38 | 'skipped': ' » ', 39 | } 40 | 41 | _default_character = ' » ' 42 | 43 | def __str__(self) -> str: 44 | outcome = self._CHARACTER_BY_OUTCOME.get( 45 | self.wrapped.outcome, self._default_character 46 | ) 47 | return formatters.format_result_str( 48 | outcome=outcome, node_str=str(self.wrapped.node) 49 | ) 50 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from setuptools import setup 5 | 6 | 7 | def read(fname): 8 | path = os.path.join(os.path.dirname(__file__), fname) 9 | with open(path) as f: 10 | return f.read() 11 | 12 | 13 | setup( 14 | name='pytest-testdox', 15 | version='3.1.0', 16 | description='A testdox format reporter for pytest', 17 | long_description=read('README.md'), 18 | long_description_content_type='text/markdown', 19 | author='Renan Ivo', 20 | author_email='renanivom@gmail.com', 21 | url='https://github.com/renanivo/pytest-testdox', 22 | keywords='pytest testdox test report bdd', 23 | install_requires=[ 24 | 'pytest>=6.0.0', 25 | ], 26 | packages=['pytest_testdox'], 27 | python_requires=">=3.10", 28 | classifiers=[ 29 | 'Development Status :: 5 - Production/Stable', 30 | 'Framework :: Pytest', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: MIT License', 33 | 'Operating System :: OS Independent', 34 | 'Programming Language :: Python :: 3 :: Only', 35 | 'Programming Language :: Python :: 3', 36 | 'Programming Language :: Python :: 3.10', 37 | 'Programming Language :: Python :: 3.11', 38 | 'Programming Language :: Python :: 3.12', 39 | 'Programming Language :: Python :: 3.13', 40 | 'Programming Language :: Python :: 3.14', 41 | 'Programming Language :: Python :: Implementation :: CPython', 42 | 'Programming Language :: Python :: Implementation :: PyPy', 43 | 'Programming Language :: Python', 44 | 'Topic :: Software Development :: Testing', 45 | ], 46 | entry_points={ 47 | 'pytest11': [ 48 | 'testdox = pytest_testdox.plugin', 49 | ], 50 | }, 51 | ) 52 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | - workflow_dispatch 7 | 8 | jobs: 9 | ci: 10 | runs-on: ${{ matrix.os }} 11 | if: "!contains(github.event.head_commit.message, '[ci skip]') && !contains(github.event.head_commit.message, '[skip ci]')" 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: 16 | - "3.10" 17 | - "3.11" 18 | - "3.12" 19 | - "3.13" 20 | - "3.14" 21 | - "3.14t" 22 | - "pypy-3.10" 23 | os: [ubuntu-latest, macos-latest] 24 | pytest: 25 | - "pytest>=6.0.0,<7.0.0" 26 | - "pytest>=7.0.0,<8.0.0" 27 | - "pytest>=8.0.0,<9.0.0" 28 | exclude: 29 | - python-version: "3.14" 30 | pytest: "pytest>=6.0.0,<7.0.0" 31 | - python-version: "3.14t" 32 | pytest: "pytest>=6.0.0,<7.0.0" 33 | 34 | steps: 35 | - uses: actions/checkout@v5 36 | 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v6 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | 42 | - name: Get pip cache dir 43 | id: pip-cache 44 | run: | 45 | echo "::set-output name=dir::$(pip cache dir)" 46 | 47 | - name: pip cache 48 | uses: actions/cache@v4 49 | with: 50 | path: ${{ steps.pip-cache.outputs.dir }} 51 | key: ${{ matrix.os }}-${{ matrix.python-version }}-v2-${{ hashFiles('**/setup.py') }} 52 | restore-keys: | 53 | ${{ matrix.os }}-${{ matrix.python-version }}-v2- 54 | 55 | - name: Install dependencies 56 | run: | 57 | python -m pip install -U pip 58 | python -m pip install "${{ matrix.pytest }}" 59 | make install 60 | 61 | - name: Test 62 | shell: bash 63 | run: | 64 | make check test 65 | 66 | - name: Upload coverage 67 | uses: codecov/codecov-action@v5.5.1 68 | with: 69 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} ${{ matrix.pytest }} 70 | token: ${{ secrets.CODECOV_TOKEN }} 71 | 72 | - name: Upload test results to Codecov 73 | if: ${{ !cancelled() }} 74 | uses: codecov/test-results-action@v1 75 | with: 76 | token: ${{ secrets.CODECOV_TOKEN }} 77 | -------------------------------------------------------------------------------- /pytest_testdox/terminal.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, TextIO, Tuple 2 | 3 | try: 4 | from pytest import Config, TestReport # type: ignore 5 | except ImportError: # For pytest < 7.0.0 6 | from _pytest.config import Config 7 | from _pytest.reports import TestReport 8 | 9 | from _pytest.terminal import TerminalReporter 10 | 11 | from pytest_testdox import data_structures, wrappers 12 | 13 | 14 | class TestdoxTerminalReporter(TerminalReporter): # type: ignore 15 | def __init__(self, config: Config, file: Optional[TextIO] = None) -> None: 16 | super().__init__(config, file) 17 | self._last_header_id: Optional[str] = None 18 | self.pattern_config = data_structures.PatternConfig( 19 | files=self.config.getini('python_files'), 20 | functions=self.config.getini('python_functions'), 21 | classes=self.config.getini('python_classes'), 22 | ) 23 | self.result_wrappers: List[type] = [] 24 | 25 | if config.getini('testdox_format') != 'plaintext': 26 | self.result_wrappers.append(wrappers.UTF8Wrapper) 27 | 28 | if config.option.color != 'no': 29 | self.result_wrappers.append(wrappers.ColorWrapper) 30 | 31 | def _register_stats(self, report: TestReport): 32 | """ 33 | This method is not created for this plugin, but it is needed in order 34 | to the reporter display the tests summary at the end. 35 | 36 | Originally from: 37 | https://github.com/pytest-dev/pytest/blob/47a2a77/_pytest/terminal.py#L198-L201 38 | """ 39 | res = self.config.hook.pytest_report_teststatus( 40 | report=report, config=self.config 41 | ) 42 | category = res[0] 43 | self.stats.setdefault(category, []).append(report) 44 | self._tests_ran = True 45 | 46 | def pytest_runtest_logreport(self, report: TestReport) -> None: 47 | self._register_stats(report) 48 | 49 | if report.when != 'call' and not report.skipped: 50 | return 51 | 52 | result = data_structures.Result.create(report, self.pattern_config) 53 | 54 | for wrapper in self.result_wrappers: 55 | result = wrapper(result) 56 | 57 | if result.header_id != self._last_header_id: 58 | self._last_header_id = result.header_id 59 | self._tw.sep(' ') 60 | self._tw.line(result.header) 61 | 62 | self._tw.line(str(result)) 63 | 64 | def pytest_runtest_logstart( 65 | self, nodeid: str, location: Tuple[str, Optional[int], str] 66 | ) -> None: 67 | # Ensure that the path is printed before the 68 | # 1st test of a module starts running. 69 | self.write_fspath_result(nodeid, '') 70 | 71 | # To support Pytest < 6.0.0 72 | if hasattr(self, 'flush'): 73 | self.flush() 74 | -------------------------------------------------------------------------------- /pytest_testdox/formatters.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from typing import List 4 | 5 | TRIM_SPACES_REGEX = r'(^[\s]+|[\s]+$)' 6 | 7 | 8 | def format_title(title, patterns: List[str]): 9 | return _remove_patterns(title, patterns).replace('_', ' ').strip() 10 | 11 | 12 | def format_class_name(class_name: str, patterns: List[str]): 13 | formatted = '' 14 | 15 | class_name = _remove_patterns(class_name, patterns) 16 | 17 | for index, letter in enumerate(class_name): 18 | if letter.isupper() and _has_lower_letter_besides(index, class_name): 19 | formatted += ' ' 20 | 21 | formatted += letter 22 | 23 | return formatted.strip() 24 | 25 | 26 | def format_module_name(module_name: str, patterns: List[str]): 27 | return format_title(module_name.split('/')[-1], patterns) 28 | 29 | 30 | def format_result_str(outcome: str, node_str: str): 31 | lines = node_str.split(os.linesep) 32 | if len(lines) == 1: 33 | return outcome + node_str 34 | 35 | characters_length = len(outcome) 36 | result = [] 37 | result.append(outcome + lines[0]) 38 | 39 | for line in lines[1:]: 40 | if not line: 41 | continue 42 | 43 | pad = len(line) + characters_length 44 | result.append(line.rjust(pad)) 45 | 46 | return os.linesep.join(result) 47 | 48 | 49 | def trim_multi_line_text(text: str): 50 | return re.sub(TRIM_SPACES_REGEX, '', text, flags=re.MULTILINE) 51 | 52 | 53 | def include_parametrized(title: str, original_title: str): 54 | first_bracket = original_title.find('[') 55 | last_bracket = original_title.rfind(']') 56 | 57 | has_parameters = last_bracket > first_bracket 58 | 59 | if not has_parameters: 60 | return title 61 | 62 | parameters = original_title[first_bracket + 1 : last_bracket] 63 | 64 | return '{title}[{parameters}]'.format(title=title, parameters=parameters) 65 | 66 | 67 | def _remove_patterns(statement: str, patterns: List[str]): 68 | for glob_pattern in patterns: 69 | pattern = glob_pattern.replace('*', '') 70 | 71 | if glob_pattern.startswith('*'): 72 | pattern = '{}$'.format(pattern) 73 | statement = re.sub(pattern, '', statement) 74 | 75 | elif glob_pattern.endswith('*'): 76 | pattern = '^{}'.format(pattern) 77 | statement = re.sub(pattern, '', statement) 78 | 79 | elif '*' in glob_pattern: 80 | infix_patterns = glob_pattern.split('*', 2) 81 | infix_patterns[0] = '{}*'.format(infix_patterns[0]) 82 | infix_patterns[1] = '*{}'.format(infix_patterns[1]) 83 | statement = _remove_patterns(statement, infix_patterns) 84 | 85 | else: 86 | pattern = '^{}'.format(pattern) 87 | statement = re.sub(pattern, '', statement) 88 | 89 | return statement 90 | 91 | 92 | def _has_lower_letter_besides(index: int, string: str): 93 | letter_before = string[index - 1] if index > 0 else '' 94 | letter_after = string[index + 1] if index < len(string) - 1 else '' 95 | 96 | return letter_before.islower() or letter_after.islower() 97 | -------------------------------------------------------------------------------- /pytest_testdox/plugin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from typing import Generator 3 | 4 | import pytest 5 | 6 | try: 7 | from pytest import CallInfo, Config, Parser, TestReport # type: ignore 8 | except ImportError: # For pytest < 7.0.0 9 | from _pytest.config import Config 10 | from _pytest.config.argparsing import Parser 11 | from _pytest.reports import TestReport 12 | from _pytest.runner import CallInfo 13 | 14 | from pytest import Item 15 | 16 | from pytest_testdox import constants, terminal 17 | 18 | 19 | def pytest_addoption(parser: Parser): 20 | group = parser.getgroup('terminal reporting', 'reporting', after='general') 21 | group.addoption( 22 | '--testdox', 23 | action='store_true', 24 | dest='testdox', 25 | default=False, 26 | help='Report test progress in testdox format', 27 | ) 28 | group.addoption( 29 | '--force-testdox', 30 | action='store_true', 31 | dest='force_testdox', 32 | default=False, 33 | help='Force testdox output even when not in real terminal', 34 | ) 35 | parser.addini( 36 | 'testdox_format', 37 | help='TestDox report format (plaintext|utf8)', 38 | default='utf8', 39 | ) 40 | 41 | 42 | def should_enable_plugin(config: Config): 43 | return ( 44 | config.option.testdox and sys.stdout.isatty() 45 | ) or config.option.force_testdox 46 | 47 | 48 | @pytest.hookimpl(trylast=True) 49 | def pytest_configure(config: Config): 50 | config.addinivalue_line( 51 | "markers", 52 | "{}(title): Override testdox report test title".format( 53 | constants.TITLE_MARK 54 | ), 55 | ) 56 | config.addinivalue_line( 57 | "markers", 58 | "{}(title): Override testdox report class title".format( 59 | constants.CLASS_NAME_MARK 60 | ), 61 | ) 62 | 63 | if should_enable_plugin(config): 64 | # Get the standard terminal reporter plugin and replace it with ours 65 | standard_reporter = config.pluginmanager.getplugin('terminalreporter') 66 | testdox_reporter = terminal.TestdoxTerminalReporter( 67 | standard_reporter.config 68 | ) 69 | config.pluginmanager.unregister(standard_reporter) 70 | config.pluginmanager.register(testdox_reporter, 'terminalreporter') 71 | 72 | 73 | @pytest.hookimpl(hookwrapper=True) 74 | def pytest_runtest_makereport( 75 | item: Item, call: CallInfo 76 | ) -> Generator[None, TestReport, None]: 77 | result = yield 78 | 79 | report = result.get_result() 80 | 81 | testdox_title = next( 82 | ( 83 | mark.args[0] 84 | for mark in item.iter_markers(name=constants.TITLE_MARK) 85 | ), 86 | None, 87 | ) 88 | testdox_class_name = next( 89 | ( 90 | mark.args[0] 91 | for mark in item.iter_markers(name=constants.CLASS_NAME_MARK) 92 | ), 93 | None, 94 | ) 95 | if testdox_title: 96 | report.testdox_title = testdox_title 97 | 98 | if testdox_class_name: 99 | report.testdox_class_name = testdox_class_name 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-testdox 2 | 3 | [![PyPI](https://img.shields.io/pypi/v/pytest-testdox.svg?color=brightgreen)](https://pypi.org/project/pytest-testdox/) 4 | [![Continuous Integration Status](https://github.com/renanivo/pytest-testdox/workflows/Continuous%20Integration/badge.svg)](https://github.com/renanivo/pytest-testdox/actions?query=workflow%3A%22Continuous+Integration%22) 5 | [![codecov](https://codecov.io/gh/renanivo/pytest-testdox/branch/master/graph/badge.svg)](https://codecov.io/gh/renanivo/pytest-testdox) 6 | 7 | A [TestDox format](https://en.wikipedia.org/wiki/TestDox) reporter for pytest. 8 | 9 | ![](https://i.imgur.com/rJRL4x9.png) 10 | 11 | ## Install 12 | 13 | ``` 14 | pip install pytest-testdox 15 | ``` 16 | 17 | ## Usage 18 | 19 | Add the parameter `--testdox` when running `pytest`. For example: 20 | 21 | ```sh 22 | pytest --testdox your-tests/ 23 | ``` 24 | 25 | Tip: If you don't want to type `--testdox` every time you run `pytest`, add it 26 | to [`addopts`](https://docs.pytest.org/en/latest/customize.html#confval-addopts) 27 | in your [ini file](https://docs.pytest.org/en/latest/customize.html#initialization-determining-rootdir-and-inifile). 28 | For example: 29 | 30 | ```ini 31 | # content of pytest.ini or tox.ini 32 | [pytest] 33 | addopts = --testdox 34 | 35 | # or if you use setup.cfg 36 | [tool:pytest] 37 | addopts = --testdox 38 | ``` 39 | 40 | When using `--testdox`, the plugin will disable itself when not running on a 41 | terminal. If you want the testdox report no matter what, use the parameter 42 | `--force-testdox` instead. 43 | 44 | 45 | ## Markers 46 | 47 | ### @pytest.mark.describe 48 | 49 | Override the class name in the testdox report. For example: 50 | 51 | ```python 52 | # test_demo.py 53 | @pytest.mark.describe('create_file') 54 | class TestCreateFile(): 55 | 56 | def test_creates_a_file_in_the_so(self): 57 | pass 58 | ``` 59 | 60 | Will produce the output: 61 | 62 | ``` 63 | test_demo.py 64 | 65 | create_file 66 | [x] creates a file in the so 67 | ``` 68 | 69 | ### @pytest.mark.it 70 | 71 | Override the test title in the testdox report. For example: 72 | 73 | ```python 74 | # test_demo.py 75 | class TestCreateFile(): 76 | 77 | @pytest.mark.it('Creates a local file in the SO') 78 | def test_creates_a_file_in_the_so(self): 79 | pass 80 | ``` 81 | 82 | Will produce the output: 83 | 84 | 85 | ``` 86 | test_demo.py 87 | 88 | Create File 89 | [x] Creates a local file in the SO 90 | ``` 91 | 92 | ## Configuration file options 93 | 94 | ### testdox_format 95 | 96 | Specifies TestDox report format, `plaintext` or `utf8` (default: 97 | `utf8`). For example: 98 | 99 | ```ini 100 | # content of pytest.ini 101 | # (or tox.ini or setup.cfg) 102 | [pytest] 103 | testdox_format = plaintext 104 | ``` 105 | 106 | ```console 107 | $ pytest test_demo.py 108 | ============================= test session starts ============================== 109 | platform darwin -- Python 3.5.0, pytest-3.0.7, py-1.4.33, pluggy-0.4.0 110 | rootdir: /private/tmp/demo, inifile: pytest.ini 111 | plugins: testdox-dev 112 | collected 2 items 113 | 114 | test_demo.py 115 | Pytest Testdox 116 | [x] prints a BDD style output to your tests 117 | [x] lets you focus on the behavior 118 | ``` 119 | -------------------------------------------------------------------------------- /pytest_testdox/data_structures.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import List, NamedTuple 5 | 6 | from _pytest.reports import TestReport 7 | 8 | from pytest_testdox import formatters 9 | 10 | 11 | class PatternConfig(NamedTuple): 12 | files: List[str] 13 | functions: List[str] 14 | classes: List[str] 15 | 16 | 17 | @dataclass 18 | class Node: 19 | module_name: str 20 | title: str | None 21 | class_name: str | None 22 | 23 | def __str__(self): 24 | return self.title 25 | 26 | @classmethod 27 | def parse( 28 | cls, 29 | nodeid: str, 30 | pattern_config: PatternConfig, 31 | title: str | None = None, 32 | class_name: str | None = None, 33 | ): 34 | node_parts = nodeid.split('::') 35 | 36 | if title: 37 | title = formatters.include_parametrized( 38 | formatters.trim_multi_line_text(title), node_parts[-1] 39 | ) 40 | else: 41 | title = formatters.format_title( 42 | node_parts[-1], pattern_config.functions 43 | ) 44 | 45 | module_name = formatters.format_module_name( 46 | node_parts[0], pattern_config.files 47 | ) 48 | 49 | if class_name: 50 | class_name = formatters.trim_multi_line_text(class_name) 51 | else: 52 | if '()' in node_parts[-2]: 53 | class_name = formatters.format_class_name( 54 | node_parts[-3], pattern_config.classes 55 | ) 56 | elif len(node_parts) > 2: 57 | class_name = formatters.format_class_name( 58 | node_parts[-2], pattern_config.classes 59 | ) 60 | 61 | return cls(title=title, class_name=class_name, module_name=module_name) 62 | 63 | 64 | @dataclass(frozen=True) 65 | class Result: 66 | outcome: str 67 | node: Node 68 | 69 | _OUTCOME_REPRESENTATION = { 70 | 'passed': ' [x] ', 71 | 'failed': ' [ ] ', 72 | 'skipped': ' >>> ', 73 | } 74 | _default_outcome_representation = '>>>' 75 | 76 | def __str__(self) -> str: 77 | representation = self._OUTCOME_REPRESENTATION.get( 78 | self.outcome, self._default_outcome_representation 79 | ) 80 | 81 | return formatters.format_result_str( 82 | outcome=representation, node_str=str(self.node) 83 | ) 84 | 85 | @property 86 | def header(self) -> str: 87 | return self.node.class_name or self.node.module_name # type: ignore 88 | 89 | @property 90 | def header_id(self) -> str: 91 | """ 92 | Return the same value when the result should be aggregated under the 93 | same class or module (this is not guaranteed in "header" property, 94 | which should be used when displaying to the user) 95 | """ 96 | return self.node.module_name + (self.node.class_name or '') 97 | 98 | @classmethod 99 | def create( 100 | cls, report: TestReport, pattern_config: PatternConfig 101 | ) -> Result: 102 | title = getattr(report, 'testdox_title', None) 103 | class_name = getattr(report, 'testdox_class_name', None) 104 | 105 | node = Node.parse( 106 | nodeid=report.nodeid, 107 | pattern_config=pattern_config, 108 | title=title, 109 | class_name=class_name, 110 | ) 111 | return cls(report.outcome, node) 112 | -------------------------------------------------------------------------------- /tests/test_data_structures.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from pytest_testdox import formatters 7 | from pytest_testdox.data_structures import Node, PatternConfig, Result 8 | 9 | 10 | @pytest.fixture 11 | def node(): 12 | return Node(title='title', class_name='class_name', module_name='module') 13 | 14 | 15 | @pytest.fixture 16 | def report(): 17 | return mock.Mock( 18 | spec=('nodeid', 'outcome'), 19 | nodeid='folder/test_file.py::test_title', 20 | outcome='passed', 21 | ) 22 | 23 | 24 | @pytest.fixture 25 | def pattern_config(): 26 | return PatternConfig( 27 | files=['test_*.py'], functions=['test*'], classes=['Test*'] 28 | ) 29 | 30 | 31 | class TestNode: 32 | def test_parse_should_return_a_node_instance(self, pattern_config): 33 | nodeid = 'tests/test_module.py::test_title' 34 | node = Node.parse(nodeid, pattern_config) 35 | 36 | assert isinstance(node, Node) 37 | 38 | def test_parse_should_parse_node_id_attributes(self, pattern_config): 39 | nodeid = 'tests/test_module.py::test_title' 40 | node = Node.parse(nodeid, pattern_config) 41 | 42 | assert node.title == formatters.format_title( 43 | 'test_title', pattern_config.functions 44 | ) 45 | assert node.module_name == ( 46 | formatters.format_module_name( 47 | 'tests/test_module.py', pattern_config.files 48 | ) 49 | ) 50 | 51 | @pytest.mark.parametrize( 52 | ('attribute,value'), 53 | ( 54 | ('title', ' new title '), 55 | ('class_name', ' new class name '), 56 | ), 57 | ) 58 | def test_parse_should_use_overridden_attribute_instead_of_parse_node_id( 59 | self, attribute, value, pattern_config 60 | ): 61 | nodeid = 'tests/test_module.py::test_title' 62 | 63 | node = Node.parse(nodeid, pattern_config, **{attribute: value}) 64 | 65 | result = getattr(node, attribute) 66 | 67 | assert result == formatters.trim_multi_line_text(value) 68 | 69 | @pytest.mark.parametrize( 70 | 'nodeid,class_name', 71 | ( 72 | ('tests/test_module.py::test_title', None), 73 | ( 74 | 'tests/test_module.py::TestClassName::()::test_title', 75 | formatters.format_class_name('TestClassName', ['Test*']), 76 | ), 77 | ( 78 | 'tests/test_module.py::TestClassName::test_title', 79 | formatters.format_class_name('TestClassName', ['Test*']), 80 | ), 81 | ), 82 | ) 83 | def test_parse_with_class_name(self, pattern_config, nodeid, class_name): 84 | node = Node.parse(nodeid, pattern_config) 85 | 86 | assert node.class_name == class_name 87 | 88 | 89 | class TestResult: 90 | @pytest.fixture 91 | def result(self, node): 92 | return Result('passed', node) 93 | 94 | def test_create_should_return_a_result_with_a_parsed_node( 95 | self, report, pattern_config 96 | ): 97 | result = Result.create(report, pattern_config) 98 | 99 | assert isinstance(result, Result) 100 | assert result.outcome == report.outcome 101 | assert result.node == Node.parse(report.nodeid, pattern_config) 102 | 103 | @pytest.mark.parametrize( 104 | 'report_attribute,parameter,value', 105 | ( 106 | ('testdox_title', 'title', 'some title'), 107 | ('testdox_class_name', 'class_name', 'some class name'), 108 | ), 109 | ) 110 | def test_create_should_call_parse_with_overridden_attributes( 111 | self, report_attribute, parameter, value, report, pattern_config 112 | ): 113 | setattr(report, report_attribute, value) 114 | 115 | result = Result.create(report, pattern_config) 116 | 117 | kwargs = {parameter: value} 118 | 119 | assert result.node == Node.parse( 120 | nodeid=report.nodeid, pattern_config=pattern_config, **kwargs 121 | ) 122 | 123 | def test_str_should_format_result_str(self, node): 124 | node.title = 'some{}text'.format(os.linesep) 125 | result = Result('passed', node) 126 | 127 | assert formatters.format_result_str(' [x] ', node.title) in str(result) 128 | -------------------------------------------------------------------------------- /tests/test_formatters.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from pytest_testdox import formatters 6 | 7 | 8 | class TestFormatTitle: 9 | @pytest.fixture 10 | def patterns(self): 11 | return ['test*'] 12 | 13 | def test_should_replace_underscores_with_spaces(self, patterns): 14 | assert formatters.format_title('a_test_name', patterns) == ( 15 | 'a test name' 16 | ) 17 | 18 | def test_should_remove_test_pattern(self, patterns): 19 | assert formatters.format_title('test_a_thing', patterns) == 'a thing' 20 | assert formatters.format_title('a_thing_test', patterns) == ( 21 | 'a thing test' 22 | ) 23 | 24 | 25 | class TestFormatClassName: 26 | @pytest.fixture 27 | def patterns(self): 28 | return ['Test*'] 29 | 30 | def test_should_add_spaces_before_upercased_letters(self, patterns): 31 | formatted = formatters.format_class_name('AThingBuilder', patterns) 32 | assert formatted == 'A Thing Builder' 33 | 34 | def test_should_remove_test_pattern(self, patterns): 35 | assert formatters.format_class_name('TestAThing', patterns) == ( 36 | 'A Thing' 37 | ) 38 | assert formatters.format_class_name('AThingTest', patterns) == ( 39 | 'A Thing Test' 40 | ) 41 | 42 | @pytest.mark.parametrize( 43 | 'class_name,expected', 44 | ( 45 | ('SimpleHTTPServer', 'Simple HTTP Server'), 46 | ('MyAPI', 'My API'), 47 | ), 48 | ) 49 | def test_should_not_split_letters_in_an_abbreviation( 50 | self, class_name, expected, patterns 51 | ): 52 | formatted = formatters.format_class_name(class_name, patterns) 53 | assert formatted == expected 54 | 55 | 56 | class TestFormatModuleName: 57 | @pytest.fixture 58 | def patterns(self): 59 | return ['test*.py'] 60 | 61 | def test_should_remove_py_file_pattern(self, patterns): 62 | assert formatters.format_module_name('pymodule.py', patterns) == ( 63 | 'pymodule' 64 | ) 65 | 66 | def test_should_replace_underscores_with_spaces(self, patterns): 67 | assert formatters.format_module_name('a_test_name', patterns) == ( 68 | 'a test name' 69 | ) 70 | 71 | def test_should_remove_test_pattern(self, patterns): 72 | assert formatters.format_module_name('test_a_thing.py', patterns) == ( 73 | 'a thing' 74 | ) 75 | assert formatters.format_module_name('a_test.py', patterns) == 'a test' 76 | 77 | def test_should_remove_folders_from_the_name(self, patterns): 78 | formatted = formatters.format_module_name( 79 | 'tests/sub/test_module.py', patterns 80 | ) 81 | 82 | assert formatted == 'module' 83 | 84 | def test_should_remove_infix_glob_patterns(self): 85 | formatted = formatters.format_module_name( 86 | 'test_module.py', ['test_*.py'] 87 | ) 88 | 89 | assert formatted == 'module' 90 | 91 | 92 | class TestTrimMultiLineText: 93 | def test_should_strip_spaces_from_begin_and_end(self): 94 | assert formatters.trim_multi_line_text(' works ') == 'works' 95 | 96 | def test_should_srip_spaces_from_multiple_lines(self): 97 | assert ( 98 | formatters.trim_multi_line_text( 99 | ''' 100 | works when used in very specific 101 | conditions of temperature and pressure 102 | ''' 103 | ) 104 | == ( 105 | 'works when used in very specific\n' 106 | 'conditions of temperature and pressure' 107 | ) 108 | ) 109 | 110 | 111 | class TestFormatResult: 112 | def test_should_not_pad_single_line_text(self): 113 | assert formatters.format_result_str('>>> ', 'some text') == ( 114 | '>>> some text' 115 | ) 116 | 117 | def test_should_pad_the_following_lines_to_the_width_of_given_characters( 118 | self, 119 | ): 120 | text = ( 121 | 'first line{0}' 'second line{0}' 'third line{0}' 'fourth line' 122 | ).format(os.linesep) 123 | assert formatters.format_result_str('>>> ', text) == ( 124 | '>>> first line{0}' 125 | ' second line{0}' 126 | ' third line{0}' 127 | ' fourth line'.format(os.linesep) 128 | ) 129 | 130 | def test_should_remove_empty_lines(self): 131 | text = ( 132 | 'first line{0}' '{0}' 'second line{0}' '{0}' '{0}' 'third line' 133 | ).format(os.linesep) 134 | assert formatters.format_result_str('>>> ', text) == ( 135 | '>>> first line{0}' 136 | ' second line{0}' 137 | ' third line'.format(os.linesep) 138 | ) 139 | 140 | 141 | class TestIncludeParametrized: 142 | def test_should_return_title_when_no_parameters_are_found(self): 143 | assert ( 144 | formatters.include_parametrized( 145 | title='Should return value', 146 | original_title='test_should_return_value', 147 | ) 148 | == 'Should return value' 149 | ) 150 | 151 | def test_should_return_parameters_in_title(self): 152 | assert ( 153 | formatters.include_parametrized( 154 | title='A title', 155 | original_title='test_should_return_value[params]', 156 | ) 157 | == 'A title[params]' 158 | ) 159 | 160 | def test_should_return_parameters_containing_brackets(self): 161 | assert ( 162 | formatters.include_parametrized( 163 | title='A title', 164 | original_title='test_should_return_value[[[[params]]]]', 165 | ) 166 | == 'A title[[[[params]]]]' 167 | ) 168 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | 3 | import pytest 4 | 5 | from pytest_testdox import constants 6 | 7 | 8 | class TestReport: 9 | @pytest.fixture 10 | def testdir(self, testdir): 11 | testdir.makeconftest( 12 | """ 13 | pytest_plugins = 'pytest_testdox.plugin' 14 | """ 15 | ) 16 | return testdir 17 | 18 | def test_should_print_a_green_passing_test(self, testdir): 19 | testdir.makepyfile( 20 | """ 21 | def test_a_feature_is_working(): 22 | assert True 23 | """ 24 | ) 25 | 26 | result = testdir.runpytest('--force-testdox') 27 | 28 | expected = '\033[92m ✓ a feature is working\033[0m' 29 | assert expected in result.stdout.str() 30 | 31 | def test_should_print_a_red_failing_test(self, testdir): 32 | testdir.makepyfile( 33 | """ 34 | def test_a_failed_test_of_a_feature(): 35 | assert False 36 | """ 37 | ) 38 | 39 | result = testdir.runpytest('--force-testdox') 40 | expected = '\033[91m ✗ a failed test of a feature\033[0m' 41 | 42 | assert expected in result.stdout.str() 43 | 44 | def test_should_print_a_yellow_skipped_test(self, testdir): 45 | testdir.makepyfile( 46 | """ 47 | import pytest 48 | 49 | @pytest.mark.skip 50 | def test_a_skipped_test(): 51 | pass 52 | """ 53 | ) 54 | 55 | result = testdir.runpytest('--force-testdox') 56 | expected = '\033[93m » a skipped test\033[0m' 57 | 58 | assert expected in result.stdout.str() 59 | 60 | def test_should_not_print_colors_when_disabled_by_parameter(self, testdir): 61 | testdir.makepyfile( 62 | """ 63 | def test_a_feature_is_working(): 64 | assert True 65 | """ 66 | ) 67 | result = testdir.runpytest('--color=no', '--force-testdox') 68 | 69 | assert '\033[92m' not in result.stdout.str() 70 | 71 | def test_should_output_plaintext_using_a_config_option(self, testdir): 72 | testdir.makeini( 73 | """ 74 | [pytest] 75 | testdox_format=plaintext 76 | """ 77 | ) 78 | testdir.makepyfile( 79 | """ 80 | def test_a_feature_is_working(): 81 | assert True 82 | """ 83 | ) 84 | result = testdir.runpytest('--force-testdox') 85 | 86 | expected = '\033[92m [x] a feature is working\033[0m' 87 | assert expected in result.stdout.str() 88 | 89 | def test_should_print_the_test_class_name(self, testdir): 90 | testdir.makepyfile( 91 | """ 92 | class TestFoo: 93 | def test_foo(self): 94 | pass 95 | 96 | class TestBar: 97 | def test_bar(self): 98 | pass 99 | """ 100 | ) 101 | result = testdir.runpytest('--force-testdox') 102 | 103 | lines = result.stdout.get_lines_after('Foo') 104 | assert '✓ foo' in lines[0] 105 | 106 | lines = result.stdout.get_lines_after('Bar') 107 | assert '✓ bar' in lines[0] 108 | 109 | def test_should_print_the_module_name_of_a_test_without_class( 110 | self, testdir 111 | ): 112 | testdir.makefile( 113 | '.py', 114 | test_module_name=""" 115 | def test_a_failed_test_of_a_feature(): 116 | assert False 117 | """, 118 | ) 119 | 120 | result = testdir.runpytest('--force-testdox') 121 | result.stdout.fnmatch_lines(['module name']) 122 | 123 | def test_should_print_test_summary(self, testdir): 124 | testdir.makefile( 125 | '.py', 126 | test_module_name=""" 127 | def test_a_passing_test(): 128 | assert True 129 | """, 130 | ) 131 | 132 | result = testdir.runpytest('--force-testdox') 133 | assert '1 passed' in result.stdout.str() 134 | 135 | def test_should_use_python_patterns_configuration(self, testdir): 136 | testdir.makeini( 137 | """ 138 | [pytest] 139 | python_classes=Describe* 140 | python_files=*spec.py 141 | python_functions=it* 142 | """ 143 | ) 144 | testdir.makefile( 145 | '.py', 146 | module_spec=""" 147 | class DescribeTest: 148 | def it_runs(self): 149 | pass 150 | """, 151 | ) 152 | 153 | result = testdir.runpytest('--force-testdox') 154 | 155 | lines = result.stdout.get_lines_after('Test') 156 | assert '✓ runs' in lines[0] 157 | 158 | def test_should_override_test_titles_with_title_mark(self, testdir): 159 | testdir.makefile( 160 | '.py', 161 | test_module_name=""" 162 | import pytest 163 | 164 | @pytest.mark.{}(''' 165 | My Title 166 | My precious title 167 | ''') 168 | def test_a_passing_test(): 169 | assert True 170 | """.format( 171 | constants.TITLE_MARK 172 | ), 173 | ) 174 | 175 | result = testdir.runpytest('--force-testdox') 176 | 177 | assert 'My Title\n My precious title' in result.stdout.str() 178 | 179 | def test_should_override_class_names_with_class_name_mark(self, testdir): 180 | testdir.makefile( 181 | '.py', 182 | test_module_name=""" 183 | import pytest 184 | 185 | @pytest.mark.{}(''' 186 | My Class 187 | My precious class 188 | ''') 189 | class TestClass: 190 | 191 | def test_foo(self): 192 | pass 193 | """.format( 194 | constants.CLASS_NAME_MARK 195 | ), 196 | ) 197 | 198 | result = testdir.runpytest('--force-testdox') 199 | 200 | assert 'My Class\nMy precious class' in result.stdout.str() 201 | 202 | def test_should_override_test_titles_with_title_mark_parametrize( 203 | self, testdir 204 | ): 205 | testdir.makefile( 206 | '.py', 207 | test_module_name=""" 208 | import pytest 209 | 210 | @pytest.mark.parametrize('par', ['param1', 'param2']) 211 | @pytest.mark.{}('should pass with parameters') 212 | def test_a_passing_test(par): 213 | assert True 214 | """.format( 215 | constants.TITLE_MARK 216 | ), 217 | ) 218 | 219 | result = testdir.runpytest('--force-testdox') 220 | 221 | assert 'should pass with parameters[param1]' in result.stdout.str() 222 | assert 'should pass with parameters[param2]' in result.stdout.str() 223 | 224 | def test_decorator_order_should_not_affect_parametrize(self, testdir): 225 | testdir.makefile( 226 | '.py', 227 | test_module_name=""" 228 | import pytest 229 | 230 | @pytest.mark.{}('should pass with parameters') 231 | @pytest.mark.parametrize('par', ['param1', 'param2']) 232 | def test_a_passing_test(par): 233 | assert True 234 | """.format( 235 | constants.TITLE_MARK 236 | ), 237 | ) 238 | 239 | result = testdir.runpytest('--force-testdox') 240 | 241 | assert 'should pass with parameters[param1]' in result.stdout.str() 242 | assert 'should pass with parameters[param2]' in result.stdout.str() 243 | 244 | def test_should_not_enable_plugin_when_test_run_out_of_tty(self, testdir): 245 | testdir.makepyfile( 246 | """ 247 | def test_a_feature_is_working(): 248 | assert True 249 | """ 250 | ) 251 | 252 | result = testdir.runpytest('--testdox') 253 | 254 | expected_testdox_output = '\033[92m ✓ a feature is working\033[0m' 255 | 256 | assert expected_testdox_output not in result.stdout.str() 257 | 258 | def test_should_not_aggregate_tests_under_same_class_in_different_modules( 259 | self, testdir 260 | ): 261 | testdir.makepyfile( 262 | test_first=""" 263 | class TestFoo(object): 264 | def test_a_feature_is_working(self): 265 | assert True 266 | """, 267 | test_second=""" 268 | class TestFoo(object): 269 | def test_a_feature_is_working_in_another_module(self): 270 | assert True 271 | """, 272 | ) 273 | 274 | result = testdir.runpytest('--force-testdox') 275 | word_count = Counter(result.stdout.lines) 276 | 277 | assert word_count['Foo'] == 2 278 | 279 | def test_verbose_mode_should_not_affect_the_filename_output(self, testdir): 280 | file = testdir.makepyfile( 281 | """ 282 | def test_foo(): 283 | assert True 284 | 285 | def test_bar(): 286 | assert True 287 | """ 288 | ) 289 | 290 | result = testdir.runpytest('--verbose', '--force-testdox') 291 | 292 | result.stdout.re_match_lines(r'^' + file.basename + r'\s+$') 293 | --------------------------------------------------------------------------------