diff --git a/AUTHORS b/AUTHORS index 44ae6aa43..0492a1659 100644 --- a/AUTHORS +++ b/AUTHORS @@ -74,6 +74,7 @@ Grig Gheorghiu Grigorii Eremeev (budulianin) Guido Wesdorp Harald Armin Massa +Henk-Jaap Wagenaar Hugo van Kemenade Hui Wang (coldnight) Ian Bicking diff --git a/_pytest/assertion/rewrite.py b/_pytest/assertion/rewrite.py index 92deb6539..db3674930 100644 --- a/_pytest/assertion/rewrite.py +++ b/_pytest/assertion/rewrite.py @@ -179,11 +179,13 @@ class AssertionRewritingHook(object): The named module or package as well as any nested modules will be rewritten on import. """ - already_imported = set(names).intersection(set(sys.modules)) - if already_imported: - for name in already_imported: - if name not in self._rewritten_names: - self._warn_already_imported(name) + already_imported = (set(names) + .intersection(sys.modules) + .difference(self._rewritten_names)) + for name in already_imported: + if not AssertionRewriter.is_rewrite_disabled( + sys.modules[name].__doc__ or ""): + self._warn_already_imported(name) self._must_rewrite.update(names) def _warn_already_imported(self, name): @@ -635,7 +637,8 @@ class AssertionRewriter(ast.NodeVisitor): not isinstance(field, ast.expr)): nodes.append(field) - def is_rewrite_disabled(self, docstring): + @staticmethod + def is_rewrite_disabled(docstring): return "PYTEST_DONT_REWRITE" in docstring def variable(self): diff --git a/_pytest/fixtures.py b/_pytest/fixtures.py index e09ffaddb..a7a63f505 100644 --- a/_pytest/fixtures.py +++ b/_pytest/fixtures.py @@ -267,7 +267,6 @@ class FixtureRequest(FuncargnamesCompatAttr): self.fixturename = None #: Scope string, one of "function", "class", "module", "session" self.scope = "function" - self._fixture_values = {} # argname -> fixture value self._fixture_defs = {} # argname -> FixtureDef fixtureinfo = pyfuncitem._fixtureinfo self._arg2fixturedefs = fixtureinfo.name2fixturedefs.copy() @@ -450,8 +449,7 @@ class FixtureRequest(FuncargnamesCompatAttr): raise # remove indent to prevent the python3 exception # from leaking into the call - result = self._getfixturevalue(fixturedef) - self._fixture_values[argname] = result + self._compute_fixture_value(fixturedef) self._fixture_defs[argname] = fixturedef return fixturedef @@ -466,7 +464,14 @@ class FixtureRequest(FuncargnamesCompatAttr): values.append(fixturedef) current = current._parent_request - def _getfixturevalue(self, fixturedef): + def _compute_fixture_value(self, fixturedef): + """ + Creates a SubRequest based on "self" and calls the execute method of the given fixturedef object. This will + force the FixtureDef object to throw away any previous results and compute a new fixture value, which + will be stored into the FixtureDef object itself. + + :param FixtureDef fixturedef: + """ # prepare a subrequest object before calling fixture function # (latter managed by fixturedef) argname = fixturedef.argname @@ -515,12 +520,11 @@ class FixtureRequest(FuncargnamesCompatAttr): exc_clear() try: # call the fixture function - val = fixturedef.execute(request=subrequest) + fixturedef.execute(request=subrequest) finally: # if fixture function failed it might have registered finalizers self.session._setupstate.addfinalizer(functools.partial(fixturedef.finish, request=subrequest), subrequest.node) - return val def _check_scope(self, argname, invoking_scope, requested_scope): if argname == "request": @@ -573,7 +577,6 @@ class SubRequest(FixtureRequest): self.scope = scope self._fixturedef = fixturedef self._pyfuncitem = request._pyfuncitem - self._fixture_values = request._fixture_values self._fixture_defs = request._fixture_defs self._arg2fixturedefs = request._arg2fixturedefs self._arg2index = request._arg2index diff --git a/_pytest/hookspec.py b/_pytest/hookspec.py index 440bf99d3..f004dd097 100644 --- a/_pytest/hookspec.py +++ b/_pytest/hookspec.py @@ -12,22 +12,31 @@ hookspec = HookspecMarker("pytest") @hookspec(historic=True) def pytest_addhooks(pluginmanager): """called at plugin registration time to allow adding new hooks via a call to - pluginmanager.add_hookspecs(module_or_class, prefix).""" + ``pluginmanager.add_hookspecs(module_or_class, prefix)``. + + + :param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager + """ @hookspec(historic=True) def pytest_namespace(): """ - DEPRECATED: this hook causes direct monkeypatching on pytest, its use is strongly discouraged + (**Deprecated**) this hook causes direct monkeypatching on pytest, its use is strongly discouraged return dict of name->object to be made globally available in - the pytest namespace. This hook is called at plugin registration - time. + the pytest namespace. + + This hook is called at plugin registration time. """ @hookspec(historic=True) def pytest_plugin_registered(plugin, manager): - """ a new pytest plugin got registered. """ + """ a new pytest plugin got registered. + + :param plugin: the plugin module or instance + :param _pytest.config.PytestPluginManager manager: pytest plugin manager + """ @hookspec(historic=True) @@ -41,7 +50,7 @@ def pytest_addoption(parser): files situated at the tests root directory due to how pytest :ref:`discovers plugins during startup `. - :arg parser: To add command line options, call + :arg _pytest.config.Parser parser: To add command line options, call :py:func:`parser.addoption(...) <_pytest.config.Parser.addoption>`. To add ini-file values call :py:func:`parser.addini(...) <_pytest.config.Parser.addini>`. @@ -56,8 +65,7 @@ def pytest_addoption(parser): a value read from an ini-style file. The config object is passed around on many internal objects via the ``.config`` - attribute or can be retrieved as the ``pytestconfig`` fixture or accessed - via (deprecated) ``pytest.config``. + attribute or can be retrieved as the ``pytestconfig`` fixture. """ @@ -72,8 +80,7 @@ def pytest_configure(config): After that, the hook is called for other conftest files as they are imported. - :arg config: pytest config object - :type config: _pytest.config.Config + :arg _pytest.config.Config config: pytest config object """ # ------------------------------------------------------------------------- @@ -87,11 +94,22 @@ def pytest_configure(config): def pytest_cmdline_parse(pluginmanager, args): """return initialized config object, parsing the specified args. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult` + + :param _pytest.config.PytestPluginManager pluginmanager: pytest plugin manager + :param list[str] args: list of arguments passed on the command line + """ def pytest_cmdline_preparse(config, args): - """(deprecated) modify command line arguments before option parsing. """ + """(**Deprecated**) modify command line arguments before option parsing. + + This hook is considered deprecated and will be removed in a future pytest version. Consider + using :func:`pytest_load_initial_conftests` instead. + + :param _pytest.config.Config config: pytest config object + :param list[str] args: list of arguments passed on the command line + """ @hookspec(firstresult=True) @@ -99,12 +117,20 @@ def pytest_cmdline_main(config): """ called for performing the main command line action. The default implementation will invoke the configure hooks and runtest_mainloop. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult` + + :param _pytest.config.Config config: pytest config object + """ def pytest_load_initial_conftests(early_config, parser, args): """ implements the loading of initial conftest files ahead - of command line option parsing. """ + of command line option parsing. + + :param _pytest.config.Config early_config: pytest config object + :param list[str] args: list of arguments passed on the command line + :param _pytest.config.Parser parser: to add command line options + """ # ------------------------------------------------------------------------- @@ -113,18 +139,29 @@ def pytest_load_initial_conftests(early_config, parser, args): @hookspec(firstresult=True) def pytest_collection(session): - """ perform the collection protocol for the given session. + """Perform the collection protocol for the given session. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult`. + + :param _pytest.main.Session session: the pytest session object + """ def pytest_collection_modifyitems(session, config, items): """ called after collection has been performed, may filter or re-order - the items in-place.""" + the items in-place. + + :param _pytest.main.Session session: the pytest session object + :param _pytest.config.Config config: pytest config object + :param List[_pytest.main.Item] items: list of item objects + """ def pytest_collection_finish(session): - """ called after collection has been performed and modified. """ + """ called after collection has been performed and modified. + + :param _pytest.main.Session session: the pytest session object + """ @hookspec(firstresult=True) @@ -134,6 +171,9 @@ def pytest_ignore_collect(path, config): more specific hooks. Stops at first non-None result, see :ref:`firstresult` + + :param str path: the path to analyze + :param _pytest.config.Config config: pytest config object """ @@ -141,12 +181,18 @@ def pytest_ignore_collect(path, config): def pytest_collect_directory(path, parent): """ called before traversing a directory for collection files. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult` + + :param str path: the path to analyze + """ def pytest_collect_file(path, parent): """ return collection Node or None for the given path. Any new node - needs to have the specified ``parent`` as a parent.""" + needs to have the specified ``parent`` as a parent. + + :param str path: the path to collect + """ # logging hooks for collection @@ -212,7 +258,12 @@ def pytest_make_parametrize_id(config, val, argname): by @pytest.mark.parametrize calls. Return None if the hook doesn't know about ``val``. The parameter name is available as ``argname``, if required. - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult` + + :param _pytest.config.Config config: pytest config object + :param val: the parametrized value + :param str argname: the automatic parameter name produced by pytest + """ # ------------------------------------------------------------------------- # generic runtest related hooks @@ -224,11 +275,14 @@ def pytest_runtestloop(session): """ called for performing the main runtest loop (after collection finished). - Stops at first non-None result, see :ref:`firstresult` """ + Stops at first non-None result, see :ref:`firstresult` + + :param _pytest.main.Session session: the pytest session object + """ def pytest_itemstart(item, node): - """ (deprecated, use pytest_runtest_logstart). """ + """(**Deprecated**) use pytest_runtest_logstart. """ @hookspec(firstresult=True) @@ -307,15 +361,25 @@ def pytest_fixture_post_finalizer(fixturedef, request): def pytest_sessionstart(session): - """ before session.main() is called. """ + """ before session.main() is called. + + :param _pytest.main.Session session: the pytest session object + """ def pytest_sessionfinish(session, exitstatus): - """ whole test run finishes. """ + """ whole test run finishes. + + :param _pytest.main.Session session: the pytest session object + :param int exitstatus: the status which pytest will return to the system + """ def pytest_unconfigure(config): - """ called before test process is exited. """ + """ called before test process is exited. + + :param _pytest.config.Config config: pytest config object + """ # ------------------------------------------------------------------------- @@ -329,6 +393,8 @@ def pytest_assertrepr_compare(config, op, left, right): of strings. The strings will be joined by newlines but any newlines *in* a string will be escaped. Note that all but the first line will be indented slightly, the intention is for the first line to be a summary. + + :param _pytest.config.Config config: pytest config object """ # ------------------------------------------------------------------------- @@ -339,7 +405,7 @@ def pytest_assertrepr_compare(config, op, left, right): def pytest_report_header(config, startdir): """ return a string or list of strings to be displayed as header info for terminal reporting. - :param config: the pytest config object. + :param _pytest.config.Config config: pytest config object :param startdir: py.path object with the starting dir .. note:: @@ -358,7 +424,7 @@ def pytest_report_collectionfinish(config, startdir, items): This strings will be displayed after the standard "collected X items" message. - :param config: the pytest config object. + :param _pytest.config.Config config: pytest config object :param startdir: py.path object with the starting dir :param items: list of pytest items that are going to be executed; this list should not be modified. """ @@ -418,6 +484,5 @@ def pytest_enter_pdb(config): """ called upon pdb.set_trace(), can be used by plugins to take special action just before the python debugger enters in interactive mode. - :arg config: pytest config object - :type config: _pytest.config.Config + :param _pytest.config.Config config: pytest config object """ diff --git a/_pytest/logging.py b/_pytest/logging.py index ed4db25ad..9e82e801d 100644 --- a/_pytest/logging.py +++ b/_pytest/logging.py @@ -79,39 +79,28 @@ def pytest_addoption(parser): @contextmanager -def logging_using_handler(handler, logger=None): - """Context manager that safely registers a given handler.""" - logger = logger or logging.getLogger(logger) - - if handler in logger.handlers: # reentrancy - # Adding the same handler twice would confuse logging system. - # Just don't do that. - yield - else: - logger.addHandler(handler) - try: - yield - finally: - logger.removeHandler(handler) - - -@contextmanager -def catching_logs(handler, formatter=None, - level=logging.NOTSET, logger=None): +def catching_logs(handler, formatter=None, level=logging.NOTSET): """Context manager that prepares the whole logging machinery properly.""" - logger = logger or logging.getLogger(logger) + root_logger = logging.getLogger() if formatter is not None: handler.setFormatter(formatter) handler.setLevel(level) - with logging_using_handler(handler, logger): - orig_level = logger.level - logger.setLevel(min(orig_level, level)) - try: - yield handler - finally: - logger.setLevel(orig_level) + # Adding the same handler twice would confuse logging system. + # Just don't do that. + add_new_handler = handler not in root_logger.handlers + + if add_new_handler: + root_logger.addHandler(handler) + orig_level = root_logger.level + root_logger.setLevel(min(orig_level, level)) + try: + yield handler + finally: + root_logger.setLevel(orig_level) + if add_new_handler: + root_logger.removeHandler(handler) class LogCaptureHandler(logging.StreamHandler): diff --git a/_pytest/main.py b/_pytest/main.py index 25554098d..142240c99 100644 --- a/_pytest/main.py +++ b/_pytest/main.py @@ -1,8 +1,10 @@ """ core implementation of testing process: init, session, runtest loop. """ from __future__ import absolute_import, division, print_function +import contextlib import functools import os +import pkgutil import six import sys @@ -206,6 +208,46 @@ def pytest_ignore_collect(path, config): return False +@contextlib.contextmanager +def _patched_find_module(): + """Patch bug in pkgutil.ImpImporter.find_module + + When using pkgutil.find_loader on python<3.4 it removes symlinks + from the path due to a call to os.path.realpath. This is not consistent + with actually doing the import (in these versions, pkgutil and __import__ + did not share the same underlying code). This can break conftest + discovery for pytest where symlinks are involved. + + The only supported python<3.4 by pytest is python 2.7. + """ + if six.PY2: # python 3.4+ uses importlib instead + def find_module_patched(self, fullname, path=None): + # Note: we ignore 'path' argument since it is only used via meta_path + subname = fullname.split(".")[-1] + if subname != fullname and self.path is None: + return None + if self.path is None: + path = None + else: + # original: path = [os.path.realpath(self.path)] + path = [self.path] + try: + file, filename, etc = pkgutil.imp.find_module(subname, + path) + except ImportError: + return None + return pkgutil.ImpLoader(fullname, file, filename, etc) + + old_find_module = pkgutil.ImpImporter.find_module + pkgutil.ImpImporter.find_module = find_module_patched + try: + yield + finally: + pkgutil.ImpImporter.find_module = old_find_module + else: + yield + + class FSHookProxy: def __init__(self, fspath, pm, remove_mods): self.fspath = fspath @@ -728,9 +770,10 @@ class Session(FSCollector): """Convert a dotted module name to path. """ - import pkgutil + try: - loader = pkgutil.find_loader(x) + with _patched_find_module(): + loader = pkgutil.find_loader(x) except ImportError: return x if loader is None: @@ -738,7 +781,8 @@ class Session(FSCollector): # This method is sometimes invoked when AssertionRewritingHook, which # does not define a get_filename method, is already in place: try: - path = loader.get_filename(x) + with _patched_find_module(): + path = loader.get_filename(x) except AttributeError: # Retrieve path from AssertionRewritingHook: path = loader.modules[x][0].co_filename diff --git a/_pytest/pytester.py b/_pytest/pytester.py index 0b25d839b..ee7ca24cd 100644 --- a/_pytest/pytester.py +++ b/_pytest/pytester.py @@ -347,7 +347,7 @@ class RunResult: :stdout: :py:class:`LineMatcher` of stdout, use ``stdout.str()`` to reconstruct stdout or the commonly used ``stdout.fnmatch_lines()`` method - :stderrr: :py:class:`LineMatcher` of stderr + :stderr: :py:class:`LineMatcher` of stderr :duration: duration in seconds """ diff --git a/_pytest/terminal.py b/_pytest/terminal.py index 1aba5e845..b20bf5ea8 100644 --- a/_pytest/terminal.py +++ b/_pytest/terminal.py @@ -153,7 +153,8 @@ class TerminalReporter: self.hasmarkup = self._tw.hasmarkup self.isatty = file.isatty() self._progress_items_reported = 0 - self._show_progress_info = self.config.getini('console_output_style') == 'progress' + self._show_progress_info = (self.config.getoption('capture') != 'no' and + self.config.getini('console_output_style') == 'progress') def hasopt(self, char): char = {'xfailed': 'x', 'skipped': 's'}.get(char, char) diff --git a/changelog/2981.bugfix b/changelog/2981.bugfix new file mode 100644 index 000000000..fd6dde37a --- /dev/null +++ b/changelog/2981.bugfix @@ -0,0 +1 @@ +Fix **memory leak** where objects returned by fixtures were never destructed by the garbage collector. diff --git a/changelog/2985.bugfix b/changelog/2985.bugfix new file mode 100644 index 000000000..1a268c0c5 --- /dev/null +++ b/changelog/2985.bugfix @@ -0,0 +1 @@ +Fix conversion of pyargs to filename to not convert symlinks and not use deprecated features on Python 3. diff --git a/changelog/2995.bugfix b/changelog/2995.bugfix new file mode 100644 index 000000000..7a3dde4c8 --- /dev/null +++ b/changelog/2995.bugfix @@ -0,0 +1 @@ +``PYTEST_DONT_REWRITE`` is now checked for plugins too rather than only for test modules. diff --git a/changelog/3021.trivial b/changelog/3021.trivial new file mode 100644 index 000000000..a63ba06e6 --- /dev/null +++ b/changelog/3021.trivial @@ -0,0 +1 @@ +Code cleanup. diff --git a/changelog/3038.feature b/changelog/3038.feature new file mode 100644 index 000000000..a0da2eef3 --- /dev/null +++ b/changelog/3038.feature @@ -0,0 +1 @@ +Console output fallsback to "classic" mode when capture is disabled (``-s``), otherwise the output gets garbled to the point of being useless. diff --git a/doc/en/writing_plugins.rst b/doc/en/writing_plugins.rst index eb5255830..8320d2c6a 100644 --- a/doc/en/writing_plugins.rst +++ b/doc/en/writing_plugins.rst @@ -616,6 +616,7 @@ Collection hooks ``pytest`` calls the following hooks for collecting files and directories: +.. autofunction:: pytest_collection .. autofunction:: pytest_ignore_collect .. autofunction:: pytest_collect_directory .. autofunction:: pytest_collect_file @@ -687,6 +688,14 @@ Reference of objects involved in hooks :members: :show-inheritance: +.. autoclass:: _pytest.main.FSCollector() + :members: + :show-inheritance: + +.. autoclass:: _pytest.main.Session() + :members: + :show-inheritance: + .. autoclass:: _pytest.main.Item() :members: :show-inheritance: diff --git a/tasks/generate.py b/tasks/generate.py index fa8ee6557..5aa4752f5 100644 --- a/tasks/generate.py +++ b/tasks/generate.py @@ -151,7 +151,7 @@ def publish_release(ctx, version, user, pypi_name): @invoke.task(help={ 'version': 'version being released', - 'write_out': 'write changes to the actial changelog' + 'write_out': 'write changes to the actual changelog' }) def changelog(ctx, version, write_out=False): if write_out: diff --git a/testing/acceptance_test.py b/testing/acceptance_test.py index b4277e46d..4480fc2cf 100644 --- a/testing/acceptance_test.py +++ b/testing/acceptance_test.py @@ -3,6 +3,8 @@ from __future__ import absolute_import, division, print_function import os import sys +import six + import _pytest._code import py import pytest @@ -645,6 +647,69 @@ class TestInvocationVariants(object): "*1 passed*" ]) + @pytest.mark.skipif(not hasattr(os, "symlink"), reason="requires symlinks") + def test_cmdline_python_package_symlink(self, testdir, monkeypatch): + """ + test --pyargs option with packages with path containing symlink can + have conftest.py in their package (#2985) + """ + monkeypatch.delenv('PYTHONDONTWRITEBYTECODE', raising=False) + + search_path = ["lib", os.path.join("local", "lib")] + + dirname = "lib" + d = testdir.mkdir(dirname) + foo = d.mkdir("foo") + foo.ensure("__init__.py") + lib = foo.mkdir('bar') + lib.ensure("__init__.py") + lib.join("test_bar.py"). \ + write("def test_bar(): pass\n" + "def test_other(a_fixture):pass") + lib.join("conftest.py"). \ + write("import pytest\n" + "@pytest.fixture\n" + "def a_fixture():pass") + + d_local = testdir.mkdir("local") + symlink_location = os.path.join(str(d_local), "lib") + if six.PY2: + os.symlink(str(d), symlink_location) + else: + os.symlink(str(d), symlink_location, target_is_directory=True) + + # The structure of the test directory is now: + # . + # ├── local + # │ └── lib -> ../lib + # └── lib + # └── foo + # ├── __init__.py + # └── bar + # ├── __init__.py + # ├── conftest.py + # └── test_bar.py + + def join_pythonpath(*dirs): + cur = os.getenv('PYTHONPATH') + if cur: + dirs += (cur,) + return os.pathsep.join(str(p) for p in dirs) + + monkeypatch.setenv('PYTHONPATH', join_pythonpath(*search_path)) + for p in search_path: + monkeypatch.syspath_prepend(p) + + # module picked up in symlink-ed directory: + result = testdir.runpytest("--pyargs", "-v", "foo.bar") + testdir.chdir() + assert result.ret == 0 + result.stdout.fnmatch_lines([ + "*lib/foo/bar/test_bar.py::test_bar*PASSED*", + "*lib/foo/bar/test_bar.py::test_other*PASSED*", + "*2 passed*" + ]) + def test_cmdline_python_package_not_exists(self, testdir): result = testdir.runpytest("--pyargs", "tpkgwhatv") assert result.ret @@ -848,3 +913,46 @@ def test_deferred_hook_checking(testdir): }) result = testdir.runpytest() result.stdout.fnmatch_lines(['* 1 passed *']) + + +def test_fixture_values_leak(testdir): + """Ensure that fixture objects are properly destroyed by the garbage collector at the end of their expected + life-times (#2981). + """ + testdir.makepyfile(""" + import attr + import gc + import pytest + import weakref + + @attr.s + class SomeObj(object): + name = attr.ib() + + fix_of_test1_ref = None + session_ref = None + + @pytest.fixture(scope='session') + def session_fix(): + global session_ref + obj = SomeObj(name='session-fixture') + session_ref = weakref.ref(obj) + return obj + + @pytest.fixture + def fix(session_fix): + global fix_of_test1_ref + obj = SomeObj(name='local-fixture') + fix_of_test1_ref = weakref.ref(obj) + return obj + + def test1(fix): + assert fix_of_test1_ref() is fix + + def test2(): + gc.collect() + # fixture "fix" created during test1 must have been destroyed by now + assert fix_of_test1_ref() is None + """) + result = testdir.runpytest() + result.stdout.fnmatch_lines(['* 2 passed *']) diff --git a/testing/test_assertrewrite.py b/testing/test_assertrewrite.py index 0e22c6dac..77cfd2900 100644 --- a/testing/test_assertrewrite.py +++ b/testing/test_assertrewrite.py @@ -128,6 +128,16 @@ class TestAssertionRewrite(object): assert len(m.body) == 1 assert m.body[0].msg is None + def test_dont_rewrite_plugin(self, testdir): + contents = { + "conftest.py": "pytest_plugins = 'plugin'; import plugin", + "plugin.py": "'PYTEST_DONT_REWRITE'", + "test_foo.py": "def test_foo(): pass", + } + testdir.makepyfile(**contents) + result = testdir.runpytest_subprocess() + assert "warnings" not in "".join(result.outlines) + def test_name(self): def f(): assert False diff --git a/testing/test_terminal.py b/testing/test_terminal.py index 97c2f71fb..0db56f6f9 100644 --- a/testing/test_terminal.py +++ b/testing/test_terminal.py @@ -1037,3 +1037,11 @@ class TestProgress: r'\[gw\d\] \[\s*\d+%\] PASSED test_foo.py::test_foo\[1\]', r'\[gw\d\] \[\s*\d+%\] PASSED test_foobar.py::test_foobar\[1\]', ]) + + def test_capture_no(self, many_tests_file, testdir): + output = testdir.runpytest('-s') + output.stdout.re_match_lines([ + r'test_bar.py \.{10}', + r'test_foo.py \.{5}', + r'test_foobar.py \.{5}', + ])