Files
2026-07-13 13:22:34 +08:00

244 lines
7.2 KiB
Python

from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
from mlflow.pytest import session as _session
from mlflow.tracking import MlflowClient
from mlflow.utils.mlflow_tags import MLFLOW_RUN_TYPE, MLFLOW_RUN_TYPE_TEST
# ---------------------------------------------------------------------------
# End-to-end: run pytest in a subprocess (its own session) and verify a single
# test run is created in the tracking store with the right tags. Marked tests
# run only here, never in this shared suite, so they don't leak an active run.
# ---------------------------------------------------------------------------
_GENERATED_TEST = """
import mlflow
from mlflow.pytest import session
from mlflow.genai.scorers import scorer
@scorer
def always_pass(*, outputs):
return True
@mlflow.test
def test_marked_one():
# Identity is the full test id; set for the running marked test.
name, case_id = session.current_test()
assert name.endswith("::test_marked_one")
assert case_id is None
mlflow.genai.evaluate(
data=[{"inputs": {"text": "hi"}, "outputs": "hi"}],
scorers=[always_pass],
)
@mlflow.test()
def test_marked_called():
# The called form @mlflow.test() works too.
mlflow.genai.evaluate(
data=[{"inputs": {"text": "bye"}, "outputs": "bye"}],
scorers=[always_pass],
)
"""
def _run_pytest(
tmp_path: Path, file_name: str, *, enable_plugin: bool = True
) -> tuple[subprocess.CompletedProcess, str]:
"""Run pytest on ``file_name`` in a subprocess against a sqlite store.
The plugin is opt-in (no pytest11 entry point), so it is enabled explicitly
with ``-p mlflow.pytest.plugin`` unless ``enable_plugin=False``. Returns the
completed process and the tracking URI so the caller can inspect the runs
that were actually persisted.
"""
tracking_uri = f"sqlite:///{tmp_path / 'mlflow.db'}"
env = {**os.environ, "MLFLOW_TRACKING_URI": tracking_uri}
cmd = [sys.executable, "-m", "pytest", file_name, "-p", "no:cacheprovider", "-q"]
if enable_plugin:
cmd += ["-p", "mlflow.pytest.plugin"]
result = subprocess.run(
cmd,
cwd=tmp_path,
capture_output=True,
text=True,
env=env,
)
return result, tracking_uri
def _test_runs(tracking_uri: str):
client = MlflowClient(tracking_uri=tracking_uri)
experiment_ids = [e.experiment_id for e in client.search_experiments()]
runs = client.search_runs(experiment_ids=experiment_ids) if experiment_ids else []
return [r for r in runs if r.data.tags.get(MLFLOW_RUN_TYPE) == MLFLOW_RUN_TYPE_TEST]
def test_pytest_run_creates_single_mlflow_run(tmp_path: Path):
test_file = tmp_path / "test_generated.py"
test_file.write_text(_GENERATED_TEST)
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
assert result.returncode == 0, result.stdout + result.stderr
# Two @mlflow.test cases share one session-scoped run, not one run each.
test_runs = _test_runs(tracking_uri)
assert len(test_runs) == 1
assert test_runs[0].info.status == "FINISHED"
assert _session.TAG_SESSION_ID in test_runs[0].data.tags
def test_unmarked_pytest_run_creates_no_test_run(tmp_path: Path):
test_file = tmp_path / "test_plain.py"
test_file.write_text("def test_plain():\n assert True\n")
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
assert result.returncode == 0, result.stdout + result.stderr
# No @mlflow.test marker -> the plugin never opens a run.
assert _test_runs(tracking_uri) == []
# ---------------------------------------------------------------------------
# Run status reflects only @mlflow.test outcomes.
# ---------------------------------------------------------------------------
_MARKED_FAILS = """
import mlflow
from mlflow.genai.scorers import scorer
@scorer
def always_pass(*, outputs):
return True
@mlflow.test
def test_marked_fails():
mlflow.genai.evaluate(
data=[{"inputs": {"text": "hi"}, "outputs": "hi"}],
scorers=[always_pass],
)
assert False
"""
_ONLY_UNMARKED_FAILS = """
import mlflow
from mlflow.genai.scorers import scorer
@scorer
def always_pass(*, outputs):
return True
@mlflow.test
def test_marked_passes():
mlflow.genai.evaluate(
data=[{"inputs": {"text": "hi"}, "outputs": "hi"}],
scorers=[always_pass],
)
def test_unmarked_fails():
assert False
"""
def test_run_marked_failed_when_a_marked_test_fails(tmp_path: Path):
test_file = tmp_path / "test_marked_fails.py"
test_file.write_text(_MARKED_FAILS)
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
assert result.returncode != 0
test_runs = _test_runs(tracking_uri)
assert len(test_runs) == 1
assert test_runs[0].info.status == "FAILED"
def test_run_finished_when_only_unmarked_test_fails(tmp_path: Path):
test_file = tmp_path / "test_only_unmarked_fails.py"
test_file.write_text(_ONLY_UNMARKED_FAILS)
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
assert result.returncode != 0 # the unmarked test failed
# Run status reflects only @mlflow.test outcomes -> the marked test passed.
test_runs = _test_runs(tracking_uri)
assert len(test_runs) == 1
assert test_runs[0].info.status == "FINISHED"
# ---------------------------------------------------------------------------
# Parametrized marked test: case_id comes from pytest's callspec id, even when
# the param value contains brackets.
# ---------------------------------------------------------------------------
_PARAMETRIZED = """
import mlflow
import pytest
from mlflow.pytest import session
from mlflow.genai.scorers import scorer
@scorer
def always_pass(*, outputs):
return True
@pytest.mark.parametrize("value", ["a", "[", "]"])
@mlflow.test
def test_param(value):
name, case_id = session.current_test()
assert "::test_param[" in name
assert case_id == value
mlflow.genai.evaluate(
data=[{"inputs": {"text": value}, "outputs": value}],
scorers=[always_pass],
)
"""
def test_parametrized_marked_test_captures_case_id(tmp_path: Path):
test_file = tmp_path / "test_param.py"
test_file.write_text(_PARAMETRIZED)
result, tracking_uri = _run_pytest(tmp_path, test_file.name)
assert result.returncode == 0, result.stdout + result.stderr
# All three cases share the one session run.
assert len(_test_runs(tracking_uri)) == 1
# ---------------------------------------------------------------------------
# The plugin is opt-in: a marked test without it fails loudly with instructions
# instead of silently running without run/trace management.
# ---------------------------------------------------------------------------
_MARKED_MINIMAL = """
import mlflow
@mlflow.test
def test_marked():
pass
"""
def test_marked_test_fails_loudly_when_plugin_not_enabled(tmp_path: Path):
test_file = tmp_path / "test_no_plugin.py"
test_file.write_text(_MARKED_MINIMAL)
result, tracking_uri = _run_pytest(tmp_path, test_file.name, enable_plugin=False)
assert result.returncode != 0
assert "pytest_plugins" in result.stdout
assert "mlflow.pytest.plugin" in result.stdout
assert _test_runs(tracking_uri) == []