deflake-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deflake_py-0.1.0/.gitignore +25 -0
- deflake_py-0.1.0/CHANGELOG.md +16 -0
- deflake_py-0.1.0/LICENSE.txt +21 -0
- deflake_py-0.1.0/PKG-INFO +256 -0
- deflake_py-0.1.0/README.md +229 -0
- deflake_py-0.1.0/pyproject.toml +91 -0
- deflake_py-0.1.0/src/deflake/__init__.py +6 -0
- deflake_py-0.1.0/src/deflake/__main__.py +7 -0
- deflake_py-0.1.0/src/deflake/adapters/__init__.py +115 -0
- deflake_py-0.1.0/src/deflake/adapters/django.py +40 -0
- deflake_py-0.1.0/src/deflake/adapters/pytest.py +78 -0
- deflake_py-0.1.0/src/deflake/adapters/unittest.py +48 -0
- deflake_py-0.1.0/src/deflake/bisect.py +290 -0
- deflake_py-0.1.0/src/deflake/check.py +184 -0
- deflake_py-0.1.0/src/deflake/child.py +295 -0
- deflake_py-0.1.0/src/deflake/cli.py +227 -0
- deflake_py-0.1.0/src/deflake/django/__init__.py +19 -0
- deflake_py-0.1.0/src/deflake/django/databases.py +70 -0
- deflake_py-0.1.0/src/deflake/django/leaks.py +344 -0
- deflake_py-0.1.0/src/deflake/django/runner.py +212 -0
- deflake_py-0.1.0/src/deflake/events.py +123 -0
- deflake_py-0.1.0/src/deflake/execution.py +695 -0
- deflake_py-0.1.0/src/deflake/investigation.py +211 -0
- deflake_py-0.1.0/src/deflake/leaks/__init__.py +111 -0
- deflake_py-0.1.0/src/deflake/leaks/clock.py +31 -0
- deflake_py-0.1.0/src/deflake/leaks/env.py +40 -0
- deflake_py-0.1.0/src/deflake/leaks/logs.py +49 -0
- deflake_py-0.1.0/src/deflake/leaks/modules.py +582 -0
- deflake_py-0.1.0/src/deflake/leaks/patches.py +39 -0
- deflake_py-0.1.0/src/deflake/leaks/process.py +97 -0
- deflake_py-0.1.0/src/deflake/leaks/states.py +449 -0
- deflake_py-0.1.0/src/deflake/leaks/threads.py +17 -0
- deflake_py-0.1.0/src/deflake/leaks/warning_filters.py +24 -0
- deflake_py-0.1.0/src/deflake/models.py +507 -0
- deflake_py-0.1.0/src/deflake/pytest_plugin.py +526 -0
- deflake_py-0.1.0/src/deflake/repeat.py +200 -0
- deflake_py-0.1.0/src/deflake/reports/__init__.py +37 -0
- deflake_py-0.1.0/src/deflake/reports/json.py +86 -0
- deflake_py-0.1.0/src/deflake/reports/text.py +213 -0
- deflake_py-0.1.0/src/deflake/result_file.py +25 -0
- deflake_py-0.1.0/src/deflake/tail.py +76 -0
- deflake_py-0.1.0/src/deflake/unittest_runner.py +391 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
.venv/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.coverage
|
|
12
|
+
htmlcov/
|
|
13
|
+
|
|
14
|
+
# Scratch space
|
|
15
|
+
/tmp/
|
|
16
|
+
|
|
17
|
+
# macOS
|
|
18
|
+
.DS_Store
|
|
19
|
+
._*
|
|
20
|
+
|
|
21
|
+
# Editors
|
|
22
|
+
.vscode/
|
|
23
|
+
.history/
|
|
24
|
+
*.code-workspace
|
|
25
|
+
.idea/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0]
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
- `deflake TEST_PATH[:LINE|::NAME]` runs a test file many times, each in a fresh process with a new seed, and reports how often every test failed, with errors grouped by message and the seeds that reproduce them. A folder checks every test file in it.
|
|
8
|
+
- Runs pytest, Django's `manage.py test` (on top of the project's own `TEST_RUNNER`) and unittest; the runner is picked from the project, or with `--runner`.
|
|
9
|
+
- Every failing test is run again on its own, which tells a test that fails on its own from one that fails only after other tests.
|
|
10
|
+
- Bisect finds the tests that break an order-dependent test, names every set of them that does it on its own, and points at a fixture or hook of a group when any of its tests would do. A test that fails at random gets no culprits.
|
|
11
|
+
- Leaks of global state: environment variables, the working directory, freezegun and time-machine, module globals, `mock.patch`, `sys`, logging, warning filters, threads, signal handlers, the socket timeout and the `decimal` context; with Django also settings, local memory caches, the active language, time zone and URLconf, signal receivers and database rows. Leaks of fixtures wider than a test and of `setUpClass` are reported apart from the tests.
|
|
12
|
+
- A leak next to a failure is proven its cause: deflake puts the state back after the culprit, sees the test pass, sets it again and sees the test fail. This works for environment variables, module values, `mock.patch`, freezegun, the working directory, `sys.path`, the recursion limit, the socket timeout, the decimal context, signal handlers, warning filters and logging, and under Django for the active language, time zone, URLconf, settings, local memory caches and signal receivers. Other leaks of a culprit are shown under the failing test as not checked as the cause.
|
|
13
|
+
- `random`, Faker, factory_boy and NumPy are seeded before every test.
|
|
14
|
+
- `--jobs` runs test processes at once, with test databases of their own under Django and pytest-django; `--timeout` stops a hanging one; `--format json` gives a report for tools.
|
|
15
|
+
- An interrupt lets the tests tear down what they set up, as test databases, before their processes are stopped; a second interrupt stops them at once. Processes the tests start, servers in a session of their own included, are stopped with them.
|
|
16
|
+
- Plugins that reorder or rerun tests (pytest-randomly, pytest-random-order, pytest-xdist, pytest-rerunfailures, flaky) are switched off in the test processes deflake starts, and tests marked `flaky` are pointed out.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Роман Беляев
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: deflake-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find flaky tests in pytest, unittest and Django and explain why they fail.
|
|
5
|
+
Project-URL: Homepage, https://github.com/SlyDeath/python-deflake
|
|
6
|
+
Project-URL: Source, https://github.com/SlyDeath/python-deflake
|
|
7
|
+
Project-URL: Changelog, https://github.com/SlyDeath/python-deflake/blob/main/CHANGELOG.md
|
|
8
|
+
Project-URL: Issues, https://github.com/SlyDeath/python-deflake/issues
|
|
9
|
+
Author-email: Roman Belyaev <slyrs@yandex.ru>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE.txt
|
|
12
|
+
Keywords: bisect,django,flaky,pytest,test pollution,tests,unittest
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Framework :: Django
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: Operating System :: MacOS
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Provides-Extra: django
|
|
25
|
+
Requires-Dist: django>=5.2; extra == 'django'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
<div align="center">
|
|
29
|
+
|
|
30
|
+
# deflake
|
|
31
|
+
|
|
32
|
+
**Find flaky tests in pytest, unittest and Django and explain why they fail.**
|
|
33
|
+
|
|
34
|
+
Repeat · Isolate · Bisect · Detect leaked state
|
|
35
|
+
|
|
36
|
+
[](https://pypi.org/project/deflake-py/)
|
|
37
|
+
[](https://github.com/SlyDeath/python-deflake/actions/workflows/ci.yml)
|
|
38
|
+
[](https://www.python.org)
|
|
39
|
+
[](LICENSE.txt)
|
|
40
|
+
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
A flaky test fails once in a while, someone adds a rerun, and the cause stays.
|
|
44
|
+
deflake runs a suspicious test many times and answers three questions:
|
|
45
|
+
|
|
46
|
+
1. **How often does it fail**, and with which errors and seeds?
|
|
47
|
+
2. **Does it fail on its own**, or only after some other test? If the latter, which one?
|
|
48
|
+
3. **What global state does a test leave behind**: environment variables, the clock, module globals, active `mock.patch`es, Django settings, caches and database rows?
|
|
49
|
+
4. **Is that leak what breaks the other test?** deflake puts it back and sets it again to prove it.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install deflake-py
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
or add `deflake-py` to the development dependencies of your project. The package is `deflake-py`; the command and the module are `deflake`. It needs Python 3.11 or newer on Linux (with pidfd, kernel 5.3 or newer) or macOS, and has no dependencies of its own. It works with pytest 8 or newer, unittest and Django 5.2 or newer.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
Run deflake from the root of your project, where you run your tests (next to `manage.py` in a Django project):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
deflake tests/test_orders.py --runs 50
|
|
65
|
+
deflake tests/test_orders.py::test_total --runs 50 --jobs 4
|
|
66
|
+
deflake tests/test_orders.py:42 --runs 50
|
|
67
|
+
deflake shop/tests.py::OrderTest --runs 20
|
|
68
|
+
deflake tests --runs 5
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
test_pollutes: failed 0 of 10 runs (0%)
|
|
73
|
+
leaks: os.environ['FEATURE_FLAG'] was set (10 of 10 runs)
|
|
74
|
+
leaks: test_order.STATE was changed (10 of 10 runs)
|
|
75
|
+
test_victim: failed 5 of 10 runs (50%)
|
|
76
|
+
verdict: fails only after other tests (alone: failed 0 of 10 runs (0%))
|
|
77
|
+
culprit: test_pollutes
|
|
78
|
+
reproduce: DEFLAKE_SEED=40331 python -m pytest -p deflake.pytest_plugin test_order.py
|
|
79
|
+
seeds 40331, 48172, 30602, 40073, 7643:
|
|
80
|
+
assert not True
|
|
81
|
+
at test_order.py:12
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
What happens:
|
|
85
|
+
|
|
86
|
+
1. **The whole file runs `--runs` times** (10 by default), every time in a fresh process with a new seed. The seed shuffles the classes and the tests inside them, but keeps every module and class together, so their fixtures, `setUpClass` and `setUpModule` still run once (pytest itself still regroups tests by the parameters of module and class fixtures, as it does without deflake). Before every test `random` is seeded with it, and so are Faker, factory_boy and NumPy when the tests use them. Running the whole file, not just one test, is what makes order-dependent failures show up. A test that takes its process down (`os._exit`, a crash, a signal) counts as failed in that run.
|
|
87
|
+
2. **Every test that failed runs again on its own**, as many times. The verdict is *fails on its own* if it still fails, which points at time, randomness or a race inside the test, or *fails only after other tests* if it does not. When it cannot run alone at all, as with errors outside of tests, the verdict is *failed, could not run it alone*.
|
|
88
|
+
3. **Order-dependent failures are bisected** with one of the failing seeds, down to a smallest set of tests that break the victim. deflake has the test runner run just those tests in the recorded order (pytest may regroup them by parametrized fixtures, as above) and watches only the victim, so other failing tests do not get in the way. A failure counts only when it is the failure seen in the runs of the file (the same error apart from numbers, or the same kind of error at the same line of the test file) and happens twice in a row, the set found must break the victim again at the end, and the victim must pass every time without each culprit, so a test that fails at random gets no culprits: the verdict is then *fails at random, not after particular tests*, with the command that runs that seed. When the failure came back with the same order but not with fewer tests, the report says so, with the command of that seed. When other tests break the victim just as well on their own, they are named too, after `or, just as well:`. Up to three failing seeds are tried. A culprit that two other tests of its class or module replace, because the state comes from a fixture they share, is named as such. A victim stopped by `--timeout` does not count as failing in a bisect, and a run that never got to the victim, because another test hung, is tried again.
|
|
89
|
+
4. **Every test is checked for leaks**, tests that skip themselves included: a snapshot of global state before the test, its fixtures and `setUp` included, and after their teardown. For a Django test that is before its transaction, fixtures and settings are set up and after they are rolled back, and for an `IsolatedAsyncioTestCase` after its event loop is closed. What a fixture wider than a test (`scope="module"`, `"class"`, `"package"` or `"session"`) or a `setUpClass` or `setUpModule` sets up and never puts back shows up under *errors outside of tests*, as `fixture NAME (module scope), outside its tests: ...`, not as a leak of the test that happened to run first.
|
|
90
|
+
|
|
91
|
+
A process that fails while every test in it passed, a file that cannot be collected or imported, or a failing `setUpClass`, is reported as *errors outside of tests*, with the end of its output. A run that breaks before its first test does not stop the others; only when no run gets to a test does deflake give up with exit code 2 and show how the test process ended.
|
|
92
|
+
|
|
93
|
+
`TEST_PATH::NAME` runs the whole file but reports only that test: all its parameters for a parametrized test, every test of a class for a class name. A unittest or Django test is named `Class.test_name`, and `::test_name` or `::Class` works too. `TEST_PATH:LINE` does the same for the test whose body holds the line, every test of the innermost class around the line, or else the test declared closest above it. A selected test that never started in any run makes deflake say so and exit with 1; the JSON report lists it with the verdict `not_run`.
|
|
94
|
+
|
|
95
|
+
`TEST_PATH` may be a folder: deflake then finds every `test_*.py`, `*_test.py` and `tests.py` in it and in its subfolders, leaving out virtual environments, `site-packages` and `node_modules`, and checks them one after another, each as if it were given on its own. The report of each file starts with `== FILE (3 of 40)`; with `--format json` that line goes to stderr as `deflake: checking FILE (3 of 40)`. A file that cannot run is reported and the others are still checked. At the end deflake names the files with failing or leaking tests and those it could not check, also after Ctrl-C, for the files checked so far. When the reader of the report stops early, as `head` does, deflake stops too, with exit code 141 if files were left unchecked.
|
|
96
|
+
|
|
97
|
+
### Options
|
|
98
|
+
|
|
99
|
+
| Option | Default | |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `-n`, `--runs N` | 10 | How many times to run each file |
|
|
102
|
+
| `-j`, `--jobs N` | 1 | How many test processes to run at once |
|
|
103
|
+
| `-t`, `--timeout SECONDS` | none | Stop a test process that runs longer; the test it was running counts as failed |
|
|
104
|
+
| `-f`, `--format FORMAT` | `text` | `text` or `json` |
|
|
105
|
+
| `-r`, `--runner RUNNER` | `auto` | `auto`, `pytest`, `django` or `unittest` |
|
|
106
|
+
|
|
107
|
+
Exit code: 0 when every test passed and nothing leaked (or every test skipped itself), 1 otherwise, 2 on usage errors, when the file has no tests or a filter of the project leaves them all out, or when the tests could not run (deflake then shows their output). For a folder it is the highest code of its files, and 2 when it has no test files. When interrupted with Ctrl-C, deflake interrupts its test processes, gives their tests up to 5 seconds to tear down what they set up, such as test databases, kills what is left and exits with 130 (a second Ctrl-C kills them at once), so a shell loop around it stops too. SIGTERM and SIGHUP, as from a CI timeout or a closed terminal, stop the test processes as well, with exit code 143 or 129.
|
|
108
|
+
|
|
109
|
+
### Test runners
|
|
110
|
+
|
|
111
|
+
With `--runner auto` deflake picks pytest when the project configures it (`pytest.toml`, `pytest.ini`, `[tool.pytest]` or `[tool.pytest.ini_options]` in `pyproject.toml`, `[pytest]` in `tox.ini`, `[tool:pytest]` in `setup.cfg`, or a `conftest.py` from the file up to the current folder) or when the file imports pytest or has test functions of its own. Otherwise it is Django's runner when there is a `manage.py` in the current folder, and unittest when there is not. A project with pytest-django goes to pytest, and Django's leak detectors work there too.
|
|
112
|
+
|
|
113
|
+
| Runner | Command | Reproduce |
|
|
114
|
+
| --- | --- | --- |
|
|
115
|
+
| pytest | `python -m pytest -p deflake.pytest_plugin FILE` | `DEFLAKE_SEED=N python -m pytest -p deflake.pytest_plugin FILE` |
|
|
116
|
+
| Django | `python manage.py test --testrunner deflake.django.Runner FILE` | `DEFLAKE_SEED=N python manage.py test --testrunner deflake.django.Runner FILE` |
|
|
117
|
+
| unittest | `python -m deflake.unittest_runner FILE` | `DEFLAKE_SEED=N python -m deflake.unittest_runner FILE` |
|
|
118
|
+
|
|
119
|
+
Run the `reproduce:` command as it is: `DEFLAKE_SEED` alone does nothing in a plain test run.
|
|
120
|
+
|
|
121
|
+
#### pytest
|
|
122
|
+
|
|
123
|
+
deflake runs pytest with the project's own configuration, and takes the order, the selection and the results into its own hands:
|
|
124
|
+
|
|
125
|
+
- Plugins that reorder or rerun tests are switched off in these processes: pytest-randomly, pytest-random-order, pytest-xdist, pytest-rerunfailures and flaky. Their options still parse, so `addopts = -n auto --reruns 3` keeps working; a `flaky` marker is accepted under `--strict-markers`.
|
|
126
|
+
- So are options that select, reorder or stop the tests on their own: `--lf`, `--ff`, `--nf`, `--sw`, `-x`, `--maxfail`, `--pdb` and `--trace`.
|
|
127
|
+
- pytest's cache goes to a folder of deflake's own, so `--lf` in your next run still remembers your last run, not deflake's.
|
|
128
|
+
- A test marked `flaky` is reported as such: its failures are hidden by reruns in the project's own runs, and a marked test that never fails here may not need the marker any more.
|
|
129
|
+
- pytest-timeout stays on and its timer is not reported as a leak. Its `signal` method fails the test as usual. Its `thread` method ends the whole process, so deflake reports the test as interrupted, sees no leaks after it and never takes such a run as evidence of a cause. deflake's own `--timeout` limits the whole test process.
|
|
130
|
+
|
|
131
|
+
#### Django
|
|
132
|
+
|
|
133
|
+
`deflake.django.Runner` is the project's own `TEST_RUNNER` with deflake on top, so what a custom runner sets up still happens. The file is taken as the module it is on the project's `sys.path`, as `manage.py` sets it up, so apps kept in a folder such as `apps/` are found. Django runs `TestCase`s first, then other database tests, then the rest; the seed shuffles each kind and keeps that order. `--parallel`, `--shuffle`, `--reverse`, `--failfast`, `--pdb` and `--debug-sql` are switched off, as deflake runs tests in its own order, one process per run.
|
|
134
|
+
|
|
135
|
+
#### unittest
|
|
136
|
+
|
|
137
|
+
`deflake.unittest_runner` imports the file as `python -m unittest FILE` does, as a module named after its path from the current folder, and runs its tests with unittest's own runner, `load_tests` included.
|
|
138
|
+
|
|
139
|
+
Every test process runs in a session of its own, without a terminal, as in CI: its input is empty and its output is not a TTY.
|
|
140
|
+
|
|
141
|
+
Processes left in its group are killed when the run ends. On Linux and macOS, processes started from the test interpreter through Python's `subprocess.Popen`, `os.fork` and `os.posix_spawn` are also registered and stopped even if they create another session, together with the processes of the group such a process leads, as a server started with `start_new_session=True` and its workers. Cleanup sends signals through Linux pidfds or macOS audit tokens, so PID reuse cannot redirect a signal to a replacement process. Group members are captured before the test process is reaped and signalled individually through these identities. A system without the required kernel API is rejected before tests start; cleanup never falls back to numeric PID or group signals. Forked Python children inherit this tracking until they exec another program. Programs that daemonize further after exec, out of that group, must be stopped by the test's teardown.
|
|
142
|
+
|
|
143
|
+
### Parallel runs and databases
|
|
144
|
+
|
|
145
|
+
With `--jobs` several processes run at once. Each gets `DEFLAKE_JOB` set to `1`, `2`, `3`... With Django's runner and with pytest-django every process gets test databases of its own: `test_myapp_1`, `test_myapp_2`... (an in-memory SQLite database is the process's own anyway). Tests that share another outside resource, such as a port, can do the same:
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
# conftest.py
|
|
149
|
+
import os
|
|
150
|
+
|
|
151
|
+
DATABASE = f"myapp_test{os.environ.get('DEFLAKE_JOB', '')}"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The runs alone use `--jobs` and `DEFLAKE_JOB` as well. Bisect runs one process at a time, without `DEFLAKE_JOB`.
|
|
155
|
+
|
|
156
|
+
### Leak detectors
|
|
157
|
+
|
|
158
|
+
| Detector | Reports |
|
|
159
|
+
| --- | --- |
|
|
160
|
+
| Environment | Variables in `os.environ` set, changed or removed |
|
|
161
|
+
| Working directory | `os.chdir` not undone |
|
|
162
|
+
| Clock | freezegun or time-machine left on (`start()` without `stop()`) |
|
|
163
|
+
| Module globals | Global variables of the project's own modules assigned or changed in place; modules of installed packages are left out. Data is compared by value: numbers, strings, dates, paths and the like, lists, tuples, dicts and sets of them, objects of the standard library such as a counter, and objects of the project's own classes by their attributes, dataclasses, namespaces and pydantic models included. A global of more than 50,000 parts is compared as `pickle` dumps it, by the same rules, but with the order of its keys and of the items of its sets. An object of an installed library, such as a client, a logger, a QuerySet, a Faker or a lazy proxy, counts by identity: replacing it is a leak, what it sets up inside on its own use is not, and it is never asked to compute itself. Random generators and decimal contexts count by identity too, as they change on their own use. Python's own files count as installed even when its environment is inside the project folder, as with pixi or conda. Cached properties and methods an object of the project puts on itself on first use are left out |
|
|
164
|
+
| Patches | `mock.patch(...).start()` without `stop()` |
|
|
165
|
+
| sys | `sys.path`, the standard streams and the recursion limit |
|
|
166
|
+
| Logging | The level, handlers, `propagate` or `disabled` of a logger, new loggers included, and `logging.disable()` |
|
|
167
|
+
| Warning filters | `warnings.simplefilter` or `filterwarnings` outside of `catch_warnings` |
|
|
168
|
+
| Threads | Threads a test started and left running; numbers in their names, as in `Thread-3` or `ThreadPoolExecutor-0_1`, are left out, as they depend on the tests before |
|
|
169
|
+
| Signals | Signal handlers replaced and not put back |
|
|
170
|
+
| Process settings | `socket.setdefaulttimeout` and the `decimal` context |
|
|
171
|
+
|
|
172
|
+
With Django configured, by its test runner or by pytest-django, these are watched too:
|
|
173
|
+
|
|
174
|
+
| Detector | Reports |
|
|
175
|
+
| --- | --- |
|
|
176
|
+
| Settings | A setting assigned and not put back, and `override_settings(...).enable()` without `disable()` |
|
|
177
|
+
| Caches | New keys in a local memory cache (`LocMemCache`); other backends live outside of the test process |
|
|
178
|
+
| Activated | `translation.activate`, `timezone.activate` and `set_urlconf` without a matching deactivate |
|
|
179
|
+
| Receivers | Signal receivers of Django's own signals connected or disconnected and not put back; a file storage connecting itself on first use is Django's own doing |
|
|
180
|
+
| Rows | Rows created or deleted outside of a rolled back transaction, in every database, as a plain `unittest.TestCase` or a `TransactionTestCase` that flushes the data of migrations does. A test that may not use the database, such as a `SimpleTestCase`, is not checked. Only test databases are counted, never the project's own, and a mirror (`TEST['MIRROR']`) is counted with the database it mirrors |
|
|
181
|
+
|
|
182
|
+
A detector is any object with two methods: `snapshot()` returns the current state, or `None` when it does not apply, and `diff(before, after)` returns one message per leak. Add your own in `conftest.py`; detectors run only in the test processes deflake starts, so your regular test runs are not affected:
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
from deflake import leaks
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class FeatureFlagLeaks:
|
|
189
|
+
def snapshot(self):
|
|
190
|
+
return sorted(FeatureFlags.enabled())
|
|
191
|
+
|
|
192
|
+
def diff(self, before, after):
|
|
193
|
+
return [f"feature flag {flag} left on" for flag in sorted(set(after) - set(before))]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
leaks.detectors.append(FeatureFlagLeaks())
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The Module globals detector compares every global of the project before and after each test, and the Rows detector counts the rows of every table; if one of them is too slow for your project, leave it out, in `conftest.py` or in the test settings of a Django project:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from deflake import leaks
|
|
203
|
+
from deflake.django.leaks import Rows
|
|
204
|
+
from deflake.leaks.modules import Modules
|
|
205
|
+
|
|
206
|
+
leaks.excluded += [Modules, Rows]
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Proving a leak is the cause
|
|
210
|
+
|
|
211
|
+
A leak next to a failure is a suspect, not a proof. For each culprit deflake takes every piece of state it leaked and runs an experiment in fresh processes with the same seed and order: two runs as they are, two that put the state back after the culprit, and two that put it back and set it again. A leak is confirmed as the cause only when the victim fails the same way in the runs as they are and when set again, and passes both times when the state is put back. Skips, timeouts, interrupted processes, errors outside of tests and other failures never confirm a cause.
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
test_victim: failed 5 of 10 runs (50%)
|
|
215
|
+
verdict: fails only after other tests (alone: failed 0 of 10 runs (0%))
|
|
216
|
+
culprit: test_pollutes
|
|
217
|
+
environment cause: FEATURE_FLAG from test_order.py::test_pollutes: confirmed (seed 40331)
|
|
218
|
+
baseline: same_failure, same_failure; repair: passed, passed; reapply: same_failure, same_failure
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
This works for:
|
|
222
|
+
|
|
223
|
+
- environment variables and plain values of the project's modules (`None`, bools, numbers, strings and bytes);
|
|
224
|
+
- `mock.patch` left started, set again with the culprit's own mock, and the keys a `mock.patch.dict` patched;
|
|
225
|
+
- time freezegun left frozen;
|
|
226
|
+
- the working directory, `sys.path`, the recursion limit, the socket timeout, the `decimal` context, signal handlers, warning filters and logging;
|
|
227
|
+
- under Django, the active language, time zone and URLconf, settings (an `override_settings` left enabled is tried setting by setting), local memory caches and signal receivers.
|
|
228
|
+
|
|
229
|
+
Other leaks of a culprit, such as a list changed in place, rows of a database, a thread or time-machine, are shown under the victim as `not checked as the cause`: they are next to the failure, but not proven to cause it. Only what the report shows as the culprit's own leaks is tried, up to twenty per culprit; a change of a wider fixture set up with it is not the culprit's. A confirmed cause holds for the order tested; putting a value back cannot undo copies of it already made into other objects or services.
|
|
230
|
+
|
|
231
|
+
The JSON report adds `causes` to each test, with `culprit`, `key`, `seed`, `baseline`, `repair`, `reapply`, `detector` (`environment`, `module`, `patch`, `clock`, `process` or `django`) and `confirmed`, and `culprit_leaks` with every leak of the culprits and whether it was `checked` and `confirmed`. Values of variables are never included.
|
|
232
|
+
|
|
233
|
+
### JSON report
|
|
234
|
+
|
|
235
|
+
`--format json` prints one array for the whole run, with one object per test that ran or leaked (a test that skipped itself in every run and leaked nothing is left out, as in the text report) with `file`, `name`, `id` (the pytest node id), `verdict` (`stable`, `failed`, `fails_on_its_own`, `order_dependent`, `random`, `not_run` for a selected test that never started, or `not_checked` for a file of a folder that could not run, with its error under `errors`; `failed` means a test could not run alone, or that every run looking for its culprits broke before it), `runs` and `isolated_runs` counts (`isolated_runs` is `null` when the test never failed or could not run alone) (`total`, `failures`, `failure_rate` and `skipped`: `total` leaves out the runs where the test skipped itself), `errors` with their seeds, `leaks` (with `runs`, counting the runs where the test skipped itself too) and `culprits` (`ids`, `names`, `command`, `alone` when the test fails with no test before it, `group_wide`, `others` and `other_names` for further sets of tests that break it just as well on their own, `reproduced` when the failure came back with the same order but not with fewer tests, and `broken` when every run broke before the test) and `marked_flaky`. The format may change until 1.0.
|
|
236
|
+
|
|
237
|
+
## Tested on real projects
|
|
238
|
+
|
|
239
|
+
[Recorded cases](https://github.com/SlyDeath/python-deflake/blob/main/docs/real_world_cases.json) from SecureNative Python, Runway model-sdk and humanize, at pinned revisions: deflake finds the known flaky test and its cause before the published fix, and the test passes after it.
|
|
240
|
+
|
|
241
|
+
## Limitations
|
|
242
|
+
|
|
243
|
+
- deflake runs one file at a time, even when given a folder. A test broken by a test in another file shows up as failing only when the two files run together, which deflake does not do yet.
|
|
244
|
+
- A folder is searched by file name, not by your `python_files` setting.
|
|
245
|
+
- Leak detection sees what changed in the test process. It cannot see changes made by other processes, such as a browser or a server started by a test.
|
|
246
|
+
|
|
247
|
+
## Development
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
uv sync
|
|
251
|
+
uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## License
|
|
255
|
+
|
|
256
|
+
MIT, see [LICENSE.txt](https://github.com/SlyDeath/python-deflake/blob/main/LICENSE.txt).
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# deflake
|
|
4
|
+
|
|
5
|
+
**Find flaky tests in pytest, unittest and Django and explain why they fail.**
|
|
6
|
+
|
|
7
|
+
Repeat · Isolate · Bisect · Detect leaked state
|
|
8
|
+
|
|
9
|
+
[](https://pypi.org/project/deflake-py/)
|
|
10
|
+
[](https://github.com/SlyDeath/python-deflake/actions/workflows/ci.yml)
|
|
11
|
+
[](https://www.python.org)
|
|
12
|
+
[](LICENSE.txt)
|
|
13
|
+
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
A flaky test fails once in a while, someone adds a rerun, and the cause stays.
|
|
17
|
+
deflake runs a suspicious test many times and answers three questions:
|
|
18
|
+
|
|
19
|
+
1. **How often does it fail**, and with which errors and seeds?
|
|
20
|
+
2. **Does it fail on its own**, or only after some other test? If the latter, which one?
|
|
21
|
+
3. **What global state does a test leave behind**: environment variables, the clock, module globals, active `mock.patch`es, Django settings, caches and database rows?
|
|
22
|
+
4. **Is that leak what breaks the other test?** deflake puts it back and sets it again to prove it.
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install deflake-py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
or add `deflake-py` to the development dependencies of your project. The package is `deflake-py`; the command and the module are `deflake`. It needs Python 3.11 or newer on Linux (with pidfd, kernel 5.3 or newer) or macOS, and has no dependencies of its own. It works with pytest 8 or newer, unittest and Django 5.2 or newer.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Run deflake from the root of your project, where you run your tests (next to `manage.py` in a Django project):
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
deflake tests/test_orders.py --runs 50
|
|
38
|
+
deflake tests/test_orders.py::test_total --runs 50 --jobs 4
|
|
39
|
+
deflake tests/test_orders.py:42 --runs 50
|
|
40
|
+
deflake shop/tests.py::OrderTest --runs 20
|
|
41
|
+
deflake tests --runs 5
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
test_pollutes: failed 0 of 10 runs (0%)
|
|
46
|
+
leaks: os.environ['FEATURE_FLAG'] was set (10 of 10 runs)
|
|
47
|
+
leaks: test_order.STATE was changed (10 of 10 runs)
|
|
48
|
+
test_victim: failed 5 of 10 runs (50%)
|
|
49
|
+
verdict: fails only after other tests (alone: failed 0 of 10 runs (0%))
|
|
50
|
+
culprit: test_pollutes
|
|
51
|
+
reproduce: DEFLAKE_SEED=40331 python -m pytest -p deflake.pytest_plugin test_order.py
|
|
52
|
+
seeds 40331, 48172, 30602, 40073, 7643:
|
|
53
|
+
assert not True
|
|
54
|
+
at test_order.py:12
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
What happens:
|
|
58
|
+
|
|
59
|
+
1. **The whole file runs `--runs` times** (10 by default), every time in a fresh process with a new seed. The seed shuffles the classes and the tests inside them, but keeps every module and class together, so their fixtures, `setUpClass` and `setUpModule` still run once (pytest itself still regroups tests by the parameters of module and class fixtures, as it does without deflake). Before every test `random` is seeded with it, and so are Faker, factory_boy and NumPy when the tests use them. Running the whole file, not just one test, is what makes order-dependent failures show up. A test that takes its process down (`os._exit`, a crash, a signal) counts as failed in that run.
|
|
60
|
+
2. **Every test that failed runs again on its own**, as many times. The verdict is *fails on its own* if it still fails, which points at time, randomness or a race inside the test, or *fails only after other tests* if it does not. When it cannot run alone at all, as with errors outside of tests, the verdict is *failed, could not run it alone*.
|
|
61
|
+
3. **Order-dependent failures are bisected** with one of the failing seeds, down to a smallest set of tests that break the victim. deflake has the test runner run just those tests in the recorded order (pytest may regroup them by parametrized fixtures, as above) and watches only the victim, so other failing tests do not get in the way. A failure counts only when it is the failure seen in the runs of the file (the same error apart from numbers, or the same kind of error at the same line of the test file) and happens twice in a row, the set found must break the victim again at the end, and the victim must pass every time without each culprit, so a test that fails at random gets no culprits: the verdict is then *fails at random, not after particular tests*, with the command that runs that seed. When the failure came back with the same order but not with fewer tests, the report says so, with the command of that seed. When other tests break the victim just as well on their own, they are named too, after `or, just as well:`. Up to three failing seeds are tried. A culprit that two other tests of its class or module replace, because the state comes from a fixture they share, is named as such. A victim stopped by `--timeout` does not count as failing in a bisect, and a run that never got to the victim, because another test hung, is tried again.
|
|
62
|
+
4. **Every test is checked for leaks**, tests that skip themselves included: a snapshot of global state before the test, its fixtures and `setUp` included, and after their teardown. For a Django test that is before its transaction, fixtures and settings are set up and after they are rolled back, and for an `IsolatedAsyncioTestCase` after its event loop is closed. What a fixture wider than a test (`scope="module"`, `"class"`, `"package"` or `"session"`) or a `setUpClass` or `setUpModule` sets up and never puts back shows up under *errors outside of tests*, as `fixture NAME (module scope), outside its tests: ...`, not as a leak of the test that happened to run first.
|
|
63
|
+
|
|
64
|
+
A process that fails while every test in it passed, a file that cannot be collected or imported, or a failing `setUpClass`, is reported as *errors outside of tests*, with the end of its output. A run that breaks before its first test does not stop the others; only when no run gets to a test does deflake give up with exit code 2 and show how the test process ended.
|
|
65
|
+
|
|
66
|
+
`TEST_PATH::NAME` runs the whole file but reports only that test: all its parameters for a parametrized test, every test of a class for a class name. A unittest or Django test is named `Class.test_name`, and `::test_name` or `::Class` works too. `TEST_PATH:LINE` does the same for the test whose body holds the line, every test of the innermost class around the line, or else the test declared closest above it. A selected test that never started in any run makes deflake say so and exit with 1; the JSON report lists it with the verdict `not_run`.
|
|
67
|
+
|
|
68
|
+
`TEST_PATH` may be a folder: deflake then finds every `test_*.py`, `*_test.py` and `tests.py` in it and in its subfolders, leaving out virtual environments, `site-packages` and `node_modules`, and checks them one after another, each as if it were given on its own. The report of each file starts with `== FILE (3 of 40)`; with `--format json` that line goes to stderr as `deflake: checking FILE (3 of 40)`. A file that cannot run is reported and the others are still checked. At the end deflake names the files with failing or leaking tests and those it could not check, also after Ctrl-C, for the files checked so far. When the reader of the report stops early, as `head` does, deflake stops too, with exit code 141 if files were left unchecked.
|
|
69
|
+
|
|
70
|
+
### Options
|
|
71
|
+
|
|
72
|
+
| Option | Default | |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| `-n`, `--runs N` | 10 | How many times to run each file |
|
|
75
|
+
| `-j`, `--jobs N` | 1 | How many test processes to run at once |
|
|
76
|
+
| `-t`, `--timeout SECONDS` | none | Stop a test process that runs longer; the test it was running counts as failed |
|
|
77
|
+
| `-f`, `--format FORMAT` | `text` | `text` or `json` |
|
|
78
|
+
| `-r`, `--runner RUNNER` | `auto` | `auto`, `pytest`, `django` or `unittest` |
|
|
79
|
+
|
|
80
|
+
Exit code: 0 when every test passed and nothing leaked (or every test skipped itself), 1 otherwise, 2 on usage errors, when the file has no tests or a filter of the project leaves them all out, or when the tests could not run (deflake then shows their output). For a folder it is the highest code of its files, and 2 when it has no test files. When interrupted with Ctrl-C, deflake interrupts its test processes, gives their tests up to 5 seconds to tear down what they set up, such as test databases, kills what is left and exits with 130 (a second Ctrl-C kills them at once), so a shell loop around it stops too. SIGTERM and SIGHUP, as from a CI timeout or a closed terminal, stop the test processes as well, with exit code 143 or 129.
|
|
81
|
+
|
|
82
|
+
### Test runners
|
|
83
|
+
|
|
84
|
+
With `--runner auto` deflake picks pytest when the project configures it (`pytest.toml`, `pytest.ini`, `[tool.pytest]` or `[tool.pytest.ini_options]` in `pyproject.toml`, `[pytest]` in `tox.ini`, `[tool:pytest]` in `setup.cfg`, or a `conftest.py` from the file up to the current folder) or when the file imports pytest or has test functions of its own. Otherwise it is Django's runner when there is a `manage.py` in the current folder, and unittest when there is not. A project with pytest-django goes to pytest, and Django's leak detectors work there too.
|
|
85
|
+
|
|
86
|
+
| Runner | Command | Reproduce |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| pytest | `python -m pytest -p deflake.pytest_plugin FILE` | `DEFLAKE_SEED=N python -m pytest -p deflake.pytest_plugin FILE` |
|
|
89
|
+
| Django | `python manage.py test --testrunner deflake.django.Runner FILE` | `DEFLAKE_SEED=N python manage.py test --testrunner deflake.django.Runner FILE` |
|
|
90
|
+
| unittest | `python -m deflake.unittest_runner FILE` | `DEFLAKE_SEED=N python -m deflake.unittest_runner FILE` |
|
|
91
|
+
|
|
92
|
+
Run the `reproduce:` command as it is: `DEFLAKE_SEED` alone does nothing in a plain test run.
|
|
93
|
+
|
|
94
|
+
#### pytest
|
|
95
|
+
|
|
96
|
+
deflake runs pytest with the project's own configuration, and takes the order, the selection and the results into its own hands:
|
|
97
|
+
|
|
98
|
+
- Plugins that reorder or rerun tests are switched off in these processes: pytest-randomly, pytest-random-order, pytest-xdist, pytest-rerunfailures and flaky. Their options still parse, so `addopts = -n auto --reruns 3` keeps working; a `flaky` marker is accepted under `--strict-markers`.
|
|
99
|
+
- So are options that select, reorder or stop the tests on their own: `--lf`, `--ff`, `--nf`, `--sw`, `-x`, `--maxfail`, `--pdb` and `--trace`.
|
|
100
|
+
- pytest's cache goes to a folder of deflake's own, so `--lf` in your next run still remembers your last run, not deflake's.
|
|
101
|
+
- A test marked `flaky` is reported as such: its failures are hidden by reruns in the project's own runs, and a marked test that never fails here may not need the marker any more.
|
|
102
|
+
- pytest-timeout stays on and its timer is not reported as a leak. Its `signal` method fails the test as usual. Its `thread` method ends the whole process, so deflake reports the test as interrupted, sees no leaks after it and never takes such a run as evidence of a cause. deflake's own `--timeout` limits the whole test process.
|
|
103
|
+
|
|
104
|
+
#### Django
|
|
105
|
+
|
|
106
|
+
`deflake.django.Runner` is the project's own `TEST_RUNNER` with deflake on top, so what a custom runner sets up still happens. The file is taken as the module it is on the project's `sys.path`, as `manage.py` sets it up, so apps kept in a folder such as `apps/` are found. Django runs `TestCase`s first, then other database tests, then the rest; the seed shuffles each kind and keeps that order. `--parallel`, `--shuffle`, `--reverse`, `--failfast`, `--pdb` and `--debug-sql` are switched off, as deflake runs tests in its own order, one process per run.
|
|
107
|
+
|
|
108
|
+
#### unittest
|
|
109
|
+
|
|
110
|
+
`deflake.unittest_runner` imports the file as `python -m unittest FILE` does, as a module named after its path from the current folder, and runs its tests with unittest's own runner, `load_tests` included.
|
|
111
|
+
|
|
112
|
+
Every test process runs in a session of its own, without a terminal, as in CI: its input is empty and its output is not a TTY.
|
|
113
|
+
|
|
114
|
+
Processes left in its group are killed when the run ends. On Linux and macOS, processes started from the test interpreter through Python's `subprocess.Popen`, `os.fork` and `os.posix_spawn` are also registered and stopped even if they create another session, together with the processes of the group such a process leads, as a server started with `start_new_session=True` and its workers. Cleanup sends signals through Linux pidfds or macOS audit tokens, so PID reuse cannot redirect a signal to a replacement process. Group members are captured before the test process is reaped and signalled individually through these identities. A system without the required kernel API is rejected before tests start; cleanup never falls back to numeric PID or group signals. Forked Python children inherit this tracking until they exec another program. Programs that daemonize further after exec, out of that group, must be stopped by the test's teardown.
|
|
115
|
+
|
|
116
|
+
### Parallel runs and databases
|
|
117
|
+
|
|
118
|
+
With `--jobs` several processes run at once. Each gets `DEFLAKE_JOB` set to `1`, `2`, `3`... With Django's runner and with pytest-django every process gets test databases of its own: `test_myapp_1`, `test_myapp_2`... (an in-memory SQLite database is the process's own anyway). Tests that share another outside resource, such as a port, can do the same:
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# conftest.py
|
|
122
|
+
import os
|
|
123
|
+
|
|
124
|
+
DATABASE = f"myapp_test{os.environ.get('DEFLAKE_JOB', '')}"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The runs alone use `--jobs` and `DEFLAKE_JOB` as well. Bisect runs one process at a time, without `DEFLAKE_JOB`.
|
|
128
|
+
|
|
129
|
+
### Leak detectors
|
|
130
|
+
|
|
131
|
+
| Detector | Reports |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| Environment | Variables in `os.environ` set, changed or removed |
|
|
134
|
+
| Working directory | `os.chdir` not undone |
|
|
135
|
+
| Clock | freezegun or time-machine left on (`start()` without `stop()`) |
|
|
136
|
+
| Module globals | Global variables of the project's own modules assigned or changed in place; modules of installed packages are left out. Data is compared by value: numbers, strings, dates, paths and the like, lists, tuples, dicts and sets of them, objects of the standard library such as a counter, and objects of the project's own classes by their attributes, dataclasses, namespaces and pydantic models included. A global of more than 50,000 parts is compared as `pickle` dumps it, by the same rules, but with the order of its keys and of the items of its sets. An object of an installed library, such as a client, a logger, a QuerySet, a Faker or a lazy proxy, counts by identity: replacing it is a leak, what it sets up inside on its own use is not, and it is never asked to compute itself. Random generators and decimal contexts count by identity too, as they change on their own use. Python's own files count as installed even when its environment is inside the project folder, as with pixi or conda. Cached properties and methods an object of the project puts on itself on first use are left out |
|
|
137
|
+
| Patches | `mock.patch(...).start()` without `stop()` |
|
|
138
|
+
| sys | `sys.path`, the standard streams and the recursion limit |
|
|
139
|
+
| Logging | The level, handlers, `propagate` or `disabled` of a logger, new loggers included, and `logging.disable()` |
|
|
140
|
+
| Warning filters | `warnings.simplefilter` or `filterwarnings` outside of `catch_warnings` |
|
|
141
|
+
| Threads | Threads a test started and left running; numbers in their names, as in `Thread-3` or `ThreadPoolExecutor-0_1`, are left out, as they depend on the tests before |
|
|
142
|
+
| Signals | Signal handlers replaced and not put back |
|
|
143
|
+
| Process settings | `socket.setdefaulttimeout` and the `decimal` context |
|
|
144
|
+
|
|
145
|
+
With Django configured, by its test runner or by pytest-django, these are watched too:
|
|
146
|
+
|
|
147
|
+
| Detector | Reports |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| Settings | A setting assigned and not put back, and `override_settings(...).enable()` without `disable()` |
|
|
150
|
+
| Caches | New keys in a local memory cache (`LocMemCache`); other backends live outside of the test process |
|
|
151
|
+
| Activated | `translation.activate`, `timezone.activate` and `set_urlconf` without a matching deactivate |
|
|
152
|
+
| Receivers | Signal receivers of Django's own signals connected or disconnected and not put back; a file storage connecting itself on first use is Django's own doing |
|
|
153
|
+
| Rows | Rows created or deleted outside of a rolled back transaction, in every database, as a plain `unittest.TestCase` or a `TransactionTestCase` that flushes the data of migrations does. A test that may not use the database, such as a `SimpleTestCase`, is not checked. Only test databases are counted, never the project's own, and a mirror (`TEST['MIRROR']`) is counted with the database it mirrors |
|
|
154
|
+
|
|
155
|
+
A detector is any object with two methods: `snapshot()` returns the current state, or `None` when it does not apply, and `diff(before, after)` returns one message per leak. Add your own in `conftest.py`; detectors run only in the test processes deflake starts, so your regular test runs are not affected:
|
|
156
|
+
|
|
157
|
+
```python
|
|
158
|
+
from deflake import leaks
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class FeatureFlagLeaks:
|
|
162
|
+
def snapshot(self):
|
|
163
|
+
return sorted(FeatureFlags.enabled())
|
|
164
|
+
|
|
165
|
+
def diff(self, before, after):
|
|
166
|
+
return [f"feature flag {flag} left on" for flag in sorted(set(after) - set(before))]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
leaks.detectors.append(FeatureFlagLeaks())
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The Module globals detector compares every global of the project before and after each test, and the Rows detector counts the rows of every table; if one of them is too slow for your project, leave it out, in `conftest.py` or in the test settings of a Django project:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
from deflake import leaks
|
|
176
|
+
from deflake.django.leaks import Rows
|
|
177
|
+
from deflake.leaks.modules import Modules
|
|
178
|
+
|
|
179
|
+
leaks.excluded += [Modules, Rows]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Proving a leak is the cause
|
|
183
|
+
|
|
184
|
+
A leak next to a failure is a suspect, not a proof. For each culprit deflake takes every piece of state it leaked and runs an experiment in fresh processes with the same seed and order: two runs as they are, two that put the state back after the culprit, and two that put it back and set it again. A leak is confirmed as the cause only when the victim fails the same way in the runs as they are and when set again, and passes both times when the state is put back. Skips, timeouts, interrupted processes, errors outside of tests and other failures never confirm a cause.
|
|
185
|
+
|
|
186
|
+
```
|
|
187
|
+
test_victim: failed 5 of 10 runs (50%)
|
|
188
|
+
verdict: fails only after other tests (alone: failed 0 of 10 runs (0%))
|
|
189
|
+
culprit: test_pollutes
|
|
190
|
+
environment cause: FEATURE_FLAG from test_order.py::test_pollutes: confirmed (seed 40331)
|
|
191
|
+
baseline: same_failure, same_failure; repair: passed, passed; reapply: same_failure, same_failure
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This works for:
|
|
195
|
+
|
|
196
|
+
- environment variables and plain values of the project's modules (`None`, bools, numbers, strings and bytes);
|
|
197
|
+
- `mock.patch` left started, set again with the culprit's own mock, and the keys a `mock.patch.dict` patched;
|
|
198
|
+
- time freezegun left frozen;
|
|
199
|
+
- the working directory, `sys.path`, the recursion limit, the socket timeout, the `decimal` context, signal handlers, warning filters and logging;
|
|
200
|
+
- under Django, the active language, time zone and URLconf, settings (an `override_settings` left enabled is tried setting by setting), local memory caches and signal receivers.
|
|
201
|
+
|
|
202
|
+
Other leaks of a culprit, such as a list changed in place, rows of a database, a thread or time-machine, are shown under the victim as `not checked as the cause`: they are next to the failure, but not proven to cause it. Only what the report shows as the culprit's own leaks is tried, up to twenty per culprit; a change of a wider fixture set up with it is not the culprit's. A confirmed cause holds for the order tested; putting a value back cannot undo copies of it already made into other objects or services.
|
|
203
|
+
|
|
204
|
+
The JSON report adds `causes` to each test, with `culprit`, `key`, `seed`, `baseline`, `repair`, `reapply`, `detector` (`environment`, `module`, `patch`, `clock`, `process` or `django`) and `confirmed`, and `culprit_leaks` with every leak of the culprits and whether it was `checked` and `confirmed`. Values of variables are never included.
|
|
205
|
+
|
|
206
|
+
### JSON report
|
|
207
|
+
|
|
208
|
+
`--format json` prints one array for the whole run, with one object per test that ran or leaked (a test that skipped itself in every run and leaked nothing is left out, as in the text report) with `file`, `name`, `id` (the pytest node id), `verdict` (`stable`, `failed`, `fails_on_its_own`, `order_dependent`, `random`, `not_run` for a selected test that never started, or `not_checked` for a file of a folder that could not run, with its error under `errors`; `failed` means a test could not run alone, or that every run looking for its culprits broke before it), `runs` and `isolated_runs` counts (`isolated_runs` is `null` when the test never failed or could not run alone) (`total`, `failures`, `failure_rate` and `skipped`: `total` leaves out the runs where the test skipped itself), `errors` with their seeds, `leaks` (with `runs`, counting the runs where the test skipped itself too) and `culprits` (`ids`, `names`, `command`, `alone` when the test fails with no test before it, `group_wide`, `others` and `other_names` for further sets of tests that break it just as well on their own, `reproduced` when the failure came back with the same order but not with fewer tests, and `broken` when every run broke before the test) and `marked_flaky`. The format may change until 1.0.
|
|
209
|
+
|
|
210
|
+
## Tested on real projects
|
|
211
|
+
|
|
212
|
+
[Recorded cases](https://github.com/SlyDeath/python-deflake/blob/main/docs/real_world_cases.json) from SecureNative Python, Runway model-sdk and humanize, at pinned revisions: deflake finds the known flaky test and its cause before the published fix, and the test passes after it.
|
|
213
|
+
|
|
214
|
+
## Limitations
|
|
215
|
+
|
|
216
|
+
- deflake runs one file at a time, even when given a folder. A test broken by a test in another file shows up as failing only when the two files run together, which deflake does not do yet.
|
|
217
|
+
- A folder is searched by file name, not by your `python_files` setting.
|
|
218
|
+
- Leak detection sees what changed in the test process. It cannot see changes made by other processes, such as a browser or a server started by a test.
|
|
219
|
+
|
|
220
|
+
## Development
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
uv sync
|
|
224
|
+
uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## License
|
|
228
|
+
|
|
229
|
+
MIT, see [LICENSE.txt](https://github.com/SlyDeath/python-deflake/blob/main/LICENSE.txt).
|