Skip to content

Commit 9fe8710

Browse files
Merge pull request #4147 from davidszotten/stepwise
Stepwise
2 parents e986d06 + f947cb2 commit 9fe8710

File tree

7 files changed

+262
-1
lines changed

7 files changed

+262
-1
lines changed

AUTHORS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Danielle Jenkins
5959
Dave Hunt
6060
David Díaz-Barquero
6161
David Mohr
62+
David Szotten
6263
David Vierra
6364
Daw-Ran Liou
6465
Denis Kirisov
@@ -161,6 +162,7 @@ Miro Hrončok
161162
Nathaniel Waisbrot
162163
Ned Batchelder
163164
Neven Mundar
165+
Niclas Olofsson
164166
Nicolas Delaby
165167
Oleg Pidsadnyi
166168
Oleg Sushchenko

changelog/4147.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add ``-sw``, ``--stepwise`` as an alternative to ``--lf -x`` for stopping at the first failure, but starting the next test invocation from that test. See `the documentation <https://docs.pytest.org/en/latest/cache.html#stepwise>`_ for more info.

doc/en/cache.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,9 @@ by adding the ``--cache-clear`` option like this::
260260
This is recommended for invocations from Continuous Integration
261261
servers where isolation and correctness is more important
262262
than speed.
263+
264+
265+
Stepwise
266+
--------
267+
268+
As an alternative to ``--lf -x``, especially for cases where you expect a large part of the test suite will fail, ``--sw``, ``--stepwise`` allows you to fix them one at a time. The test suite will run until the first failure and then stop. At the next invocation, tests will continue from the last failing test and then run until the next failing test. You may use the ``--stepwise-skip`` option to ignore one failing test and stop the test execution on the second failing test instead. This is useful if you get stuck on a failing test and just want to ignore it until later.

src/_pytest/config/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ def directory_arg(path, optname):
134134
"freeze_support",
135135
"setuponly",
136136
"setupplan",
137+
"stepwise",
137138
"warnings",
138139
"logging",
139140
)

src/_pytest/stepwise.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import pytest
2+
3+
4+
def pytest_addoption(parser):
5+
group = parser.getgroup("general")
6+
group.addoption(
7+
"--sw",
8+
"--stepwise",
9+
action="store_true",
10+
dest="stepwise",
11+
help="exit on test fail and continue from last failing test next time",
12+
)
13+
group.addoption(
14+
"--stepwise-skip",
15+
action="store_true",
16+
dest="stepwise_skip",
17+
help="ignore the first failing test but stop on the next failing test",
18+
)
19+
20+
21+
@pytest.hookimpl
22+
def pytest_configure(config):
23+
config.pluginmanager.register(StepwisePlugin(config), "stepwiseplugin")
24+
25+
26+
class StepwisePlugin:
27+
def __init__(self, config):
28+
self.config = config
29+
self.active = config.getvalue("stepwise")
30+
self.session = None
31+
32+
if self.active:
33+
self.lastfailed = config.cache.get("cache/stepwise", None)
34+
self.skip = config.getvalue("stepwise_skip")
35+
36+
def pytest_sessionstart(self, session):
37+
self.session = session
38+
39+
def pytest_collection_modifyitems(self, session, config, items):
40+
if not self.active or not self.lastfailed:
41+
return
42+
43+
already_passed = []
44+
found = False
45+
46+
# Make a list of all tests that have been run before the last failing one.
47+
for item in items:
48+
if item.nodeid == self.lastfailed:
49+
found = True
50+
break
51+
else:
52+
already_passed.append(item)
53+
54+
# If the previously failed test was not found among the test items,
55+
# do not skip any tests.
56+
if not found:
57+
already_passed = []
58+
59+
for item in already_passed:
60+
items.remove(item)
61+
62+
config.hook.pytest_deselected(items=already_passed)
63+
64+
def pytest_collectreport(self, report):
65+
if self.active and report.failed:
66+
self.session.shouldstop = (
67+
"Error when collecting test, stopping test execution."
68+
)
69+
70+
def pytest_runtest_logreport(self, report):
71+
# Skip this hook if plugin is not active or the test is xfailed.
72+
if not self.active or "xfail" in report.keywords:
73+
return
74+
75+
if report.failed:
76+
if self.skip:
77+
# Remove test from the failed ones (if it exists) and unset the skip option
78+
# to make sure the following tests will not be skipped.
79+
if report.nodeid == self.lastfailed:
80+
self.lastfailed = None
81+
82+
self.skip = False
83+
else:
84+
# Mark test as the last failing and interrupt the test session.
85+
self.lastfailed = report.nodeid
86+
self.session.shouldstop = (
87+
"Test failed, continuing from this test next run."
88+
)
89+
90+
else:
91+
# If the test was actually run and did pass.
92+
if report.when == "call":
93+
# Remove test from the failed ones, if exists.
94+
if report.nodeid == self.lastfailed:
95+
self.lastfailed = None
96+
97+
def pytest_sessionfinish(self, session):
98+
if self.active:
99+
self.config.cache.set("cache/stepwise", self.lastfailed)
100+
else:
101+
# Clear the list of failing tests if the plugin is not active.
102+
self.config.cache.set("cache/stepwise", [])

testing/test_cacheprovider.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ def test_error():
6666
)
6767
result = testdir.runpytest("-rw")
6868
assert result.ret == 1
69-
result.stdout.fnmatch_lines(["*could not create cache path*", "*2 warnings*"])
69+
# warnings from nodeids, lastfailed, and stepwise
70+
result.stdout.fnmatch_lines(["*could not create cache path*", "*3 warnings*"])
7071

7172
def test_config_cache(self, testdir):
7273
testdir.makeconftest(

testing/test_stepwise.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import pytest
2+
3+
4+
@pytest.fixture
5+
def stepwise_testdir(testdir):
6+
# Rather than having to modify our testfile between tests, we introduce
7+
# a flag for wether or not the second test should fail.
8+
testdir.makeconftest(
9+
"""
10+
def pytest_addoption(parser):
11+
group = parser.getgroup('general')
12+
group.addoption('--fail', action='store_true', dest='fail')
13+
group.addoption('--fail-last', action='store_true', dest='fail_last')
14+
"""
15+
)
16+
17+
# Create a simple test suite.
18+
testdir.makepyfile(
19+
test_a="""
20+
def test_success_before_fail():
21+
assert 1
22+
23+
def test_fail_on_flag(request):
24+
assert not request.config.getvalue('fail')
25+
26+
def test_success_after_fail():
27+
assert 1
28+
29+
def test_fail_last_on_flag(request):
30+
assert not request.config.getvalue('fail_last')
31+
32+
def test_success_after_last_fail():
33+
assert 1
34+
"""
35+
)
36+
37+
testdir.makepyfile(
38+
test_b="""
39+
def test_success():
40+
assert 1
41+
"""
42+
)
43+
44+
return testdir
45+
46+
47+
@pytest.fixture
48+
def error_testdir(testdir):
49+
testdir.makepyfile(
50+
test_a="""
51+
def test_error(nonexisting_fixture):
52+
assert 1
53+
54+
def test_success_after_fail():
55+
assert 1
56+
"""
57+
)
58+
59+
return testdir
60+
61+
62+
@pytest.fixture
63+
def broken_testdir(testdir):
64+
testdir.makepyfile(
65+
working_testfile="def test_proper(): assert 1", broken_testfile="foobar"
66+
)
67+
return testdir
68+
69+
70+
def test_run_without_stepwise(stepwise_testdir):
71+
result = stepwise_testdir.runpytest("-v", "--strict", "--fail")
72+
73+
result.stdout.fnmatch_lines(["*test_success_before_fail PASSED*"])
74+
result.stdout.fnmatch_lines(["*test_fail_on_flag FAILED*"])
75+
result.stdout.fnmatch_lines(["*test_success_after_fail PASSED*"])
76+
77+
78+
def test_fail_and_continue_with_stepwise(stepwise_testdir):
79+
# Run the tests with a failing second test.
80+
result = stepwise_testdir.runpytest("-v", "--strict", "--stepwise", "--fail")
81+
assert not result.stderr.str()
82+
83+
stdout = result.stdout.str()
84+
# Make sure we stop after first failing test.
85+
assert "test_success_before_fail PASSED" in stdout
86+
assert "test_fail_on_flag FAILED" in stdout
87+
assert "test_success_after_fail" not in stdout
88+
89+
# "Fix" the test that failed in the last run and run it again.
90+
result = stepwise_testdir.runpytest("-v", "--strict", "--stepwise")
91+
assert not result.stderr.str()
92+
93+
stdout = result.stdout.str()
94+
# Make sure the latest failing test runs and then continues.
95+
assert "test_success_before_fail" not in stdout
96+
assert "test_fail_on_flag PASSED" in stdout
97+
assert "test_success_after_fail PASSED" in stdout
98+
99+
100+
def test_run_with_skip_option(stepwise_testdir):
101+
result = stepwise_testdir.runpytest(
102+
"-v", "--strict", "--stepwise", "--stepwise-skip", "--fail", "--fail-last"
103+
)
104+
assert not result.stderr.str()
105+
106+
stdout = result.stdout.str()
107+
# Make sure first fail is ignore and second fail stops the test run.
108+
assert "test_fail_on_flag FAILED" in stdout
109+
assert "test_success_after_fail PASSED" in stdout
110+
assert "test_fail_last_on_flag FAILED" in stdout
111+
assert "test_success_after_last_fail" not in stdout
112+
113+
114+
def test_fail_on_errors(error_testdir):
115+
result = error_testdir.runpytest("-v", "--strict", "--stepwise")
116+
117+
assert not result.stderr.str()
118+
stdout = result.stdout.str()
119+
120+
assert "test_error ERROR" in stdout
121+
assert "test_success_after_fail" not in stdout
122+
123+
124+
def test_change_testfile(stepwise_testdir):
125+
result = stepwise_testdir.runpytest(
126+
"-v", "--strict", "--stepwise", "--fail", "test_a.py"
127+
)
128+
assert not result.stderr.str()
129+
130+
stdout = result.stdout.str()
131+
assert "test_fail_on_flag FAILED" in stdout
132+
133+
# Make sure the second test run starts from the beginning, since the
134+
# test to continue from does not exist in testfile_b.
135+
result = stepwise_testdir.runpytest("-v", "--strict", "--stepwise", "test_b.py")
136+
assert not result.stderr.str()
137+
138+
stdout = result.stdout.str()
139+
assert "test_success PASSED" in stdout
140+
141+
142+
def test_stop_on_collection_errors(broken_testdir):
143+
result = broken_testdir.runpytest(
144+
"-v", "--strict", "--stepwise", "working_testfile.py", "broken_testfile.py"
145+
)
146+
147+
stdout = result.stdout.str()
148+
assert "errors during collection" in stdout

0 commit comments

Comments
 (0)