Skip to content

Commit a49c8bb

Browse files
test: add integration tests with in-memory Phoenix (#36)
1 parent c31f93d commit a49c8bb

6 files changed

Lines changed: 452 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,37 @@ jobs:
6363
- name: Test with coverage
6464
run: uv run pytest tests/ -q --cov=evalwire --cov-report=term-missing --cov-fail-under=85
6565

66+
test-integration:
67+
name: Integration tests
68+
runs-on: ubuntu-latest
69+
70+
steps:
71+
- uses: actions/checkout@v6
72+
73+
- name: Install uv
74+
uses: astral-sh/setup-uv@v8.0.0
75+
with:
76+
enable-cache: true
77+
78+
- name: Set up Python
79+
uses: actions/setup-python@v6
80+
with:
81+
python-version-file: .python-version
82+
83+
- name: Install dependencies
84+
run: uv sync --group dev
85+
86+
- name: Run integration tests
87+
run: uv run pytest tests/ -q -m integration
88+
6689
# https://github.com/marketplace/actions/alls-green#why
6790
ci-success: # This job does nothing and is only used for the branch protection
6891
name: CI Success
6992
if: always()
7093
needs:
7194
- lint
7295
- test
96+
- test-integration
7397
- docs-check
7498
runs-on: ubuntu-latest
7599
steps:

Makefile

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.DEFAULT_GOAL := help
22

3-
.PHONY: help install install-dev install-demo sync lint format typecheck test coverage check fix pre-commit docs docs-serve clean
3+
.PHONY: help install install-dev install-demo sync lint format typecheck test test-integration coverage check fix pre-commit docs docs-serve clean
44

55
help: ## Show this help message
66
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
@@ -27,9 +27,12 @@ format: ## Run ruff formatter (check only)
2727
typecheck: ## Run ty type checker
2828
uv run ty check
2929

30-
test: ## Run pytest
30+
test: ## Run pytest (unit tests only)
3131
uv run pytest tests/ -q
3232

33+
test-integration: ## Run integration tests (requires Phoenix)
34+
uv run pytest tests/ -q -m integration
35+
3336
coverage: ## Run pytest with coverage report
3437
uv run pytest tests/ -q --cov=evalwire --cov-report=term-missing --cov-fail-under=85
3538

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ filterwarnings = [
6464
"ignore::DeprecationWarning:alembic",
6565
"ignore::sqlalchemy.exc.SAWarning",
6666
]
67+
markers = [
68+
"integration: tests that require a running Phoenix instance (deselect with '-m not integration')",
69+
]
70+
addopts = "-m 'not integration'"
6771

6872
[dependency-groups]
6973
dev = [

tests/integration/conftest.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
"""Shared fixtures for integration tests.
2+
3+
These tests spin up an in-memory Phoenix server per session and create a
4+
real ``phoenix.client.Client`` connected to it. They are gated behind
5+
the ``integration`` marker so they can be skipped in fast CI runs::
6+
7+
pytest -m integration # run only integration tests
8+
pytest -m 'not integration' # skip integration tests (default)
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
import socket
15+
import textwrap
16+
import uuid
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
22+
def _find_free_port() -> int:
23+
"""Return an available TCP port on localhost."""
24+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
25+
s.bind(("", 0))
26+
return s.getsockname()[1]
27+
28+
29+
def _unique_tag() -> str:
30+
"""Return a short unique suffix for dataset names."""
31+
return uuid.uuid4().hex[:8]
32+
33+
34+
@pytest.fixture(scope="session")
35+
def phoenix_server():
36+
"""Launch an in-memory Phoenix instance for the entire test session.
37+
38+
Yields the session URL (e.g. ``http://localhost:54321``).
39+
Cleans up the server and all data after the session ends.
40+
"""
41+
import phoenix as px
42+
43+
port = _find_free_port()
44+
px.launch_app(run_in_thread=True, use_temp_dir=True, port=port)
45+
url = f"http://localhost:{port}"
46+
47+
yield url
48+
49+
px.close_app(delete_data=True)
50+
51+
52+
@pytest.fixture()
53+
def phoenix_client(phoenix_server: str):
54+
"""Return a ``phoenix.client.Client`` connected to the test server.
55+
56+
Sets ``PHOENIX_COLLECTOR_ENDPOINT`` so that code which creates its own
57+
``Client()`` (like ``evalwire.cli._make_client``) also points at the
58+
test instance.
59+
"""
60+
from phoenix.client import Client
61+
62+
old = os.environ.get("PHOENIX_COLLECTOR_ENDPOINT")
63+
os.environ["PHOENIX_COLLECTOR_ENDPOINT"] = phoenix_server
64+
65+
client = Client()
66+
67+
yield client
68+
69+
# Restore original env
70+
if old is None:
71+
os.environ.pop("PHOENIX_COLLECTOR_ENDPOINT", None)
72+
else:
73+
os.environ["PHOENIX_COLLECTOR_ENDPOINT"] = old
74+
75+
76+
@pytest.fixture()
77+
def integration_csv(tmp_path: Path) -> tuple[Path, str, str]:
78+
"""Write a small CSV testset with unique tag names.
79+
80+
Returns ``(csv_path, search_tag, router_tag)`` so tests can reference
81+
the unique dataset names.
82+
"""
83+
tag = _unique_tag()
84+
search_tag = f"search_{tag}"
85+
router_tag = f"router_{tag}"
86+
87+
content = textwrap.dedent(f"""\
88+
user_query,expected_output,tags
89+
"find cycling paths","url-a | url-b","{search_tag} | {router_tag}"
90+
"find parks","url-c","{search_tag}"
91+
"route me home","home","{router_tag}"
92+
""")
93+
csv_file = tmp_path / "testset.csv"
94+
csv_file.write_text(content)
95+
return csv_file, search_tag, router_tag
96+
97+
98+
@pytest.fixture()
99+
def integration_experiments(tmp_path: Path) -> tuple[Path, str]:
100+
"""Create a minimal experiments/ directory for integration testing.
101+
102+
Returns ``(experiments_dir, experiment_name)`` where *experiment_name*
103+
is the unique dataset-matching directory name.
104+
"""
105+
tag = _unique_tag()
106+
exp_name = f"exp_{tag}"
107+
108+
base = tmp_path / "experiments"
109+
base.mkdir()
110+
111+
exp_dir = base / exp_name
112+
exp_dir.mkdir()
113+
(exp_dir / "task.py").write_text(
114+
textwrap.dedent("""\
115+
async def task(example):
116+
return example.input.get("user_query", "") + " result"
117+
""")
118+
)
119+
(exp_dir / "check_output.py").write_text(
120+
textwrap.dedent("""\
121+
def check_output(output, expected):
122+
return 1.0 if output else 0.0
123+
""")
124+
)
125+
126+
return base, exp_name
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Integration tests for evalwire.runner.ExperimentRunner.
2+
3+
These tests run against a real in-memory Phoenix instance and verify the
4+
full experiment lifecycle: dataset lookup, task execution, evaluator scoring,
5+
and result retrieval.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import textwrap
11+
import uuid
12+
from pathlib import Path
13+
14+
import pandas as pd
15+
import pytest
16+
17+
from evalwire.runner import ExperimentRunner
18+
19+
pytestmark = pytest.mark.integration
20+
21+
22+
def _create_dataset(phoenix_client, name: str) -> None:
23+
"""Create a minimal Phoenix dataset with the given name."""
24+
df = pd.DataFrame(
25+
{
26+
"user_query": ["find parks", "find trails"],
27+
"expected_output": ["park-url", "trail-url"],
28+
}
29+
)
30+
phoenix_client.datasets.create_dataset(
31+
dataframe=df,
32+
name=name,
33+
input_keys=["user_query"],
34+
output_keys=["expected_output"],
35+
)
36+
37+
38+
_DEFAULT_EVALUATOR = {
39+
"check_output": """\
40+
def check_output(output, expected):
41+
return 1.0 if output else 0.0
42+
""",
43+
}
44+
45+
46+
def _make_experiment(
47+
base: Path,
48+
name: str,
49+
task_body: str = "async def task(example): return 'result'",
50+
evaluators: dict[str, str] | None = None,
51+
) -> None:
52+
"""Create an experiment directory matching a dataset name."""
53+
exp_dir = base / name
54+
exp_dir.mkdir(parents=True, exist_ok=True)
55+
(exp_dir / "task.py").write_text(textwrap.dedent(task_body))
56+
for ev_name, ev_body in (evaluators or _DEFAULT_EVALUATOR).items():
57+
(exp_dir / f"{ev_name}.py").write_text(textwrap.dedent(ev_body))
58+
59+
60+
class TestRunnerLifecycle:
61+
"""Upload datasets, then run experiments against them."""
62+
63+
def test_dry_run_completes(self, phoenix_client, tmp_path: Path):
64+
name = f"exp_dry_{uuid.uuid4().hex[:8]}"
65+
_create_dataset(phoenix_client, name)
66+
67+
base = tmp_path / "experiments"
68+
base.mkdir()
69+
_make_experiment(base, name)
70+
71+
runner = ExperimentRunner(
72+
experiments_dir=base,
73+
phoenix_client=phoenix_client,
74+
dry_run=True,
75+
)
76+
results = runner.run()
77+
assert len(results) == 1
78+
79+
def test_full_run_returns_experiment_results(self, phoenix_client, tmp_path: Path):
80+
name = f"exp_full_{uuid.uuid4().hex[:8]}"
81+
_create_dataset(phoenix_client, name)
82+
83+
base = tmp_path / "experiments"
84+
base.mkdir()
85+
_make_experiment(base, name)
86+
87+
runner = ExperimentRunner(
88+
experiments_dir=base,
89+
phoenix_client=phoenix_client,
90+
)
91+
results = runner.run()
92+
assert len(results) == 1
93+
94+
def test_run_with_evaluator(self, phoenix_client, tmp_path: Path):
95+
name = f"exp_eval_{uuid.uuid4().hex[:8]}"
96+
_create_dataset(phoenix_client, name)
97+
98+
base = tmp_path / "experiments"
99+
base.mkdir()
100+
_make_experiment(
101+
base,
102+
name,
103+
evaluators={
104+
"score": """\
105+
def score(output, expected):
106+
return 0.5
107+
""",
108+
},
109+
)
110+
111+
runner = ExperimentRunner(
112+
experiments_dir=base,
113+
phoenix_client=phoenix_client,
114+
dry_run=True,
115+
)
116+
results = runner.run()
117+
assert len(results) == 1
118+
119+
def test_run_with_name_filter(self, phoenix_client, tmp_path: Path):
120+
name = f"exp_filter_{uuid.uuid4().hex[:8]}"
121+
_create_dataset(phoenix_client, name)
122+
123+
base = tmp_path / "experiments"
124+
base.mkdir()
125+
_make_experiment(base, name)
126+
127+
runner = ExperimentRunner(
128+
experiments_dir=base,
129+
phoenix_client=phoenix_client,
130+
dry_run=True,
131+
)
132+
results = runner.run(names=[name])
133+
assert len(results) == 1
134+
135+
def test_nonexistent_dataset_raises_system_exit(
136+
self, phoenix_client, tmp_path: Path
137+
):
138+
name = f"exp_missing_{uuid.uuid4().hex[:8]}"
139+
140+
base = tmp_path / "experiments"
141+
base.mkdir()
142+
_make_experiment(base, name)
143+
144+
runner = ExperimentRunner(
145+
experiments_dir=base,
146+
phoenix_client=phoenix_client,
147+
)
148+
with pytest.raises(SystemExit) as exc_info:
149+
runner.run()
150+
assert exc_info.value.code == 1
151+
152+
def test_concurrent_run(self, phoenix_client, tmp_path: Path):
153+
name = f"exp_conc_{uuid.uuid4().hex[:8]}"
154+
_create_dataset(phoenix_client, name)
155+
156+
base = tmp_path / "experiments"
157+
base.mkdir()
158+
_make_experiment(base, name)
159+
160+
runner = ExperimentRunner(
161+
experiments_dir=base,
162+
phoenix_client=phoenix_client,
163+
concurrency=2,
164+
dry_run=True,
165+
)
166+
results = runner.run()
167+
assert len(results) == 1
168+
169+
def test_custom_prefix_and_metadata(self, phoenix_client, tmp_path: Path):
170+
name = f"exp_meta_{uuid.uuid4().hex[:8]}"
171+
_create_dataset(phoenix_client, name)
172+
173+
base = tmp_path / "experiments"
174+
base.mkdir()
175+
_make_experiment(base, name)
176+
177+
runner = ExperimentRunner(
178+
experiments_dir=base,
179+
phoenix_client=phoenix_client,
180+
dry_run=True,
181+
)
182+
results = runner.run(
183+
experiment_name_prefix="integ",
184+
metadata={"ci": True},
185+
)
186+
assert len(results) == 1

0 commit comments

Comments
 (0)