Skip to content

Commit 7c1af0a

Browse files
committed
fix(scripts): local test runner skips suites and reports false success (#7229)
scripts/run_tests.py is the documented local pre-PR test entry point, but it did not execute the complete suite and could report success while silently skipping tests: 1. Unit mode iterated only child directories of tests/unit, so test files placed directly in tests/unit (e.g. test_memory_reranker.py, test_integration_timeout_config.py) were never collected. Run the complete tests/unit tree in one pytest invocation instead. 2. Default/--all mode never ran tests/contract. Add contract tests to the run_all path, matching the tiers GitHub Actions runs. 3. --integrated looked for a nonexistent tests/integrated directory and treated the missing directory as success (exit 0 + "All tests completed successfully!"). Point it at tests/integration and make any missing suite directory fail loudly with a nonzero exit status. 4. Status symbols (✓ ✗ ⚠ ℹ) raised UnicodeEncodeError on CP936/GBK Windows terminals before the outcome was reported. Reconfigure stdout/stderr with errors="replace" so printing stays safe on limited encodings. Also invoke pytest through sys.executable from the repository root so the active interpreter and repository pytest configuration are used even when the pytest entry point is not on PATH, and keep the existing -i / --integrated and -u DIR command-line compatibility. Adds tests/unit/test_run_tests_script.py: 6 regression tests covering suite coverage of all modes, the wrong-directory fix, fail-loud behaviour for missing suites, and GBK-safe status output. All pass; pre-commit (black/flake8/pylint/mypy) clean, pylint 10.00/10. 署名:秦琼·CIOps@QPQAT
1 parent 6c90762 commit 7c1af0a

2 files changed

Lines changed: 261 additions & 75 deletions

File tree

scripts/run_tests.py

Lines changed: 134 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
99
Options:
1010
-u, --unit [DIR] Run unit tests (optionally specify subdirectory)
11-
-i, --integrated Run integrated tests
11+
-i, --integrated Run integration tests (tests/integration)
1212
-a, --all Run all tests (default)
1313
-c, --coverage Generate coverage report
1414
-p, --parallel Run tests in parallel
@@ -18,9 +18,19 @@
1818
python scripts/run_tests.py # Run all tests
1919
python scripts/run_tests.py -u # Run all unit tests
2020
python scripts/run_tests.py -u providers # Run unit tests in providers
21-
python scripts/run_tests.py -i # Run integrated tests
21+
python scripts/run_tests.py -i # Run integration tests
2222
python scripts/run_tests.py -a -c # Run all tests with coverage
2323
python scripts/run_tests.py -p # Run tests in parallel
24+
25+
Notes:
26+
* The default ``-a`` run executes the complete ``tests/unit`` tree
27+
(root-level files included), ``tests/contract`` and
28+
``tests/integration`` — the same tiers GitHub Actions runs.
29+
* A missing test suite directory is reported as an error with a
30+
nonzero exit status instead of being silently skipped.
31+
* Output is safe on terminals with limited encodings (for example
32+
CP936/GBK on Windows): unencodable status symbols are replaced
33+
instead of raising ``UnicodeEncodeError``.
2434
"""
2535

2636
import argparse
@@ -30,7 +40,26 @@
3040
from typing import Optional
3141

3242

33-
class Colors:
43+
def _make_output_safe(stream) -> None:
44+
"""Keep printing from crashing on limited terminal encodings.
45+
46+
Windows consoles frequently use CP936/GBK; the Unicode status
47+
symbols below used to raise ``UnicodeEncodeError`` before the test
48+
outcome was reported. Replacing unencodable characters keeps the
49+
runner usable there.
50+
"""
51+
if hasattr(stream, "reconfigure"):
52+
try:
53+
stream.reconfigure(errors="replace")
54+
except (ValueError, OSError):
55+
pass
56+
57+
58+
_make_output_safe(sys.stdout)
59+
_make_output_safe(sys.stderr)
60+
61+
62+
class Colors: # pylint: disable=too-few-public-methods
3463
"""ANSI color codes for terminal output."""
3564

3665
RED = "\033[0;31m"
@@ -61,10 +90,10 @@ def print_warning(message: str) -> None:
6190

6291

6392
def check_pytest() -> bool:
64-
"""Check if pytest is installed."""
93+
"""Check that pytest is usable by the active interpreter."""
6594
try:
6695
subprocess.run(
67-
["pytest", "--version"],
96+
[sys.executable, "-m", "pytest", "--version"],
6897
capture_output=True,
6998
check=True,
7099
)
@@ -73,103 +102,121 @@ def check_pytest() -> bool:
73102
return False
74103

75104

105+
def run_pytest(
106+
project_root: Path,
107+
test_path: Path,
108+
coverage: bool = False,
109+
parallel: bool = False,
110+
) -> int:
111+
"""Run pytest for ``test_path`` from the repository root.
112+
113+
Running from the root lets pytest pick up the repository
114+
configuration (markers, timeouts, ...), and invoking pytest through
115+
``sys.executable`` targets the active interpreter even when the
116+
``pytest`` entry point is not on PATH.
117+
"""
118+
cmd = [sys.executable, "-m", "pytest", "-v", str(test_path)]
119+
120+
if coverage:
121+
cmd.extend(
122+
[
123+
"--cov=src/qwenpaw",
124+
"--cov-report=html",
125+
"--cov-report=term-missing",
126+
],
127+
)
128+
129+
if parallel:
130+
cmd.extend(["-n", "auto"])
131+
132+
try:
133+
result = subprocess.run(cmd, cwd=project_root, check=True)
134+
return result.returncode
135+
except subprocess.CalledProcessError as e:
136+
return e.returncode
137+
138+
76139
def run_unit_tests(
77140
project_root: Path,
78141
subdir: Optional[str] = None,
79142
coverage: bool = False,
80143
parallel: bool = False,
81144
) -> int:
82-
"""Run unit tests."""
145+
"""Run unit tests.
146+
147+
Without ``subdir`` the complete ``tests/unit`` tree is executed in
148+
one pytest invocation, so files placed directly in ``tests/unit``
149+
are included as well.
150+
"""
83151
if subdir:
84-
# Run specific subdirectory
85152
test_path = project_root / "tests" / "unit" / subdir
86153
if not test_path.is_dir():
87154
print_error(f"Unit test directory not found: {test_path}")
88155
return 1
89156

90157
print_info(f"Running unit tests in: {subdir}")
91-
return_code = run_pytest(test_path, coverage, parallel)
158+
return_code = run_pytest(project_root, test_path, coverage, parallel)
92159
if return_code == 0:
93160
print_success(f"Unit tests in {subdir} completed")
94161
return return_code
95-
else:
96-
# Run all unit test subdirectories
97-
print_info("Running all unit tests...")
98-
unit_dir = project_root / "tests" / "unit"
99162

100-
if not unit_dir.is_dir():
101-
print_warning("Unit test directory not found: tests/unit")
102-
return 0
103-
104-
subdirs = [d for d in unit_dir.iterdir() if d.is_dir()]
105-
if not subdirs:
106-
print_warning("No unit test subdirectories found")
107-
return 0
108-
109-
overall_return_code = 0
110-
for test_dir in subdirs:
111-
dirname = test_dir.name
112-
print_info(f"Running unit tests in: {dirname}")
113-
return_code = run_pytest(test_dir, coverage, parallel)
114-
if return_code == 0:
115-
print_success(f"Unit tests in {dirname} completed")
116-
else:
117-
overall_return_code = return_code
118-
print()
163+
unit_dir = project_root / "tests" / "unit"
164+
if not unit_dir.is_dir():
165+
print_error("Unit test directory not found: tests/unit")
166+
return 1
119167

120-
return overall_return_code
168+
print_info("Running all unit tests...")
169+
return_code = run_pytest(project_root, unit_dir, coverage, parallel)
170+
if return_code == 0:
171+
print_success("Unit tests completed")
172+
return return_code
121173

122174

123-
def run_integrated_tests(
175+
def run_contract_tests(
124176
project_root: Path,
125177
coverage: bool = False,
126178
parallel: bool = False,
127179
) -> int:
128-
"""Run integrated tests."""
129-
print_info("Running integrated tests...")
130-
integrated_dir = project_root / "tests" / "integrated"
131-
132-
if not integrated_dir.is_dir():
133-
print_warning("Integrated test directory not found: tests/integrated")
134-
return 0
135-
136-
# Check if there are any Python test files
137-
test_files = list(integrated_dir.glob("*.py"))
138-
if not test_files:
139-
print_warning("No integrated test files found in tests/integrated")
140-
return 0
180+
"""Run contract tests (tests/contract)."""
181+
print_info("Running contract tests...")
182+
contract_dir = project_root / "tests" / "contract"
183+
if not contract_dir.is_dir():
184+
print_error("Contract test directory not found: tests/contract")
185+
return 1
141186

142-
return_code = run_pytest(integrated_dir, coverage, parallel)
187+
return_code = run_pytest(project_root, contract_dir, coverage, parallel)
143188
if return_code == 0:
144-
print_success("Integrated tests completed")
189+
print_success("Contract tests completed")
145190
return return_code
146191

147192

148-
def run_pytest(
149-
test_path: Path,
193+
def run_integrated_tests(
194+
project_root: Path,
150195
coverage: bool = False,
151196
parallel: bool = False,
152197
) -> int:
153-
"""Run pytest with specified options."""
154-
cmd = ["pytest", "-v", str(test_path)]
155-
156-
if coverage:
157-
cmd.extend(
158-
[
159-
"--cov=src/qwenpaw",
160-
"--cov-report=html",
161-
"--cov-report=term-missing",
162-
],
198+
"""Run integration tests (tests/integration).
199+
200+
A missing directory is an error: silently returning success here
201+
used to mask the fact that no integration test ran at all.
202+
"""
203+
print_info("Running integration tests...")
204+
integration_dir = project_root / "tests" / "integration"
205+
if not integration_dir.is_dir():
206+
print_error(
207+
"Integration test directory not found: tests/integration",
163208
)
209+
return 1
164210

165-
if parallel:
166-
cmd.extend(["-n", "auto"])
167-
168-
try:
169-
result = subprocess.run(cmd, cwd=test_path.parents[2], check=True)
170-
return result.returncode
171-
except subprocess.CalledProcessError as e:
172-
return e.returncode
211+
return_code = run_pytest(
212+
project_root,
213+
integration_dir,
214+
coverage,
215+
parallel,
216+
)
217+
if return_code == 0:
218+
print_success("Integration tests completed")
219+
return return_code
173220

174221

175222
def main() -> int:
@@ -191,7 +238,7 @@ def main() -> int:
191238
"-i",
192239
"--integrated",
193240
action="store_true",
194-
help="Run integrated tests",
241+
help="Run integration tests (tests/integration)",
195242
)
196243
parser.add_argument(
197244
"-a",
@@ -245,12 +292,18 @@ def main() -> int:
245292
parallel=args.parallel,
246293
)
247294
print()
295+
contract_code = run_contract_tests(
296+
project_root,
297+
coverage=args.coverage,
298+
parallel=args.parallel,
299+
)
300+
print()
248301
integrated_code = run_integrated_tests(
249302
project_root,
250303
coverage=args.coverage,
251304
parallel=args.parallel,
252305
)
253-
return_code = unit_code or integrated_code
306+
return_code = unit_code or contract_code or integrated_code
254307
elif args.unit is not None:
255308
return_code = run_unit_tests(
256309
project_root,
@@ -266,12 +319,18 @@ def main() -> int:
266319
)
267320

268321
print()
269-
if args.coverage:
270-
print_success(
271-
"Test run completed! Coverage report generated in htmlcov/index.html",
272-
)
322+
if return_code == 0:
323+
if args.coverage:
324+
print_success(
325+
"Test run completed! Coverage report generated in "
326+
"htmlcov/index.html",
327+
)
328+
else:
329+
print_success("All test suites completed successfully!")
273330
else:
274-
print_success("Test run completed!")
331+
print_error(
332+
f"Test run finished with failures (exit code {return_code})",
333+
)
275334
print()
276335

277336
return return_code

0 commit comments

Comments
 (0)