-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_bytecode_cache.py
More file actions
100 lines (76 loc) · 3.61 KB
/
Copy pathtest_bytecode_cache.py
File metadata and controls
100 lines (76 loc) · 3.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
"""Regression: a stale bytecode cache must not survive a mutation run.
This trap was not theoretical. It was measured on the bundled example: after
a completed run the source file was byte-identical to the original, yet
importing it produced the *mutant's* answer (17.97 instead of 29.63), because
``__pycache__`` still held bytecode compiled from the mutant.
Two properties are at stake:
* during the run, a cached mutant makes a killed mutation look survived
(or the reverse) -- the verdicts are simply wrong;
* after the run, the tree is left poisoned for everyone else.
Both come from the same cause: CPython validates a ``.pyc`` against the
source's *(mtime, size)*, and a mutation that preserves length, written and
restored inside one clock second, keeps that pair unchanged.
"""
import os
import subprocess
import sys
import pytest
from mutant.cli import main
PY = sys.executable
SOURCE = "def total():\n return 20 + 1\n"
MUTATED_SIZE_IS_EQUAL = "def total():\n return 20 - 1\n"
@pytest.fixture()
def project(tmp_path, monkeypatch):
(tmp_path / "src").mkdir()
(tmp_path / "src" / "calc.py").write_text(SOURCE, encoding="utf-8", newline="")
(tmp_path / "check.py").write_text(
"import sys\n"
"sys.path.insert(0, 'src')\n"
"import calc\n"
"assert calc.total() == 21, calc.total()\n",
encoding="utf-8",
newline="",
)
monkeypatch.chdir(tmp_path)
return tmp_path
def _import_total(project) -> int:
"""Import the module in a fresh interpreter that MAY write and use __pycache__.
The environment is scrubbed on purpose. When this suite itself runs *under*
mutant, it inherits ``PYTHONDONTWRITEBYTECODE=1`` — the very fix under test —
and the warm cache this trap depends on would silently never be created.
A test that cannot set up its own precondition is not a test, it is a
green light with no bulb behind it.
"""
env = {key: value for key, value in os.environ.items()
if key not in ("PYTHONDONTWRITEBYTECODE", "PYTHONPYCACHEPREFIX")}
completed = subprocess.run(
[PY, "-c", "import sys; sys.path.insert(0, 'src'); import calc; print(calc.total())"],
cwd=project,
capture_output=True,
text=True,
check=True,
env=env,
)
return int(completed.stdout.strip())
def test_the_mutation_really_keeps_the_file_length(tmp_path):
"""The premise of this whole test: the sizes must match, or the trap is not set."""
assert len(SOURCE) == len(MUTATED_SIZE_IS_EQUAL)
def test_tree_is_not_poisoned_by_bytecode_after_a_run(project):
# Warm the cache the way a normal developer would, before mutant ever runs.
assert _import_total(project) == 21
assert list(project.glob("src/__pycache__/*.pyc")), "the trap needs a warm cache"
main(["run", "--tests", f'"{PY}" check.py', "--src", "src/**/*.py", "--quiet"])
assert project.joinpath("src", "calc.py").read_text(encoding="utf-8") == SOURCE
assert _import_total(project) == 21, (
"the source is original but the import disagrees — stale bytecode from a "
"mutant is still being served"
)
def test_verdicts_are_not_decided_by_a_stale_cache(project, capsys):
assert _import_total(project) == 21
exit_code = main(["run", "--tests", f'"{PY}" check.py', "--src", "src/**/*.py", "--quiet"])
out = capsys.readouterr().out
# `20 + 1` is fully pinned by `== 21`, so every mutation of it must die.
# If a cached original were served instead of the mutant, they would all
# "survive" — the exact false negative this guards.
assert "SURVIVED" not in out, out
assert exit_code == 0