Skip to content

Commit 7aa5676

Browse files
Initial commit: flake-bisect
Delta-debugging CLI that finds the minimal set of preceding pytest tests that poison a flaky target. Wraps Zeller's ddmin around subprocess pytest runs, pinning collection order via a bundled internal plugin so results are stable regardless of pytest-randomly or alphabetical ordering. - flake_bisect/ core (bisect, runner, order plugin, CLI) - examples/polluting_demo/ minimal reproducer suite - tests/ unit + end-to-end coverage
0 parents  commit 7aa5676

20 files changed

Lines changed: 827 additions & 0 deletions

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
__pycache__/
2+
*.py[cod]
3+
*$py.class
4+
*.egg-info/
5+
.pytest_cache/
6+
.venv/
7+
venv/
8+
.env
9+
.coverage
10+
htmlcov/
11+
build/
12+
dist/
13+
.DS_Store
14+
.idea/
15+
.vscode/
16+
AGENTS.md
17+
.claude/
18+
CLAUDE.md

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 python-testing-debugging
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# flake-bisect
2+
3+
Find the pytest test(s) that **poison** a flaky target.
4+
5+
You have a test that passes when you run it alone but fails as part of the full
6+
suite. Some other test mutates global state — `os.environ`, a singleton, a
7+
module-level cache, a database row, the current working directory, a registered
8+
signal handler — and your target is the one that notices. `flake-bisect`
9+
narrows the polluter down to a minimal set using
10+
[delta-debugging](https://www.st.cs.uni-saarland.de/papers/tse2002/) over the
11+
test ordering, so you stop guessing and start reading the right diff.
12+
13+
```
14+
$ python -m flake_bisect --workdir examples/polluting_demo \
15+
--target test_target.py::test_assumes_clean_env
16+
flake-bisect 0.1.0
17+
workdir : .../examples/polluting_demo
18+
target : test_target.py::test_assumes_clean_env
19+
Collecting tests...
20+
Collected 8 tests (7 candidates).
21+
Sanity check: target alone...
22+
OK (passes alone)
23+
Sanity check: target after full suite...
24+
OK (target outcome: FAILED)
25+
Bisecting 7 candidate predecessors...
26+
27+
Minimal poisoning set (1 test):
28+
test_pollute.py::test_sets_env_flag
29+
30+
Reproduce locally:
31+
pytest test_pollute.py::test_sets_env_flag test_target.py::test_assumes_clean_env
32+
33+
pytest invocations during bisect: 3 (cap: 200)
34+
```
35+
36+
A naive linear search across N candidate predecessors would take up to N runs
37+
of the suite. `flake-bisect` typically converges in **O(log N)** pytest
38+
invocations when there is a single polluter, and stays sub-linear with a small
39+
number of polluters.
40+
41+
## Why this exists
42+
43+
The Python testing & debugging community has converged on a clear playbook for
44+
non-flaky suites: ban global state in tests, use fixtures with `monkeypatch`,
45+
isolate the DB per test, run with [`pytest-randomly`][pytest-randomly] in CI to
46+
surface ordering bugs early. The hard part is what to do when CI catches one.
47+
The failing line tells you *what* broke; it never tells you *who* set the
48+
landmine 200 tests earlier.
49+
50+
`flake-bisect` does that last mile: given a known-flaky target, it points at
51+
the test that poisons it.
52+
53+
[pytest-randomly]: https://github.com/pytest-dev/pytest-randomly
54+
55+
## Running it
56+
57+
`flake-bisect` is a self-contained Python package with no third-party
58+
dependencies. It needs `pytest` available in the same Python environment as
59+
the project you're bisecting (it shells out to `python -m pytest`).
60+
61+
Clone the repo and run from source:
62+
63+
```bash
64+
git clone https://github.com/python-testing-debugging/flake-bisect.git
65+
cd flake-bisect
66+
python -m flake_bisect --help
67+
```
68+
69+
To use it against your own project, point `--workdir` at your project root and
70+
add `flake-bisect` to `PYTHONPATH` so the module is importable:
71+
72+
```bash
73+
PYTHONPATH=/path/to/flake-bisect python -m flake_bisect \
74+
--workdir /path/to/your/project \
75+
--target tests/test_widgets.py::test_render_safely
76+
```
77+
78+
Or run it from inside the flake-bisect checkout with an absolute `--workdir`.
79+
80+
### Common flags
81+
82+
| Flag | Purpose |
83+
| ---------------- | -------------------------------------------------------------------- |
84+
| `--target` | Required. The flaky test's nodeid as pytest reports it. |
85+
| `--testpaths` | Limit candidate predecessors to specific paths (otherwise full suite).|
86+
| `--workdir` | Run pytest from this directory (default: cwd). |
87+
| `--max-runs` | Hard cap on pytest invocations during bisect (default: 200). |
88+
| `--pytest-arg` | Forward an arg to every pytest invocation. Repeat to pass multiple. |
89+
| `-v` | Show per-iteration progress. |
90+
91+
### Forwarding pytest options
92+
93+
If your project needs particular pytest options to even collect (a `-p` plugin,
94+
`-o` override, marker filter, etc.), forward them with repeated `--pytest-arg`:
95+
96+
```bash
97+
python -m flake_bisect \
98+
--target tests/test_x.py::test_y \
99+
--pytest-arg -o --pytest-arg "addopts=" \
100+
--pytest-arg -m --pytest-arg "not slow"
101+
```
102+
103+
## How it works
104+
105+
1. **Collect** all nodeids in the suite via `pytest --collect-only`.
106+
2. **Sanity check 1**: run the target alone; bail out if it fails (then the
107+
issue isn't ordering, it's the test itself).
108+
3. **Sanity check 2**: run `[…all other tests…, target]` in order; bail out if
109+
the target passes (no reproducible pollution to bisect).
110+
4. **Delta-debug** the predecessor list with Zeller's `ddmin`. Each candidate
111+
subset is run as `pytest <subset…> <target>` in a fresh subprocess, with
112+
collection order pinned by a bundled internal plugin so the result doesn't
113+
depend on `pytest-randomly` or alphabetical ordering surprises.
114+
5. **Report** the minimal subset that still reproduces the failure plus a
115+
copy-pasteable `pytest` command to reproduce locally.
116+
117+
Determinism note: `flake-bisect` cannot help with flakes caused by **time,
118+
threads, networking, or RNG without a fixed seed**. Those are not ordering
119+
bugs. If sanity check 2 doesn't reproduce the failure deterministically, the
120+
bug is somewhere else and the CLI will say so.
121+
122+
## Exit codes
123+
124+
| Code | Meaning |
125+
| ---: | ---------------------------------------------------------------- |
126+
| 0 | Bisect completed; poisoning set printed. |
127+
| 2 | Collection problem (no tests, target nodeid not found, ...). |
128+
| 3 | Target fails when run alone — not an ordering issue. |
129+
| 4 | Target passes in the full ordered run — no pollution reproduced. |
130+
| 5 | `--max-runs` budget exhausted. |
131+
132+
These are stable; CI can branch on them.
133+
134+
## Demo
135+
136+
The `examples/polluting_demo/` directory contains a six-test suite with one
137+
polluter and one target. Use it to verify the tool runs in your environment:
138+
139+
```bash
140+
python -m flake_bisect \
141+
--workdir examples/polluting_demo \
142+
--target test_target.py::test_assumes_clean_env
143+
```
144+
145+
You should see `test_pollute.py::test_sets_env_flag` named as the culprit.
146+
147+
## Background reading
148+
149+
Deeper material on flaky tests, pytest internals, isolation patterns, and the
150+
delta-debugging algorithm lives at
151+
[python-testing-debugging.com](https://python-testing-debugging.com).
152+
153+
## License
154+
155+
[MIT](LICENSE)

examples/polluting_demo/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# polluting_demo
2+
3+
A miniature pytest suite that reproduces a classic test-pollution flake:
4+
5+
- `test_pollute.py::test_sets_env_flag` writes to `os.environ`.
6+
- `test_target.py::test_assumes_clean_env` asserts the same env var is **not** set.
7+
8+
The target passes when run alone but fails after the polluter has run. Use this
9+
suite as a fixture to exercise `flake-bisect`:
10+
11+
```bash
12+
python -m flake_bisect \
13+
--workdir examples/polluting_demo \
14+
--target test_target.py::test_assumes_clean_env
15+
```
16+
17+
Expected: flake-bisect narrows down to `test_pollute.py::test_sets_env_flag`.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Empty conftest so pytest treats this directory as a rootdir on its own."""
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
def test_addition():
2+
assert 1 + 1 == 2
3+
4+
5+
def test_string_format():
6+
assert f"{'flake':>10}" == " flake"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
def test_list_extend():
2+
xs = [1, 2]
3+
xs.extend([3, 4])
4+
assert xs == [1, 2, 3, 4]
5+
6+
7+
def test_dict_merge():
8+
a = {"x": 1}
9+
b = {"y": 2}
10+
assert {**a, **b} == {"x": 1, "y": 2}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
def test_set_intersection():
2+
assert {1, 2, 3} & {2, 3, 4} == {2, 3}
3+
4+
5+
def test_tuple_unpack():
6+
a, b = (10, 20)
7+
assert a + b == 30
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import os
2+
3+
POLLUTION_KEY = "FLAKE_BISECT_DEMO_POLLUTED"
4+
5+
6+
def test_sets_env_flag():
7+
"""Innocent-looking test that forgets to clean up after itself."""
8+
os.environ[POLLUTION_KEY] = "1"
9+
assert os.environ[POLLUTION_KEY] == "1"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import os
2+
3+
POLLUTION_KEY = "FLAKE_BISECT_DEMO_POLLUTED"
4+
5+
6+
def test_assumes_clean_env():
7+
"""Fails iff a previous test set POLLUTION_KEY in os.environ."""
8+
assert POLLUTION_KEY not in os.environ, (
9+
f"another test polluted os.environ with {POLLUTION_KEY}"
10+
)

0 commit comments

Comments
 (0)