diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fad3408..531d644 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', 'pypy-3.8'] + python-version: ['3.9', '3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v4 diff --git a/examples/conftest.py b/examples/conftest.py index c0297fe..6b83115 100644 --- a/examples/conftest.py +++ b/examples/conftest.py @@ -1,3 +1 @@ """Conftest for examples.""" - -# No special fixtures needed for basic load testing examples diff --git a/examples/test_load_example.py b/examples/test_load_example.py index 5849188..83c41ab 100644 --- a/examples/test_load_example.py +++ b/examples/test_load_example.py @@ -9,11 +9,12 @@ TEST_CODE: ```python -result = pytester.runpytest('--load-test', '-n', '2', '-v') +result = run_with_timeout(pytester, '--load-test', '-n', '2', '-v') +# Should complete successfully with interrupt +assert result.ret == pytest.ExitCode.INTERRUPTED result.stdout.fnmatch_lines([ '*Interrupted: Test session completed*', ]) -assert result.ret == pytest.ExitCode.INTERRUPTED ``` """ @@ -21,7 +22,8 @@ from pytest_load_testing import stop_load_testing, weight -# Simple counter without shared state +# Simple counter - note this won't work across xdist workers +# but is sufficient for demonstration purposes _iteration_count = 0 @@ -31,8 +33,8 @@ def iteration_counter(request): global _iteration_count _iteration_count += 1 - # Stop after 100 total test executions - if _iteration_count >= 100: + # Stop after 50 total test executions (reduced for faster testing) + if _iteration_count >= 50: stop_load_testing(request, "Test session completed") @@ -58,3 +60,6 @@ def test_admin_operations(iteration_counter): def test_health_check(iteration_counter): """1% of requests - simulates health check.""" assert True + + +# Made with Bob diff --git a/pyproject.toml b/pyproject.toml index d59a785..2fdebaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ classifiers = [ "License :: OSI Approved :: MIT License", ] dependencies = [ - "pytest>=6.2.0", + "pytest>=8.4.2", "pytest-xdist>=2.0.0", ] [project.urls] diff --git a/tests/conftest.py b/tests/conftest.py index 6bb0df9..906c0af 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,67 @@ import pytest +from _pytest.pytester import Pytester pytest_plugins = "pytester" +PYTESTER_TIMEOUT = 10 + @pytest.fixture(autouse=True) def wide_terminal(monkeypatch): """Make all pytester tests use wider terminal for better output visibility.""" monkeypatch.setenv("COLUMNS", "120") + + +@pytest.fixture +def run_with_timeout(): + """Fixture that provides a helper to run pytester with timeout + + Returns: + A callable that runs pytester.runpytest_subprocess with timeout handling + """ + + def _run(pytester, *args, timeout=PYTESTER_TIMEOUT, **kwargs): + """Run pytester with timeout and proper error handling. + + Args: + pytester: The pytester fixture + *args: Arguments to pass to runpytest_subprocess + timeout: Timeout in seconds (default: PYTESTER_TIMEOUT) + **kwargs: Keyword arguments to pass to runpytest_subprocess + + Returns: + The result object from runpytest_subprocess + + Raises: + pytest.fail: If the subprocess times out + """ + try: + return pytester.runpytest_subprocess(*args, timeout=timeout, **kwargs) + except Pytester.TimeoutExpired as e: + # Read stdout/stderr from pytester path + stdout_path = pytester.path.joinpath("stdout") + stderr_path = pytester.path.joinpath("stderr") + + stdout = stdout_path.read_text() if stdout_path.exists() else "" + stderr = stderr_path.read_text() if stderr_path.exists() else "" + + # Truncate long output (first 100 + last 100 lines if > 200 lines) + def truncate_output(text: str) -> str: + lines = text.splitlines() + if len(lines) > 200: + first_100 = "\n".join(lines[:100]) + last_100 = "\n".join(lines[-100:]) + return f"{first_100}\n\n... ({len(lines) - 200} lines omitted) ...\n\n{last_100}" + return text + + stdout = truncate_output(stdout) + stderr = truncate_output(stderr) + + pytest.fail( + f"Test timed out after {timeout} seconds - load test did not complete\n" + f"Error: {e}\n" + f"STDOUT:\n{stdout}\n" + f"STDERR:\n{stderr}" + ) + + return _run diff --git a/tests/test_examples.py b/tests/test_examples.py index 7d110b4..8cf3f4b 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -118,13 +118,13 @@ def test_missing_code(pytester): return test_missing_code # Create test function that executes the test code - def test_from_code(pytester): + def test_from_code(pytester, run_with_timeout): # Copy example and conftest pytester.copy_example(f"examples/{file_name}") pytester.copy_example("examples/conftest.py") # Execute the test code - exec(test_code, {"pytester": pytester, "pytest": pytest}) + exec(test_code, {"pytester": pytester, "pytest": pytest, "run_with_timeout": run_with_timeout}) test_from_code.__name__ = test_name test_from_code.__doc__ = f"Auto-generated test for {file_name}" diff --git a/tests/test_failure_tracking.py b/tests/test_failure_tracking.py index d57ed58..9e585cd 100644 --- a/tests/test_failure_tracking.py +++ b/tests/test_failure_tracking.py @@ -211,7 +211,7 @@ def test_dummy(): assert "test_example.py::test_one" in scheduler.last_success_time -def test_failure_tracking_integration(pytester): +def test_failure_tracking_integration(pytester, run_with_timeout): """Integration test: verify failure tracking works during actual test execution.""" # Create a conftest that will capture the scheduler for inspection pytester.makeconftest(""" @@ -246,9 +246,11 @@ def pytest_sessionfinish(session): @pytest.fixture(scope="session") def run_count(tmp_path_factory): - counts_file = tmp_path_factory.mktemp("data") / "counts.json" + root_tmp_dir = tmp_path_factory.getbasetemp().parent + counts_file = root_tmp_dir / "counts.json" lock_file = counts_file.with_suffix('.lock') + # Initialize file with a lock with FileLock(str(lock_file)): if not counts_file.exists(): counts_file.write_text(json.dumps({'failing': 0, 'passing': 0})) @@ -256,10 +258,11 @@ def run_count(tmp_path_factory): class Counter: def __init__(self, file_path, lock_path): self.file = file_path - self.lock = lock_path + self.lock_path = lock_path def increment(self, key): - with FileLock(str(self.lock)): + # Create new FileLock for each operation (xdist-safe) + with FileLock(str(self.lock_path)): data = json.loads(self.file.read_text()) data[key] += 1 self.file.write_text(json.dumps(data)) @@ -286,7 +289,7 @@ def test_passing(request, run_count): assert True """) - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") # Verify tests ran and load testing stopped result.stdout.fnmatch_lines( diff --git a/tests/test_fixture_lifecycle.py b/tests/test_fixture_lifecycle.py index 447c223..69378e9 100644 --- a/tests/test_fixture_lifecycle.py +++ b/tests/test_fixture_lifecycle.py @@ -5,7 +5,7 @@ import pytest -def test_request_fixture_available(pytester): +def test_request_fixture_available(pytester, run_with_timeout): """ Test that the request fixture is available on each test execution. @@ -22,18 +22,17 @@ def test_request_fixture_available(pytester): def test_request_fixture_available(request): global count + count += 1 + if count >= 5: + stop_load_testing(request, "Request fixture verified") + # The request fixture should be available assert request is not None assert hasattr(request, 'node') assert hasattr(request, 'session') - - if count < 5: - count += 1 - else: - stop_load_testing(request, "Request fixture verified") """) - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v", timeout=5) # Should stop gracefully result.stdout.fnmatch_lines( @@ -44,7 +43,7 @@ def test_request_fixture_available(request): assert result.ret == pytest.ExitCode.INTERRUPTED -def test_all_fixture_scopes(pytester): +def test_all_fixture_scopes(pytester, run_with_timeout): """Test that fixtures with different scopes provide correct data.""" pytester.makepyfile(""" import pytest @@ -55,9 +54,11 @@ def test_all_fixture_scopes(pytester): @pytest.fixture(scope="session") def fixture_counts(tmp_path_factory): - counts_file = tmp_path_factory.mktemp("data") / "fixture_counts.json" + root_tmp_dir = tmp_path_factory.getbasetemp().parent + counts_file = root_tmp_dir / "fixture_counts.json" lock_file = counts_file.with_suffix('.lock') + # Initialize file with a lock with FileLock(str(lock_file)): if not counts_file.exists(): counts_file.write_text(json.dumps({ @@ -69,17 +70,19 @@ def fixture_counts(tmp_path_factory): class Counter: def __init__(self, file_path, lock_path): self.file = file_path - self.lock = lock_path + self.lock_path = lock_path def increment(self, key): - with FileLock(str(self.lock)): + # Create new FileLock for each operation (xdist-safe) + with FileLock(str(self.lock_path)): data = json.loads(self.file.read_text()) data[key] += 1 self.file.write_text(json.dumps(data)) return data[key] def read(self): - with FileLock(str(self.lock)): + # Create new FileLock for each operation (xdist-safe) + with FileLock(str(self.lock_path)): return json.loads(self.file.read_text()) counter = Counter(counts_file, lock_file) @@ -90,9 +93,11 @@ def read(self): @pytest.fixture(scope="session") def execution_counts(tmp_path_factory): - counts_file = tmp_path_factory.mktemp("data") / "execution_counts.json" + root_tmp_dir = tmp_path_factory.getbasetemp().parent + counts_file = root_tmp_dir / "execution_counts.json" lock_file = counts_file.with_suffix('.lock') + # Initialize file with a lock with FileLock(str(lock_file)): if not counts_file.exists(): counts_file.write_text(json.dumps({"test1": 0, "test2": 0})) @@ -100,10 +105,11 @@ def execution_counts(tmp_path_factory): class Counter: def __init__(self, file_path, lock_path): self.file = file_path - self.lock = lock_path + self.lock_path = lock_path def increment(self, key): - with FileLock(str(self.lock)): + # Create new FileLock for each operation (xdist-safe) + with FileLock(str(self.lock_path)): data = json.loads(self.file.read_text()) data[key] += 1 self.file.write_text(json.dumps(data)) @@ -175,7 +181,7 @@ def test_function_fixture_reinitialization(request, function_fixture, execution_ stop_load_testing(request, f"All fixture scopes verified (test1: {execution_count_test1}, test2: {execution_count_test2})") """) - result = pytester.runpytest("--load-test", "-n", "1", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "1", "-v") # Should stop gracefully result.stdout.fnmatch_lines( diff --git a/tests/test_plugin.py b/tests/test_plugin.py new file mode 100644 index 0000000..42db9fd --- /dev/null +++ b/tests/test_plugin.py @@ -0,0 +1,295 @@ +"""Tests for pytest-xdist-load-testing plugin.""" + +import pytest + +from pytest_load_testing.api import weight +from pytest_load_testing.scheduler import LoadTestScheduler + + +def test_weight_decorator(): + """Test that the weight decorator properly sets the weight marker.""" + + @weight(5) + def test_func(): + pass + + # The weight decorator now returns a pytest marker + assert hasattr(test_func, "pytestmark") + # Check that it's a weight marker with value 5 + markers = test_func.pytestmark if isinstance(test_func.pytestmark, list) else [test_func.pytestmark] # type: ignore[attr-defined] + weight_markers = [m for m in markers if m.name == "weight"] + assert len(weight_markers) == 1 + assert weight_markers[0].args == (5,) + + +def test_weight_decorator_default(): + """Test that tests without weight decorator have default weight of 1.""" + + def test_func(): + pass + + assert not hasattr(test_func, "__pytest_weight__") + + +def test_weighted_tests_basic(pytester, run_with_timeout): + """Test basic weighted test execution.""" + pytester.makepyfile(""" + from pytest_load_testing import weight, stop_load_testing + + @weight(1) + def test_low_weight(): + assert True + + @weight(10) + def test_high_weight(): + assert True + + def test_default_weight(request): + stop_load_testing(request, "Test complete") + assert True + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Verify load testing stopped gracefully + result.stdout.fnmatch_lines( + [ + "*Interrupted: Test complete*", + ] + ) + # Load test mode exits with code 2 (interrupted) when stopped gracefully + assert result.ret == pytest.ExitCode.INTERRUPTED + + +def test_scheduler_add_node(pytester): + """Test adding nodes to the scheduler.""" + pytester.makepyfile(""" + def test_dummy(): + pass + """) + config = pytester.parseconfigure("--tx", "2*popen") + + scheduler = LoadTestScheduler(config, None) + + # Create a mock node + class MockNode: + shutting_down = False + + def send_runtest_some(self, indices): + pass + + def shutdown(self): + pass + + node = MockNode() + scheduler.add_node(node) # pyright: ignore + + assert node in scheduler.node2pending + assert scheduler.node2pending[node] == [] + + +def test_scheduler_with_weights(pytester): + """Test that scheduler respects test weights from runtime data.""" + pytester.makepyfile(""" + def test_dummy(): + pass + """) + config = pytester.parseconfigure("--tx", "2*popen") + + scheduler = LoadTestScheduler(config, None) + + # Set up collection with nodeids + scheduler.collection = ["test1.py::test_a", "test2.py::test_b"] + + # Initialize weights (starts with default weight of 1) + scheduler._initialize_weights() + assert scheduler.weights == [1, 1] + + # Simulate weight updates as tests run and report their weights + scheduler.update_weight("test1.py::test_a", 5) + scheduler.update_weight("test2.py::test_b", 10) + + # Weights should be updated + assert len(scheduler.weights) == 2 + assert scheduler.weights == [5, 10] + + +def test_plugin_integration(pytester, run_with_timeout): + """Test full plugin integration with pytest.""" + pytester.makepyfile(""" + from pytest_load_testing import weight, stop_load_testing + + @weight(2) + def test_weighted(): + assert True + + def test_normal(request): + stop_load_testing(request, "Test complete") + assert True + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Verify load testing stopped gracefully + result.stdout.fnmatch_lines( + [ + "*Interrupted: Test complete*", + ] + ) + # Load test mode exits with code 2 (interrupted) when stopped gracefully + assert result.ret == pytest.ExitCode.INTERRUPTED + + +def test_multiple_weighted_tests(pytester, run_with_timeout): + """Test multiple tests with different weights.""" + pytester.makepyfile(""" + from pytest_load_testing import weight, stop_load_testing + + @weight(1) + def test_weight_1(): + assert True + + @weight(5) + def test_weight_5(): + assert True + + @weight(10) + def test_weight_10(): + assert True + + def test_no_weight(request): + stop_load_testing(request, "Test complete") + assert True + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Load test mode exits with code 2 (interrupted) when stopped gracefully + assert result.ret == pytest.ExitCode.INTERRUPTED + + +def test_weight_with_parametrize(pytester, run_with_timeout): + """Test that weight decorator works with parametrized tests.""" + pytester.makepyfile(""" + import pytest + from pytest_load_testing import weight, stop_load_testing + + @weight(3) + @pytest.mark.parametrize("value", [1, 2, 3]) + def test_parametrized(value, request): + assert value > 0 + if value == 3: + stop_load_testing(request, "Test complete") + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Verify load testing stopped gracefully + result.stdout.fnmatch_lines( + [ + "*Interrupted: Test complete*", + ] + ) + # Load test mode exits with code 2 (interrupted) when stopped gracefully + assert result.ret == pytest.ExitCode.INTERRUPTED + + +def test_scheduler_remove_node(pytester): + """Test removing nodes from the scheduler.""" + pytester.makepyfile(""" + def test_dummy(): + pass + """) + config = pytester.parseconfigure("--tx", "2*popen") + + scheduler = LoadTestScheduler(config, None) + + # Create a mock node + class MockNode: + shutting_down = False + + def send_runtest_some(self, indices): + pass + + def shutdown(self): + pass + + node = MockNode() + scheduler.add_node(node) # type: ignore[arg-type] + assert node in scheduler.node2pending + + scheduler.remove_node(node) + assert node not in scheduler.node2pending + + +def test_load_test_option(pytester, run_with_timeout): + """Test the --load-test command line option.""" + pytester.makepyfile(""" + from pytest_load_testing import stop_load_testing + + def test_simple(request): + stop_load_testing(request, "Test complete") + assert True + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Load test mode exits with code 2 (interrupted) when stopped gracefully + assert result.ret == pytest.ExitCode.INTERRUPTED + + +def test_help_message(pytester): + """Test that help message includes load testing options.""" + result = pytester.runpytest("--help") + result.stdout.fnmatch_lines( + [ + "*xdist-load-testing:*", + "*--load-test*Enable load testing mode*", + ] + ) + + +def test_stop_load_testing_does_not_fail_test(pytester, run_with_timeout): + """Test that stop_load_testing does not mark the test as failed.""" + pytester.makepyfile(""" + from pytest_load_testing import stop_load_testing + + def test_graceful_stop(request): + # This test should PASS, not fail + stop_load_testing(request, "Graceful stop requested") + assert True # This assertion should still pass + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Test should pass, not fail + result.stdout.fnmatch_lines( + [ + "*PASSED*test_graceful_stop*", + ] + ) + # Session is interrupted (exit code 2) but test passed + assert result.ret == pytest.ExitCode.INTERRUPTED + # Verify the interruption message + result.stdout.fnmatch_lines( + [ + "*Interrupted: Graceful stop requested*", + ] + ) + + +def test_stop_load_testing_with_session(pytester, run_with_timeout): + """Test that stop_load_testing works with session.shouldstop.""" + pytester.makepyfile(""" + from pytest_load_testing import stop_load_testing + + def test_first(): + assert True + + def test_stop(request): + stop_load_testing(request, "Stopping after this test") + assert True + """) + + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") + # Verify it stopped gracefully + assert result.ret == pytest.ExitCode.INTERRUPTED + result.stdout.fnmatch_lines( + [ + "*Interrupted: Stopping after this test*", + ] + ) diff --git a/tests/test_single_module_validation.py b/tests/test_single_module_validation.py index cb3c771..6676ca3 100644 --- a/tests/test_single_module_validation.py +++ b/tests/test_single_module_validation.py @@ -1,8 +1,9 @@ """Tests for single module validation in load testing mode.""" + import pytest -def test_single_module_accepted(pytester): +def test_single_module_accepted(pytester, run_with_timeout): """Test that load testing works with a single module.""" pytester.makepyfile(""" from pytest_load_testing import stop_load_testing @@ -15,64 +16,78 @@ def test_two(): assert True """) - result = pytester.runpytest('--load-test', '-n', '2') + result = run_with_timeout(pytester, "--load-test", "-n", "2") # Should run successfully and stop gracefully - result.stdout.fnmatch_lines([ - '*Interrupted: Test complete*', - ]) + result.stdout.fnmatch_lines( + [ + "*Interrupted: Test complete*", + ] + ) assert result.ret == pytest.ExitCode.INTERRUPTED -def test_multiple_modules_rejected(pytester): +def test_multiple_modules_rejected(pytester, run_with_timeout): """Test that load testing rejects multiple modules.""" # Create two test files - pytester.makepyfile(test_module1=""" + pytester.makepyfile( + test_module1=""" def test_in_module1(): assert True - """) + """ + ) - pytester.makepyfile(test_module2=""" + pytester.makepyfile( + test_module2=""" def test_in_module2(): assert True - """) + """ + ) # Try to run both modules - result = pytester.runpytest('--load-test', '-n', '2', 'test_module1.py', 'test_module2.py') + result = run_with_timeout(pytester, "--load-test", "-n", "2", "test_module1.py", "test_module2.py") # Should fail with error message about multiple modules - result.stdout.fnmatch_lines([ - '*Load testing requires tests from a single module only*', - '*Found tests from 2 different modules*', - ]) + result.stdout.fnmatch_lines( + [ + "*Load testing requires tests from a single module only*", + "*Found tests from 2 different modules*", + ] + ) # Should mention both modules - assert 'test_module1.py' in result.stdout.str() - assert 'test_module2.py' in result.stdout.str() + assert "test_module1.py" in result.stdout.str() + assert "test_module2.py" in result.stdout.str() -def test_multiple_modules_in_directory_rejected(pytester): +def test_multiple_modules_in_directory_rejected(pytester, run_with_timeout): """Test that load testing rejects when discovering multiple modules.""" # Create two test files in the same directory - pytester.makepyfile(test_a=""" + pytester.makepyfile( + test_a=""" def test_a(): assert True - """) + """ + ) - pytester.makepyfile(test_b=""" + pytester.makepyfile( + test_b=""" def test_b(): assert True - """) + """ + ) # Try to run all tests in directory (will discover both) - result = pytester.runpytest('--load-test', '-n', '2') + result = run_with_timeout(pytester, "--load-test", "-n", "2") # Should fail with error message about multiple modules - result.stdout.fnmatch_lines([ - '*Load testing requires tests from a single module only*', - '*Found tests from 2 different modules*', - ]) + result.stdout.fnmatch_lines( + [ + "*Load testing requires tests from a single module only*", + "*Found tests from 2 different modules*", + ] + ) -def test_single_module_with_classes(pytester): +def test_single_module_with_classes(pytester, run_with_timeout): """Test that load testing works with test classes in a single module.""" pytester.makepyfile(""" from pytest_load_testing import stop_load_testing @@ -90,9 +105,11 @@ def test_three(self, request): assert True """) - result = pytester.runpytest('--load-test', '-n', '2') + result = run_with_timeout(pytester, "--load-test", "-n", "2") # Should run successfully - all tests are in the same module - result.stdout.fnmatch_lines([ - '*Interrupted: Test complete*', - ]) + result.stdout.fnmatch_lines( + [ + "*Interrupted: Test complete*", + ] + ) assert result.ret == pytest.ExitCode.INTERRUPTED diff --git a/tests/test_skip_tracking.py b/tests/test_skip_tracking.py index 808cccb..d720b3f 100644 --- a/tests/test_skip_tracking.py +++ b/tests/test_skip_tracking.py @@ -103,7 +103,7 @@ def shutdown(self): assert "skipped" in str(exc_info.value).lower() -def test_conditional_skip_detection(pytester): +def test_conditional_skip_detection(pytester, run_with_timeout): """Test that conditional skips are detected.""" pytester.makepyfile(""" import pytest @@ -118,7 +118,7 @@ def test_normal(request): assert True """) - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") # In load testing mode, tests run multiple times until stopped # Just verify it stopped gracefully @@ -130,7 +130,7 @@ def test_normal(request): ) -def test_skip_during_setup(pytester): +def test_skip_during_setup(pytester, run_with_timeout): """Test that skips during setup phase are handled.""" pytester.makepyfile(""" import pytest @@ -148,7 +148,7 @@ def test_normal(request): assert True """) - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") # In load testing mode, tests run multiple times until stopped # Just verify it stopped gracefully @@ -210,7 +210,7 @@ def test_dummy(): assert scheduler.weights[0] == 0 -def test_all_tests_eventually_skip(pytester): +def test_all_tests_eventually_skip(pytester, run_with_timeout): """Test that scheduler stops when all tests start skipping after iterations.""" pytester.makepyfile(""" import pytest @@ -254,7 +254,7 @@ def test_eventually_skips_b(test_counters): assert True """) - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v", timeout=2) # Should detect all tests are skipped and exit properly assert result.ret == pytest.ExitCode.INTERRUPTED @@ -269,7 +269,7 @@ def test_eventually_skips_b(test_counters): ) -def test_all_tests_marked_skip_integration(pytester): +def test_all_tests_marked_skip_integration(pytester, run_with_timeout): """Test integration when all tests are marked with @pytest.mark.skip.""" pytester.makepyfile(""" import pytest @@ -287,7 +287,7 @@ def test_skipped_three(): assert False, "Should not run" """) - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") # Should detect all tests are skipped # Exit code 0 is acceptable when tests complete naturally diff --git a/tests/test_weight_distribution.py b/tests/test_weight_distribution.py index e39f055..f939db6 100644 --- a/tests/test_weight_distribution.py +++ b/tests/test_weight_distribution.py @@ -3,7 +3,7 @@ import pytest -def test_weight_distribution_verification(pytester): +def test_weight_distribution_verification(pytester, run_with_timeout): """ Test that weights actually affect test distribution. @@ -68,7 +68,7 @@ def test_high_weight(test_counts): """) # Run the load test - result = pytester.runpytest("--load-test", "-n", "2", "-v") + result = run_with_timeout(pytester, "--load-test", "-n", "2", "-v") # Should stop gracefully assert result.ret == pytest.ExitCode.INTERRUPTED diff --git a/tox.ini b/tox.ini index ec0a36e..e654cdd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,15 @@ # For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -envlist = py38,py39,py310,py311,py312,pypy3,lint +envlist = py{39,310,311,312}-pytest{80,latest},pypy3,lint [testenv] deps = - pytest>=6.2.0 + pytest70: pytest>=7.0.0,<8.0.0 + pytest80: pytest>=8.0.0,<9.0.0 + pytest-latest: pytest pytest-xdist filelock -commands = pytest -n auto {posargs:tests} +commands = pytest -vv -n auto -p no:terminalprogress {posargs:tests} [testenv:lint] skip_install = true