├── .github └── workflows │ └── run-checks.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── COMMUNITY.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── labels.toml ├── pytest.ini ├── setup.cfg ├── setup.py ├── src └── pytest_md │ ├── __init__.py │ └── plugin.py ├── tests ├── conftest.py └── test_generate_report.py └── tox.ini /.github/workflows/run-checks.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | tox: 11 | 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | environment: ["py36", "py37", "py36-emoji", "py37-emoji", "mypy", "flake8"] 16 | include: 17 | - environment: "py36" 18 | python: "3.6" 19 | - environment: "py37" 20 | python: "3.7" 21 | - environment: "py36-emoji" 22 | python: "3.6" 23 | - environment: "py37-emoji" 24 | python: "3.7" 25 | - environment: "mypy" 26 | python: "3.7" 27 | - environment: "flake8" 28 | python: "3.7" 29 | 30 | container: 31 | image: python:${{ matrix.python }} 32 | 33 | steps: 34 | - uses: actions/checkout@v2 35 | - name: Install tox 36 | run: | 37 | python -m pip install --upgrade pip 38 | python -m pip install tox 39 | - name: Run tox 40 | run: | 41 | tox -e ${{ matrix.environment }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | 106 | # VS Code 107 | .vscode/ -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at raphael@hackebrot.de. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /COMMUNITY.md: -------------------------------------------------------------------------------- 1 | # Community 2 | 3 | - [@hackebrot] 4 | - [@seanson] 5 | - [@ssd71] 6 | 7 | [@seanson]: https://github.com/seanson 8 | [@hackebrot]: https://github.com/hackebrot 9 | [@ssd71]: https://github.com/ssd71 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Raphael Pierzina 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | 4 | recursive-exclude * __pycache__ 5 | recursive-exclude * *.py[co] 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-md 2 | 3 | Plugin for generating Markdown reports for [pytest] results 📝 4 | 5 | [pytest]: https://github.com/pytest-dev/pytest 6 | 7 | ## Installation 8 | 9 | **pytest-md** is available on [PyPI][PyPI] for Python versions 3.6 and newer 10 | and can be installed into your enviroment from your terminal via [pip][pip]: 11 | 12 | ```text 13 | $ pip install pytest-md 14 | ``` 15 | 16 | [PyPI]: https://pypi.org/ 17 | [pip]: https://pypi.org/project/pip/ 18 | 19 | ## Usage 20 | 21 | The following example code produces all of the different pytest test outcomes. 22 | 23 | ```python 24 | import random 25 | import pytest 26 | 27 | 28 | def test_failed(): 29 | assert "emoji" == "hello world" 30 | 31 | 32 | @pytest.mark.xfail 33 | def test_xfailed(): 34 | assert random.random() == 1.0 35 | 36 | 37 | @pytest.mark.xfail 38 | def test_xpassed(): 39 | assert 0.0 < random.random() < 1.0 40 | 41 | 42 | @pytest.mark.skip(reason="don't run this test") 43 | def test_skipped(): 44 | assert "pytest-emoji" != "" 45 | 46 | 47 | @pytest.mark.parametrize( 48 | "name, expected", 49 | [ 50 | ("Sara", "Hello Sara!"), 51 | ("Mat", "Hello Mat!"), 52 | ("Annie", "Hello Annie!"), 53 | ], 54 | ) 55 | def test_passed(name, expected): 56 | assert f"Hello {name}!" == expected 57 | 58 | 59 | @pytest.fixture 60 | def number(): 61 | return 1234 / 0 62 | 63 | 64 | def test_error(number): 65 | assert number == number 66 | ``` 67 | 68 | With **pytest-md** installed, you can now generate a Markdown test report as 69 | follows: 70 | 71 | ```text 72 | $ pytest --md report.md 73 | ``` 74 | 75 | ```Markdown 76 | # Test Report 77 | 78 | *Report generated on 25-Feb-2019 at 17:18:29 by [pytest-md]* 79 | 80 | [pytest-md]: https://github.com/hackebrot/pytest-md 81 | 82 | ## Summary 83 | 84 | 8 tests ran in 0.05 seconds 85 | 86 | - 1 failed 87 | - 3 passed 88 | - 1 skipped 89 | - 1 xfailed 90 | - 1 xpassed 91 | - 1 error 92 | ``` 93 | 94 | ## pytest-emoji 95 | 96 | **pytest-md** also integrates with [pytest-emoji], which allows us to include 97 | emojis in the generated Markdown test report: 98 | 99 | ```text 100 | $ pytest --emoji -v --md report.md 101 | ``` 102 | 103 | ```Markdown 104 | # Test Report 105 | 106 | *Report generated on 25-Feb-2019 at 17:18:29 by [pytest-md]* 📝 107 | 108 | [pytest-md]: https://github.com/hackebrot/pytest-md 109 | 110 | ## Summary 111 | 112 | 8 tests ran in 0.06 seconds ⏱ 113 | 114 | - 1 failed 😰 115 | - 3 passed 😃 116 | - 1 skipped 🙄 117 | - 1 xfailed 😞 118 | - 1 xpassed 😲 119 | - 1 error 😡 120 | ``` 121 | 122 | [pytest-emoji]: https://github.com/hackebrot/pytest-emoji 123 | 124 | ## Credits 125 | 126 | This project is inspired by the fantastic [pytest-html] plugin! 💻 127 | 128 | [pytest-html]: https://github.com/pytest-dev/pytest-html 129 | 130 | ## Community 131 | 132 | Would you like to contribute to **pytest-md**? You're awesome! 😃 133 | 134 | Please check out the [good first issue][good first issue] label for tasks, 135 | that are good candidates for your first contribution to **pytest-md**. Your 136 | contributions are greatly appreciated! Every little bit helps, and credit 137 | will always be given. 138 | 139 | Please note that **pytest-md** is released with a [Contributor Code of 140 | Conduct][code of conduct]. By participating in this project you agree to 141 | abide by its terms. 142 | 143 | Join the pytest-md [community][community]! 🌍🌏🌎 144 | 145 | [good first issue]: https://github.com/hackebrot/pytest-md/labels/good%20first%20issue 146 | [code of conduct]: https://github.com/hackebrot/pytest-md/blob/master/CODE_OF_CONDUCT.md 147 | [community]: https://github.com/hackebrot/pytest-md/blob/master/COMMUNITY.md 148 | 149 | ## License 150 | 151 | Distributed under the terms of the MIT license, **pytest-md** is free and open 152 | source software. 153 | -------------------------------------------------------------------------------- /labels.toml: -------------------------------------------------------------------------------- 1 | [bug] 2 | color = "ffeb95" 3 | description = "Bugs and problems with pytest-md" 4 | name = "bug" 5 | 6 | ["code quality"] 7 | color = "c792ea" 8 | description = "Tasks related to linting, coding style, type checks" 9 | name = "code quality" 10 | 11 | [dependencies] 12 | color = "c792ea" 13 | description = "Tasks related to managing dependencies" 14 | name = "dependencies" 15 | 16 | [discussion] 17 | color = "82aaff" 18 | description = "Issues for discussing ideas for features" 19 | name = "discussion" 20 | 21 | ["do not merge"] 22 | color = "ef5350" 23 | description = "Pull requests which must not be merged" 24 | name = "do not merge" 25 | 26 | [docs] 27 | color = "21c7a8" 28 | description = "Tasks to write and update documentation" 29 | name = "docs" 30 | 31 | [enhancement] 32 | color = "82aaff" 33 | description = "New feature or enhancement for pytest-md" 34 | name = "enhancement" 35 | 36 | ["good first issue"] 37 | color = "7fdbca" 38 | description = "Good tasks for newcomers to pytest-md" 39 | name = "good first issue" 40 | 41 | [misc] 42 | color = "ecc48d" 43 | description = "Tasks that don't fit any of the other categories" 44 | name = "misc" 45 | 46 | [project] 47 | color = "d6deeb" 48 | description = "Tasks related to managing this project" 49 | name = "project" 50 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | markers = 3 | emoji: tests which are skipped if pytest-emoji is not installed. 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_file = LICENSE 3 | 4 | [flake8] 5 | max-line-length = 88 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import setuptools 3 | 4 | 5 | def read(*args): 6 | file_path = pathlib.Path(__file__).parent.joinpath(*args) 7 | return file_path.read_text("utf-8") 8 | 9 | 10 | setuptools.setup( 11 | name="pytest-md", 12 | version="0.2.0", 13 | author="Raphael Pierzina", 14 | author_email="raphael@hackebrot.de", 15 | maintainer="Raphael Pierzina", 16 | maintainer_email="raphael@hackebrot.de", 17 | license="MIT", 18 | url="https://github.com/hackebrot/pytest-md", 19 | description="Plugin for generating Markdown reports for pytest results", 20 | long_description=read("README.md"), 21 | long_description_content_type="text/markdown", 22 | packages=setuptools.find_packages("src"), 23 | package_dir={"": "src"}, 24 | include_package_data=True, 25 | zip_safe=False, 26 | python_requires=">=3.6", 27 | install_requires=["pytest>=4.2.1"], 28 | classifiers=[ 29 | "Development Status :: 3 - Alpha", 30 | "Framework :: Pytest", 31 | "Intended Audience :: Developers", 32 | "Topic :: Software Development :: Testing", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.6", 36 | "Programming Language :: Python :: 3.7", 37 | "Programming Language :: Python :: Implementation :: CPython", 38 | "Operating System :: OS Independent", 39 | "License :: OSI Approved :: MIT License", 40 | ], 41 | entry_points={"pytest11": ["md = pytest_md.plugin"]}, 42 | keywords=["pytest", "markdown"], 43 | ) 44 | -------------------------------------------------------------------------------- /src/pytest_md/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hackebrot/pytest-md/0ffe4f6934fab7b4b6ada8e945be67e6543703a8/src/pytest_md/__init__.py -------------------------------------------------------------------------------- /src/pytest_md/plugin.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import datetime 3 | import enum 4 | import pathlib 5 | import time 6 | from typing import Dict, List 7 | 8 | 9 | class Outcome(enum.Enum): 10 | """Enum for the different pytest outcomes.""" 11 | 12 | ERROR = "error" 13 | FAILED = "failed" 14 | PASSED = "passed" 15 | SKIPPED = "skipped" 16 | XFAILED = "xfailed" 17 | XPASSED = "xpassed" 18 | 19 | 20 | class MarkdownPlugin: 21 | """Plugin for generating Markdown reports.""" 22 | 23 | def __init__(self, config, report_path, emojis_enabled: bool = False) -> None: 24 | self.config = config 25 | self.report_path = report_path 26 | self.report = "" 27 | self.emojis_enabled = emojis_enabled 28 | 29 | self.reports: Dict[Outcome, List] = collections.defaultdict(list) 30 | 31 | if emojis_enabled: 32 | self.emojis_by_outcome = self._retrieve_emojis() 33 | 34 | def _retrieve_emojis(self): 35 | """Return a mapping from report Outcome to an emoji text.""" 36 | 37 | def emoji(short, verbose): 38 | """Return the short or verbose emoji based on self.config.""" 39 | 40 | if self.config.option.verbose > 0: 41 | return verbose 42 | 43 | return short 44 | 45 | return { 46 | outcome: emoji(*emoji_hook(config=self.config)) 47 | for outcome, emoji_hook in ( 48 | (Outcome.PASSED, self.config.hook.pytest_emoji_passed), 49 | (Outcome.ERROR, self.config.hook.pytest_emoji_error), 50 | (Outcome.SKIPPED, self.config.hook.pytest_emoji_skipped), 51 | (Outcome.FAILED, self.config.hook.pytest_emoji_failed), 52 | (Outcome.XFAILED, self.config.hook.pytest_emoji_xfailed), 53 | (Outcome.XPASSED, self.config.hook.pytest_emoji_xpassed), 54 | ) 55 | } 56 | 57 | def pytest_runtest_logreport(self, report) -> None: 58 | """Hook implementation that collects test reports by outcome.""" 59 | 60 | if report.when in ("setup", "teardown"): 61 | if report.failed: 62 | self.reports[Outcome.ERROR].append(report) 63 | return 64 | elif report.skipped: 65 | self.reports[Outcome.SKIPPED].append(report) 66 | return 67 | 68 | if hasattr(report, "wasxfail"): 69 | if report.skipped: 70 | self.reports[Outcome.XFAILED].append(report) 71 | return 72 | elif report.passed: 73 | self.reports[Outcome.XPASSED].append(report) 74 | return 75 | else: 76 | return 77 | 78 | if report.when == "call": 79 | if report.passed: 80 | self.reports[Outcome.PASSED].append(report) 81 | return 82 | elif report.skipped: 83 | self.reports[Outcome.SKIPPED].append(report) 84 | return 85 | elif report.failed: 86 | self.reports[Outcome.FAILED].append(report) 87 | return 88 | 89 | def pytest_terminal_summary(self, terminalreporter) -> None: 90 | """Hook implementation that writes the path to the generated report to 91 | the terminal. 92 | """ 93 | 94 | terminalreporter.write_sep( 95 | "-", f"generated Markdown report: {self.report_path}" 96 | ) 97 | 98 | def pytest_sessionstart(self, session) -> None: 99 | """Hook implementation to store the time when the session started.""" 100 | 101 | self.session_start = time.time() 102 | 103 | def create_header(self) -> str: 104 | """Create a header for the Markdown report.""" 105 | 106 | return "# Test Report\n" 107 | 108 | def create_project_link(self) -> str: 109 | """Create a project link for the Markdown report.""" 110 | 111 | extra = "" 112 | 113 | if self.emojis_enabled: 114 | extra = " 📝" 115 | 116 | now = datetime.datetime.now() 117 | report_date = now.strftime("%d-%b-%Y") 118 | report_time = now.strftime("%H:%M:%S") 119 | 120 | repo = "https://github.com/hackebrot/pytest-md" 121 | 122 | project_link = "" 123 | project_link += f"*Report generated on {report_date} at {report_time} " 124 | project_link += f"by [pytest-md]*{extra}\n\n" 125 | project_link += f"[pytest-md]: {repo}\n" 126 | 127 | return project_link 128 | 129 | def create_summary(self) -> str: 130 | """Create a summary for the Markdown report.""" 131 | 132 | outcome_text = "" 133 | total_count = 0 134 | 135 | for outcome in (o for o in Outcome if o in self.reports): 136 | count = len(self.reports[outcome]) 137 | total_count += count 138 | 139 | text = outcome.value 140 | 141 | if self.emojis_enabled: 142 | text = self.emojis_by_outcome[outcome].strip() 143 | 144 | outcome_text += f"- {count} {text}\n".lower() 145 | 146 | summary = "## Summary\n\n" 147 | summary += f"{total_count} tests ran in {self.session_duration:.2f} seconds" 148 | 149 | if self.emojis_enabled: 150 | summary = f"{summary} ⏱" 151 | 152 | return summary + "\n\n" + outcome_text 153 | 154 | def create_results(self) -> str: 155 | """Create results for the individual tests for the Markdown report.""" 156 | 157 | outcomes = {} 158 | 159 | for outcome in (o for o in Outcome if o in self.reports): 160 | reports_by_file: Dict[str, List] = collections.defaultdict(list) 161 | 162 | for report in self.reports[outcome]: 163 | test_file = report.location[0] 164 | reports_by_file[test_file].append(report) 165 | 166 | outcomes[outcome] = reports_by_file 167 | 168 | results = "" 169 | 170 | for outcome, reports_by_file in outcomes.items(): 171 | outcome_text = outcome.value 172 | 173 | if self.emojis_enabled: 174 | outcome_text = self.emojis_by_outcome[outcome].strip() 175 | 176 | results += f"## {len(self.reports[outcome])} {outcome_text}\n\n".lower() 177 | 178 | for test_file, reports in reports_by_file.items(): 179 | results += f"### {test_file}\n\n" 180 | 181 | for report in reports: 182 | test_function = report.location[2] 183 | 184 | if outcome is Outcome.ERROR: 185 | results += ( 186 | f"`{outcome.value} at {report.when} of {test_function}`" 187 | ) 188 | else: 189 | results += f"`{test_function}`" 190 | 191 | results += f" {report.duration:.2f}s" 192 | if self.emojis_enabled: 193 | results += " ⏱" 194 | 195 | results += "\n" 196 | 197 | if outcome in (Outcome.ERROR, Outcome.FAILED): 198 | results += f"\n```\n{report.longreprtext}\n```\n" 199 | 200 | results += "\n" 201 | 202 | return results 203 | 204 | def pytest_sessionfinish(self, session) -> None: 205 | """Hook implementation that generates a Markdown report and writes it 206 | to disk. 207 | """ 208 | 209 | self.session_finish = time.time() 210 | self.session_duration = self.session_finish - self.session_start 211 | 212 | header = self.create_header() 213 | project_link = self.create_project_link() 214 | summary = self.create_summary() 215 | 216 | self.report += f"{header}\n" 217 | self.report += f"{project_link}\n" 218 | self.report += f"{summary}\n" 219 | 220 | if self.config.option.verbose > 0: 221 | results = self.create_results() 222 | self.report += f"{results}" 223 | 224 | self.report_path.write_text(self.report.rstrip() + "\n", encoding="utf-8") 225 | 226 | 227 | def pytest_addoption(parser): 228 | """Hook implementation that adds a "--md" CLI flag.""" 229 | 230 | group = parser.getgroup("terminal reporting") 231 | group.addoption( 232 | "--md", 233 | action="store", 234 | dest="mdpath", 235 | metavar="path", 236 | default=None, 237 | help="create markdown report file at given path.", 238 | ) 239 | 240 | 241 | def pytest_configure(config) -> None: 242 | """Hook implementation that registers the plugin if "--md" is specified.""" 243 | 244 | mdpath = config.getoption("mdpath") 245 | 246 | if not mdpath: 247 | return 248 | 249 | def emojis_enabled() -> bool: 250 | """Check if pytest-emoji is installed and enabled.""" 251 | 252 | if not config.pluginmanager.hasplugin("emoji"): 253 | return False 254 | 255 | return config.option.emoji is True 256 | 257 | config._md = MarkdownPlugin( 258 | config, 259 | report_path=pathlib.Path(mdpath).expanduser().resolve(), 260 | emojis_enabled=emojis_enabled(), 261 | ) 262 | 263 | config.pluginmanager.register(config._md, "md_plugin") 264 | 265 | 266 | def pytest_unconfigure(config) -> None: 267 | """Hook implementation that unregisters the plugin.""" 268 | 269 | md = getattr(config, "_md", None) 270 | 271 | if not md: 272 | return 273 | 274 | del config._md 275 | config.pluginmanager.unregister(md) 276 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import datetime 3 | import textwrap 4 | 5 | import freezegun 6 | import pytest 7 | 8 | pytest_plugins = ["pytester"] 9 | 10 | 11 | @pytest.fixture(name="emoji_tests", autouse=True) 12 | def fixture_emoji_tests(testdir): 13 | """Create a test module with several tests that produce all the different 14 | pytest test outcomes. 15 | """ 16 | emoji_tests = textwrap.dedent( 17 | """\ 18 | import pytest 19 | 20 | 21 | def test_failed(): 22 | assert "emoji" == "hello world" 23 | 24 | 25 | @pytest.mark.xfail 26 | def test_xfailed(): 27 | assert 1234 == 100 28 | 29 | 30 | @pytest.mark.xfail 31 | def test_xpass(): 32 | assert 1234 == 1234 33 | 34 | 35 | @pytest.mark.skip(reason="don't run this test") 36 | def test_skipped(): 37 | assert "pytest-emoji" != "" 38 | 39 | 40 | @pytest.mark.parametrize( 41 | "name, expected", 42 | [ 43 | ("Sara", "Hello Sara!"), 44 | ("Mat", "Hello Mat!"), 45 | ("Annie", "Hello Annie!"), 46 | ], 47 | ) 48 | def test_passed(name, expected): 49 | assert f"Hello {name}!" == expected 50 | 51 | 52 | @pytest.fixture 53 | def number(): 54 | return 1234 / 0 55 | 56 | 57 | def test_error(number): 58 | assert number == number 59 | """ 60 | ) 61 | 62 | testdir.makepyfile(test_emoji_tests=emoji_tests) 63 | 64 | 65 | @pytest.fixture(name="custom_emojis", autouse=True) 66 | def fixture_custom_emojis(request, testdir): 67 | """Create a conftest.py file for emoji tests, which implements the 68 | pytest-emoji hooks. 69 | """ 70 | 71 | if "emoji" not in request.keywords: 72 | # Only create a conftest.py for emoji tests 73 | return 74 | 75 | conftest = textwrap.dedent( 76 | """\ 77 | def pytest_emoji_passed(config): 78 | return "🦊 ", "PASSED 🦊 " 79 | 80 | 81 | def pytest_emoji_failed(config): 82 | return "😿 ", "FAILED 😿 " 83 | 84 | 85 | def pytest_emoji_skipped(config): 86 | return "🙈 ", "SKIPPED 🙈 " 87 | 88 | 89 | def pytest_emoji_error(config): 90 | return "💩 ", "ERROR 💩 " 91 | 92 | 93 | def pytest_emoji_xfailed(config): 94 | return "🤓 ", "XFAILED 🤓 " 95 | 96 | 97 | def pytest_emoji_xpassed(config): 98 | return "😜 ", "XPASSED 😜 " 99 | """ 100 | ) 101 | 102 | testdir.makeconftest(conftest) 103 | 104 | 105 | class Mode(enum.Enum): 106 | """Enum for the several test scenarios.""" 107 | 108 | NORMAL = "normal" 109 | VERBOSE = "verbose" 110 | EMOJI_NORMAL = "emoji_normal" 111 | EMOJI_VERBOSE = "emoji_verbose" 112 | 113 | 114 | @pytest.fixture(name="cli_options") 115 | def fixture_cli_options(mode): 116 | """Return CLI options for the different test scenarios.""" 117 | cli_options = { 118 | Mode.NORMAL: [], 119 | Mode.VERBOSE: ["--verbose"], 120 | Mode.EMOJI_NORMAL: ["--emoji"], 121 | Mode.EMOJI_VERBOSE: ["--verbose", "--emoji"], 122 | } 123 | return cli_options[mode] 124 | 125 | 126 | @pytest.fixture(name="now") 127 | def fixture_now(): 128 | """Patch the current time for reproducable test reports.""" 129 | now = datetime.datetime(2019, 1, 21, 18, 30, 40) 130 | freezer = freezegun.freeze_time(now) 131 | freezer.start() 132 | yield now 133 | freezer.stop() 134 | 135 | 136 | @pytest.fixture(name="report_content") 137 | def fixture_report_content(mode, now): 138 | """Return the expected Markdown report for the different test scenarios.""" 139 | report_date = now.strftime("%d-%b-%Y") 140 | report_time = now.strftime("%H:%M:%S") 141 | 142 | if mode is Mode.EMOJI_NORMAL: 143 | return textwrap.dedent( 144 | f"""\ 145 | # Test Report 146 | 147 | *Report generated on {report_date} at {report_time} by [pytest-md]* 📝 148 | 149 | [pytest-md]: https://github.com/hackebrot/pytest-md 150 | 151 | ## Summary 152 | 153 | 8 tests ran in 0.00 seconds ⏱ 154 | 155 | - 1 💩 156 | - 1 😿 157 | - 3 🦊 158 | - 1 🙈 159 | - 1 🤓 160 | - 1 😜 161 | """ 162 | ) 163 | 164 | if mode is Mode.EMOJI_VERBOSE: 165 | return textwrap.dedent( 166 | f"""\ 167 | # Test Report 168 | 169 | *Report generated on {report_date} at {report_time} by [pytest-md]* 📝 170 | 171 | [pytest-md]: https://github.com/hackebrot/pytest-md 172 | 173 | ## Summary 174 | 175 | 8 tests ran in 0.00 seconds ⏱ 176 | 177 | - 1 error 💩 178 | - 1 failed 😿 179 | - 3 passed 🦊 180 | - 1 skipped 🙈 181 | - 1 xfailed 🤓 182 | - 1 xpassed 😜 183 | 184 | ## 1 error 💩 185 | 186 | ### test_emoji_tests.py 187 | 188 | `error at setup of test_error` 0.00s ⏱ 189 | 190 | ``` 191 | @pytest.fixture 192 | def number(): 193 | > return 1234 / 0 194 | E ZeroDivisionError: division by zero 195 | 196 | test_emoji_tests.py:37: ZeroDivisionError 197 | ``` 198 | 199 | ## 1 failed 😿 200 | 201 | ### test_emoji_tests.py 202 | 203 | `test_failed` 0.00s ⏱ 204 | 205 | ``` 206 | def test_failed(): 207 | > assert "emoji" == "hello world" 208 | E AssertionError: assert 'emoji' == 'hello world' 209 | E - hello world 210 | E + emoji 211 | 212 | test_emoji_tests.py:5: AssertionError 213 | ``` 214 | 215 | ## 3 passed 🦊 216 | 217 | ### test_emoji_tests.py 218 | 219 | `test_passed[Sara-Hello Sara!]` 0.00s ⏱ 220 | 221 | `test_passed[Mat-Hello Mat!]` 0.00s ⏱ 222 | 223 | `test_passed[Annie-Hello Annie!]` 0.00s ⏱ 224 | 225 | ## 1 skipped 🙈 226 | 227 | ### test_emoji_tests.py 228 | 229 | `test_skipped` 0.00s ⏱ 230 | 231 | ## 1 xfailed 🤓 232 | 233 | ### test_emoji_tests.py 234 | 235 | `test_xfailed` 0.00s ⏱ 236 | 237 | ## 1 xpassed 😜 238 | 239 | ### test_emoji_tests.py 240 | 241 | `test_xpass` 0.00s ⏱ 242 | """ 243 | ) 244 | 245 | # Return the default report for Mode.NORMAL and Mode.VERBOSE 246 | if mode is Mode.VERBOSE: 247 | return textwrap.dedent( 248 | f"""\ 249 | # Test Report 250 | 251 | *Report generated on {report_date} at {report_time} by [pytest-md]* 252 | 253 | [pytest-md]: https://github.com/hackebrot/pytest-md 254 | 255 | ## Summary 256 | 257 | 8 tests ran in 0.00 seconds 258 | 259 | - 1 error 260 | - 1 failed 261 | - 3 passed 262 | - 1 skipped 263 | - 1 xfailed 264 | - 1 xpassed 265 | 266 | ## 1 error 267 | 268 | ### test_emoji_tests.py 269 | 270 | `error at setup of test_error` 0.00s 271 | 272 | ``` 273 | @pytest.fixture 274 | def number(): 275 | > return 1234 / 0 276 | E ZeroDivisionError: division by zero 277 | 278 | test_emoji_tests.py:37: ZeroDivisionError 279 | ``` 280 | 281 | ## 1 failed 282 | 283 | ### test_emoji_tests.py 284 | 285 | `test_failed` 0.00s 286 | 287 | ``` 288 | def test_failed(): 289 | > assert "emoji" == "hello world" 290 | E AssertionError: assert 'emoji' == 'hello world' 291 | E - hello world 292 | E + emoji 293 | 294 | test_emoji_tests.py:5: AssertionError 295 | ``` 296 | 297 | ## 3 passed 298 | 299 | ### test_emoji_tests.py 300 | 301 | `test_passed[Sara-Hello Sara!]` 0.00s 302 | 303 | `test_passed[Mat-Hello Mat!]` 0.00s 304 | 305 | `test_passed[Annie-Hello Annie!]` 0.00s 306 | 307 | ## 1 skipped 308 | 309 | ### test_emoji_tests.py 310 | 311 | `test_skipped` 0.00s 312 | 313 | ## 1 xfailed 314 | 315 | ### test_emoji_tests.py 316 | 317 | `test_xfailed` 0.00s 318 | 319 | ## 1 xpassed 320 | 321 | ### test_emoji_tests.py 322 | 323 | `test_xpass` 0.00s 324 | """ 325 | ) 326 | 327 | return textwrap.dedent( 328 | f"""\ 329 | # Test Report 330 | 331 | *Report generated on {report_date} at {report_time} by [pytest-md]* 332 | 333 | [pytest-md]: https://github.com/hackebrot/pytest-md 334 | 335 | ## Summary 336 | 337 | 8 tests ran in 0.00 seconds 338 | 339 | - 1 error 340 | - 1 failed 341 | - 3 passed 342 | - 1 skipped 343 | - 1 xfailed 344 | - 1 xpassed 345 | """ 346 | ) 347 | 348 | 349 | @pytest.fixture(name="report_path") 350 | def fixture_report_path(tmp_path): 351 | """Return a temporary path for writing the Markdown report.""" 352 | return tmp_path / "emoji_report.md" 353 | 354 | 355 | def pytest_make_parametrize_id(config, val): 356 | """Return a custom test ID for Mode parameters.""" 357 | if isinstance(val, Mode): 358 | return val.value 359 | return f"{val!r}" 360 | 361 | 362 | def pytest_generate_tests(metafunc): 363 | """Generate several values for the "mode" fixture and add the "emoji" 364 | marker for certain test scenarios. 365 | """ 366 | if "mode" not in metafunc.fixturenames: 367 | return 368 | 369 | metafunc.parametrize( 370 | "mode", 371 | [ 372 | Mode.NORMAL, 373 | Mode.VERBOSE, 374 | pytest.param(Mode.EMOJI_NORMAL, marks=pytest.mark.emoji), 375 | pytest.param(Mode.EMOJI_VERBOSE, marks=pytest.mark.emoji), 376 | ], 377 | ) 378 | 379 | 380 | def pytest_collection_modifyitems(items, config): 381 | """Skip tests marked with "emoji" if pytest-emoji is not installed.""" 382 | if config.pluginmanager.hasplugin("emoji"): 383 | return 384 | 385 | for item in items: 386 | if item.get_closest_marker("emoji"): 387 | item.add_marker(pytest.mark.skip(reason="pytest-emoji is not installed")) 388 | -------------------------------------------------------------------------------- /tests/test_generate_report.py: -------------------------------------------------------------------------------- 1 | def test_generate_report(testdir, cli_options, report_path, report_content): 2 | """Check the contents of a generated Markdown report.""" 3 | # run pytest with the following CLI options 4 | result = testdir.runpytest(*cli_options, "--md", f"{report_path}") 5 | 6 | # make sure that that we get a '1' exit code 7 | # as we have at least one failure 8 | assert result.ret == 1 9 | 10 | # Check the generated Markdown report 11 | assert report_path.read_text() == report_content 12 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py36,py37,{py36,py37}-emoji,mypy,flake8 3 | 4 | [testenv] 5 | deps = 6 | freezegun 7 | pytest>=5.4.0 8 | emoji: pytest-emoji 9 | commands = pytest -v {posargs:tests} 10 | 11 | [testenv:flake8] 12 | deps = flake8 13 | commands = flake8 14 | 15 | [testenv:mypy] 16 | deps = mypy 17 | commands = mypy {toxinidir}/src/ 18 | --------------------------------------------------------------------------------