From 71ee0c3144d6b0ae0ffa2eb8640c0f01f0361211 Mon Sep 17 00:00:00 2001 From: eol Date: Mon, 1 Dec 2025 05:08:51 -0500 Subject: [PATCH 1/2] Base changes to simplify tests and make them more robust --- hooks/post_gen_project.py | 6 +- .../acceptance-scenarios/.gitignore | 0 .../simple_calculation.feature | 15 ---- .../docs/gen_pages.py | 28 ------- .../docs/mkdocs.yaml | 1 - {{cookiecutter.project_slug}}/pyproject.toml | 26 ++++--- .../tests/basic_test.py | 20 +++++ .../tests/conftest.py | 45 +++++++++++ .../tests/scenarios/conftest.py | 74 ------------------- .../steps/simple_calculation_test.py | 65 ---------------- 10 files changed, 86 insertions(+), 194 deletions(-) delete mode 100644 {{cookiecutter.project_slug}}/acceptance-scenarios/.gitignore delete mode 100644 {{cookiecutter.project_slug}}/acceptance-scenarios/simple_calculation.feature create mode 100644 {{cookiecutter.project_slug}}/tests/basic_test.py create mode 100644 {{cookiecutter.project_slug}}/tests/conftest.py delete mode 100644 {{cookiecutter.project_slug}}/tests/scenarios/conftest.py delete mode 100644 {{cookiecutter.project_slug}}/tests/scenarios/steps/simple_calculation_test.py diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 9c5a825..0cf0c17 100644 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -10,15 +10,17 @@ """ import shutil +import os from pathlib import Path REMOVE_PATHS = [ - "acceptance-scenarios", - "tests/scenarios/steps", + "tests/basic_test.py" ] for path in REMOVE_PATHS: p = Path(".") / Path(path) if p and p.exists() and p.is_dir(): shutil.rmtree(p) + elif p and p.exists() and p.is_file(): + os.remove(p) {% endif %} diff --git a/{{cookiecutter.project_slug}}/acceptance-scenarios/.gitignore b/{{cookiecutter.project_slug}}/acceptance-scenarios/.gitignore deleted file mode 100644 index e69de29..0000000 diff --git a/{{cookiecutter.project_slug}}/acceptance-scenarios/simple_calculation.feature b/{{cookiecutter.project_slug}}/acceptance-scenarios/simple_calculation.feature deleted file mode 100644 index 09eadc6..0000000 --- a/{{cookiecutter.project_slug}}/acceptance-scenarios/simple_calculation.feature +++ /dev/null @@ -1,15 +0,0 @@ -Feature: divide - The user should be able to divide two numbers. - - Scenario Outline: Divide 'a' by 'b' - Given I have two numbers and - - When I divide by - - Then I should see - - Examples: - | a | b | output | - | 2.0 | 2.0 | 1.0 | - | 6.0 | 2.0 | 3.0 | - | 1.0 | 2.0 | 0.5 | diff --git a/{{cookiecutter.project_slug}}/docs/gen_pages.py b/{{cookiecutter.project_slug}}/docs/gen_pages.py index 6e21e0b..3e04719 100644 --- a/{{cookiecutter.project_slug}}/docs/gen_pages.py +++ b/{{cookiecutter.project_slug}}/docs/gen_pages.py @@ -19,31 +19,3 @@ ) as f: f.write(r.read()) - -# Injects feature files into the documentation -head_lines = ( - "Feature:", - "Scenario:", - "Scenario Outline:", - "Rule:", - "Example:", - "Background:", -) -ignore_lines = ("@", "#") -features_dir = docs_parent_dir / "acceptance-scenarios" -for feature_path in features_dir.glob("**/*.feature"): - with Path.open(feature_path, "r") as f: - relative_dir = feature_path.parent.relative_to(features_dir) - with mkdocs_gen_files.open( - f"scenarios/{relative_dir}/{feature_path.stem}.md", "w" - ) as gf: - f_line_list = f.readlines() - for line in f_line_list: - if any(line.strip().startswith(hl) for hl in head_lines): - write_line = f"### {line}\n" - elif any(line.strip().startswith(il) for il in ignore_lines): - continue - else: - write_line = f"> {line}" - - gf.write(write_line) diff --git a/{{cookiecutter.project_slug}}/docs/mkdocs.yaml b/{{cookiecutter.project_slug}}/docs/mkdocs.yaml index d86b542..e7f6a3b 100644 --- a/{{cookiecutter.project_slug}}/docs/mkdocs.yaml +++ b/{{cookiecutter.project_slug}}/docs/mkdocs.yaml @@ -43,4 +43,3 @@ nav: - index.md - ... | glob=readme.md - reference.md - - ... | regex=scenarios/.+.md diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index d36a6a2..c030ac5 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -25,16 +25,23 @@ dev = [ "mkdocs-material>=9.6.11", "mkdocstrings[python]>=0.29.1", "pytest>=8.3.5", - "pytest-bdd>=8.1.0", "pytest-cov>=6.1.1", "pytest-html>=4.1.1", "ruff>=0.11.5", "taskipy>=1.14.1", + "hypothesis>=6.148.4", + "cosmic-ray>=8.4.3", ] [tool.setuptools] packages = ["{{cookiecutter.package_name}}"] +[tool.mutmut] +paths_to_mutate = [ "{{cookiecutter.package_name}}/" ] +pytest_add_cli_args_test_selection= [ "tests/" ] +mutate_only_covered_lines=true + + [tool.ruff.lint] ignore = [] select = [ @@ -100,7 +107,6 @@ testpaths = [ python_files = ["*_test.py"] python_functions = ["test_*"] render_collapsed = true -bdd_features_base_dir = "acceptance-scenarios" [tool.coverage.report] exclude_lines = [ @@ -118,13 +124,15 @@ exclude_lines = [ run = "python -m {{cookiecutter.package_name}}.{{cookiecutter.module_name}}" test-report = """\ pytest \ - --cov-config=pyproject.toml \ --doctest-modules \ - --cov-fail-under=90 \ - --cov-report=term-missing \ - --cov-report=html:docs/cov-report \ + --cov-config=pyproject.toml \ + --cov-report html:docs/htmlcov \ + --cov-report term:skip-covered \ + --cov={{cookiecutter.package_name}} \ + --cov-fail-under={{cookiecutter.minimum_coverage}} \ --html=docs/pytest_report.html \ - --self-contained-html\ + --self-contained-html \ + --hypothesis-show-statistics \ """ test = """\ python -c "import subprocess, sys; print('Running Smoke Tests...'); sys.exit(0 if subprocess.run(['pytest', '-m', 'smoke']).returncode in (0,5) else 1)" && \ @@ -135,8 +143,8 @@ task test-report\ ruff-check = "ruff check **/*.py --fix" ruff-format = "ruff format **/*.py" lint = "task ruff-check && task ruff-format" -doc = "mkdocs serve --use-directory-urls -f docs/mkdocs.yaml" -doc-html = "mkdocs build --no-directory-urls -f docs/mkdocs.yaml" +doc-serve = "mkdocs serve --use-directory-urls -f docs/mkdocs.yaml" +doc-report = "mkdocs build --no-directory-urls -f docs/mkdocs.yaml" doc-publish = """mkdocs gh-deploy \ --config-file docs/mkdocs.yaml \ --no-directory-urls \ diff --git a/{{cookiecutter.project_slug}}/tests/basic_test.py b/{{cookiecutter.project_slug}}/tests/basic_test.py new file mode 100644 index 0000000..6adea69 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/basic_test.py @@ -0,0 +1,20 @@ +{%- if cookiecutter.include_examples == "true" -%}from hypothesis import given, example, strategies as st +import math + +from {{cookiecutter.package_name}} import {{cookiecutter.module_name}} as m + + +@example(a=6, b=3) # result = 2 +@example(a=-8, b=2) # result = -4 +@example(a=0, b=5) # zero dividend +@example(a=579, b=9105) # the earlier failing example (float rounding) +@given( + a=st.integers(min_value=-10_000, max_value=10_000), + b=st.integers(min_value=-10_000, max_value=10_000).filter(lambda x: x != 0), +) +def test_divide_inverse(a: int, b: int) -> None: + """Check that multiplication is the inverse of division (within float tolerance).""" + result = m.Calculator.divide(a, b) + + assert math.isclose(result * b, a, rel_tol=1e-12, abs_tol=1e-12) +{% endif %} diff --git a/{{cookiecutter.project_slug}}/tests/conftest.py b/{{cookiecutter.project_slug}}/tests/conftest.py new file mode 100644 index 0000000..f41f906 --- /dev/null +++ b/{{cookiecutter.project_slug}}/tests/conftest.py @@ -0,0 +1,45 @@ +import inspect + +from _pytest.config import Config +from _pytest.nodes import Item + + +def pytest_configure(config: Config) -> None: + """Initialize per-session state for docstring printing. + + Creates a set on the config object used to track which test + node IDs (without parameterization suffixes) have already had + their docstrings printed. + """ + config._printed_docstrings = set() # type: ignore[attr-defined] + + +def pytest_runtest_setup(item: Item) -> None: + """Print a test function's docstring the first time it is encountered. + + The docstring is printed only once per “base” nodeid. For example, + a parametrized test like ``test_func[param]`` will only have its + docstring printed for the first parameterization. Subsequent cases + skip printing. + """ + tr = item.config.pluginmanager.getplugin("terminalreporter") + if not tr: + return + + # strip parameterization suffix: + # "path/to/test.py::test_func[param]" → keep the part before "[" + base_nodeid = item.nodeid.split("[", 1)[0] + + if base_nodeid in item.config._printed_docstrings: # type: ignore[attr-defined] + return + + doc = inspect.getdoc(item.obj) or "" + if not doc.strip(): + item.config._printed_docstrings.add(base_nodeid) # type: ignore[attr-defined] + return + + for line in doc.splitlines(): + tr.write_line(" " + line) + tr.write_line("") + + item.config._printed_docstrings.add(base_nodeid) # type: ignore[attr-defined] diff --git a/{{cookiecutter.project_slug}}/tests/scenarios/conftest.py b/{{cookiecutter.project_slug}}/tests/scenarios/conftest.py deleted file mode 100644 index 47d15f6..0000000 --- a/{{cookiecutter.project_slug}}/tests/scenarios/conftest.py +++ /dev/null @@ -1,74 +0,0 @@ -"""A module for configuring pytest to include new features. - -This module provides a function to add new features automatically -as test files in pytest. The new features will trigger errors because -steps are not implemented. -""" - -from pathlib import Path -from typing import Any, Callable - -import pytest -from pytest_bdd.feature import get_features -from pytest_bdd.scenario import get_features_base_dir -from pytest_bdd.utils import get_caller_module_path - - -def pytest_configure() -> None: - """Configure tests to include new features. - - This function adds new features automatically as test files. - Adding new features will trigger errors because steps are not implemented. - - Args: - config (Config): Configuration provided by pytest. - """ - conftest_dir = Path(__file__).parent - caller_module_path = Path(get_caller_module_path()) - features_base_dir = Path(get_features_base_dir(caller_module_path)) - - features = ( - get_features([features_base_dir]) - if features_base_dir.exists() - else [] - ) - - for feat in features: - feature_dir = Path(feat.filename).parent - file_dir = ( - conftest_dir / "steps" / feature_dir.relative_to(features_base_dir) - ) - file_name = Path(feat.filename).stem + "_test.py" - file_path = file_dir / file_name - feature_rel_path = Path(feat.filename).relative_to(features_base_dir) - - txt = ( - '"""Feature steps implementation.\n\n' - f"Source file: {feature_rel_path}\n" - '"""\n' - "from pytest_bdd import scenarios\n\n" - f'scenarios("{feature_rel_path}")' - ) - - file_dir.mkdir(parents=True, exist_ok=True) - - if not file_path.exists(): - with Path.open(file_path, "w") as f: - f.write(txt) - - -def pytest_bdd_apply_tag(tag: str, function: Callable[..., Any]) -> bool | None: - """Apply custom tag behavior for pytest-bdd. - - Args: - tag (str): The tag specified in the feature file. - function (Callable): The test function the tag applies to. - - Returns: - bool | None: True if the tag was handled, otherwise None. - """ - if tag.lower() == "todo": - marker = pytest.mark.skip(reason="Not implemented yet") - marker(function) - return True - return None diff --git a/{{cookiecutter.project_slug}}/tests/scenarios/steps/simple_calculation_test.py b/{{cookiecutter.project_slug}}/tests/scenarios/steps/simple_calculation_test.py deleted file mode 100644 index 1e9eec6..0000000 --- a/{{cookiecutter.project_slug}}/tests/scenarios/steps/simple_calculation_test.py +++ /dev/null @@ -1,65 +0,0 @@ -{%- if cookiecutter.include_examples == "true" -%} -"""Feature steps implementation. - -This script defines three steps for a BDD test using pytest-bdd. The test scenario is described in the simple_calculation.feature file. - -""" - -from pytest_bdd import given, parsers, scenarios, then, when - -from {{cookiecutter.package_name}}.{{cookiecutter.module_name}} import Calculator - -scenarios("simple_calculation.feature") - - -@given( - parsers.parse("I have two numbers {a:f} and {b:f}"), target_fixture="varl" -) -def given_numbers(a: float, b: float) -> dict: - """Set the initial values for the calculator test. - - Args: - a (float): The first number. - b (float): The second number. - - Returns: - dict: A dictionary with keys "a" and "b" and their corresponding values. - - """ - return {"a": a, "b": b} - - -@when(parsers.parse("I divide {a:f} by {b:f}")) -def when_divide(varl: dict, a: float, b: float) -> None: - """Perform a division operation using the calculator. - - Args: - varl (dict): A dictionary with the initial values for the calculator. - a (float): The dividend. - b (float): The divisor. - - Returns: - None - """ - calc = Calculator() - varl["output"] = calc.divide(varl["a"], varl["b"]) - - -@then(parsers.parse("I should see {output:f}")) -def then_should_see(varl: dict, output: float) -> None: - """Verify if the result of the operation matches the expected output. - - Args: - varl (dict): A dictionary with the initial values for the calculator and the result of the operation. - output (float): The expected output. - - Returns: - None - """ - assert varl["output"] == output -{%- elif cookiecutter.include_examples != "true" -%} -"""Feature steps implementation. - -This script defines three steps for a BDD test using pytest-bdd. The test scenario is described in the simple_calculation.feature file. - -"""{% endif %} From ca549c76dbcd7cf5fd60e10e837108a46a3d96d9 Mon Sep 17 00:00:00 2001 From: eol Date: Mon, 1 Dec 2025 20:33:45 -0500 Subject: [PATCH 2/2] Adding cosmic-ray task --- {{cookiecutter.project_slug}}/pyproject.toml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/{{cookiecutter.project_slug}}/pyproject.toml b/{{cookiecutter.project_slug}}/pyproject.toml index c030ac5..51982cc 100644 --- a/{{cookiecutter.project_slug}}/pyproject.toml +++ b/{{cookiecutter.project_slug}}/pyproject.toml @@ -36,12 +36,6 @@ dev = [ [tool.setuptools] packages = ["{{cookiecutter.package_name}}"] -[tool.mutmut] -paths_to_mutate = [ "{{cookiecutter.package_name}}/" ] -pytest_add_cli_args_test_selection= [ "tests/" ] -mutate_only_covered_lines=true - - [tool.ruff.lint] ignore = [] select = [ @@ -149,3 +143,12 @@ doc-publish = """mkdocs gh-deploy \ --config-file docs/mkdocs.yaml \ --no-directory-urls \ --remote-branch docs""" +mut-report = """ + uv run cosmic-ray new-config mut.toml && \ + uv run cosmic-ray init mut.toml mut.sqlite && \ + uv run cosmic-ray --verbosity=INFO baseline mut.toml && \ + uv run cosmic-ray exec mut.toml mut.sqlite && \ + uv run cr-html mut.sqlite > docs/mut_report.html && \ + rm mut.toml && \ + rm mut.sqlite +"""