Skip to content

Commit bd82ab7

Browse files
authored
Contemporary test practices (#9)
* Base changes to simplify tests and make them more robust * Adding cosmic-ray task * Adding hypothesis * Removing pytest-bdd
1 parent 9e0850e commit bd82ab7

File tree

10 files changed

+89
-194
lines changed

10 files changed

+89
-194
lines changed

hooks/post_gen_project.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,17 @@
1010
"""
1111

1212
import shutil
13+
import os
1314
from pathlib import Path
1415

1516
REMOVE_PATHS = [
16-
"acceptance-scenarios",
17-
"tests/scenarios/steps",
17+
"tests/basic_test.py"
1818
]
1919

2020
for path in REMOVE_PATHS:
2121
p = Path(".") / Path(path)
2222
if p and p.exists() and p.is_dir():
2323
shutil.rmtree(p)
24+
elif p and p.exists() and p.is_file():
25+
os.remove(p)
2426
{% endif %}

{{cookiecutter.project_slug}}/acceptance-scenarios/.gitignore

Whitespace-only changes.

{{cookiecutter.project_slug}}/acceptance-scenarios/simple_calculation.feature

Lines changed: 0 additions & 15 deletions
This file was deleted.

{{cookiecutter.project_slug}}/docs/gen_pages.py

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,3 @@
1919
) as f:
2020
f.write(r.read())
2121

22-
23-
# Injects feature files into the documentation
24-
head_lines = (
25-
"Feature:",
26-
"Scenario:",
27-
"Scenario Outline:",
28-
"Rule:",
29-
"Example:",
30-
"Background:",
31-
)
32-
ignore_lines = ("@", "#")
33-
features_dir = docs_parent_dir / "acceptance-scenarios"
34-
for feature_path in features_dir.glob("**/*.feature"):
35-
with Path.open(feature_path, "r") as f:
36-
relative_dir = feature_path.parent.relative_to(features_dir)
37-
with mkdocs_gen_files.open(
38-
f"scenarios/{relative_dir}/{feature_path.stem}.md", "w"
39-
) as gf:
40-
f_line_list = f.readlines()
41-
for line in f_line_list:
42-
if any(line.strip().startswith(hl) for hl in head_lines):
43-
write_line = f"### {line}\n"
44-
elif any(line.strip().startswith(il) for il in ignore_lines):
45-
continue
46-
else:
47-
write_line = f"> {line}"
48-
49-
gf.write(write_line)

{{cookiecutter.project_slug}}/docs/mkdocs.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,3 @@ nav:
4343
- index.md
4444
- ... | glob=readme.md
4545
- reference.md
46-
- ... | regex=scenarios/.+.md

{{cookiecutter.project_slug}}/pyproject.toml

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,12 @@ dev = [
2525
"mkdocs-material>=9.6.11",
2626
"mkdocstrings[python]>=0.29.1",
2727
"pytest>=8.3.5",
28-
"pytest-bdd>=8.1.0",
2928
"pytest-cov>=6.1.1",
3029
"pytest-html>=4.1.1",
3130
"ruff>=0.11.5",
3231
"taskipy>=1.14.1",
32+
"hypothesis>=6.148.4",
33+
"cosmic-ray>=8.4.3",
3334
]
3435

3536
[tool.setuptools]
@@ -100,7 +101,6 @@ testpaths = [
100101
python_files = ["*_test.py"]
101102
python_functions = ["test_*"]
102103
render_collapsed = true
103-
bdd_features_base_dir = "acceptance-scenarios"
104104

105105
[tool.coverage.report]
106106
exclude_lines = [
@@ -118,13 +118,15 @@ exclude_lines = [
118118
run = "python -m {{cookiecutter.package_name}}.{{cookiecutter.module_name}}"
119119
test-report = """\
120120
pytest \
121-
--cov-config=pyproject.toml \
122121
--doctest-modules \
123-
--cov-fail-under=90 \
124-
--cov-report=term-missing \
125-
--cov-report=html:docs/cov-report \
122+
--cov-config=pyproject.toml \
123+
--cov-report html:docs/htmlcov \
124+
--cov-report term:skip-covered \
125+
--cov={{cookiecutter.package_name}} \
126+
--cov-fail-under={{cookiecutter.minimum_coverage}} \
126127
--html=docs/pytest_report.html \
127-
--self-contained-html\
128+
--self-contained-html \
129+
--hypothesis-show-statistics \
128130
"""
129131
test = """\
130132
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,9 +137,18 @@ task test-report\
135137
ruff-check = "ruff check **/*.py --fix"
136138
ruff-format = "ruff format **/*.py"
137139
lint = "task ruff-check && task ruff-format"
138-
doc = "mkdocs serve --use-directory-urls -f docs/mkdocs.yaml"
139-
doc-html = "mkdocs build --no-directory-urls -f docs/mkdocs.yaml"
140+
doc-serve = "mkdocs serve --use-directory-urls -f docs/mkdocs.yaml"
141+
doc-report = "mkdocs build --no-directory-urls -f docs/mkdocs.yaml"
140142
doc-publish = """mkdocs gh-deploy \
141143
--config-file docs/mkdocs.yaml \
142144
--no-directory-urls \
143145
--remote-branch docs"""
146+
mut-report = """
147+
uv run cosmic-ray new-config mut.toml && \
148+
uv run cosmic-ray init mut.toml mut.sqlite && \
149+
uv run cosmic-ray --verbosity=INFO baseline mut.toml && \
150+
uv run cosmic-ray exec mut.toml mut.sqlite && \
151+
uv run cr-html mut.sqlite > docs/mut_report.html && \
152+
rm mut.toml && \
153+
rm mut.sqlite
154+
"""
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{%- if cookiecutter.include_examples == "true" -%}from hypothesis import given, example, strategies as st
2+
import math
3+
4+
from {{cookiecutter.package_name}} import {{cookiecutter.module_name}} as m
5+
6+
7+
@example(a=6, b=3) # result = 2
8+
@example(a=-8, b=2) # result = -4
9+
@example(a=0, b=5) # zero dividend
10+
@example(a=579, b=9105) # the earlier failing example (float rounding)
11+
@given(
12+
a=st.integers(min_value=-10_000, max_value=10_000),
13+
b=st.integers(min_value=-10_000, max_value=10_000).filter(lambda x: x != 0),
14+
)
15+
def test_divide_inverse(a: int, b: int) -> None:
16+
"""Check that multiplication is the inverse of division (within float tolerance)."""
17+
result = m.Calculator.divide(a, b)
18+
19+
assert math.isclose(result * b, a, rel_tol=1e-12, abs_tol=1e-12)
20+
{% endif %}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import inspect
2+
3+
from _pytest.config import Config
4+
from _pytest.nodes import Item
5+
6+
7+
def pytest_configure(config: Config) -> None:
8+
"""Initialize per-session state for docstring printing.
9+
10+
Creates a set on the config object used to track which test
11+
node IDs (without parameterization suffixes) have already had
12+
their docstrings printed.
13+
"""
14+
config._printed_docstrings = set() # type: ignore[attr-defined]
15+
16+
17+
def pytest_runtest_setup(item: Item) -> None:
18+
"""Print a test function's docstring the first time it is encountered.
19+
20+
The docstring is printed only once per “base” nodeid. For example,
21+
a parametrized test like ``test_func[param]`` will only have its
22+
docstring printed for the first parameterization. Subsequent cases
23+
skip printing.
24+
"""
25+
tr = item.config.pluginmanager.getplugin("terminalreporter")
26+
if not tr:
27+
return
28+
29+
# strip parameterization suffix:
30+
# "path/to/test.py::test_func[param]" → keep the part before "["
31+
base_nodeid = item.nodeid.split("[", 1)[0]
32+
33+
if base_nodeid in item.config._printed_docstrings: # type: ignore[attr-defined]
34+
return
35+
36+
doc = inspect.getdoc(item.obj) or ""
37+
if not doc.strip():
38+
item.config._printed_docstrings.add(base_nodeid) # type: ignore[attr-defined]
39+
return
40+
41+
for line in doc.splitlines():
42+
tr.write_line(" " + line)
43+
tr.write_line("")
44+
45+
item.config._printed_docstrings.add(base_nodeid) # type: ignore[attr-defined]

{{cookiecutter.project_slug}}/tests/scenarios/conftest.py

Lines changed: 0 additions & 74 deletions
This file was deleted.

{{cookiecutter.project_slug}}/tests/scenarios/steps/simple_calculation_test.py

Lines changed: 0 additions & 65 deletions
This file was deleted.

0 commit comments

Comments
 (0)