diff --git a/README.rst b/README.rst index b839d63..205d795 100644 --- a/README.rst +++ b/README.rst @@ -65,14 +65,16 @@ The standard context available for all templates include the following: * ``started``: Unix timestamp when session started * ``ended``: Unix timestamp when session was finished * ``warnings[]``: List of warnings.WarningMessage +* ``items``: Dictionary of collected items with nodeid as keys * ``tests[]``: List of each test run as dictionaries with the following keys: * ``item``: `Item `_ * ``phases[]``: List of each test phase (setup, call, teardown) as dictionaries with the following keys: - * ``call``: `CallInfo `_ + * ``call`` (optional): `CallInfo `_ if available. * ``report``: `TestReport `_ + * ``sections``: Same as ``report.sections`` but only the sections captured for this phase * ``log_records[]``: List of `logging.LogRecord `_ recorded during the test phase * ``status``: Dictionary with the following keys: diff --git a/pytest_reporter/plugin.py b/pytest_reporter/plugin.py index d3c2dc9..c0aca80 100644 --- a/pytest_reporter/plugin.py +++ b/pytest_reporter/plugin.py @@ -1,3 +1,4 @@ +import collections from pathlib import Path import logging import time @@ -47,6 +48,14 @@ def pytest_configure(config): config.pluginmanager.register(config._reporter) +@pytest.hookimpl(hookwrapper=True) +def pytest_collection(session): + yield + if not hasattr(session, "items") and hasattr(session.config, "_reporter"): + # Collection was skipped (probably due to xdist) + session.perform_collect() + + @pytest.hookimpl(tryfirst=True) def pytest_reporter_template_dirs(config): return config.getoption("--template-dir") @@ -54,8 +63,22 @@ def pytest_reporter_template_dirs(config): def pytest_reporter_context(context, config): """Add status to test runs and phases.""" + nof_sections_per_node = {} for test in context["tests"]: for phase in test["phases"]: + # The sections attribute in the TestReport object contains not only the + # sections captured in this specific call, but also any previous + # sections captured for this node, including e.g. setup or reruns. + # Therefore we create a new sections key with only captures that are + # new for this phase for convenience to the templates. + nodeid = phase["report"].nodeid + nof_sections = nof_sections_per_node.get(nodeid, 0) + phase["sections"] = phase["report"].sections[nof_sections:] + nof_sections_per_node[nodeid] = nof_sections + len(phase["sections"]) + + # Get test status (e.g. passed, failed, error, skipped et.c.) for + # this report. These will be empty strings except for the phase which + # determines the status for the whole test. category, letter, word = config.hook.pytest_report_teststatus( report=phase["report"], config=config ) @@ -69,6 +92,7 @@ def pytest_reporter_context(context, config): "word": word, "style": style, } + # Set whole test status if this phase determined the outcome if letter or word: test["status"] = phase["status"] @@ -92,43 +116,63 @@ class ReportGenerator: def __init__(self, config): self.config = config self.context = config.template_context + self._items = {} + self._active_tests = {} self._loaders = [] - self._active_item = None - self._active_test = None self._log_handler = LogHandler() self._reports = set() + def _get_testrun(self, nodeid): + testrun = self._active_tests.get(nodeid) + if testrun is None: + testrun = { + "item": self._items.get(nodeid), + "phases": [], + } + self._active_tests[nodeid] = testrun + return testrun + def pytest_sessionstart(self, session): self.context["session"] = session self.context["started"] = time.time() logging.getLogger().addHandler(self._log_handler) - def pytest_runtest_protocol(self, item): - self._active_item = item - - def pytest_runtest_logstart(self): - self._active_test = { - "item": self._active_item, - "phases": [], - "status": { - "category": "unknown", - "letter": "?", - "word": "UNKNOWN", - "style": {}, - }, - } + def pytest_report_collectionfinish(self, config, items): + self._items = {item.nodeid: item for item in items} + self.context["items"] = self._items - def pytest_runtest_logfinish(self): - self.context["tests"].append(self._active_test) + def pytest_runtest_logstart(self, nodeid): + testrun = self._get_testrun(nodeid) + testrun["started"] = time.time() @pytest.hookimpl(hookwrapper=True) def pytest_runtest_makereport(self, item, call): - phase = {"call": call} + testrun = self._get_testrun(item.nodeid) + phase = {} + phase["call"] = call outcome = yield - report = outcome.get_result() + # rerunfailures doesn't always call pytest_runtest_logreport so we collect + # the report here as well just to be sure + phase["report"] = outcome.get_result() + testrun["phases"].append(phase) + + def pytest_runtest_logreport(self, report): + testrun = self._get_testrun(report.nodeid) + # Check if there already is an existing phase from makereport + for phase in testrun["phases"]: + if phase["report"].when == report.when: + break + else: + phase = {} + testrun["phases"].append(phase) phase["report"] = report phase["log_records"] = self._log_handler.pop_records() - self._active_test["phases"].append(phase) + + def pytest_runtest_logfinish(self, nodeid): + testrun = self._get_testrun(nodeid) + testrun["ended"] = time.time() + self.context["tests"].append(testrun) + del self._active_tests[nodeid] def pytest_warning_captured(self, warning_message): self.context["warnings"].append(warning_message)