diff --git a/pyproject.toml b/pyproject.toml index b6d96c7..8a9fd8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,7 @@ disallow_untyped_decorators = true no_implicit_optional = true no_implicit_reexport = true strict_equality = true -strict_concatenate = true +extra_checks = true [tool.ruff] src = ["src", "tests"] diff --git a/requirements-lint.txt b/requirements-lint.txt index e073daa..0a4c45c 100644 --- a/requirements-lint.txt +++ b/requirements-lint.txt @@ -1,3 +1,4 @@ ruff black mypy +pytest>=7.4.1 # Added pluggy typing diff --git a/src/pytest_litter/plugin/plugin.py b/src/pytest_litter/plugin/plugin.py index 3733af4..6563bbd 100644 --- a/src/pytest_litter/plugin/plugin.py +++ b/src/pytest_litter/plugin/plugin.py @@ -20,9 +20,25 @@ TreeSnapshotFactory, ) +PARSER_GROUP = "pytest-litter" +RUN_CHECK_OPTION_DEST_NAME = "check_litter" + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add options to pytest (pytest hook function).""" + group = parser.getgroup(PARSER_GROUP) + group.addoption( + "--check-litter", + action="store_true", + dest=RUN_CHECK_OPTION_DEST_NAME, + help="Fail if tests create/remove files.", + ) + def pytest_configure(config: pytest.Config) -> None: """Configure pytest-litter plugin (pytest hook function).""" + if not config.getoption(RUN_CHECK_OPTION_DEST_NAME): + return ignore_specs: list[IgnoreSpec] = [] basetemp: Optional[str] = config.getoption("basetemp", None) if basetemp is not None: @@ -42,11 +58,12 @@ def pytest_configure(config: pytest.Config) -> None: config.stash[COMPARATOR_KEY] = SnapshotComparator(config=litter_config) -@pytest.hookimpl(hookwrapper=True) # type: ignore[misc] +@pytest.hookimpl(hookwrapper=True) def pytest_runtest_call(item: pytest.Item): # type: ignore[no-untyped-def] yield - run_snapshot_comparison( - test_name=item.name, - config=item.config, - mismatch_cb=raise_test_error_from_comparison, - ) + if item.config.getoption(RUN_CHECK_OPTION_DEST_NAME): + run_snapshot_comparison( + test_name=item.name, + config=item.config, + mismatch_cb=raise_test_error_from_comparison, + ) diff --git a/tests/system_test/system_test.sh b/tests/system_test/system_test.sh index 513ae1c..2e3b897 100755 --- a/tests/system_test/system_test.sh +++ b/tests/system_test/system_test.sh @@ -13,7 +13,7 @@ python3 -m venv "${VENV}" "${VENV}/bin/python3" -m pip install --quiet --editable "${ROOT_DIR}" || exit 1 echo "Executing system test..." -pytest -p pytest-litter --basetemp=tmp || exit 1 +pytest -p pytest-litter --basetemp=tmp --check-litter || exit 1 popd &> /dev/null || exit 1 diff --git a/tests/test_plugin.py b/tests/test_plugin.py index c02e23d..a89c534 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,8 +1,9 @@ """Tests for the plugin module.""" +import itertools from collections.abc import Iterable from pathlib import Path from typing import TYPE_CHECKING, Optional -from unittest.mock import Mock, call +from unittest.mock import ANY, Mock, call import pytest @@ -137,8 +138,26 @@ def fake_cb(tc: str, comparison: snapshots.SnapshotComparison) -> None: mock_cb.assert_called_once_with(test_name, mock_comparator.compare.return_value) -@pytest.mark.parametrize("basetemp", [None, Path("tmp")]) -def test_pytest_configure(monkeypatch: "MonkeyPatch", basetemp: Optional[Path]) -> None: +def test_pytest_addoption() -> None: + mock_parser = Mock(spec=pytest.Parser) + plugin.pytest_addoption(mock_parser) + assert mock_parser.mock_calls == [ + call.getgroup("pytest-litter"), + call.getgroup().addoption( + "--check-litter", + action="store_true", + dest=plugin.RUN_CHECK_OPTION_DEST_NAME, + help=ANY, + ), + ] + + +@pytest.mark.parametrize( + "run_check, basetemp", itertools.product([True, False], [None, Path("tmp")]) +) +def test_pytest_configure( + monkeypatch: "MonkeyPatch", run_check: bool, basetemp: Optional[Path] +) -> None: mock_snapshot_factory_cls = Mock(spec=snapshots.TreeSnapshotFactory) monkeypatch.setattr( "pytest_litter.plugin.plugin.TreeSnapshotFactory", @@ -153,7 +172,7 @@ def test_pytest_configure(monkeypatch: "MonkeyPatch", basetemp: Optional[Path]) spec=pytest.Config, rootpath=Path("rootpath"), stash={}, - getoption=Mock(spec=pytest.Config.getoption, return_value=basetemp), + getoption=Mock(spec=pytest.Config.getoption, side_effect=[run_check, basetemp]), ) mock_litter_config_cls = Mock(spec=snapshots.LitterConfig) monkeypatch.setattr( @@ -179,40 +198,55 @@ def test_pytest_configure(monkeypatch: "MonkeyPatch", basetemp: Optional[Path]) plugin.pytest_configure(mock_config) - mock_config.getoption.assert_called_once_with("basetemp", None) - mock_litter_config_cls.assert_called_once_with( - ignore_specs=expected_ignore_specs, - ) - mock_snapshot_factory_cls.assert_called_once_with( - config=mock_litter_config_cls.return_value - ) - mock_snapshot_factory_cls.return_value.create_snapshot.assert_called_once_with( - root=mock_config.rootpath - ) - assert ( - mock_config.stash[utils.SNAPSHOT_FACTORY_KEY] - is mock_snapshot_factory_cls.return_value - ) - assert ( - mock_config.stash[utils.SNAPSHOT_KEY] - is mock_snapshot_factory_cls.return_value.create_snapshot.return_value - ) - assert mock_config.stash[utils.COMPARATOR_KEY] is mock_comparator_cls.return_value + expected_getoption_calls = [ + call(plugin.RUN_CHECK_OPTION_DEST_NAME), + ] + if run_check: + expected_getoption_calls.append(call("basetemp", None)) + assert mock_config.getoption.mock_calls == expected_getoption_calls - if basetemp is not None: - mock_dir_ignore_spec.assert_has_calls( - [call(directory=mock_config.rootpath / basetemp)] + if run_check: + mock_litter_config_cls.assert_called_once_with( + ignore_specs=expected_ignore_specs, + ) + mock_snapshot_factory_cls.assert_called_once_with( + config=mock_litter_config_cls.return_value + ) + mock_snapshot_factory_cls.return_value.create_snapshot.assert_called_once_with( + root=mock_config.rootpath + ) + assert ( + mock_config.stash[utils.SNAPSHOT_FACTORY_KEY] + is mock_snapshot_factory_cls.return_value + ) + assert ( + mock_config.stash[utils.SNAPSHOT_KEY] + is mock_snapshot_factory_cls.return_value.create_snapshot.return_value + ) + assert ( + mock_config.stash[utils.COMPARATOR_KEY] is mock_comparator_cls.return_value + ) + + if basetemp is not None: + mock_dir_ignore_spec.assert_has_calls( + [call(directory=mock_config.rootpath / basetemp)] + ) + else: + mock_dir_ignore_spec.assert_not_called() + mock_name_ignore_spec.assert_has_calls( + [ + call(name="__pycache__"), + call(name="venv"), + call(name=".venv"), + call(name=".pytest_cache"), + ] ) else: - mock_dir_ignore_spec.assert_not_called() - mock_name_ignore_spec.assert_has_calls( - [ - call(name="__pycache__"), - call(name="venv"), - call(name=".venv"), - call(name=".pytest_cache"), - ] - ) + mock_litter_config_cls.assert_not_called() + mock_snapshot_factory_cls.assert_not_called() + assert utils.SNAPSHOT_FACTORY_KEY not in mock_config.stash + assert utils.SNAPSHOT_KEY not in mock_config.stash + assert utils.COMPARATOR_KEY not in mock_config.stash def test_pytest_runtest_call(monkeypatch: "MonkeyPatch") -> None: @@ -240,11 +274,40 @@ def test_pytest_runtest_call(monkeypatch: "MonkeyPatch") -> None: ) +def test_pytest_runtest_call__no_check_litter(monkeypatch: "MonkeyPatch") -> None: + mock_run_comparison = Mock(spec=utils.run_snapshot_comparison) + monkeypatch.setattr( + "pytest_litter.plugin.plugin.run_snapshot_comparison", + mock_run_comparison, + ) + mock_item = Mock(spec=pytest.Item) + mock_item.config = Mock(spec=pytest.Config) + mock_item.config.getoption.return_value = False + + # The list comprehension is just to get past the yield statement# + _ = list(plugin.pytest_runtest_call(mock_item)) + + mock_item.config.getoption.assert_called_once_with( + plugin.RUN_CHECK_OPTION_DEST_NAME + ) + mock_run_comparison.assert_not_called() + + @pytest.mark.integration_test -def test_plugin_with_pytester(pytester: pytest.Pytester) -> None: +def test_plugin_with_pytester__check_litter(pytester: pytest.Pytester) -> None: # pytester uses basetemp internally, so the case without basetemp # cannot be tested using pytester. pytester.copy_example("pytest.ini") pytester.copy_example("suite_tests.py") - result: pytest.RunResult = pytester.runpytest() + result: pytest.RunResult = pytester.runpytest("--check-litter") result.assert_outcomes(passed=2, xfailed=2) + + +@pytest.mark.integration_test +def test_plugin_with_pytester__no_check_litter(pytester: pytest.Pytester) -> None: + # pytester uses basetemp internally, so the case without basetemp + # cannot be tested using pytester. + pytester.copy_example("pytest.ini") + pytester.copy_example("suite_tests.py") + result: pytest.RunResult = pytester.runpytest() + result.assert_outcomes(passed=2, xpassed=2)