Skip to content

Commit b7ae9ee

Browse files
authored
Add golden tests for generated files (#87)
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. Fixes #50.
2 parents 1cfd3f3 + 02e9e0d commit b7ae9ee

File tree

177 files changed

+7388
-49
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

177 files changed

+7388
-49
lines changed

.github/labeler.yml

Lines changed: 53 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,42 +7,71 @@
77
# https://github.com/marketplace/actions/labeler
88

99
"part:docs":
10-
- "**/*.md"
11-
- "docs/**"
12-
- "examples/**"
13-
- LICENSE
10+
- any:
11+
- "**/*.md"
12+
- "docs/**"
13+
- "examples/**"
14+
- LICENSE
15+
all:
16+
- "!tests/**"
17+
- "!tests_golden/**"
1418

1519
"part:tests":
1620
- "tests/**"
21+
- "tests_golden/**"
1722

1823
"part:tooling":
19-
- "**/*.ini"
20-
- "**/*.toml"
21-
- "**/*.yaml"
22-
- "**/*.yml"
23-
- ".git*"
24-
- ".git*/**"
25-
- CODEOWNERS
26-
- MANIFEST.in
27-
- noxfile.py
24+
- any:
25+
- "**/*.ini"
26+
- "**/*.toml"
27+
- "**/*.yaml"
28+
- "**/*.yml"
29+
- ".git*"
30+
- ".git*/**"
31+
- CODEOWNERS
32+
- MANIFEST.in
33+
- noxfile.py
34+
all:
35+
- "!tests/**"
36+
- "!tests_golden/**"
2837

2938
"part:ci":
30-
- "**/.github/*labeler.*"
31-
- "**/.github/dependabot.*"
32-
- "**/.github/workflows/*"
39+
- any:
40+
- "**/.github/*labeler.*"
41+
- "**/.github/dependabot.*"
42+
- "**/.github/workflows/*"
43+
all:
44+
- "!tests/**"
45+
- "!tests_golden/**"
3346

3447
"part:cookiecutter":
35-
- "cookiecutter/**"
48+
- any:
49+
- "cookiecutter/**"
50+
all:
51+
- "!tests/**"
52+
- "!tests_golden/**"
3653

3754
"part:mkdocs":
38-
- "**/docs/*.py"
39-
- "**/mkdocs.*"
40-
- "src/frequenz/repo/config/mkdocs*"
55+
- any:
56+
- "**/docs/*.py"
57+
- "**/mkdocs.*"
58+
- "src/frequenz/repo/config/mkdocs*"
59+
all:
60+
- "!tests/**"
61+
- "!tests_golden/**"
4162

4263
"part:nox":
43-
- "**/noxfile.py"
44-
- "src/frequenz/repo/config/nox/**"
64+
- any:
65+
- "**/noxfile.py"
66+
- "src/frequenz/repo/config/nox/**"
67+
all:
68+
- "!tests/**"
69+
- "!tests_golden/**"
4570

4671
"part:protobuf":
47-
- "src/frequenz/repo/config/setuptools/grpc*"
48-
- "src/frequenz/repo/config/protobuf*"
72+
- any:
73+
- "src/frequenz/repo/config/setuptools/grpc*"
74+
- "src/frequenz/repo/config/protobuf*"
75+
all:
76+
- "!tests/**"
77+
- "!tests_golden/**"

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ dist/
1414
downloads/
1515
eggs/
1616
.eggs/
17-
lib/
18-
lib64/
1917
parts/
2018
sdist/
2119
var/

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

cookiecutter/hooks/post_gen_project.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import configparser as _configparser
1111
import dataclasses as _dataclasses
1212
import json as _json
13+
import os
1314
import pathlib as _pathlib
1415
import shutil as _shutil
1516
import subprocess as _subprocess
@@ -189,6 +190,11 @@ def initialize_git_submodules() -> bool:
189190
Returns:
190191
Whether any git submodules were initialized.
191192
"""
193+
if is_golden_testing():
194+
# We don't use external tools when running tests because they are too flaky, as
195+
# different versions emit different outputs
196+
return False
197+
192198
gitmodules_path = _pathlib.Path(".gitmodules")
193199

194200
if not gitmodules_path.exists():
@@ -287,8 +293,14 @@ def initialize_git_repo() -> bool:
287293
Returns:
288294
Whether the project was initialized as a git repository.
289295
"""
296+
if is_golden_testing():
297+
# We don't use external tools when running tests because they are too flaky, as
298+
# different versions emit different outputs
299+
return False
300+
290301
if _pathlib.Path(".git").exists():
291302
return False
303+
292304
print()
293305
note("Initializing git repository...")
294306
try_run(
@@ -309,6 +321,11 @@ def commit_git_changes(*, first_commit: bool) -> None:
309321
Args:
310322
first_commit: Whether this is the first commit in the git repository.
311323
"""
324+
if is_golden_testing():
325+
# We don't use external tools when running tests because they are too flaky, as
326+
# different versions emit different outputs
327+
return
328+
312329
if not try_run(["git", "status", "--porcelain"]):
313330
return
314331

@@ -367,6 +384,11 @@ def is_file_empty(path: _pathlib.Path) -> bool:
367384

368385
def print_generated_tree() -> None:
369386
"""Print the generated files tree."""
387+
if is_golden_testing():
388+
# We don't use external tools when running tests because they are too flaky, as
389+
# different versions emit different outputs
390+
return
391+
370392
result = try_run(["tree"])
371393
if result is not None and result.returncode == 0:
372394
print()
@@ -376,12 +398,13 @@ def print_todos() -> None:
376398
"""Print all TODOs in the generated project."""
377399
todo_str = "TODO(cookiecutter):"
378400
repo = cookiecutter.github_repo_name
379-
cmd = ["grep", "-r", "--color", rf"\<{todo_str}.*", "."]
401+
cmd = rf"grep -r --color '\<{todo_str}.*' . | sort"
380402
try_run(
381403
cmd,
382404
warn_on_error=True,
383-
warn_on_bad_status=f"No `{todo_str}` found using `{' '.join(cmd)}`",
405+
warn_on_bad_status=f"No `{todo_str}` found using `{cmd}`",
384406
note_on_failure=f"Please search for `{todo_str}` in `{repo}/` manually.",
407+
shell=True,
385408
)
386409
print()
387410
note(
@@ -467,13 +490,14 @@ def finish_model_setup() -> None:
467490

468491

469492
def try_run(
470-
cmd: list[str],
493+
cmd: list[str] | str,
471494
/,
472495
*,
473496
warn_on_error: bool = False,
474497
warn_on_bad_status: str | None = None,
475498
note_on_failure: str | None = None,
476499
verbose: bool = False,
500+
shell: bool = False,
477501
) -> _subprocess.CompletedProcess[Any] | None:
478502
"""Try to run a command.
479503
@@ -487,17 +511,20 @@ def try_run(
487511
note_on_failure: If not `None`, print this note if the command fails (either
488512
because of an error or a non-zero status code).
489513
verbose: Whether to print the command before running it.
514+
shell: Whether to run the command in a shell. If `True`, `cmd` must be a
515+
string, otherwise it must be a list of strings.
490516
491517
Returns:
492518
The result of the command or `None` if the command could not be run because
493519
of an error.
494520
"""
521+
assert isinstance(cmd, str) and shell or isinstance(cmd, list) and not shell
495522
result = None
496523
failed = False
497524
if verbose:
498525
print(f"Executing: {' '.join(cmd)}")
499526
try:
500-
result = _subprocess.run(cmd, check=False)
527+
result = _subprocess.run(cmd, check=False, shell=shell)
501528
except (OSError, _subprocess.CalledProcessError) as exc:
502529
if warn_on_error:
503530
warn(f"Failed to run the search command `{' '.join(cmd)}`: {exc}")
@@ -513,6 +540,15 @@ def try_run(
513540
return result
514541

515542

543+
def is_golden_testing() -> bool:
544+
"""Return `True` if we are running as part of a golden testing.
545+
546+
Returns:
547+
Whether we are running as part of a golden testing.
548+
"""
549+
return os.environ.get("GOLDEN_TEST", None) is not None
550+
551+
516552
def recursive_overwrite_move(src: _pathlib.Path, dst: _pathlib.Path) -> None:
517553
"""Recursively move a directory overwriting the target files if they exist.
518554

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,5 +132,6 @@ testpaths = ["tests"]
132132
markers = [
133133
"integration: integration tests (deselect with '-m \"not integration\"')",
134134
]
135+
135136
[tool.setuptools_scm]
136137
version_scheme = "post-release"

0 commit comments

Comments
 (0)