Files
workflows-py/tests/dev_cli/test_pytest_cmd.py
T
Adrian Lyjak 5ded4b40fa chore: CI/testing improvements for parallel test execution (#317)
* chore: CI/testing improvements for parallel test execution

- Add workflows-dev pytest command with parallel test runner
- Add pytest-xdist for parallel test execution (-nauto)
- Add timeouts to prevent test hangs (--timeout=10/60/120)
- Use module-scoped async fixtures for faster tests
- Remove mypy in favor of basedpyright/ty type checking
- Add test-docker CI job for integration tests with testcontainers
- Exclude integration tests from Python 3.9/3.14 in CI matrix
2026-02-02 13:27:10 -05:00

875 lines
29 KiB
Python

# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Tests for the pytest CLI command."""
from __future__ import annotations
import subprocess
import threading
import time
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import Mock, patch
import pytest
from click.testing import CliRunner
from conftest import is_sync_call, sync_success
from dev_cli.cli import cli
from dev_cli.commands.pytest_cmd import (
PackageInfo,
_render_progress_table,
discover_test_packages,
extract_failed_test_names,
extract_failures_section,
run_tests_with_rich_progress,
)
if TYPE_CHECKING:
from collections.abc import Callable
@pytest.fixture
def runner() -> CliRunner:
return CliRunner()
@pytest.fixture
def packages_dir(tmp_path: Path) -> Path:
"""Create a packages directory."""
packages = tmp_path / "packages"
packages.mkdir()
return packages
@pytest.fixture
def create_pkg(packages_dir: Path) -> Callable[[str], PackageInfo]:
"""Factory to create a package with tests subdirectory."""
def _create(name: str) -> PackageInfo:
pkg = packages_dir / name
pkg.mkdir()
(pkg / "tests").mkdir()
return PackageInfo(path=pkg, name=name)
return _create
# --- discover_test_packages tests ---
def test_discover_finds_packages_with_tests(packages_dir: Path) -> None:
# Create package with tests manually (not using create_pkg fixture)
pkg = packages_dir / "pkg-with-tests"
pkg.mkdir()
(pkg / "tests").mkdir()
# Package without tests
(packages_dir / "pkg-without-tests").mkdir()
result = discover_test_packages(packages_dir.parent)
assert len(result) == 1
assert result[0].name == "pkg-with-tests"
def test_discover_returns_sorted_list(packages_dir: Path) -> None:
for name in ["zebra-pkg", "alpha-pkg", "middle-pkg"]:
pkg = packages_dir / name
pkg.mkdir()
(pkg / "tests").mkdir()
result = discover_test_packages(packages_dir.parent)
assert [p.name for p in result] == ["alpha-pkg", "middle-pkg", "zebra-pkg"]
def test_discover_empty_when_no_packages_dir(tmp_path: Path) -> None:
result = discover_test_packages(tmp_path)
assert result == []
# --- Basic CLI tests ---
def test_pytest_help(runner: CliRunner) -> None:
result = runner.invoke(cli, ["pytest", "--help"])
assert result.exit_code == 0
assert "--package" in result.output
assert "-p" in result.output
def test_pytest_filters_by_package(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkg_a = create_pkg("pkg-a")
pkg_b = create_pkg("pkg-b")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[pkg_a, pkg_b],
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
runner.invoke(cli, ["pytest", "-p", "pkg-a"])
# 2 calls: sync + pytest for pkg-a only
assert mock_run.call_count == 2
call_args = mock_run.call_args_list[1][0][0]
assert "pkg-a" in str(call_args)
def test_pytest_errors_on_unknown_package(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
real_pkg = create_pkg("real-pkg")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[real_pkg],
):
result = runner.invoke(cli, ["pytest", "-p", "unknown-pkg"])
assert result.exit_code == 1
assert "No packages matched: unknown-pkg" in result.output
assert "Available: real-pkg" in result.output
@pytest.mark.parametrize(
"pkg_names,filters,expected_matches",
[
(["pkg-foo", "pkg-foo-client", "pkg-bar"], ["foo"], 2), # substring
(["foo", "foo-client"], ["foo"], 1), # exact takes precedence
(["foo", "foo-client", "bar"], ["foo", "bar"], 2), # mixed
],
ids=["substring", "exact", "mixed"],
)
def test_pytest_package_filter_matching(
runner: CliRunner,
create_pkg: Callable[[str], PackageInfo],
pkg_names: list[str],
filters: list[str],
expected_matches: int,
) -> None:
pkgs = [create_pkg(n) for n in pkg_names]
filter_args = [arg for f in filters for arg in ["-p", f]]
with patch("dev_cli.commands.pytest_cmd.discover_test_packages", return_value=pkgs):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
runner.invoke(cli, ["pytest", *filter_args])
assert mock_run.call_count == expected_matches * 2 # sync + pytest per pkg
def test_pytest_passes_args_through(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
my_pkg = create_pkg("my-pkg")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[my_pkg],
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0)
runner.invoke(cli, ["pytest", "--", "-v", "--tb=short", "-k", "test_foo"])
call_args = mock_run.call_args[0][0]
assert "-v" in call_args
assert "--tb=short" in call_args
assert "-k" in call_args
assert "test_foo" in call_args
def test_pytest_continues_on_failure(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b", "pkg-c"]]
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.side_effect = [
sync_success(),
Mock(returncode=1, stdout="", stderr=""),
sync_success(),
Mock(returncode=0, stdout="", stderr=""),
sync_success(),
Mock(returncode=0, stdout="", stderr=""),
]
result = runner.invoke(cli, ["pytest"])
assert mock_run.call_count == 6
assert result.exit_code == 1
assert "FAILED" in result.output
assert "PASSED" in result.output
def test_pytest_shows_summary(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b"]]
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=0)
result = runner.invoke(cli, ["pytest"])
assert "Test Summary" in result.output
assert "pkg-a" in result.output
assert "pkg-b" in result.output
assert "2 passed" in result.output
# --- Quiet/verbose mode tests ---
def test_pytest_quiet_mode_hides_streaming_output(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""Quiet mode (default) shows compact progress, not full pytest output."""
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b"]]
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(
returncode=0,
stdout="collected 5 items\ntest_foo.py::test_one PASSED\n",
stderr="",
)
result = runner.invoke(cli, ["pytest"])
assert "[1/2]" in result.output
assert "[2/2]" in result.output
# Pytest's "collected X items" should NOT appear
assert "collected" not in result.output
def test_pytest_verbose_shows_streaming_output(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""Verbose mode shows full pytest output AND aggregate summary."""
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b"]]
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(
returncode=0,
stdout="collected 5 items\ntest_foo.py::test_one PASSED\n",
stderr="",
)
result = runner.invoke(cli, ["pytest", "--verbose"])
assert "collected 5 items" in result.output
assert "test_foo.py::test_one PASSED" in result.output
assert "Test Summary" in result.output
assert "2 passed" in result.output
def test_pytest_failure_recap_always_shown(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""Failure recap is shown in both quiet and verbose modes."""
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b"]]
failure_output = (
"collected 3 items\n"
"test_foo.py::test_failing FAILED\n"
"FAILURES\n"
"test_failing - AssertionError: expected True\n"
)
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
for verbose_flag in [[], ["--verbose"]]:
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.side_effect = [
sync_success(),
Mock(returncode=0, stdout="all tests passed\n", stderr=""),
sync_success(),
Mock(returncode=1, stdout=failure_output, stderr=""),
]
result = runner.invoke(cli, ["pytest", *verbose_flag])
assert "FAILURES" in result.output or "FAILED" in result.output
assert "pkg-b" in result.output
assert "AssertionError" in result.output
# --- Parallel execution tests ---
def test_pytest_runs_parallel_by_default(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""Tests run in parallel by default - verified by concurrent execution."""
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b", "pkg-c"]]
started_count = 0
started_lock = threading.Lock()
all_started = threading.Event()
call_order: list[str] = []
def mock_subprocess_run(
cmd: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
nonlocal started_count
if is_sync_call(cmd):
return subprocess.CompletedProcess(
args=cmd, returncode=0, stdout="", stderr=""
)
pkg_name = "unknown"
for i, arg in enumerate(cmd):
if arg == "--directory" and i + 1 < len(cmd):
pkg_name = Path(cmd[i + 1]).name
break
with started_lock:
started_count += 1
call_order.append(f"start:{pkg_name}")
if started_count >= 3:
all_started.set()
all_started.wait(timeout=2.0)
with started_lock:
call_order.append(f"end:{pkg_name}")
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch(
"dev_cli.commands.pytest_cmd.subprocess.run",
side_effect=mock_subprocess_run,
):
result = runner.invoke(cli, ["pytest"])
# Verify parallel: at least 2 starts before any end
start_indices = [
i for i, x in enumerate(call_order) if x.startswith("start:")
]
end_indices = [i for i, x in enumerate(call_order) if x.startswith("end:")]
assert len(start_indices) >= 2
assert max(start_indices[:2]) < min(end_indices)
assert result.exit_code == 0
def test_pytest_parallel_one_runs_sequentially(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""--parallel 1 runs packages one at a time."""
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b", "pkg-c"]]
call_order: list[str] = []
lock = threading.Lock()
active_count = 0
max_concurrent = 0
def mock_subprocess_run(
cmd: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
nonlocal active_count, max_concurrent
if is_sync_call(cmd):
return subprocess.CompletedProcess(
args=cmd, returncode=0, stdout="", stderr=""
)
pkg_name = "unknown"
for i, arg in enumerate(cmd):
if arg == "--directory" and i + 1 < len(cmd):
pkg_name = Path(cmd[i + 1]).name
break
with lock:
active_count += 1
max_concurrent = max(max_concurrent, active_count)
call_order.append(f"start:{pkg_name}")
time.sleep(0.01)
with lock:
call_order.append(f"end:{pkg_name}")
active_count -= 1
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch(
"dev_cli.commands.pytest_cmd.subprocess.run",
side_effect=mock_subprocess_run,
):
result = runner.invoke(cli, ["pytest", "--parallel", "1"])
assert max_concurrent == 1
# Verify strict ordering: start, end, start, end...
for i in range(0, len(call_order) - 1, 2):
assert call_order[i].startswith("start:")
assert call_order[i + 1].startswith("end:")
assert result.exit_code == 0
def test_pytest_parallel_handles_mixed_results(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""Parallel execution handles mixed passed/failed results correctly."""
pkgs = [create_pkg(name) for name in ["pkg-fail", "pkg-pass-1", "pkg-pass-2"]]
completed: list[str] = []
lock = threading.Lock()
def mock_subprocess_run(
cmd: list[str], **kwargs: object
) -> subprocess.CompletedProcess[str]:
if is_sync_call(cmd):
return subprocess.CompletedProcess(
args=cmd, returncode=0, stdout="", stderr=""
)
pkg_name = "unknown"
for i, arg in enumerate(cmd):
if arg == "--directory" and i + 1 < len(cmd):
pkg_name = Path(cmd[i + 1]).name
break
with lock:
completed.append(pkg_name)
if pkg_name == "pkg-fail":
return subprocess.CompletedProcess(
args=cmd,
returncode=1,
stdout="FAILED test_example.py::test_bad\nAssertionError: bad\n",
stderr="",
)
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch(
"dev_cli.commands.pytest_cmd.subprocess.run",
side_effect=mock_subprocess_run,
):
result = runner.invoke(cli, ["pytest"])
assert set(completed) == {"pkg-fail", "pkg-pass-1", "pkg-pass-2"}
assert result.exit_code == 1
assert "2 passed" in result.output
assert "1 failed" in result.output
# --- extract_failures_section tests ---
def test_extract_failures_returns_failures_block() -> None:
pytest_output = """
============================= test session starts ==============================
collected 5 items
test_example.py::test_one PASSED
test_example.py::test_two FAILED
=================================== FAILURES ===================================
_________________________ test_two _________________________
def test_two():
> assert False
E AssertionError
test_example.py:10: AssertionError
=========================== short test summary info ============================
FAILED test_example.py::test_two - AssertionError
============================== 1 failed, 1 passed ==============================
"""
result = extract_failures_section(pytest_output)
assert result is not None
assert "FAILURES" in result
assert "test_two" in result
assert "short test summary" not in result
def test_extract_failures_returns_none_for_passing() -> None:
pytest_output = """
============================= test session starts ==============================
collected 2 items
test_example.py::test_one PASSED
============================== 2 passed ==============================
"""
result = extract_failures_section(pytest_output)
assert result is None
def test_extract_failures_excludes_warnings() -> None:
pytest_output = """
=================================== FAILURES ===================================
_________________________ test_failing _________________________
AssertionError
=============================== warnings summary ===============================
test_example.py:3: DeprecationWarning: deprecated
=========================== short test summary info ============================
"""
result = extract_failures_section(pytest_output)
assert result is not None
assert "FAILURES" in result
assert "DeprecationWarning" not in result
def test_extract_failures_handles_multiple() -> None:
pytest_output = """
=================================== FAILURES ===================================
_________________________ test_one _________________________
AssertionError: first failure
_________________________ test_two _________________________
AssertionError: second failure
=========================== short test summary info ============================
"""
result = extract_failures_section(pytest_output)
assert result is not None
assert "first failure" in result
assert "second failure" in result
# --- extract_failed_test_names tests ---
def test_extract_failed_names_from_short_summary() -> None:
pytest_output = """
=========================== short test summary info ============================
FAILED tests/test_example.py::test_one - AssertionError
FAILED tests/test_example.py::test_two - ValueError
============================== 2 failed ==============================
"""
result = extract_failed_test_names(pytest_output)
assert len(result) == 2
test_names = [name for name, _ in result]
reasons = [reason for _, reason in result]
assert "tests/test_example.py::test_one" in test_names
assert "AssertionError" in reasons
assert "ValueError" in reasons
def test_extract_failed_names_fallback_to_headers() -> None:
pytest_output = """
=================================== FAILURES ===================================
_________________________ test_one _________________________
AssertionError
_________________________ test_two _________________________
ValueError
============================== 2 failed ==============================
"""
result = extract_failed_test_names(pytest_output)
assert len(result) == 2
test_names = [name for name, _ in result]
assert "test_one" in test_names
assert "test_two" in test_names
def test_extract_failed_names_empty_for_passing() -> None:
pytest_output = """
============================= test session starts ==============================
test_example.py::test_one PASSED
============================== 2 passed ==============================
"""
result = extract_failed_test_names(pytest_output)
assert result == []
def test_extract_failed_names_ignores_separator_lines() -> None:
"""Separator lines like '_ _ _ _ _' should not match as test names."""
pytest_output = """
=================================== FAILURES ===================================
_________________________ test_one _________________________
AssertionError
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
During handling of the above exception...
_________________________ test_two _________________________
AssertionError
"""
result = extract_failed_test_names(pytest_output)
test_names = [name for name, _ in result]
assert len(result) == 2
assert all("_ _ _" not in name for name in test_names)
def test_extract_failed_names_handles_ansi_codes() -> None:
pytest_output = """
=========================== short test summary info ============================
\x1b[31mFAILED\x1b[0m tests/test_example.py::test_one - AssertionError
============================== 1 failed ==============================
"""
result = extract_failed_test_names(pytest_output)
assert len(result) == 1
test_name, reason = result[0]
assert test_name == "tests/test_example.py::test_one"
assert reason == "AssertionError"
# --- Output ordering tests ---
def test_pytest_summary_appears_last(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkgs = [create_pkg(name) for name in ["pkg-a", "pkg-b"]]
failure_output = (
"test_foo.py::test_failing FAILED\n"
"=================================== FAILURES ===================================\n"
"_________________________ test_failing _________________________\n"
"AssertionError\n"
"=========================== short test summary info ============================\n"
)
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=pkgs,
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.side_effect = [
sync_success(),
Mock(returncode=0, stdout="all tests passed\n", stderr=""),
sync_success(),
Mock(returncode=1, stdout=failure_output, stderr=""),
]
result = runner.invoke(cli, ["pytest"])
failures_pos = result.output.find("FAILURES")
summary_pos = result.output.find("Test Summary")
failed_tests_pos = result.output.find("Failed tests:")
assert failures_pos < summary_pos < failed_tests_pos
def test_pytest_extracts_failures_not_full_output(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkg_a = create_pkg("pkg-a")
failure_output = (
"============================= test session starts ==============================\n"
"platform linux -- Python 3.11.0\n"
"plugins: asyncio-0.21.0\n"
"collected 1 item\n"
"\n"
"=================================== FAILURES ===================================\n"
"_________________________ test_failing _________________________\n"
"AssertionError\n"
"=============================== warnings summary ===============================\n"
"DeprecationWarning: old API\n"
"=========================== short test summary info ============================\n"
)
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[pkg_a],
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=1, stdout=failure_output, stderr="")
result = runner.invoke(cli, ["pytest"])
assert "AssertionError" in result.output
# Should NOT include warnings or session info
assert "DeprecationWarning" not in result.output
assert "platform linux" not in result.output
def test_pytest_shows_failed_test_names_at_end(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkg_a = create_pkg("pkg-a")
failure_output = (
"=================================== FAILURES ===================================\n"
"_________________________ test_failing _________________________\n"
"AssertionError\n"
"=========================== short test summary info ============================\n"
"FAILED tests/test_example.py::test_failing - AssertionError\n"
)
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[pkg_a],
):
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_run:
mock_run.return_value = Mock(returncode=1, stdout=failure_output, stderr="")
result = runner.invoke(cli, ["pytest"])
summary_pos = result.output.find("Test Summary")
failed_tests_pos = result.output.find("Failed tests:")
assert failed_tests_pos > summary_pos
assert "tests/test_example.py::test_failing" in result.output
assert "[pkg-a]" in result.output
# --- Rich progress display tests ---
def test_render_progress_table_pending(
packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
from rich.spinner import Spinner
pkg = create_pkg("pkg-a")
results: dict[str, dict[str, bool | str | float]] = {}
start_times: dict[str, float] = {}
spinners: dict[str, Spinner] = {}
table = _render_progress_table([pkg], results, start_times, spinners)
assert table.row_count == 1
def test_render_progress_table_running(
packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
from rich.spinner import Spinner
pkg = create_pkg("pkg-a")
results: dict[str, dict[str, bool | str | float]] = {}
start_times = {"pkg-a": time.time() - 5.0}
spinners = {"pkg-a": Spinner("dots", style="yellow")}
table = _render_progress_table([pkg], results, start_times, spinners)
assert table.row_count == 1
def test_render_progress_table_completed(
packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
from rich.spinner import Spinner
pkg_a = create_pkg("pkg-a")
pkg_b = create_pkg("pkg-b")
results = {
"pkg-a": {"success": True, "duration": 2.5, "stdout": "", "stderr": ""},
"pkg-b": {"success": False, "duration": 3.0, "stdout": "", "stderr": ""},
}
start_times: dict[str, float] = {}
spinners: dict[str, Spinner] = {}
table = _render_progress_table([pkg_a, pkg_b], results, start_times, spinners)
assert table.row_count == 2
def test_run_tests_with_rich_progress_returns_results(
packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
pkg = create_pkg("pkg-a")
with patch("dev_cli.commands.pytest_cmd.run_package_tests") as mock_run:
mock_run.return_value = {
"success": True,
"stdout": "tests passed",
"stderr": "",
"duration": 1.5,
}
results = run_tests_with_rich_progress([pkg], (), max_workers=1)
assert "pkg-a" in results
assert results["pkg-a"]["success"] is True
def test_run_tests_with_rich_progress_multiple(
packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
packages = [create_pkg(name) for name in ["pkg-a", "pkg-b", "pkg-c"]]
call_count = 0
def mock_run(
pkg: PackageInfo, pytest_args: tuple[str, ...]
) -> dict[str, bool | str | float]:
nonlocal call_count
call_count += 1
return {
"success": pkg.name != "pkg-b",
"stdout": "output",
"stderr": "",
"duration": 1.0,
}
with patch("dev_cli.commands.pytest_cmd.run_package_tests", side_effect=mock_run):
results = run_tests_with_rich_progress(packages, (), max_workers=3)
assert call_count == 3
assert results["pkg-a"]["success"] is True
assert results["pkg-b"]["success"] is False
assert results["pkg-c"]["success"] is True
# --- TTY detection tests ---
def test_pytest_verbose_does_not_use_rich(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""--verbose uses simple output, not rich progress."""
pkg_a = create_pkg("pkg-a")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[pkg_a],
):
with patch(
"dev_cli.commands.pytest_cmd.run_tests_with_rich_progress"
) as mock_rich:
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_subprocess:
mock_subprocess.return_value = Mock(
returncode=0, stdout="passed\n", stderr=""
)
runner.invoke(cli, ["pytest", "--verbose"])
mock_rich.assert_not_called()
mock_subprocess.assert_called()
def test_pytest_skips_rich_when_not_tty(
runner: CliRunner, packages_dir: Path, create_pkg: Callable[[str], PackageInfo]
) -> None:
"""Rich progress is NOT used when stdout is not a TTY."""
pkg_a = create_pkg("pkg-a")
with patch(
"dev_cli.commands.pytest_cmd.discover_test_packages",
return_value=[pkg_a],
):
with patch(
"dev_cli.commands.pytest_cmd.run_tests_with_rich_progress"
) as mock_rich:
with patch("dev_cli.commands.pytest_cmd.subprocess.run") as mock_subprocess:
mock_subprocess.return_value = Mock(
returncode=0, stdout="passed", stderr=""
)
runner.invoke(cli, ["pytest"])
mock_rich.assert_not_called()
mock_subprocess.assert_called()