Skip to content

Commit 8af4669

Browse files
authored
add better unit tests (#90)
* add better unit tests
1 parent e21d32e commit 8af4669

File tree

6 files changed

+356
-328
lines changed

6 files changed

+356
-328
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ packages = ["cookiecutter_uv"]
6161

6262
[tool.pytest.ini_options]
6363
testpaths = ["tests"]
64+
markers = ["slow: tests that install dependencies and run commands in baked projects"]
6465

6566
[tool.coverage.report]
6667
skip_empty = true

tests/conftest.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import shlex
5+
import subprocess
6+
from dataclasses import dataclass, field
7+
from pathlib import Path
8+
9+
import pytest
10+
import yaml
11+
12+
13+
@dataclass
14+
class BakedProject:
15+
"""Wrapper around a baked cookiecutter project with convenience methods for testing."""
16+
17+
project_path: Path
18+
exit_code: int
19+
exception: Exception | None
20+
options: dict[str, str] = field(default_factory=dict)
21+
22+
@property
23+
def path(self) -> Path:
24+
return self.project_path
25+
26+
def has_file(self, rel_path: str) -> bool:
27+
return (self.path / rel_path).is_file()
28+
29+
def has_dir(self, rel_path: str) -> bool:
30+
return (self.path / rel_path).is_dir()
31+
32+
def read_file(self, rel_path: str) -> str:
33+
return (self.path / rel_path).read_text()
34+
35+
def file_contains(self, rel_path: str, text: str) -> bool:
36+
return text in self.read_file(rel_path)
37+
38+
def is_valid_yaml(self, rel_path: str) -> bool:
39+
path = self.path / rel_path
40+
if not path.is_file():
41+
return False
42+
try:
43+
with path.open() as f:
44+
yaml.safe_load(f)
45+
except (yaml.YAMLError, OSError):
46+
return False
47+
return True
48+
49+
def run(self, command: str, check: bool = False) -> subprocess.CompletedProcess:
50+
# Strip VIRTUAL_ENV so the outer test environment doesn't leak
51+
# into the baked project's subprocess.
52+
env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
53+
return subprocess.run(
54+
shlex.split(command),
55+
cwd=self.path,
56+
capture_output=True,
57+
text=True,
58+
check=check,
59+
env=env,
60+
)
61+
62+
def git_init(self) -> None:
63+
"""Initialize a git repo and stage all files (needed for pre-commit)."""
64+
self.run("git init", check=True)
65+
self.run("git add .", check=True)
66+
67+
def install(self) -> None:
68+
self.git_init()
69+
result = self.run("uv sync")
70+
assert result.returncode == 0, f"uv sync failed:\n{result.stderr}"
71+
72+
def run_tests(self) -> None:
73+
result = self.run("uv run make test")
74+
assert result.returncode == 0, f"make test failed:\n{result.stderr}"
75+
76+
def run_check(self) -> None:
77+
# Run pre-commit once to auto-fix formatting issues from Jinja2 rendering
78+
# (e.g. end-of-file-fixer), then re-stage and run the real check.
79+
self.run("uv run pre-commit run -a")
80+
self.run("git add .", check=True)
81+
result = self.run("uv run make check")
82+
assert result.returncode == 0, f"make check failed:\n{result.stdout}\n{result.stderr}"
83+
84+
85+
@pytest.fixture
86+
def bake(cookies):
87+
"""Fixture factory that bakes a cookiecutter project and returns a BakedProject.
88+
89+
Usage:
90+
def test_something(bake):
91+
project = bake(mkdocs="n", codecov="y")
92+
assert project.has_file("pyproject.toml")
93+
"""
94+
95+
def _bake(**options) -> BakedProject:
96+
result = cookies.bake(extra_context=options)
97+
project = BakedProject(
98+
project_path=result.project_path,
99+
exit_code=result.exit_code,
100+
exception=result.exception,
101+
options=options,
102+
)
103+
assert project.exit_code == 0, f"Bake failed with options {options}: {project.exception}"
104+
assert project.exception is None
105+
return project
106+
107+
return _bake

tests/test_combinations.py

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
MINIMAL = {
6+
"include_github_actions": "n",
7+
"publish_to_pypi": "n",
8+
"deptry": "n",
9+
"mkdocs": "n",
10+
"codecov": "n",
11+
"dockerfile": "n",
12+
"devcontainer": "n",
13+
}
14+
15+
COMBINATIONS = [
16+
pytest.param({}, id="all-defaults"),
17+
pytest.param(MINIMAL, id="minimal"),
18+
pytest.param({"layout": "src"}, id="src-layout-defaults"),
19+
pytest.param({**MINIMAL, "layout": "src"}, id="src-layout-minimal"),
20+
pytest.param({"publish_to_pypi": "n", "mkdocs": "n"}, id="no-publish-no-mkdocs"),
21+
pytest.param({"include_github_actions": "n"}, id="no-github-actions"),
22+
pytest.param({"type_checker": "ty"}, id="ty-type-checker"),
23+
pytest.param({"mkdocs": "y", "codecov": "n"}, id="mkdocs-no-codecov"),
24+
pytest.param({"codecov": "n", "include_github_actions": "n"}, id="no-codecov-no-actions"),
25+
pytest.param({"layout": "src", "type_checker": "ty", "publish_to_pypi": "n"}, id="src-ty-no-publish"),
26+
]
27+
28+
# Defaults from cookiecutter.json (first item in each list)
29+
DEFAULTS = {
30+
"layout": "flat",
31+
"include_github_actions": "y",
32+
"publish_to_pypi": "y",
33+
"deptry": "y",
34+
"mkdocs": "y",
35+
"codecov": "y",
36+
"dockerfile": "y",
37+
"devcontainer": "y",
38+
"type_checker": "mypy",
39+
}
40+
41+
42+
def resolve_options(options: dict[str, str]) -> dict[str, str]:
43+
"""Return the full set of resolved options (defaults merged with overrides)."""
44+
return {**DEFAULTS, **options}
45+
46+
47+
@pytest.mark.parametrize("options", COMBINATIONS)
48+
class TestStructure:
49+
"""Validate file presence/absence for each option combination."""
50+
51+
def test_always_present_files(self, bake, options):
52+
EXPECTED_FILES = [
53+
".gitignore",
54+
".pre-commit-config.yaml",
55+
"CONTRIBUTING.md",
56+
"LICENSE",
57+
"Makefile",
58+
"README.md",
59+
"pyproject.toml",
60+
"tests",
61+
"tox.ini",
62+
]
63+
project = bake(**options)
64+
for rel_path in EXPECTED_FILES:
65+
assert (project.path / rel_path).exists(), f"Expected {rel_path} to exist"
66+
67+
def test_conditional_files(self, bake, options):
68+
project = bake(**options)
69+
effective = resolve_options(options)
70+
71+
if effective["dockerfile"] == "y":
72+
assert project.has_file("Dockerfile")
73+
else:
74+
assert not project.has_file("Dockerfile")
75+
76+
if effective["mkdocs"] == "y":
77+
assert project.has_dir("docs")
78+
assert project.has_file("mkdocs.yml")
79+
else:
80+
assert not project.has_dir("docs")
81+
assert not project.has_file("mkdocs.yml")
82+
83+
if effective["codecov"] == "y":
84+
assert project.has_file("codecov.yaml")
85+
else:
86+
assert not project.has_file("codecov.yaml")
87+
88+
if effective["devcontainer"] == "y":
89+
assert project.has_dir(".devcontainer")
90+
else:
91+
assert not project.has_dir(".devcontainer")
92+
93+
if effective["include_github_actions"] == "y":
94+
assert project.has_dir(".github")
95+
else:
96+
assert not project.has_dir(".github")
97+
98+
def test_layout(self, bake, options):
99+
effective = resolve_options(options)
100+
project = bake(**options)
101+
if effective["layout"] == "src":
102+
assert project.has_dir("src/example_project")
103+
assert not project.has_dir("example_project")
104+
else:
105+
assert project.has_dir("example_project")
106+
assert not project.has_dir("src")
107+
108+
def test_release_workflow(self, bake, options):
109+
effective = resolve_options(options)
110+
project = bake(**options)
111+
if effective["include_github_actions"] != "y":
112+
return # no .github at all
113+
has_release = effective["publish_to_pypi"] == "y" or effective["mkdocs"] == "y"
114+
workflow = ".github/workflows/on-release-main.yml"
115+
if has_release:
116+
assert project.has_file(workflow), "Expected release workflow to exist"
117+
else:
118+
assert not project.has_file(workflow), "Expected release workflow to be absent"
119+
120+
def test_yaml_validity(self, bake, options):
121+
effective = resolve_options(options)
122+
project = bake(**options)
123+
if effective["include_github_actions"] == "y":
124+
assert project.is_valid_yaml(".github/workflows/main.yml")
125+
126+
def test_pyproject_type_checker(self, bake, options):
127+
effective = resolve_options(options)
128+
project = bake(**options)
129+
content = project.read_file("pyproject.toml")
130+
if effective["type_checker"] == "mypy":
131+
assert '"mypy' in content
132+
assert '"ty' not in content
133+
else:
134+
assert '"ty' in content
135+
assert '"mypy' not in content
136+
137+
def test_makefile_targets(self, bake, options):
138+
effective = resolve_options(options)
139+
project = bake(**options)
140+
content = project.read_file("Makefile")
141+
142+
if effective["publish_to_pypi"] == "y":
143+
assert "build-and-publish" in content
144+
else:
145+
assert "build-and-publish" not in content
146+
147+
if effective["mkdocs"] == "y":
148+
assert "docs:" in content
149+
else:
150+
assert "docs:" not in content
151+
152+
def test_codecov_workflow(self, bake, options):
153+
effective = resolve_options(options)
154+
project = bake(**options)
155+
if effective["include_github_actions"] == "y":
156+
if effective["codecov"] == "y":
157+
assert project.has_file(".github/workflows/validate-codecov-config.yml")
158+
assert project.has_file("codecov.yaml")
159+
else:
160+
assert not project.has_file(".github/workflows/validate-codecov-config.yml")
161+
assert not project.has_file("codecov.yaml")
162+
163+
164+
@pytest.mark.slow
165+
@pytest.mark.parametrize("options", COMBINATIONS)
166+
def test_install_and_run_tests(bake, options):
167+
"""Bake, install dependencies, and run the generated project's test suite."""
168+
project = bake(**options)
169+
project.install()
170+
project.run_tests()
171+
172+
173+
@pytest.mark.slow
174+
def test_check_passes_on_default_project(bake):
175+
"""A freshly baked default project should pass its own ``make check``."""
176+
project = bake()
177+
project.install()
178+
project.run_check()

0 commit comments

Comments
 (0)