Skip to content

Commit 3c61d10

Browse files
committed
feat: add FT lint rules for test conventions
Add a custom linter (flagsmith-lint-tests) enforcing Flagsmith test conventions: function-only tests (FT001), no unittest imports (FT002), test_{subject}__{condition}__{expected} naming (FT003), and Given/When/Then comments (FT004).
1 parent 8535029 commit 3c61d10

File tree

5 files changed

+607
-0
lines changed

5 files changed

+607
-0
lines changed

.pre-commit-hooks.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
- id: flagsmith-lint-tests
2+
name: flagsmith-lint-tests
3+
description: Lint Flagsmith test conventions (function-only, naming, GWT comments)
4+
entry: flagsmith-lint-tests
5+
language: python
6+
types: [python]
7+
files: test_.*\.py$
8+
args: []

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Repository = "https://github.com/flagsmith/flagsmith-common"
6060

6161
[project.scripts]
6262
flagsmith = "common.core.main:main"
63+
flagsmith-lint-tests = "common.lint_tests:main"
6364

6465
[project.entry-points.pytest11]
6566
flagsmith-test-tools = "common.test_tools.plugin"

src/common/lint_tests.py

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
"""Linter for Flagsmith test conventions.
2+
3+
Enforces:
4+
- FT001: No module-level class Test* (function-only tests)
5+
- FT002: No `import unittest` / `from unittest import TestCase` (unittest.mock is fine)
6+
- FT003: Test name must have exactly 2 `__` separators: test_{subject}__{condition}__{expected}
7+
- FT004: Test body must contain # Given, # When, and # Then comments
8+
9+
Output format matches ruff/flake8/mypy: {file}:{line}:{col}: {code} {message}
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import ast
16+
import re
17+
import sys
18+
from pathlib import Path
19+
from typing import NamedTuple
20+
21+
UNITTEST_BANNED_IMPORTS = frozenset(
22+
{"TestCase", "TestSuite", "TestLoader", "TextTestRunner"}
23+
)
24+
25+
26+
class Violation(NamedTuple):
27+
file: str
28+
line: int
29+
col: int
30+
code: str
31+
message: str
32+
33+
def __str__(self) -> str:
34+
return f"{self.file}:{self.line}:{self.col}: {self.code} {self.message}"
35+
36+
37+
def _has_fixture_decorator(node: ast.FunctionDef) -> bool:
38+
for decorator in node.decorator_list:
39+
if isinstance(decorator, ast.Attribute) and decorator.attr == "fixture":
40+
return True
41+
if isinstance(decorator, ast.Name) and decorator.id == "fixture":
42+
return True
43+
# Handle @pytest.fixture(...)
44+
if (
45+
isinstance(decorator, ast.Call)
46+
and isinstance(decorator.func, ast.Attribute)
47+
and decorator.func.attr == "fixture"
48+
):
49+
return True
50+
if (
51+
isinstance(decorator, ast.Call)
52+
and isinstance(decorator.func, ast.Name)
53+
and decorator.func.id == "fixture"
54+
):
55+
return True
56+
return False
57+
58+
59+
_COMMENT_RE = re.compile(r"#(.*)$")
60+
61+
62+
def _extract_comments(source: str) -> dict[int, str]:
63+
"""Return a mapping of line number (1-based) -> comment text."""
64+
comments: dict[int, str] = {}
65+
for lineno, line in enumerate(source.splitlines(), start=1):
66+
match = _COMMENT_RE.search(line)
67+
if match:
68+
comments[lineno] = "#" + match.group(1)
69+
return comments
70+
71+
72+
_NOQA_RE = re.compile(r"#\s*noqa\b(?::\s*(?P<codes>[A-Z0-9,\s]+))?")
73+
74+
75+
def _is_noqa_suppressed(comment: str, code: str) -> bool:
76+
"""Check if a comment contains a noqa directive that suppresses the given code."""
77+
match = _NOQA_RE.search(comment)
78+
if not match:
79+
return False
80+
codes_str = match.group("codes")
81+
# Bare noqa (without specific codes) suppresses everything
82+
if codes_str is None:
83+
return True
84+
codes = {c.strip() for c in codes_str.split(",")}
85+
return code in codes
86+
87+
88+
def check_ft001(tree: ast.Module, filepath: str) -> list[Violation]:
89+
"""FT001: Module-level class Test* detected."""
90+
violations = []
91+
for node in ast.iter_child_nodes(tree):
92+
if isinstance(node, ast.ClassDef) and node.name.startswith("Test"):
93+
violations.append(
94+
Violation(
95+
file=filepath,
96+
line=node.lineno,
97+
col=node.col_offset + 1,
98+
code="FT001",
99+
message=f"Module-level test class `{node.name}` detected; use function-based tests",
100+
)
101+
)
102+
return violations
103+
104+
105+
def check_ft002(tree: ast.Module, filepath: str) -> list[Violation]:
106+
"""FT002: import unittest / from unittest import TestCase etc. (NOT unittest.mock)."""
107+
violations = []
108+
for node in ast.walk(tree):
109+
if isinstance(node, ast.Import):
110+
for alias in node.names:
111+
# Flag `import unittest` but not `import unittest.mock`
112+
if alias.name == "unittest":
113+
violations.append(
114+
Violation(
115+
file=filepath,
116+
line=node.lineno,
117+
col=node.col_offset + 1,
118+
code="FT002",
119+
message="`import unittest` is not allowed; use pytest instead",
120+
)
121+
)
122+
elif isinstance(node, ast.ImportFrom):
123+
if node.module == "unittest":
124+
for alias in node.names:
125+
if alias.name in UNITTEST_BANNED_IMPORTS:
126+
violations.append(
127+
Violation(
128+
file=filepath,
129+
line=node.lineno,
130+
col=node.col_offset + 1,
131+
code="FT002",
132+
message=f"`from unittest import {alias.name}` is not allowed; use pytest instead",
133+
)
134+
)
135+
return violations
136+
137+
138+
def check_ft003(tree: ast.Module, filepath: str) -> list[Violation]:
139+
"""FT003: Test name doesn't follow test_{subject}__{condition}__{expected} convention."""
140+
violations = []
141+
for node in ast.iter_child_nodes(tree):
142+
if (
143+
isinstance(node, ast.FunctionDef)
144+
and node.name.startswith("test_")
145+
and not _has_fixture_decorator(node)
146+
):
147+
# Strip `test_` prefix and count `__` separators
148+
after_prefix = node.name[5:]
149+
parts = after_prefix.split("__")
150+
if len(parts) != 3:
151+
violations.append(
152+
Violation(
153+
file=filepath,
154+
line=node.lineno,
155+
col=node.col_offset + 1,
156+
code="FT003",
157+
message=f"Test name `{node.name}` doesn't match `test_{{subject}}__{{condition}}__{{expected}}` (found {len(parts)} parts, expected 3)",
158+
)
159+
)
160+
return violations
161+
162+
163+
def _find_missing_gwt(func_comments: list[str]) -> list[str]:
164+
"""Return list of missing Given/When/Then keywords from comments."""
165+
has_given = False
166+
has_when = False
167+
has_then = False
168+
for text in func_comments:
169+
normalized = text.lstrip("#").strip().lower()
170+
if normalized.startswith("given"):
171+
has_given = True
172+
# "Given / When" satisfies both
173+
if "when" in normalized:
174+
has_when = True
175+
if normalized.startswith("when"):
176+
has_when = True
177+
# "When / Then" satisfies both
178+
if "then" in normalized:
179+
has_then = True
180+
if normalized.startswith("then"):
181+
has_then = True
182+
183+
missing = []
184+
if not has_given:
185+
missing.append("Given")
186+
if not has_when:
187+
missing.append("When")
188+
if not has_then:
189+
missing.append("Then")
190+
return missing
191+
192+
193+
def check_ft004(
194+
tree: ast.Module, filepath: str, comments: dict[int, str]
195+
) -> list[Violation]:
196+
"""FT004: Missing # Given, # When, or # Then comments in test body."""
197+
violations = []
198+
for node in ast.iter_child_nodes(tree):
199+
if (
200+
isinstance(node, ast.FunctionDef)
201+
and node.name.startswith("test_")
202+
and not _has_fixture_decorator(node)
203+
):
204+
func_comments = [
205+
text
206+
for line_no, text in comments.items()
207+
if node.lineno <= line_no <= (node.end_lineno or node.lineno)
208+
]
209+
missing = _find_missing_gwt(func_comments)
210+
if missing:
211+
violations.append(
212+
Violation(
213+
file=filepath,
214+
line=node.lineno,
215+
col=node.col_offset + 1,
216+
code="FT004",
217+
message=f"Test `{node.name}` is missing GWT comments: {', '.join(missing)}",
218+
)
219+
)
220+
return violations
221+
222+
223+
def lint_file(filepath: str) -> list[Violation]:
224+
"""Run all checks on a single file."""
225+
path = Path(filepath)
226+
227+
# Only check test_*.py files
228+
if not (path.name.startswith("test_") and path.suffix == ".py"):
229+
return []
230+
231+
source = path.read_text(encoding="utf-8")
232+
try:
233+
tree = ast.parse(source, filename=filepath)
234+
except SyntaxError:
235+
return [
236+
Violation(
237+
file=filepath,
238+
line=1,
239+
col=1,
240+
code="FT000",
241+
message="Could not parse file (SyntaxError)",
242+
)
243+
]
244+
245+
comments = _extract_comments(source)
246+
247+
violations = []
248+
violations.extend(check_ft001(tree, filepath))
249+
violations.extend(check_ft002(tree, filepath))
250+
violations.extend(check_ft003(tree, filepath))
251+
violations.extend(check_ft004(tree, filepath, comments))
252+
253+
# Filter out violations suppressed by noqa comments
254+
return [
255+
v
256+
for v in violations
257+
if v.line not in comments or not _is_noqa_suppressed(comments[v.line], v.code)
258+
]
259+
260+
261+
def main(argv: list[str] | None = None) -> int:
262+
parser = argparse.ArgumentParser(
263+
description="Lint Flagsmith test conventions",
264+
)
265+
parser.add_argument("files", nargs="*", help="Files to check")
266+
args = parser.parse_args(argv)
267+
268+
has_errors = False
269+
for filepath in args.files:
270+
violations = lint_file(filepath)
271+
for v in violations:
272+
has_errors = True
273+
print(v)
274+
275+
return 1 if has_errors else 0
276+
277+
278+
if __name__ == "__main__":
279+
sys.exit(main())

tests/unit/common/lint_tests/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)