Add pre-commit hook and conftest guard for test conventions#3152
Add pre-commit hook and conftest guard for test conventions#3152
Conversation
Pre-commit hook (devtools/check_test_conventions.py) AST-parses test files and blocks commits that introduce unittest imports, TestCase subclasses, unittest.mock usage, or class-based test grouping. conftest.py guard marks any new TestCase subclass with strict xfail at test collection time. Legacy files that predate the conventions are allowlisted in both locations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Greptile SummaryThis PR adds programmatic enforcement of test conventions via an AST-based pre-commit hook (
Confidence Score: 3/5Not safe to merge as-is — the hook will self-block on future edits to One P1 defect:
Important Files Changed
Reviews (1): Last reviewed commit: "Add pre-commit hook and conftest guard t..." | Re-trigger Greptile |
| 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", | ||
| } | ||
| ) |
There was a problem hiding this comment.
conftest.py missing from allowlist causes self-defeating hook failure
test/unit/conftest.py now contains import unittest (line 3), but it is not in LEGACY_ALLOWLIST. The pre-commit hook's files pattern ^test/.*\.py$ matches test/unit/conftest.py, so the next time anyone modifies that file and stages it, the hook will reject the commit with import 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" to LEGACY_ALLOWLIST, or add an exclude key to the hook in .pre-commit-config.yaml to skip conftest.py files (e.g. exclude: conftest\.py$).
| 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 |
There was a problem hiding this comment.
ast.dump string-match for TestCase can produce false positives
ast.dump(base) serialises the entire AST node to a string and then checks if "TestCase" in base_name. This would match unrelated identifiers that happen to contain the substring "TestCase", such as class TestMyFoo(SomeLib.TestCaseLike). Prefer checking the actual attribute/name directly:
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| has_test_methods = any( | ||
| isinstance(item, ast.FunctionDef) and item.name.startswith("test_") | ||
| for item in node.body | ||
| ) |
There was a problem hiding this comment.
Async test methods not detected in class-based grouping check
The check only scans for ast.FunctionDef nodes, so async def test_* methods (ast.AsyncFunctionDef) are silently missed. A class like class TestFoo: async def test_bar(self): ... would not trigger the violation.
has_test_methods = any(
isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
and item.name.startswith("test_")
for item in node.body
)| 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 |
There was a problem hiding this comment.
endswith allowlist matching is fragile
normalized.endswith(a) would match a path like other_project/test/unit/test_secrets_decorator.py against the allowlist entry test/unit/test_secrets_decorator.py. Pre-commit passes repo-relative paths, so an exact in check after normalisation is safer and more self-documenting:
normalized = path.replace("\\", "/").lstrip("./")
if normalized in LEGACY_ALLOWLIST:
continue
Summary
devtools/check_test_conventions.py— AST-based pre-commit hook that blocks commits introducingunittestimports,TestCasesubclasses,unittest.mockusage, or class-based test grouping in test files..pre-commit-config.yamlas arepo: localhook scoped totest/**/*.py.pytest_collection_modifyitemsguard intest/unit/conftest.pythat marks any newTestCasesubclass with strictxfail.Context / Motivation
PR #3150 codifies test conventions in
CONTRIBUTING.mdandAGENTS.md. This PR adds programmatic enforcement so the conventions aren't just documentation — they block violations at commit time (pre-commit hook) and test time (conftest guard).Changes Made
devtools/check_test_conventions.py.pre-commit-config.yamlcheck-test-conventionslocal hook, runs ontest/**/*.pytest/unit/conftest.pypytest_collection_modifyitemshook that xfails non-allowlistedTestCasesubclassesTesting
55 passedfor the 3 allowlisted unit test files,151 passed, 19 skippedoverall (pre-existingimportlib.metadataerrors in mutators/inheritance tests are unrelated).Trade-offs
unittest, no classes, nounittest.mock). Judgment-call conventions (when to parametrize, factory fixtures, data placement) remain review-time guidance.🤖 Generated with Claude Code