-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Add pre-commit hook and conftest guard for test conventions #3152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| #!/usr/bin/env python | ||
| """Pre-commit hook: enforce test conventions from CONTRIBUTING.md. | ||
|
|
||
| Checks test files for: | ||
| 1. unittest imports (use pytest instead) | ||
| 2. unittest.mock usage (use pytest-mock's mocker fixture) | ||
| 3. TestCase subclasses (use plain functions) | ||
| 4. Class-based test grouping (use module-level functions) | ||
|
|
||
| Legacy files that predate these conventions are allowlisted. | ||
| Remove entries as files are migrated. | ||
| """ | ||
| import ast | ||
| import sys | ||
|
|
||
| LEGACY_ALLOWLIST = frozenset( | ||
| { | ||
| "test/unit/test_secrets_decorator.py", | ||
| "test/unit/test_s3_storage.py", | ||
| "test/unit/test_system_context.py", | ||
| "test/unit/inheritance/test_inheritance.py", | ||
| "test/unit/mutators/test_add_decorator_returns.py", | ||
| "test/unit/mutators/test_dual_inheritance.py", | ||
| "test/unit/mutators/test_flow_mutator_addition.py", | ||
| "test/unit/mutators/test_post_step_none_false.py", | ||
| "test/unit/mutators/test_remove_decorator_guard.py", | ||
| "test/unit/mutators/test_string_step_mutator.py", | ||
| "test/cmd/diff/test_metaflow_diff.py", | ||
| "test/cmd/develop/test_stub_generator.py", | ||
| } | ||
| ) | ||
|
|
||
|
|
||
| def check_file(path): | ||
| violations = [] | ||
|
|
||
| with open(path) as f: | ||
| source = f.read() | ||
|
|
||
| try: | ||
| tree = ast.parse(source, filename=path) | ||
| except SyntaxError: | ||
| return violations | ||
|
|
||
| for node in ast.walk(tree): | ||
| # 1. import unittest / from unittest import ... | ||
| if isinstance(node, ast.Import): | ||
| for alias in node.names: | ||
| if alias.name == "unittest" or alias.name.startswith("unittest."): | ||
| violations.append( | ||
| (node.lineno, f"import {alias.name}", "use pytest / pytest-mock") | ||
| ) | ||
|
|
||
| if isinstance(node, ast.ImportFrom) and node.module: | ||
| if node.module == "unittest" or node.module.startswith("unittest."): | ||
| names = ", ".join(a.name for a in node.names) | ||
| violations.append( | ||
| ( | ||
| node.lineno, | ||
| f"from {node.module} import {names}", | ||
| "use pytest / pytest-mock (mocker fixture)", | ||
| ) | ||
| ) | ||
|
|
||
| # 2. class Test*(unittest.TestCase) — TestCase subclass | ||
| if isinstance(node, ast.ClassDef): | ||
| for base in node.bases: | ||
| base_name = ast.dump(base) | ||
| if "TestCase" in base_name: | ||
| violations.append( | ||
| ( | ||
| node.lineno, | ||
| f"class {node.name}(…TestCase)", | ||
| "use module-level test functions, not TestCase", | ||
| ) | ||
| ) | ||
| break | ||
|
Comment on lines
+66
to
+77
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
def _is_testcase_base(base):
if isinstance(base, ast.Name):
return base.id == "TestCase"
if isinstance(base, ast.Attribute):
return base.attr == "TestCase"
return False |
||
|
|
||
| # 3. class Test* without TestCase — class-based grouping | ||
| if isinstance(node, ast.ClassDef) and node.name.startswith("Test"): | ||
| is_testcase = any("TestCase" in ast.dump(b) for b in node.bases) | ||
| if not is_testcase: | ||
| has_test_methods = any( | ||
| isinstance(item, ast.FunctionDef) and item.name.startswith("test_") | ||
| for item in node.body | ||
| ) | ||
|
Comment on lines
+83
to
+86
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The check only scans for has_test_methods = any(
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name.startswith("test_")
for item in node.body
) |
||
| if has_test_methods: | ||
| violations.append( | ||
| ( | ||
| node.lineno, | ||
| f"class {node.name}", | ||
| "use module-level test functions, not test classes", | ||
| ) | ||
| ) | ||
|
|
||
| return violations | ||
|
|
||
|
|
||
| def main(): | ||
| status = 0 | ||
| for path in sys.argv[1:]: | ||
| # Normalize to forward-slash relative path for allowlist matching | ||
| normalized = path.replace("\\", "/") | ||
| if any(normalized.endswith(a) for a in LEGACY_ALLOWLIST): | ||
| continue | ||
|
Comment on lines
+99
to
+105
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
normalized = path.replace("\\", "/").lstrip("./")
if normalized in LEGACY_ALLOWLIST:
continue |
||
|
|
||
| violations = check_file(path) | ||
| for lineno, what, fix in violations: | ||
| print(f"{path}:{lineno}: {what} -> {fix}") | ||
| status = 1 | ||
|
|
||
| if status: | ||
| print( | ||
| "\nSee CONTRIBUTING.md § Test conventions. " | ||
| "Legacy files are allowlisted in devtools/check_test_conventions.py." | ||
| ) | ||
|
|
||
| return status | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| sys.exit(main()) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
conftest.pymissing from allowlist causes self-defeating hook failuretest/unit/conftest.pynow containsimport unittest(line 3), but it is not inLEGACY_ALLOWLIST. The pre-commit hook'sfilespattern^test/.*\.py$matchestest/unit/conftest.py, so the next time anyone modifies that file and stages it, the hook will reject the commit withimport unittest -> use pytest / pytest-mock. The enforcement tool would block edits to the file that implements part of that enforcement.Either add
"test/unit/conftest.py"toLEGACY_ALLOWLIST, or add anexcludekey to the hook in.pre-commit-config.yamlto skipconftest.pyfiles (e.g.exclude: conftest\.py$).