DEVX-61: optimise slow unit tests and handle missing tea binary in TeaCLI
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Failing after 14s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 1m6s
Post-merge / vikunja (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 48s
Post-merge / badges (push) Successful in 1m12s

This commit was merged in pull request #101.
This commit is contained in:
2026-06-26 16:09:54 +00:00
parent f1adf22c3e
commit 06e80516d4
8 changed files with 62 additions and 19 deletions
+1 -1
View File
@@ -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 5 --max-single-seconds 0.5 python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
- name: Documentation coverage check - name: Documentation coverage check
env: env:
PYTHONPATH: src PYTHONPATH: src
+9 -6
View File
@@ -82,12 +82,15 @@ class TeaCLI:
cmd = [self._tea, *args] cmd = [self._tea, *args]
if json_output: if json_output:
cmd.extend(["--output", "json"]) cmd.extend(["--output", "json"])
result = subprocess.run( # nosec B603 try:
cmd, result = subprocess.run( # nosec B603
capture_output=True, cmd,
text=True, capture_output=True,
check=False, text=True,
) check=False,
)
except FileNotFoundError as e:
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
if result.returncode != 0: if result.returncode != 0:
raise TeaCLIError( raise TeaCLIError(
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}" f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
+3 -3
View File
@@ -317,9 +317,9 @@ class TestCollectKeys:
assert "completed" in keys assert "completed" in keys
assert "pending" in keys assert "pending" in keys
def test_default_dir_includes_dynamic_keys(self) -> None: def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
"""The default source dir should include DYNAMIC_KEYS.""" """collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR) keys = check_translations.collect_keys(tmp_path)
assert "completed" in keys assert "completed" in keys
assert "pending" in keys assert "pending" in keys
assert "in_progress" in keys assert "in_progress" in keys
+22
View File
@@ -113,6 +113,28 @@ class TestPyprojectReading:
assert cfg.VIKUNJA_PROJECT_ID == 6 assert cfg.VIKUNJA_PROJECT_ID == 6
importlib.reload(cfg) importlib.reload(cfg)
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 42
importlib.reload(cfg)
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
"""Env var override for int config takes priority over pyproject.toml."""
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
import devx.config as cfg
importlib.reload(cfg)
assert cfg.VIKUNJA_PROJECT_ID == 99
importlib.reload(cfg)
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None: def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
"""When [tool] is not a dict, defaults are used.""" """When [tool] is not a dict, defaults are used."""
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n') (tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
+6
View File
@@ -77,6 +77,12 @@ class TestTeaCLIRun:
with pytest.raises(TeaCLIError, match="auth error"): with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"]) cli._run(["labels", "list"])
def test_run_tea_not_found_raises_tea_error(self) -> None:
cli = TeaCLI(tea_bin="tea")
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
with pytest.raises(TeaCLIError, match="tea binary not found"):
cli._run(["labels", "list"])
def test_run_includes_json_flag(self) -> None: def test_run_includes_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea") cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="[]", stderr="") mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
+3 -3
View File
@@ -116,7 +116,7 @@ class TestCli:
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,
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
proc = MagicMock() proc = MagicMock()
@@ -163,7 +163,7 @@ class TestCli:
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")),
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
proc = MagicMock() proc = MagicMock()
@@ -208,7 +208,7 @@ class TestCli:
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,
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
proc = MagicMock() proc = MagicMock()
+4 -4
View File
@@ -302,7 +302,7 @@ class TestCli:
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,
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
proc = MagicMock() proc = MagicMock()
@@ -338,7 +338,7 @@ class TestCli:
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run, patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}] mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
proc = MagicMock() proc = MagicMock()
@@ -385,7 +385,7 @@ class TestCli:
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,
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process") mock_killpg.side_effect = ProcessLookupError("no such process")
@@ -432,7 +432,7 @@ class TestCli:
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,
patch("os.getpgid") as mock_getpgid, patch("os.getpgid") as mock_getpgid,
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), patch("time.sleep", side_effect=lambda x: real_sleep(0)),
): ):
mock_getpgid.return_value = 123 mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")] mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
+14 -2
View File
@@ -398,10 +398,16 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package") @patch("devx.ci.publish.build_package")
def test_create_release_already_exists_is_idempotent( def test_create_release_already_exists_is_idempotent(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock self,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None: ) -> None:
"""If create_release fails with 'already exists', treat as success.""" """If create_release fails with 'already exists', treat as success."""
mock_tea = MagicMock() mock_tea = MagicMock()
@@ -416,10 +422,16 @@ class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@patch("devx.ci.publish.build_package") @patch("devx.ci.publish.build_package")
def test_create_release_other_error_raises( def test_create_release_other_error_raises(
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock self,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_gitea_pub: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None: ) -> None:
"""If create_release fails with a non-'already exists' error, raise.""" """If create_release fails with a non-'already exists' error, raise."""
mock_tea = MagicMock() mock_tea = MagicMock()