litellm/tests/unit/test_select_ui_test_scope.py
yuneng-jiang f6882246d4
test: move tests/test_litellm root and small trees into tests/unit (#43186)
* ci: run the unit_selection.sh shard files on every event instead of only fork pull requests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci: rename fork-flag to unit-flag now that it applies on every event

* test: move tests/test_litellm root and small trees into tests/unit

Pure renames, no content changes. Follow-up commits in this PR fix
references, merge the three files that already existed in tests/unit,
keep live-provider tests in tests/test_litellm and wire CI.

* test: carry tests/test_litellm conftest isolation into tests/unit

Callback lists, routing fallbacks, cached HTTP clients, logger state, AWS,
proxy-URL and keychain env, and session-end client cleanup now reset for
unit tests too. The environment isolation owns its MonkeyPatch so a test's
own monkeypatch is undone before the model-cost teardown runs.

* test: merge, split and prune the moved root and small-tree tests

Merge batches/test_batch_utils.py and the chat_completions and messages
dispatch tests into the files that already existed in tests/unit. Keep
the live Gemini interactions tests, the async image-fetch format test and
the OpenAI embedding scorer test in tests/test_litellm since they need
real network or keys. Put test_router.py under tests/unit/test_router so
the existing package no longer shadows it. Delete eight tests the audit
found superseded by stronger ones kept in this move.

* ci: run the moved root and small-tree tests under their legacy flags

Add the misc and responses-caching-types flags to unit_selection.sh and
CircleCI, extend enterprise-routing and mcp-integration, and point the
legacy GHA shards, Makefile, redis-compat workflow, merge smoke manifest
and change classifier at the new paths.

* test: make the new tests/unit directories packages

tests/unit/test_package_layout.py requires every directory to carry an
__init__.py, and without one the moved and retained
test_litellm_responses_bridge.py modules collide on import.

* test: scope the unit socket block to tests/unit in shared sessions

The GHA shards collect the legacy test-path and the unit selection in one
pytest session. The unit conftest's loopback-only block leaked into legacy
modules that reach the network at import. The legacy conftest now lifts the
restriction at collect and setup time, and the unit conftest re-applies it
when collecting its own modules.

* test: give the shard-script tests their own GITHUB_OUTPUT

They only passed where the runner set it. The CircleCI unit job's env
allowlist drops it, so the script's redirect failed there.

* test: point the router and module-deletion checks at tests/unit

router_code_coverage and code_qa_check_tests only searched tests/test_litellm,
so the moved router tests no longer counted. The two silent-experiment tests
the audit deleted were the only direct callers of those methods; they are
replaced with tests that assert the forwarded shadow request and the
recursion guard.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-25 11:30:43 -07:00

166 lines
5.8 KiB
Python

"""Regression tests for the UI unit-test scope decision.
`.github/workflows/test-litellm-ui-unit.yml` narrows the dashboard's Vitest run to
`vitest related <changed files>` so a pull request only pays for the tests it can
affect. `related` resolves a file to the tests that import it, so a file no test
imports resolves to nothing, and with `--passWithNoTests` the job then goes green
without running a single test. `package.json`, `package-lock.json`, the Vitest and
TypeScript configs and `tests/setupTests.ts` are all such files, so a dependency
bump used to merge untested.
`.github/scripts/select_ui_test_scope.sh` is the decision function that closes
that hole: it prints `related` only when every changed file lives under `src/`,
and `full` otherwise. These tests lock both the decision and the workflow step
that consumes it, running the step's real shell against stubbed `gh`, `git` and
`npm` so a regression shows up as the wrong Vitest command.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
import yaml
REPO_ROOT = Path(__file__).resolve().parents[2]
SCOPE_SCRIPT = REPO_ROOT / ".github" / "scripts" / "select_ui_test_scope.sh"
WORKFLOW = REPO_ROOT / ".github" / "workflows" / "test-litellm-ui-unit.yml"
STEP_NAME = "Run UI unit tests (Vitest)"
FULL_SUITE_ARGV = ["run", "test", "--", "--run", "--pool", "forks", "--maxWorkers=14"]
NON_SRC_FILES = [
"package.json",
"package-lock.json",
"vitest.config.ts",
"tsconfig.json",
"tests/setupTests.ts",
"next.config.mjs",
]
def scope(changed: list[str]) -> str:
result = subprocess.run(
["bash", str(SCOPE_SCRIPT)],
input="\n".join(changed),
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
@pytest.mark.parametrize("changed_file", NON_SRC_FILES)
def test_a_file_no_test_imports_selects_the_full_suite(changed_file: str) -> None:
assert scope([changed_file]) == "full"
def test_src_only_changes_stay_on_related() -> None:
assert scope(["src/app/page.tsx", "src/lib/http/client.ts"]) == "related"
def test_one_non_src_file_pulls_a_src_only_set_up_to_full() -> None:
assert scope(["src/app/page.tsx", "package-lock.json"]) == "full"
def test_a_path_merely_prefixed_with_src_is_not_under_src() -> None:
assert scope(["srcipts/build.mjs"]) == "full"
def test_an_empty_change_set_fails_open_to_the_full_suite() -> None:
assert scope([]) == "full"
def _step_script() -> str:
workflow = yaml.safe_load(WORKFLOW.read_text())
steps = workflow["jobs"]["ui-unit-tests"]["steps"]
script = next(step["run"] for step in steps if step.get("name") == STEP_NAME)
resolved = script.replace("${{ github.repository }}", "BerriAI/litellm")
assert "${{" not in resolved, "the step uses an Actions expression this harness does not resolve"
return resolved
def _stub(bin_dir: Path, name: str, body: str) -> None:
stub = bin_dir / name
stub.write_text(f"#!/usr/bin/env bash\n{body}\n")
stub.chmod(0o755)
def _run_step(tmp_path: Path, changed: list[str], base_sha: str = "basesha") -> tuple[int, str, list[list[str]]]:
"""Run the workflow step's real shell; return its status, stdout and every npm argv."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
changed_file = tmp_path / "changed.txt"
changed_file.write_text("".join(f"{name}\n" for name in changed))
npm_log = tmp_path / "npm.log"
_stub(bin_dir, "gh", 'echo "mergebasesha"')
_stub(bin_dir, "git", 'if [ "$1" = diff ]; then cat "$CHANGED_FILES"; fi')
_stub(bin_dir, "npm", 'printf "%s\\n" "$@" >>"$NPM_LOG"; printf "\\0" >>"$NPM_LOG"')
step = tmp_path / "step.sh"
step.write_text(_step_script())
env = dict(os.environ)
env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}"
env["BASE_SHA"] = base_sha
env["HEAD_SHA"] = "headsha"
env["GITHUB_WORKSPACE"] = str(REPO_ROOT)
env["GITHUB_REF_NAME"] = "release_branch"
env["CHANGED_FILES"] = str(changed_file)
env["NPM_LOG"] = str(npm_log)
result = subprocess.run(
["bash", "--noprofile", "--norc", "-eo", "pipefail", str(step)],
cwd=tmp_path,
capture_output=True,
text=True,
env=env,
)
raw = npm_log.read_text() if npm_log.exists() else ""
invocations = [call.splitlines() for call in raw.split("\0") if call]
return result.returncode, result.stdout + result.stderr, invocations
def _related_argv(changed: list[str]) -> list[str]:
return [
"run",
"test",
"--",
"related",
*changed,
"--run",
"--passWithNoTests",
"--pool",
"forks",
"--maxWorkers=14",
]
def test_step_runs_related_for_a_src_only_pull_request(tmp_path: Path) -> None:
changed = ["src/app/page.tsx", "src/lib/http/client.ts"]
returncode, output, invocations = _run_step(tmp_path, changed)
assert returncode == 0, output
assert invocations == [_related_argv(changed)]
@pytest.mark.parametrize("changed_file", NON_SRC_FILES)
def test_step_runs_the_full_suite_when_a_changed_file_is_outside_src(tmp_path: Path, changed_file: str) -> None:
returncode, output, invocations = _run_step(tmp_path, ["src/app/page.tsx", changed_file])
assert returncode == 0, output
assert invocations == [FULL_SUITE_ARGV]
assert "related" not in " ".join(invocations[0])
def test_step_runs_the_full_suite_on_a_push(tmp_path: Path) -> None:
returncode, output, invocations = _run_step(tmp_path, ["src/app/page.tsx"], base_sha="")
assert returncode == 0, output
assert invocations == [FULL_SUITE_ARGV]
def test_step_runs_nothing_when_the_pull_request_touches_no_dashboard_file(tmp_path: Path) -> None:
returncode, output, invocations = _run_step(tmp_path, [])
assert returncode == 0, output
assert invocations == []