Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a60009d29 | ||
|
|
c20dfd185a |
@@ -27,7 +27,7 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 -m devx.tools.check_test_speed --max-seconds 60
|
python3 -m devx.tools.check_test_speed --max-seconds 60 --max-single-seconds 2.0
|
||||||
- name: Documentation coverage check
|
- name: Documentation coverage check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-06-23
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add per-test timing quality gate to check_test_speed
|
||||||
|
|
||||||
## [0.6.0] - 2026-06-23
|
## [0.6.0] - 2026-06-23
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -76,7 +76,13 @@ Validate commit messages for conventional commit format.
|
|||||||
|
|
||||||
### `devx tools check-test-speed`
|
### `devx tools check-test-speed`
|
||||||
|
|
||||||
Run unit tests and enforce a maximum execution-time budget.
|
Run unit tests and enforce execution-time budgets:
|
||||||
|
- **Total suite time** must not exceed `--max-seconds` (default: 10s).
|
||||||
|
- **Per-test time** — no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to disable).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
|
||||||
|
```
|
||||||
|
|
||||||
### `devx tools configure-repo`
|
### `devx tools configure-repo`
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# pre-commit hook: fail if unit tests take longer than 10 seconds.
|
# pre-commit hook: fail if unit tests are too slow.
|
||||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
# Checks both total suite time (10s) and per-test time (0.5s).
|
||||||
|
# Aligned with CI (ci.yml uses same thresholds).
|
||||||
set -e
|
set -e
|
||||||
export PYTHONPATH=src
|
export PYTHONPATH=src
|
||||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
python3 -m devx.tools.check_test_speed --max-seconds 10 --max-single-seconds 0.5
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.6.0"
|
__version__ = "0.7.0"
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run unit tests and enforce a maximum execution-time budget.
|
"""Run unit tests and enforce execution-time budgets.
|
||||||
|
|
||||||
|
Checks two quality gates:
|
||||||
|
1. **Total suite time** must not exceed ``--max-seconds``.
|
||||||
|
2. **Per-test time** — no individual test may exceed ``--max-single-seconds``.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 -m devx.tools.check_test_speed [--max-seconds N]
|
python3 -m devx.tools.check_test_speed [--max-seconds N] [--max-single-seconds S]
|
||||||
|
|
||||||
|
The module runs ``make test-unit`` with ``PYTEST_ADDOPTS=--durations=0`` so
|
||||||
|
that pytest emits per-test timing lines alongside the summary. Both the
|
||||||
|
total wall-clock time and individual test durations are parsed and validated.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
|
|
||||||
@@ -14,18 +23,32 @@ import click
|
|||||||
|
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
DEFAULT_MAX_SECONDS = 2.0
|
DEFAULT_MAX_SECONDS = 10.0
|
||||||
|
DEFAULT_MAX_SINGLE_SECONDS = 0.5
|
||||||
TEST_COMMAND = ["make", "test-unit"]
|
TEST_COMMAND = ["make", "test-unit"]
|
||||||
|
|
||||||
|
# Matches pytest summary line: "234 passed in 0.70s"
|
||||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||||
|
|
||||||
|
# Matches per-test duration lines from --durations=0:
|
||||||
|
# 0.51s call tests/test_foo.py::test_bar
|
||||||
|
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
|
||||||
|
|
||||||
|
|
||||||
def run_tests() -> tuple[str, str]:
|
def run_tests() -> tuple[str, str]:
|
||||||
"""Execute the unit-test suite and return (stdout, stderr)."""
|
"""Execute the unit-test suite and return (stdout, stderr).
|
||||||
|
|
||||||
|
Sets ``PYTEST_ADDOPTS=--durations=0`` so pytest emits per-test timings.
|
||||||
|
"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
existing = env.get("PYTEST_ADDOPTS", "")
|
||||||
|
env["PYTEST_ADDOPTS"] = f"--durations=0 {existing}".strip()
|
||||||
result = subprocess.run( # nosec B603
|
result = subprocess.run( # nosec B603
|
||||||
TEST_COMMAND,
|
TEST_COMMAND,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
|
env=env,
|
||||||
)
|
)
|
||||||
return result.stdout, result.stderr
|
return result.stdout, result.stderr
|
||||||
|
|
||||||
@@ -43,8 +66,23 @@ def parse_duration(output: str) -> float:
|
|||||||
raise click.ClickException(_("Could not parse test execution time from output."))
|
raise click.ClickException(_("Could not parse test execution time from output."))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_per_test_durations(output: str) -> list[tuple[str, float]]:
|
||||||
|
"""Extract per-test timings from ``--durations=0`` output.
|
||||||
|
|
||||||
|
Returns a list of ``(test_name, seconds)`` tuples sorted by duration
|
||||||
|
(slowest first).
|
||||||
|
"""
|
||||||
|
durations: list[tuple[str, float]] = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
match = _DURATION_LINE_RE.match(line.strip())
|
||||||
|
if match:
|
||||||
|
durations.append((match.group(2).strip(), float(match.group(1))))
|
||||||
|
durations.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
return durations
|
||||||
|
|
||||||
|
|
||||||
def check_speed(duration: float, max_seconds: float) -> None:
|
def check_speed(duration: float, max_seconds: float) -> None:
|
||||||
"""Validate duration is within budget; raise on violation."""
|
"""Validate total duration is within budget; raise on violation."""
|
||||||
if duration > max_seconds:
|
if duration > max_seconds:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
_(
|
_(
|
||||||
@@ -57,19 +95,58 @@ def check_speed(duration: float, max_seconds: float) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def main(max_seconds: float) -> None:
|
def check_per_test_speed(
|
||||||
"""Run tests, parse timing, and enforce the budget."""
|
durations: list[tuple[str, float]],
|
||||||
|
max_single_seconds: float,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return a list of violation messages for tests exceeding the per-test limit.
|
||||||
|
|
||||||
|
An empty list means all tests are within budget.
|
||||||
|
"""
|
||||||
|
violations: list[str] = []
|
||||||
|
for name, elapsed in durations:
|
||||||
|
if elapsed > max_single_seconds:
|
||||||
|
violations.append(
|
||||||
|
_(
|
||||||
|
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). "
|
||||||
|
"Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||||
|
name=name,
|
||||||
|
elapsed=elapsed,
|
||||||
|
limit=max_single_seconds,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def main(max_seconds: float, max_single_seconds: float) -> None:
|
||||||
|
"""Run tests, parse timings, and enforce both budgets."""
|
||||||
stdout, stderr = run_tests()
|
stdout, stderr = run_tests()
|
||||||
combined = stdout + "\n" + stderr
|
combined = stdout + "\n" + stderr
|
||||||
click.echo(combined, err=False)
|
click.echo(combined, err=False)
|
||||||
|
|
||||||
duration = parse_duration(combined)
|
duration = parse_duration(combined)
|
||||||
check_speed(duration, max_seconds)
|
check_speed(duration, max_seconds)
|
||||||
|
|
||||||
|
if max_single_seconds > 0:
|
||||||
|
per_test = parse_per_test_durations(combined)
|
||||||
|
violations = check_per_test_speed(per_test, max_single_seconds)
|
||||||
|
if violations:
|
||||||
|
msg = _(
|
||||||
|
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
|
count=len(violations),
|
||||||
|
limit=max_single_seconds,
|
||||||
|
)
|
||||||
|
click.echo(f"\n{msg}", err=True)
|
||||||
|
for v in violations:
|
||||||
|
click.echo(f" - {v}", err=True)
|
||||||
|
raise click.ClickException(msg)
|
||||||
|
|
||||||
click.echo(
|
click.echo(
|
||||||
_(
|
_(
|
||||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
duration=duration,
|
duration=duration,
|
||||||
max=max_seconds,
|
max=max_seconds,
|
||||||
|
single=max_single_seconds,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,10 +157,17 @@ def main(max_seconds: float) -> None:
|
|||||||
type=float,
|
type=float,
|
||||||
default=DEFAULT_MAX_SECONDS,
|
default=DEFAULT_MAX_SECONDS,
|
||||||
show_default=True,
|
show_default=True,
|
||||||
help="Maximum allowed execution time in seconds.",
|
help="Maximum allowed total execution time in seconds.",
|
||||||
)
|
)
|
||||||
def cli(max_seconds: float) -> None:
|
@click.option(
|
||||||
main(max_seconds)
|
"--max-single-seconds",
|
||||||
|
type=float,
|
||||||
|
default=DEFAULT_MAX_SINGLE_SECONDS,
|
||||||
|
show_default=True,
|
||||||
|
help="Maximum allowed per-test time in seconds (0 to disable).",
|
||||||
|
)
|
||||||
|
def cli(max_seconds: float, max_single_seconds: float) -> None:
|
||||||
|
main(max_seconds, max_single_seconds)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -916,13 +916,6 @@
|
|||||||
"ru": "Tests passed.",
|
"ru": "Tests passed.",
|
||||||
"zh": "Tests passed."
|
"zh": "Tests passed."
|
||||||
},
|
},
|
||||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
|
|
||||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
|
||||||
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
|
||||||
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
|
||||||
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
|
||||||
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
|
|
||||||
},
|
|
||||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
|
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
|
||||||
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||||
"bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
"bg": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
||||||
@@ -1195,5 +1188,26 @@
|
|||||||
"de": "Roles directory not found: {path}",
|
"de": "Roles directory not found: {path}",
|
||||||
"ru": "Roles directory not found: {path}",
|
"ru": "Roles directory not found: {path}",
|
||||||
"zh": "Roles directory not found: {path}"
|
"zh": "Roles directory not found: {path}"
|
||||||
|
},
|
||||||
|
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
|
||||||
|
"en": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
|
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
|
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
|
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||||
|
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
||||||
|
},
|
||||||
|
"Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.": {
|
||||||
|
"en": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||||
|
"bg": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||||
|
"de": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||||
|
"ru": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls.",
|
||||||
|
"zh": "Test '{name}' took {elapsed:.2f}s (limit: {limit}s). Optimise: use lighter fixtures, reduce I/O, or mock external calls."
|
||||||
|
},
|
||||||
|
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
|
||||||
|
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
|
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
|
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
|
"ru": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||||
|
"zh": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -566,7 +566,8 @@ class TestVikunjaClient:
|
|||||||
json={"done": True},
|
json={"done": True},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_http_error_raises_api_error(self) -> None:
|
@patch("devx.api_clients.time.sleep")
|
||||||
|
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||||
client = VikunjaClient("https://work.example.com", "tok")
|
client = VikunjaClient("https://work.example.com", "tok")
|
||||||
mock_resp = MagicMock()
|
mock_resp = MagicMock()
|
||||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Unit tests for scripts/check_test_speed.py."""
|
"""Unit tests for devx.tools.check_test_speed."""
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
@@ -8,10 +8,13 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
from devx.tools.check_test_speed import (
|
from devx.tools.check_test_speed import (
|
||||||
DEFAULT_MAX_SECONDS,
|
DEFAULT_MAX_SECONDS,
|
||||||
|
DEFAULT_MAX_SINGLE_SECONDS,
|
||||||
TEST_COMMAND,
|
TEST_COMMAND,
|
||||||
|
check_per_test_speed,
|
||||||
check_speed,
|
check_speed,
|
||||||
cli,
|
cli,
|
||||||
parse_duration,
|
parse_duration,
|
||||||
|
parse_per_test_durations,
|
||||||
run_tests,
|
run_tests,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,12 +26,23 @@ class TestRunTests:
|
|||||||
stdout, stderr = run_tests()
|
stdout, stderr = run_tests()
|
||||||
assert stdout == "out"
|
assert stdout == "out"
|
||||||
assert stderr == "err"
|
assert stderr == "err"
|
||||||
mock_run.assert_called_once_with(
|
mock_run.assert_called_once()
|
||||||
TEST_COMMAND,
|
call_kwargs = mock_run.call_args
|
||||||
capture_output=True,
|
assert call_kwargs.args[0] == TEST_COMMAND
|
||||||
text=True,
|
assert call_kwargs.kwargs["capture_output"] is True
|
||||||
check=False,
|
assert call_kwargs.kwargs["text"] is True
|
||||||
)
|
assert call_kwargs.kwargs["check"] is False
|
||||||
|
env = call_kwargs.kwargs["env"]
|
||||||
|
assert "--durations=0" in env["PYTEST_ADDOPTS"]
|
||||||
|
|
||||||
|
@patch("devx.tools.check_test_speed.subprocess.run")
|
||||||
|
def test_run_tests_preserves_existing_pytest_addopts(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
|
||||||
|
with patch.dict("os.environ", {"PYTEST_ADDOPTS": "-x"}, clear=False):
|
||||||
|
run_tests()
|
||||||
|
env = mock_run.call_args.kwargs["env"]
|
||||||
|
assert "--durations=0" in env["PYTEST_ADDOPTS"]
|
||||||
|
assert "-x" in env["PYTEST_ADDOPTS"]
|
||||||
|
|
||||||
|
|
||||||
class TestParseDuration:
|
class TestParseDuration:
|
||||||
@@ -48,6 +62,38 @@ class TestParseDuration:
|
|||||||
assert "Could not parse" in str(exc.value)
|
assert "Could not parse" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
class TestParsePerTestDurations:
|
||||||
|
def test_parses_call_lines(self) -> None:
|
||||||
|
output = "0.01s call tests/test_foo.py::test_bar\n"
|
||||||
|
durations = parse_per_test_durations(output)
|
||||||
|
assert len(durations) == 1
|
||||||
|
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
||||||
|
|
||||||
|
def test_parses_setup_and_teardown(self) -> None:
|
||||||
|
output = (
|
||||||
|
"0.02s setup tests/test_foo.py::test_bar\n"
|
||||||
|
"0.01s call tests/test_foo.py::test_bar\n"
|
||||||
|
"0.00s teardown tests/test_foo.py::test_bar\n"
|
||||||
|
)
|
||||||
|
durations = parse_per_test_durations(output)
|
||||||
|
assert len(durations) == 3
|
||||||
|
names = [d[0] for d in durations]
|
||||||
|
assert "tests/test_foo.py::test_bar" in names
|
||||||
|
|
||||||
|
def test_sorted_slowest_first(self) -> None:
|
||||||
|
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
|
||||||
|
durations = parse_per_test_durations(output)
|
||||||
|
assert durations[0][1] >= durations[1][1]
|
||||||
|
assert durations[0][1] == 0.50
|
||||||
|
|
||||||
|
def test_empty_output(self) -> None:
|
||||||
|
assert parse_per_test_durations("") == []
|
||||||
|
|
||||||
|
def test_ignores_non_duration_lines(self) -> None:
|
||||||
|
output = "Some random line\n234 passed in 0.70s\n"
|
||||||
|
assert parse_per_test_durations(output) == []
|
||||||
|
|
||||||
|
|
||||||
class TestCheckSpeed:
|
class TestCheckSpeed:
|
||||||
def test_under_budget_passes(self) -> None:
|
def test_under_budget_passes(self) -> None:
|
||||||
check_speed(1.0, 2.0) # should not raise
|
check_speed(1.0, 2.0) # should not raise
|
||||||
@@ -64,6 +110,31 @@ class TestCheckSpeed:
|
|||||||
assert "max allowed: 2.0s" in msg
|
assert "max allowed: 2.0s" in msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckPerTestSpeed:
|
||||||
|
def test_no_violations_when_all_fast(self) -> None:
|
||||||
|
durations = [("test_a", 0.1), ("test_b", 0.2)]
|
||||||
|
assert check_per_test_speed(durations, 0.5) == []
|
||||||
|
|
||||||
|
def test_violation_when_test_exceeds_limit(self) -> None:
|
||||||
|
durations = [("test_slow", 0.6), ("test_fast", 0.1)]
|
||||||
|
violations = check_per_test_speed(durations, 0.5)
|
||||||
|
assert len(violations) == 1
|
||||||
|
assert "test_slow" in violations[0]
|
||||||
|
assert "0.60s" in violations[0]
|
||||||
|
|
||||||
|
def test_multiple_violations(self) -> None:
|
||||||
|
durations = [("test_a", 0.7), ("test_b", 0.6), ("test_c", 0.1)]
|
||||||
|
violations = check_per_test_speed(durations, 0.5)
|
||||||
|
assert len(violations) == 2
|
||||||
|
|
||||||
|
def test_exact_limit_passes(self) -> None:
|
||||||
|
durations = [("test_a", 0.5)]
|
||||||
|
assert check_per_test_speed(durations, 0.5) == []
|
||||||
|
|
||||||
|
def test_empty_durations(self) -> None:
|
||||||
|
assert check_per_test_speed([], 0.5) == []
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
def test_main_module_block() -> None:
|
||||||
import devx.tools.check_test_speed as cts
|
import devx.tools.check_test_speed as cts
|
||||||
|
|
||||||
@@ -77,39 +148,71 @@ class TestMain:
|
|||||||
@patch("devx.tools.check_test_speed.run_tests")
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||||
|
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||||
def test_successful_run(
|
def test_successful_run(
|
||||||
self,
|
self,
|
||||||
|
mock_check_per: MagicMock,
|
||||||
|
mock_parse_per: MagicMock,
|
||||||
mock_check: MagicMock,
|
mock_check: MagicMock,
|
||||||
mock_parse: MagicMock,
|
mock_parse: MagicMock,
|
||||||
mock_run: MagicMock,
|
mock_run: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_run.return_value = ("stdout\n", "stderr\n")
|
mock_run.return_value = ("stdout\n", "stderr\n")
|
||||||
mock_parse.return_value = 1.5
|
mock_parse.return_value = 1.5
|
||||||
|
mock_parse_per.return_value = []
|
||||||
|
mock_check_per.return_value = []
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, [])
|
result = runner.invoke(cli, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "1.50s" in result.output
|
assert "1.50s" in result.output
|
||||||
assert "under 2.0s limit" in result.output
|
assert "under 10.0s limit" in result.output
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
|
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
|
||||||
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
|
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
|
||||||
|
mock_parse_per.assert_called_once()
|
||||||
|
mock_check_per.assert_called_once_with([], DEFAULT_MAX_SINGLE_SECONDS)
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed.run_tests")
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
def test_slow_tests_exit(
|
def test_slow_total_exits(
|
||||||
self,
|
self,
|
||||||
mock_parse: MagicMock,
|
mock_parse: MagicMock,
|
||||||
mock_run: MagicMock,
|
mock_run: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_run.return_value = ("out\n", "err\n")
|
mock_run.return_value = ("out\n", "err\n")
|
||||||
mock_parse.return_value = 3.0
|
mock_parse.return_value = 15.0
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, [])
|
result = runner.invoke(cli, [])
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "too slow" in result.output.lower()
|
assert "too slow" in result.output.lower()
|
||||||
|
|
||||||
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||||
|
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||||
|
def test_per_test_violation_exits(
|
||||||
|
self,
|
||||||
|
mock_check_per: MagicMock,
|
||||||
|
mock_parse_per: MagicMock,
|
||||||
|
mock_check: MagicMock,
|
||||||
|
mock_parse: MagicMock,
|
||||||
|
mock_run: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
mock_run.return_value = ("out\n", "err\n")
|
||||||
|
mock_parse.return_value = 3.0
|
||||||
|
mock_parse_per.return_value = [("test_slow", 0.8)]
|
||||||
|
mock_check_per.return_value = ["Test 'test_slow' took 0.80s (limit: 0.5s)."]
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Per-test speed check FAILED" in result.output
|
||||||
|
assert "test_slow" in result.output
|
||||||
|
|
||||||
@patch("devx.tools.check_test_speed.run_tests")
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
def test_parse_failure_exits(
|
def test_parse_failure_exits(
|
||||||
self,
|
self,
|
||||||
@@ -125,16 +228,67 @@ class TestMain:
|
|||||||
@patch("devx.tools.check_test_speed.run_tests")
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
@patch("devx.tools.check_test_speed.parse_duration")
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
@patch("devx.tools.check_test_speed.check_speed")
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||||
|
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||||
def test_custom_max_seconds(
|
def test_custom_max_seconds(
|
||||||
self,
|
self,
|
||||||
|
mock_check_per: MagicMock,
|
||||||
|
mock_parse_per: MagicMock,
|
||||||
mock_check: MagicMock,
|
mock_check: MagicMock,
|
||||||
mock_parse: MagicMock,
|
mock_parse: MagicMock,
|
||||||
mock_run: MagicMock,
|
mock_run: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
mock_run.return_value = ("out\n", "err\n")
|
mock_run.return_value = ("out\n", "err\n")
|
||||||
mock_parse.return_value = 0.5
|
mock_parse.return_value = 0.5
|
||||||
|
mock_parse_per.return_value = []
|
||||||
|
mock_check_per.return_value = []
|
||||||
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(cli, ["--max-seconds", "1.5"])
|
result = runner.invoke(cli, ["--max-seconds", "1.5"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_check.assert_called_once_with(0.5, 1.5)
|
mock_check.assert_called_once_with(0.5, 1.5)
|
||||||
|
|
||||||
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||||
|
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||||
|
def test_disable_per_test_check(
|
||||||
|
self,
|
||||||
|
mock_check_per: MagicMock,
|
||||||
|
mock_parse_per: MagicMock,
|
||||||
|
mock_check: MagicMock,
|
||||||
|
mock_parse: MagicMock,
|
||||||
|
mock_run: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
mock_run.return_value = ("out\n", "err\n")
|
||||||
|
mock_parse.return_value = 1.0
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--max-single-seconds", "0"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_parse_per.assert_not_called()
|
||||||
|
mock_check_per.assert_not_called()
|
||||||
|
|
||||||
|
@patch("devx.tools.check_test_speed.run_tests")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_duration")
|
||||||
|
@patch("devx.tools.check_test_speed.check_speed")
|
||||||
|
@patch("devx.tools.check_test_speed.parse_per_test_durations")
|
||||||
|
@patch("devx.tools.check_test_speed.check_per_test_speed")
|
||||||
|
def test_custom_max_single_seconds(
|
||||||
|
self,
|
||||||
|
mock_check_per: MagicMock,
|
||||||
|
mock_parse_per: MagicMock,
|
||||||
|
mock_check: MagicMock,
|
||||||
|
mock_parse: MagicMock,
|
||||||
|
mock_run: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
mock_run.return_value = ("out\n", "err\n")
|
||||||
|
mock_parse.return_value = 1.0
|
||||||
|
mock_parse_per.return_value = []
|
||||||
|
mock_check_per.return_value = []
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--max-single-seconds", "1.0"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_check_per.assert_called_once_with([], 1.0)
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
@@ -177,6 +178,7 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||||
@@ -221,6 +223,7 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
|
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
|
|||||||
Reference in New Issue
Block a user