Skip to content

Commit

Permalink
- revamped pytest isolation module, now running the test suite
Browse files Browse the repository at this point in the history
  a lot more cleanly while isolating the collection phase to
  work around any in-memory state pollution;
  • Loading branch information
jaltmayerpizzorno committed May 14, 2024
1 parent 406d98c commit 81fb131
Show file tree
Hide file tree
Showing 2 changed files with 121 additions and 72 deletions.
98 changes: 50 additions & 48 deletions src/slipcover/isolate.py
Original file line number Diff line number Diff line change
@@ -1,66 +1,68 @@
import os
import pytest
import sys
from pathlib import Path


class IsolatePlugin:
"""Pytest plugin to isolate test collection, so that if a test's collection pollutes the in-memory
state, it doesn't affect the execution of other tests."""

def __init__(self):
self._is_child = False
self._test_failed = False
class IsolateItem(pytest.Item):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def pytest_ignore_collect(self, path, config):
if self._is_child:
return True # only one test module per child process
def runtest(self):
assert False, "should never execute"

if not str(path).endswith('.py'):
return False # only fork for test modules, not directories, etc.
def run_forked(self):
# adapted from pytest-forked
import marshal
import pytest_forked as ptf
import _pytest
import py

# FIXME manage stdout/stderr to avoid children clobbering output,
# FIXME properly report executed tests
ihook = self.ihook
ihook.pytest_runtest_logstart(nodeid=self.nodeid, location=self.location)

if (pid := os.fork()):
pid, status = os.waitpid(pid, 0)
if status:
if os.WIFSIGNALED(status):
exitstatus = os.WTERMSIG(status) + 128
else:
exitstatus = os.WEXITSTATUS(status)
else:
exitstatus = 0
def runforked():
module = pytest.Module.from_parent(parent=self.parent, path=self.path)
reports = list()
for it in module.collect():
reports.extend(ptf.forked_run_report(it))
print(reports)
return marshal.dumps([ptf.serialize_report(x) for x in reports])

if exitstatus not in (pytest.ExitCode.OK, pytest.ExitCode.NO_TESTS_COLLECTED):
self._test_failed = True
ff = py.process.ForkedFunc(runforked)
result = ff.waitfinish()

return True
if result.retval is not None:
reports = [_pytest.runner.TestReport(**r) for r in marshal.loads(result.retval)]
else:
self._is_child = True
return False
reports = [ptf.report_process_crash(self, result)]

def pytest_collectreport(self, report):
if self._is_child and report.failed and report.nodeid.endswith('.py'):
self._test_failed = True
for r in reports:
ihook.pytest_runtest_logreport(report=r)

def pytest_runtest_logreport(self, report):
if self._is_child and report.failed:
self._test_failed = True
ihook.pytest_runtest_logfinish(nodeid=self.nodeid, location=self.location)

def pytest_unconfigure(self, config):
if self._is_child:
os._exit(self.get_exit_code())

def get_exit_code(self):
# FIXME this error handling is very simplistic, extend to other cases
return pytest.ExitCode.TESTS_FAILED if self._test_failed else pytest.ExitCode.OK
class IsolateModule(pytest.Module):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def collect(self):
yield IsolateItem.from_parent(parent=self, name="(module)")

if __name__ == "__main__":
plugin = IsolatePlugin()
exitcode = pytest.main(sys.argv[1:] + ['--forked'], plugins=[plugin])
if exitcode in (pytest.ExitCode.OK, pytest.ExitCode.NO_TESTS_COLLECTED):
exitcode = plugin.get_exit_code()

sys.exit(exitcode)
class IsolatePlugin:
"""Pytest plugin to isolate test collection, so that if a test's collection pollutes the in-memory
state, it doesn't affect the execution of other tests."""

@pytest.hookimpl(tryfirst=True)
def pytest_pycollect_makemodule(self, module_path, parent):
return IsolateModule.from_parent(parent, path=module_path)

@pytest.hookimpl(tryfirst=True)
def pytest_runtestloop(self, session):
for item in session.items:
item.run_forked()
return True


if __name__ == "__main__":
sys.exit(pytest.main(sys.argv[1:], plugins=[IsolatePlugin()]))
95 changes: 71 additions & 24 deletions tests/test_isolate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
from pathlib import Path
import json

pytestmark = pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only')


@pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only')
def test_isolate_all_ok(tmp_path):
out = tmp_path / "out.json"
test_file = str(Path('tests') / 'pyt.py')
Expand All @@ -23,7 +24,6 @@ def test_isolate_all_ok(tmp_path):
assert [] == cov['missing_lines']


@pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only')
def test_isolate_nontest_issue(tmp_path):
out = tmp_path / "out.json"
test_file = str(Path('tests') / 'pyt.py')
Expand All @@ -38,8 +38,17 @@ def seq2p(tests_dir, seq):
return tests_dir / f"test_{seq}.py"



FAILURES = {
'assert': 'assert False',
'exception': 'raise RuntimeError("test")',
'kill': 'os.kill(os.getpid(), 9)',
'exit': 'pytest.exit("goodbye")',
'interrupt': 'raise KeyboardInterrupt()'
}

N_TESTS=10
def make_polluted_suite(tests_dir: Path, pollute_fails_collect: bool):
def make_polluted_suite(tests_dir: Path, fail_collect: bool, fail_kind: str):
"""In a suite with 10 tests, test 6 fails; test 3 doesn't fail, but causes 6 to fail."""

for seq in range(N_TESTS):
Expand All @@ -49,16 +58,26 @@ def make_polluted_suite(tests_dir: Path, pollute_fails_collect: bool):
polluter.write_text("import sys\n" + "sys.foobar = True\n" + "def test_foo(): pass")

failing = seq2p(tests_dir, 6)
if pollute_fails_collect:
failing.write_text("import sys\n" + "assert not getattr(sys, 'foobar', False)\n" + "def test_foo(): pass")
else:
failing.write_text("import sys\n" + "def test_foo(): assert not getattr(sys, 'foobar', False)")
failing.write_text(f"""\
import sys
import os
import pytest
def failure():
{FAILURES[fail_kind]}
def test_foo():
if getattr(sys, 'foobar', False):
failure()
{'test_foo()' if fail_collect else ''}
""")

return failing, polluter


def make_failing_suite(tests_dir: Path):
"""In a suite with 10 tests, test 6 fails; test 3 doesn't fail, but causes 6 to fail."""
"""In a suite with 10 tests, test 6 fails."""

for seq in range(N_TESTS):
seq2p(tests_dir, seq).write_text('def test_foo(): pass')
Expand All @@ -67,29 +86,33 @@ def make_failing_suite(tests_dir: Path):
failing.write_text("def test_bar(): assert False")


@pytest.mark.parametrize("pollute_fails_collect", [True, False])
def test_check_suite_fails(tmp_path, monkeypatch, pollute_fails_collect):
@pytest.mark.parametrize("fail_collect", [True, False])
@pytest.mark.parametrize("fail_kind", list(FAILURES.keys() - {'kill'}))
def test_check_suite_fails(tmp_path, monkeypatch, fail_collect, fail_kind):
out = tmp_path / "out.json"

monkeypatch.chdir(tmp_path)
tests_dir = Path('tests')
tests_dir.mkdir()
make_polluted_suite(tests_dir, pollute_fails_collect)
make_polluted_suite(tests_dir, fail_collect, fail_kind)

p = subprocess.run([sys.executable, '-m', 'slipcover', '--json', '--out', str(out),
'-m', 'pytest', tests_dir], check=False)
assert p.returncode == pytest.ExitCode.INTERRUPTED if pollute_fails_collect else pytest.ExitCode.TESTS_FAILED
if fail_collect or fail_kind in ('exit', 'interrupt'):
assert p.returncode == pytest.ExitCode.INTERRUPTED
else:
assert p.returncode == pytest.ExitCode.TESTS_FAILED


@pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only')
@pytest.mark.parametrize("pollute_fails_collect", [True, False])
def test_isolate_polluted(tmp_path, monkeypatch, pollute_fails_collect):
@pytest.mark.parametrize("fail_collect", [True, False])
@pytest.mark.parametrize("fail_kind", list(FAILURES.keys()))
def test_isolate_polluted(tmp_path, monkeypatch, fail_collect, fail_kind):
out = tmp_path / "out.json"

monkeypatch.chdir(tmp_path)
tests_dir = Path('tests')
tests_dir.mkdir()
make_polluted_suite(tests_dir, pollute_fails_collect)
make_polluted_suite(tests_dir, fail_collect, fail_kind)

p = subprocess.run([sys.executable, '-m', 'slipcover', '--json', '--out', str(out), '--isolate',
'-m', 'pytest', tests_dir], check=False)
Expand All @@ -103,14 +126,14 @@ def test_isolate_polluted(tmp_path, monkeypatch, pollute_fails_collect):
assert str(p) in cov['files']


@pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only')
def test_pytest_discover_tests(tmp_path, monkeypatch):
@pytest.mark.parametrize("fail_kind", list(FAILURES.keys()))
def test_pytest_discover_tests(tmp_path, fail_kind, monkeypatch):
out = tmp_path / "out.json"

monkeypatch.chdir(tmp_path)
tests_dir = Path('tests')
tests_dir.mkdir()
make_polluted_suite(tests_dir, pollute_fails_collect=False)
make_polluted_suite(tests_dir, fail_collect=False, fail_kind=fail_kind)

p = subprocess.run([sys.executable, '-m', 'slipcover', '--json', '--out', str(out), '--isolate',
'-m', 'pytest'], check=False) # no tests_dir here
Expand All @@ -123,19 +146,43 @@ def test_pytest_discover_tests(tmp_path, monkeypatch):
p = seq2p(tests_dir, seq)
assert str(p) in cov['files']

@pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only')
@pytest.mark.parametrize("pollute_fails_collect", [True, False])
def test_isolate_failing(tmp_path, monkeypatch, pollute_fails_collect):

@pytest.mark.parametrize("fail_collect", [True, False])
@pytest.mark.parametrize("fail_kind", list(FAILURES.keys()))
def test_isolate_with_failing_test(tmp_path, monkeypatch, fail_collect, fail_kind):
out = tmp_path / "out.json"

monkeypatch.chdir(tmp_path)
tests_dir = Path('tests')
tests_dir.mkdir()
make_polluted_suite(tests_dir, pollute_fails_collect)
make_polluted_suite(tests_dir, fail_collect, fail_kind)

# _unconditionally_ failing test
failing = seq2p(tests_dir, 2)
failing.write_text("def test_bar(): assert False")
failing.write_text(f"""\
import sys
import os
import pytest
def failure():
{FAILURES[fail_kind]}
def test_foo():
failure()
{'test_foo()' if fail_collect else ''}
""")

p = subprocess.run([sys.executable, '-m', 'slipcover', '--json', '--out', str(out),
'-m', 'slipcover.isolate', tests_dir], check=False)
assert p.returncode == pytest.ExitCode.TESTS_FAILED

with out.open() as f:
cov = json.load(f)

for seq in range(N_TESTS):
p = seq2p(tests_dir, seq)

# can't capture coverage if the process gets killed
if not (p == failing and fail_kind == 'kill'):
assert str(p) in cov['files']

0 comments on commit 81fb131

Please sign in to comment.