Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ repos:
- id: shellcheck
files: ^devtools/ci/
args: [-e, SC1091] # don't follow sourced files
- repo: local
hooks:
- id: check-test-conventions
name: check test conventions
entry: python devtools/check_test_conventions.py
language: python
files: ^test/.*\.py$
types: [python]
- repo: https://github.com/ambv/black
rev: 25.12.0
hooks:
Expand Down
122 changes: 122 additions & 0 deletions devtools/check_test_conventions.py
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",
}
)
Comment on lines +16 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 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$).



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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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


# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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
)

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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


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())
28 changes: 28 additions & 0 deletions test/unit/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
"""Shared pytest configuration for unit tests."""

import unittest

import pytest

_LEGACY_TESTCASE_FILES = frozenset(
{
"test_secrets_decorator.py",
"test_s3_storage.py",
"test_system_context.py",
}
)


def pytest_collection_modifyitems(items):
for item in items:
if not isinstance(item, pytest.Function):
continue
if item.cls and issubclass(item.cls, unittest.TestCase):
filename = item.fspath.basename
if filename not in _LEGACY_TESTCASE_FILES:
item.add_marker(
pytest.mark.xfail(
reason=(
"unittest.TestCase is not allowed in new tests. "
"See CONTRIBUTING.md Test conventions."
),
strict=True,
)
)


def pytest_addoption(parser):
"""Add custom command line options shared across unit test suites."""
Expand Down
Loading