diff --git a/changelog/5172.feature.rst b/changelog/5172.feature.rst new file mode 100644 index 000000000..85b55f922 --- /dev/null +++ b/changelog/5172.feature.rst @@ -0,0 +1,2 @@ +The ``--last-failed`` (``--lf``) option got smarter and will now skip entire files if all tests +of that test file have passed in previous runs, greatly speeding up collection. diff --git a/src/_pytest/cacheprovider.py b/src/_pytest/cacheprovider.py index 63503ed2e..df02f4d54 100755 --- a/src/_pytest/cacheprovider.py +++ b/src/_pytest/cacheprovider.py @@ -158,6 +158,33 @@ class LFPlugin(object): self.lastfailed = config.cache.get("cache/lastfailed", {}) self._previously_failed_count = None self._report_status = None + self._skipped_files = 0 # count skipped files during collection due to --lf + + def last_failed_paths(self): + """Returns a set with all Paths()s of the previously failed nodeids (cached). + """ + result = getattr(self, "_last_failed_paths", None) + if result is None: + rootpath = Path(self.config.rootdir) + result = {rootpath / nodeid.split("::")[0] for nodeid in self.lastfailed} + self._last_failed_paths = result + return result + + def pytest_ignore_collect(self, path): + """ + Ignore this file path if we are in --lf mode and it is not in the list of + previously failed files. + """ + if ( + self.active + and self.config.getoption("lf") + and path.isfile() + and self.lastfailed + ): + skip_it = Path(path) not in self.last_failed_paths() + if skip_it: + self._skipped_files += 1 + return skip_it def pytest_report_collectionfinish(self): if self.active and self.config.getoption("verbose") >= 0: @@ -206,9 +233,19 @@ class LFPlugin(object): items[:] = previously_failed + previously_passed noun = "failure" if self._previously_failed_count == 1 else "failures" + if self._skipped_files > 0: + files_noun = "file" if self._skipped_files == 1 else "files" + skipped_files_msg = " (skipped {files} {files_noun})".format( + files=self._skipped_files, files_noun=files_noun + ) + else: + skipped_files_msg = "" suffix = " first" if self.config.getoption("failedfirst") else "" - self._report_status = "rerun previous {count} {noun}{suffix}".format( - count=self._previously_failed_count, suffix=suffix, noun=noun + self._report_status = "rerun previous {count} {noun}{suffix}{skipped_files}".format( + count=self._previously_failed_count, + suffix=suffix, + noun=noun, + skipped_files=skipped_files_msg, ) else: self._report_status = "no previously failed tests, " diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index 41e7ffd79..02c758424 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -445,9 +445,9 @@ class TestLastFailed(object): result = testdir.runpytest("--lf") result.stdout.fnmatch_lines( [ - "collected 4 items / 2 deselected / 2 selected", - "run-last-failure: rerun previous 2 failures", - "*2 failed, 2 deselected in*", + "collected 2 items", + "run-last-failure: rerun previous 2 failures (skipped 1 file)", + "*2 failed in*", ] ) @@ -718,7 +718,7 @@ class TestLastFailed(object): assert self.get_cached_last_failed(testdir) == ["test_foo.py::test_foo_4"] result = testdir.runpytest("--last-failed") - result.stdout.fnmatch_lines(["*1 failed, 3 deselected*"]) + result.stdout.fnmatch_lines(["*1 failed, 1 deselected*"]) assert self.get_cached_last_failed(testdir) == ["test_foo.py::test_foo_4"] # 3. fix test_foo_4, run only test_foo.py @@ -779,6 +779,58 @@ class TestLastFailed(object): result = testdir.runpytest("--lf", "--cache-clear", "--lfnf", "none") result.stdout.fnmatch_lines(["*2 desel*"]) + def test_lastfailed_skip_collection(self, testdir): + """ + Test --lf behavior regarding skipping collection of files that are not marked as + failed in the cache (#5172). + """ + testdir.makepyfile( + **{ + "pkg1/test_1.py": """ + import pytest + + @pytest.mark.parametrize('i', range(3)) + def test_1(i): pass + """, + "pkg2/test_2.py": """ + import pytest + + @pytest.mark.parametrize('i', range(5)) + def test_1(i): + assert i not in (1, 3) + """, + } + ) + # first run: collects 8 items (test_1: 3, test_2: 5) + result = testdir.runpytest() + result.stdout.fnmatch_lines(["collected 8 items", "*2 failed*6 passed*"]) + # second run: collects only 5 items from test_2, because all tests from test_1 have passed + result = testdir.runpytest("--lf") + result.stdout.fnmatch_lines( + [ + "collected 5 items / 3 deselected / 2 selected", + "run-last-failure: rerun previous 2 failures (skipped 1 file)", + "*2 failed*3 deselected*", + ] + ) + + # add another file and check if message is correct when skipping more than 1 file + testdir.makepyfile( + **{ + "pkg1/test_3.py": """ + def test_3(): pass + """ + } + ) + result = testdir.runpytest("--lf") + result.stdout.fnmatch_lines( + [ + "collected 5 items / 3 deselected / 2 selected", + "run-last-failure: rerun previous 2 failures (skipped 2 files)", + "*2 failed*3 deselected*", + ] + ) + class TestNewFirst(object): def test_newfirst_usecase(self, testdir):