Skip to content

Commit c9841a0

Browse files
authored
Add retry option (#56)
1 parent ae74f84 commit c9841a0

4 files changed

Lines changed: 281 additions & 4 deletions

File tree

README.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,25 @@ assert a + " world" == "hello world"
136136
```
137137
````
138138

139+
### Retrying Flaky Tests
140+
141+
For tests that may fail occasionally due to timing, network, or other transient issues, you can specify automatic retries using the `retry:N` syntax:
142+
143+
````markdown
144+
```python retry:3
145+
import requests
146+
response = requests.get("https://api.example.com")
147+
assert response.status_code == 200
148+
```
149+
````
150+
151+
With `retry:3`, the test runs up to 4 times total (1 initial attempt + 3 retries). The test passes if any attempt succeeds.
152+
153+
**Important notes:**
154+
- Fixtures are NOT re-run between retries - only the test code re-executes
155+
- All exceptions trigger retries (AssertionError, RuntimeError, etc.)
156+
- When using a continuation block, only the failing block retries
157+
139158
### Compatibility with Material for MkDocs
140159

141160
Material for Mkdocs is not compatible with the default syntax.
@@ -166,6 +185,7 @@ The following options can be specified using MDX comments:
166185
* notest: Exclude the code block from testing.
167186
* fixture:<name>: Apply named pytest fixtures to the code block.
168187
* continuation: Continue from the previous code block, allowing you to carry over state.
188+
* retry:<count>: Automatically retry the test up to the specified number of times if it fails.
169189

170190
This approach allows you to add metadata to the code block without modifying the code fence itself, making it particularly useful in MDX environments.
171191

src/pytest_markdown_docs/definitions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class FenceTestDefinition:
1010
start_line: int
1111
source_path: pathlib.Path
1212
runner_name: typing.Optional[str]
13+
max_retries: int = 0
1314

1415

1516
@dataclass(frozen=True)

src/pytest_markdown_docs/plugin.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,36 @@ def runtest(self):
100100
for argname, value in self.funcargs.items():
101101
all_globals[argname] = value
102102

103-
# this ensures that pytest's stdout/stderr capture works during the test:
104-
capman = self.config.pluginmanager.getplugin("capturemanager")
105-
with capman.global_and_fixture_disabled():
106-
self.runner.runtest(self.test_definition, all_globals)
103+
# Retry logic
104+
max_retries = self.test_definition.max_retries
105+
max_attempts = max_retries + 1 # +1 for initial attempt
106+
107+
last_exception = None
108+
for attempt in range(max_attempts):
109+
try:
110+
# this ensures that pytest's stdout/stderr capture works during the test:
111+
capman = self.config.pluginmanager.getplugin("capturemanager")
112+
with capman.global_and_fixture_disabled():
113+
self.runner.runtest(self.test_definition, all_globals)
114+
115+
# Success - test passed
116+
if attempt > 0:
117+
# Record retry count for reporting
118+
self.user_properties.append(("retries", str(attempt)))
119+
return
120+
121+
except Exception as e:
122+
last_exception = e
123+
if attempt < max_attempts - 1:
124+
# Not the last attempt, will retry
125+
continue
126+
else:
127+
# Last attempt failed, re-raise
128+
raise
129+
130+
# Safety fallback (should not reach here)
131+
if last_exception:
132+
raise last_exception
107133

108134
def repr_failure(
109135
self,
@@ -180,12 +206,31 @@ def extract_fence_tests(
180206
)
181207
else:
182208
runner_name = runner_names[0]
209+
210+
retry_counts = get_prefixed_strings(code_options, "retry:")
211+
if len(retry_counts) == 0:
212+
max_retries = 0
213+
elif len(retry_counts) > 1:
214+
raise Exception(
215+
f"Multiple retry counts are not supported, use a single one instead: {retry_counts}"
216+
)
217+
else:
218+
try:
219+
max_retries = int(retry_counts[0])
220+
if max_retries < 0:
221+
raise ValueError("Retry count must be non-negative")
222+
except ValueError as e:
223+
raise Exception(
224+
f"Invalid retry count '{retry_counts[0]}': must be a non-negative integer"
225+
) from e
226+
183227
yield FenceTestDefinition(
184228
code_block,
185229
fixture_names,
186230
start_line,
187231
source_path=source_path,
188232
runner_name=runner_name,
233+
max_retries=max_retries,
189234
)
190235
prev = code_block
191236

tests/plugin_test.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,214 @@ def pytest_markdown_docs_markdown_it():
510510
)
511511
result = testdir.runpytest("--markdown-docs")
512512
result.assert_outcomes(passed=1, failed=2)
513+
514+
515+
def test_retry_eventually_succeeds(testdir):
516+
"""Test that a flaky test succeeds after retrying."""
517+
testdir.makepyfile(
518+
conftest="""
519+
attempt_counter = {}
520+
"""
521+
)
522+
testdir.makefile(
523+
".md",
524+
test_file="""
525+
```python retry:3
526+
import conftest
527+
key = "test_1"
528+
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
529+
assert conftest.attempt_counter[key] >= 2 # Fails first time, passes on retry
530+
```
531+
""",
532+
)
533+
result = testdir.runpytest("--markdown-docs")
534+
result.assert_outcomes(passed=1)
535+
536+
537+
def test_retry_exhausted(testdir):
538+
"""Test that a test fails after all retry attempts are exhausted."""
539+
testdir.makefile(
540+
".md",
541+
test_file="""
542+
```python retry:2
543+
assert False # Always fails
544+
```
545+
""",
546+
)
547+
result = testdir.runpytest("--markdown-docs")
548+
result.assert_outcomes(failed=1)
549+
550+
551+
def test_retry_with_fixture(testdir):
552+
"""Test that fixtures are not re-run between retries."""
553+
testdir.makepyfile(
554+
conftest="""
555+
import pytest
556+
557+
fixture_call_count = 0
558+
559+
@pytest.fixture
560+
def counting_fixture():
561+
global fixture_call_count
562+
fixture_call_count += 1
563+
return fixture_call_count
564+
565+
attempt_counter = {}
566+
"""
567+
)
568+
testdir.makefile(
569+
".md",
570+
test_file="""
571+
```python retry:3 fixture:counting_fixture
572+
import conftest
573+
key = "test_2"
574+
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
575+
576+
# Fixture should only be called once (value should stay 1)
577+
assert counting_fixture == 1
578+
579+
# Fail on first attempt, pass on second
580+
assert conftest.attempt_counter[key] >= 2
581+
```
582+
""",
583+
)
584+
result = testdir.runpytest("--markdown-docs")
585+
result.assert_outcomes(passed=1)
586+
587+
588+
def test_retry_with_continuation(testdir):
589+
"""Test that retry works with continuation blocks."""
590+
testdir.makepyfile(
591+
conftest="""
592+
attempt_counter = {}
593+
"""
594+
)
595+
testdir.makefile(
596+
".md",
597+
test_file="""
598+
```python
599+
a = "hello"
600+
```
601+
602+
```python retry:2 continuation
603+
import conftest
604+
key = "test_3"
605+
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
606+
607+
# Variable 'a' from previous block should be available
608+
assert a + " world" == "hello world"
609+
610+
# Fail on first attempt, pass on second
611+
assert conftest.attempt_counter[key] >= 2
612+
```
613+
""",
614+
)
615+
result = testdir.runpytest("--markdown-docs")
616+
result.assert_outcomes(passed=2)
617+
618+
619+
def test_retry_invalid_negative(testdir):
620+
"""Test that negative retry counts raise an error."""
621+
testdir.makefile(
622+
".md",
623+
test_file="""
624+
```python retry:-1
625+
assert True
626+
```
627+
""",
628+
)
629+
result = testdir.runpytest("--markdown-docs")
630+
result.assert_outcomes(errors=1)
631+
result.stdout.fnmatch_lines(["*Invalid retry count*non-negative integer*"])
632+
633+
634+
def test_retry_invalid_non_numeric(testdir):
635+
"""Test that non-numeric retry counts raise an error."""
636+
testdir.makefile(
637+
".md",
638+
test_file="""
639+
```python retry:abc
640+
assert True
641+
```
642+
""",
643+
)
644+
result = testdir.runpytest("--markdown-docs")
645+
result.assert_outcomes(errors=1)
646+
result.stdout.fnmatch_lines(["*Invalid retry count*non-negative integer*"])
647+
648+
649+
def test_retry_multiple_values_error(testdir):
650+
"""Test that multiple retry values raise an error."""
651+
testdir.makefile(
652+
".md",
653+
test_file="""
654+
```python retry:2 retry:3
655+
assert True
656+
```
657+
""",
658+
)
659+
result = testdir.runpytest("--markdown-docs")
660+
result.assert_outcomes(errors=1)
661+
result.stdout.fnmatch_lines(["*Multiple retry counts are not supported*"])
662+
663+
664+
def test_retry_zero(testdir):
665+
"""Test that retry:0 behaves the same as no retry."""
666+
testdir.makefile(
667+
".md",
668+
test_file="""
669+
```python retry:0
670+
assert False
671+
```
672+
""",
673+
)
674+
result = testdir.runpytest("--markdown-docs")
675+
result.assert_outcomes(failed=1)
676+
677+
678+
def test_retry_mdx_comment(testdir):
679+
"""Test that retry works with MDX comment metadata."""
680+
testdir.makepyfile(
681+
conftest="""
682+
attempt_counter = {}
683+
"""
684+
)
685+
testdir.makefile(
686+
".mdx",
687+
test_file="""
688+
{/* pmd-metadata: retry:3 */}
689+
```python
690+
import conftest
691+
key = "test_4"
692+
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
693+
assert conftest.attempt_counter[key] >= 2
694+
```
695+
""",
696+
)
697+
result = testdir.runpytest("--markdown-docs")
698+
result.assert_outcomes(passed=1)
699+
700+
701+
def test_retry_with_docstring(testdir):
702+
"""Test that retry works in Python docstrings."""
703+
testdir.makepyfile(
704+
conftest="""
705+
attempt_counter = {}
706+
"""
707+
)
708+
testdir.makepyfile(
709+
test_module="""
710+
def my_function():
711+
\"\"\"
712+
```python retry:3
713+
import conftest
714+
key = "test_5"
715+
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
716+
assert conftest.attempt_counter[key] >= 2
717+
```
718+
\"\"\"
719+
pass
720+
"""
721+
)
722+
result = testdir.runpytest("--markdown-docs")
723+
result.assert_outcomes(passed=1)

0 commit comments

Comments
 (0)