Skip to content

Commit 2d48d01

Browse files
committed
Add golden testing for generated files
Testing the generated templates are correct is hard. For now we are testing that the basics work by running `nox` on some generated projects but this will not test bugs in documentation, comments, etc. Nor will test GitHub Actions workflows or other advanced configuration. By adding golden tests we at least ensure that once the results are manually tested, we won't introduce unexpected regressions. We considered using the pytest-golden plugin, but unfortunately it does not support using whole files and directory trees as goldens, so we built our own ad-hoc solution for now. To update the golden files the test file needs to be temporarily updated to set `UPDATE_GOLDEN` to `True` to replace the golden files with the newly generated files by running `pytest` again, which is not great, but it is good enough for now. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 1e89c91 commit 2d48d01

File tree

2 files changed

+210
-0
lines changed

2 files changed

+210
-0
lines changed

CONTRIBUTING.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,41 @@ nox -R -s pylint -- test/test_*.py
6060
nox -R -s mypy -- test/test_*.py
6161
```
6262

63+
### Golden Tests
64+
65+
To test the generated files using the Cookiecutter templates, the [golden
66+
testing](https://en.wikipedia.org/wiki/Characterization_test) technique is used
67+
to ensure that changes in the templates don't occur unexpectedly.
68+
69+
If a golden test fails, a `diff` of the contents will be provided in the test
70+
results.
71+
72+
Failures in the golden tests could indicate two things:
73+
74+
1. The generated files don't match the golden files because an unintended
75+
change was introduced. For example, there may be a bug that needs to be fixed
76+
so that the generated files match the golden files again.
77+
78+
2. The generated files don't match the golden files because an intended change
79+
was introduced. In this case, the golden files need to be updated.
80+
81+
In the latter case, manually updating files is complicated and error-prone, so
82+
a simpler (though hacky) way is provided.
83+
84+
To update the golden files, simply edit the
85+
`tests/integration/test_cookiecutter_generation.py` file and temporarily set
86+
`UPDATE_GOLDEN` to `True`. Then, run the test again using `pytest` as usual.
87+
This will replace the existing golden files (stored in `tests_golden/`) with
88+
the newly generated files.
89+
90+
Note that if you rename, or remove golden files, you should also manually
91+
remove the files that were affected. An easy way to make sure there are no old
92+
unused golden files left is to just wipe the whole `tests_golden/` directory
93+
before running `pytest` to generate the new ones.
94+
95+
**Please ensure that all introduced changes are intended before updating the
96+
golden files.**
97+
6398
### Building the documentation
6499

65100
To build the documentation, first install the dependencies (if you didn't

tests/integration/test_cookiecutter_generation.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,49 @@
55

66
import pathlib
77
import re
8+
import shutil
89
import subprocess
910

1011
import pytest
1112

1213
from frequenz.repo import config
1314

15+
UPDATE_GOLDEN: bool = False
16+
"""Set to True to update the golden files.
17+
18+
After setting to True you need to run the tests once to update the golden files.
19+
20+
Make sure to review the changes before committing them and setting this back to False.
21+
"""
22+
23+
24+
@pytest.mark.integration
25+
@pytest.mark.parametrize("repo_type", [*config.RepositoryType])
26+
def test_golden(
27+
tmp_path: pathlib.Path,
28+
repo_type: config.RepositoryType,
29+
request: pytest.FixtureRequest,
30+
) -> None:
31+
"""Test generation of a new repo comparing it to a golden tree."""
32+
cwd = pathlib.Path().cwd()
33+
golden_path = (
34+
cwd
35+
/ "tests_golden"
36+
/ request.path.relative_to(cwd / "tests").with_suffix("")
37+
/ repo_type.value
38+
)
39+
40+
generated_repo_path, run_result = _generate_repo(
41+
repo_type, tmp_path, capture_output=True
42+
)
43+
stdout, stderr = _filter_generation_output(run_result)
44+
_assert_golden_file(golden_path, "cookiecutter-stdout", stdout)
45+
_assert_golden_file(golden_path, "cookiecutter-stderr", stderr)
46+
_assert_golden_tree(
47+
generated_repo_path=generated_repo_path,
48+
golden_tree=golden_path / generated_repo_path.name,
49+
)
50+
1451

1552
@pytest.mark.integration
1653
@pytest.mark.parametrize("repo_type", [*config.RepositoryType])
@@ -66,6 +103,144 @@ def _run(
66103
return subprocess.run(cmd, cwd=cwd, check=check, capture_output=capture_output)
67104

68105

106+
def _read_golden_file(golden_path: pathlib.Path, name: str) -> str:
107+
"""Read a golden file.
108+
109+
File names will be appended with ".txt".
110+
111+
Args:
112+
golden_path: The path to the directory containing the golden files.
113+
name: The name of the golden file.
114+
115+
Returns:
116+
The contents of the file.
117+
"""
118+
with open(
119+
golden_path / f"{name}.txt", "r", encoding="utf8", errors="replace"
120+
) as golden_file:
121+
return golden_file.read()
122+
123+
124+
def _write_golden_file(golden_path: pathlib.Path, name: str, contents: str) -> int:
125+
"""Write a golden file.
126+
127+
File names will be appended with ".txt".
128+
129+
Args:
130+
golden_path: The path to the directory containing the golden files.
131+
name: The name of the golden file.
132+
133+
Returns:
134+
The number of bytes written.
135+
"""
136+
golden_path.mkdir(parents=True, exist_ok=True)
137+
with open(
138+
golden_path / f"{name}.txt", "w", encoding="utf8", errors="replace"
139+
) as golden_file:
140+
return golden_file.write(contents)
141+
142+
143+
def _assert_golden_file(
144+
golden_path: pathlib.Path,
145+
name: str,
146+
new_result: str | bytes | subprocess.CompletedProcess[bytes],
147+
) -> None:
148+
if isinstance(new_result, subprocess.CompletedProcess):
149+
_assert_golden_file(golden_path, f"{name}-stdout", new_result.stdout)
150+
_assert_golden_file(golden_path, f"{name}-stderr", new_result.stderr)
151+
return
152+
153+
if isinstance(new_result, bytes):
154+
new_result = new_result.decode("utf-8", "replace")
155+
if UPDATE_GOLDEN:
156+
_write_golden_file(golden_path, name, new_result)
157+
else:
158+
assert new_result == _read_golden_file(golden_path, name)
159+
160+
161+
def _read_golden_tree(
162+
*, generated_repo_path: pathlib.Path, golden_tree: pathlib.Path
163+
) -> subprocess.CompletedProcess[bytes]:
164+
"""Read a golden tree.
165+
166+
The `diff` command is used to compare the generated tree with the golden tree.
167+
168+
Args:
169+
generated_repo_path: The path to the generated repository tree.
170+
golden_tree: The path to the golden tree.
171+
172+
Returns:
173+
The result of the `diff` command.
174+
"""
175+
return _run(
176+
generated_repo_path,
177+
"diff",
178+
"-ru",
179+
str(generated_repo_path),
180+
str(golden_tree),
181+
check=False,
182+
capture_output=True,
183+
)
184+
185+
186+
def _write_golden_tree(
187+
*, generated_repo_path: pathlib.Path, golden_tree: pathlib.Path
188+
) -> None:
189+
"""Write a golden tree.
190+
191+
Replace all files in the golden tree with the files from the generated tree.
192+
193+
Args:
194+
generated_repo_path: The path to the generated repository tree.
195+
golden_tree: The path to the golden tree.
196+
"""
197+
if golden_tree.exists():
198+
shutil.rmtree(golden_tree)
199+
shutil.copytree(generated_repo_path, golden_tree, dirs_exist_ok=True)
200+
201+
202+
def _assert_golden_tree(
203+
*, generated_repo_path: pathlib.Path, golden_tree: pathlib.Path
204+
) -> None:
205+
if UPDATE_GOLDEN:
206+
_write_golden_tree(
207+
generated_repo_path=generated_repo_path, golden_tree=golden_tree
208+
)
209+
else:
210+
result = _read_golden_tree(
211+
generated_repo_path=generated_repo_path, golden_tree=golden_tree
212+
)
213+
if result.returncode != 0:
214+
print("Generated repo differs from golden repo:")
215+
print()
216+
print("STDOUT:")
217+
print("-" * 80)
218+
print(result.stdout.decode("utf-8"))
219+
print("-" * 80)
220+
print("STDERR:")
221+
print("-" * 80)
222+
print(result.stderr.decode("utf-8"))
223+
print("-" * 80)
224+
assert result.returncode == 0
225+
226+
227+
def _filter_generation_output(
228+
result: subprocess.CompletedProcess[bytes], /
229+
) -> tuple[bytes, bytes]:
230+
"""Filter out some lines from the output.
231+
232+
This is necessary because the output of cookiecutter is not deterministic, so we
233+
just remove all non-deterministic lines. These are lines that contain the path to
234+
the generated repo (a temporary directory).
235+
"""
236+
stdout = b"\n".join(
237+
l
238+
for l in result.stdout.splitlines()
239+
if not l.startswith((b"WARNING: The replay file's `_template` (",))
240+
)
241+
return stdout, result.stderr
242+
243+
69244
def _update_pyproject_repo_config_dep(
70245
*,
71246
repo_config_path: pathlib.Path,

0 commit comments

Comments
 (0)