Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,25 @@ assert a + " world" == "hello world"
```
````

### Retrying Flaky Tests

For tests that may fail occasionally due to timing, network, or other transient issues, you can specify automatic retries using the `retry:N` syntax:

````markdown
```python retry:3
import requests
response = requests.get("https://api.example.com")
assert response.status_code == 200
```
````

With `retry:3`, the test runs up to 4 times total (1 initial attempt + 3 retries). The test passes if any attempt succeeds.

**Important notes:**
- Fixtures are NOT re-run between retries - only the test code re-executes
- All exceptions trigger retries (AssertionError, RuntimeError, etc.)
- When using a continuation block, only the failing block retries

### Compatibility with Material for MkDocs

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it make sense to call it flaky to be consistent with @pytest.mark.flaky?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe, although retry:3 feels pretty clear about what's happening while flaky:3 is a bit ambiguous (though not totally impenetrable of course)...


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

Expand Down
1 change: 1 addition & 0 deletions src/pytest_markdown_docs/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class FenceTestDefinition:
start_line: int
source_path: pathlib.Path
runner_name: typing.Optional[str]
max_retries: int = 0


@dataclass(frozen=True)
Expand Down
53 changes: 49 additions & 4 deletions src/pytest_markdown_docs/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,36 @@ def runtest(self):
for argname, value in self.funcargs.items():
all_globals[argname] = value

# this ensures that pytest's stdout/stderr capture works during the test:
capman = self.config.pluginmanager.getplugin("capturemanager")
with capman.global_and_fixture_disabled():
self.runner.runtest(self.test_definition, all_globals)
# Retry logic
max_retries = self.test_definition.max_retries
max_attempts = max_retries + 1 # +1 for initial attempt

last_exception = None
for attempt in range(max_attempts):
try:
# this ensures that pytest's stdout/stderr capture works during the test:
capman = self.config.pluginmanager.getplugin("capturemanager")
with capman.global_and_fixture_disabled():
self.runner.runtest(self.test_definition, all_globals)

# Success - test passed
if attempt > 0:
# Record retry count for reporting
self.user_properties.append(("retries", str(attempt)))
return

except Exception as e:
last_exception = e
if attempt < max_attempts - 1:
# Not the last attempt, will retry
continue
else:
# Last attempt failed, re-raise
raise

# Safety fallback (should not reach here)
if last_exception:
raise last_exception

def repr_failure(
self,
Expand Down Expand Up @@ -180,12 +206,31 @@ def extract_fence_tests(
)
else:
runner_name = runner_names[0]

retry_counts = get_prefixed_strings(code_options, "retry:")
if len(retry_counts) == 0:
max_retries = 0
elif len(retry_counts) > 1:
raise Exception(
f"Multiple retry counts are not supported, use a single one instead: {retry_counts}"
)
else:
try:
max_retries = int(retry_counts[0])
if max_retries < 0:
raise ValueError("Retry count must be non-negative")
except ValueError as e:
raise Exception(
f"Invalid retry count '{retry_counts[0]}': must be a non-negative integer"
) from e

yield FenceTestDefinition(
code_block,
fixture_names,
start_line,
source_path=source_path,
runner_name=runner_name,
max_retries=max_retries,
)
prev = code_block

Expand Down
211 changes: 211 additions & 0 deletions tests/plugin_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,214 @@ def pytest_markdown_docs_markdown_it():
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(passed=1, failed=2)


def test_retry_eventually_succeeds(testdir):
"""Test that a flaky test succeeds after retrying."""
testdir.makepyfile(
conftest="""
attempt_counter = {}
"""
)
testdir.makefile(
".md",
test_file="""
```python retry:3
import conftest
key = "test_1"
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
assert conftest.attempt_counter[key] >= 2 # Fails first time, passes on retry
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(passed=1)


def test_retry_exhausted(testdir):
"""Test that a test fails after all retry attempts are exhausted."""
testdir.makefile(
".md",
test_file="""
```python retry:2
assert False # Always fails
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(failed=1)


def test_retry_with_fixture(testdir):
"""Test that fixtures are not re-run between retries."""
testdir.makepyfile(
conftest="""
import pytest

fixture_call_count = 0

@pytest.fixture
def counting_fixture():
global fixture_call_count
fixture_call_count += 1
return fixture_call_count

attempt_counter = {}
"""
)
testdir.makefile(
".md",
test_file="""
```python retry:3 fixture:counting_fixture
import conftest
key = "test_2"
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1

# Fixture should only be called once (value should stay 1)
assert counting_fixture == 1

# Fail on first attempt, pass on second
assert conftest.attempt_counter[key] >= 2
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(passed=1)


def test_retry_with_continuation(testdir):
"""Test that retry works with continuation blocks."""
testdir.makepyfile(
conftest="""
attempt_counter = {}
"""
)
testdir.makefile(
".md",
test_file="""
```python
a = "hello"
```

```python retry:2 continuation
import conftest
key = "test_3"
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1

# Variable 'a' from previous block should be available
assert a + " world" == "hello world"

# Fail on first attempt, pass on second
assert conftest.attempt_counter[key] >= 2
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(passed=2)


def test_retry_invalid_negative(testdir):
"""Test that negative retry counts raise an error."""
testdir.makefile(
".md",
test_file="""
```python retry:-1
assert True
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines(["*Invalid retry count*non-negative integer*"])


def test_retry_invalid_non_numeric(testdir):
"""Test that non-numeric retry counts raise an error."""
testdir.makefile(
".md",
test_file="""
```python retry:abc
assert True
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines(["*Invalid retry count*non-negative integer*"])


def test_retry_multiple_values_error(testdir):
"""Test that multiple retry values raise an error."""
testdir.makefile(
".md",
test_file="""
```python retry:2 retry:3
assert True
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(errors=1)
result.stdout.fnmatch_lines(["*Multiple retry counts are not supported*"])


def test_retry_zero(testdir):
"""Test that retry:0 behaves the same as no retry."""
testdir.makefile(
".md",
test_file="""
```python retry:0
assert False
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(failed=1)


def test_retry_mdx_comment(testdir):
"""Test that retry works with MDX comment metadata."""
testdir.makepyfile(
conftest="""
attempt_counter = {}
"""
)
testdir.makefile(
".mdx",
test_file="""
{/* pmd-metadata: retry:3 */}
```python
import conftest
key = "test_4"
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
assert conftest.attempt_counter[key] >= 2
```
""",
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(passed=1)


def test_retry_with_docstring(testdir):
"""Test that retry works in Python docstrings."""
testdir.makepyfile(
conftest="""
attempt_counter = {}
"""
)
testdir.makepyfile(
test_module="""
def my_function():
\"\"\"
```python retry:3
import conftest
key = "test_5"
conftest.attempt_counter[key] = conftest.attempt_counter.get(key, 0) + 1
assert conftest.attempt_counter[key] >= 2
```
\"\"\"
pass
"""
)
result = testdir.runpytest("--markdown-docs")
result.assert_outcomes(passed=1)
Loading