Files
confident-ai--deepeval/tests/test_integrations/test_langchain/conftest.py
T
2026-07-13 13:32:05 +08:00

457 lines
14 KiB
Python

"""
Pytest configuration for LangChain integration tests.
Mirrors the LangGraph conftest.py structure for consistency.
"""
import os
import sys
import pytest
import datetime
import logging
from typing import Dict, Any, List, Optional
from dateutil import parser as dateutil_parser
from deepeval.test_case import ToolCall
_logger = logging.getLogger(__name__)
# Module-level state for TestRun
_test_run_identifier = None
# Max length for input/output strings to avoid large payloads
MAX_FIELD_LENGTH = 2000
def _upload_enabled() -> bool:
"""Check if test run uploads are enabled via INTEGRATION_TESTS_UPLOAD_TEST_RUNS env var.
Returns True only if the env var is set to a truthy value ("1", "true", "yes").
Default is OFF (False) - no uploads, no network calls, no credentials needed.
"""
val = (
os.environ.get("INTEGRATION_TESTS_UPLOAD_TEST_RUNS", "").lower().strip()
)
return val in ("1", "true", "yes")
def pytest_configure(config):
"""Set environment variables needed for upload."""
os.environ["CONFIDENT_OPEN_BROWSER"] = "0"
os.environ["DEEPEVAL_RETRY_MAX_ATTEMPTS"] = "1"
def pytest_sessionstart(session: pytest.Session):
"""Create a TestRun at the start of the pytest session."""
if not _upload_enabled():
return
from deepeval.confident.api import is_confident
if not is_confident():
return
from deepeval.test_run import global_test_run_manager
global _test_run_identifier
# Create a unique identifier for this test run
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
_test_run_identifier = f"langchain-integrations-{timestamp}"
# Enable disk persistence and create the test run
global_test_run_manager.save_to_disk = True
global_test_run_manager.create_test_run(
identifier=_test_run_identifier,
file_name="tests/test_integrations/test_langchain",
)
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call):
"""After each test call phase, upload trace and add test case to TestRun."""
outcome = yield
report = outcome.get_result()
# Only process after the test call phase (not setup/teardown)
if call.when != "call":
return
if not _upload_enabled():
return
from deepeval.confident.api import is_confident
if not is_confident():
return
# Import the shared storage from utils
from tests.test_integrations.utils import get_stored_trace
trace_dict = get_stored_trace(item.nodeid)
if trace_dict is None:
return
# 1) Upload trace directly to /v1/traces
trace_uuid = _upload_trace_to_observatory(trace_dict)
# 2) Add test case to TestRun
if trace_uuid:
_add_test_case_to_run(
item, item.nodeid, report.passed, trace_uuid, trace_dict
)
def _upload_trace_to_observatory(trace_dict: dict) -> str:
"""Upload trace dict directly to Confident AI Observatory via /v1/traces."""
from deepeval.confident.api import Api, Endpoints, HttpMethods
trace_uuid = trace_dict.get("uuid", "unknown")
try:
api = Api()
api.send_request(
method=HttpMethods.POST,
endpoint=Endpoints.TRACES_ENDPOINT,
body=trace_dict,
)
_logger.debug("UPLOADED TRACE UUID: %s", trace_uuid)
return trace_uuid
except Exception:
_logger.exception("Failed to upload trace %s", trace_uuid)
return None
# =============================================================================
# EXTRACTION HELPERS
# =============================================================================
def _truncate(s: str, max_len: int = MAX_FIELD_LENGTH) -> str:
"""Truncate string to max_len, adding ellipsis if truncated."""
if s and len(s) > max_len:
return s[: max_len - 3] + "..."
return s
def _extract_input_from_trace(trace_dict: Dict[str, Any]) -> str:
"""Extract a readable input string from trace_dict."""
trace_input = trace_dict.get("input")
if trace_input is None:
return ""
if isinstance(trace_input, dict) and "messages" in trace_input:
messages = trace_input.get("messages", [])
if messages and isinstance(messages[0], dict):
content = messages[0].get("content", "")
if content:
return _truncate(str(content))
return _truncate(str(trace_input))
def _extract_output_from_trace(trace_dict: Dict[str, Any]) -> str:
"""Extract a readable output string from trace_dict."""
trace_output = trace_dict.get("output")
if trace_output is None:
return ""
if isinstance(trace_output, dict) and "messages" in trace_output:
messages = trace_output.get("messages", [])
if messages:
for msg in reversed(messages):
if isinstance(msg, dict) and msg.get("type") == "ai":
content = msg.get("content", "")
if content:
return _truncate(str(content))
last_msg = messages[-1]
if isinstance(last_msg, dict):
content = last_msg.get("content", "")
if content:
return _truncate(str(content))
return _truncate(str(trace_output))
def _extract_tools_called_from_trace(
trace_dict: Dict[str, Any],
) -> Optional[List[ToolCall]]:
"""Extract tools_called from trace_dict."""
result = []
tools_called = trace_dict.get("toolsCalled")
if tools_called and isinstance(tools_called, list):
for tc in tools_called:
if isinstance(tc, dict):
try:
result.append(
ToolCall(
name=tc.get("name", "unknown_tool"),
input_parameters=tc.get("inputParameters")
or tc.get("input_parameters"),
output=(
_truncate(str(tc.get("output")))
if tc.get("output")
else None
),
)
)
except Exception:
pass
if not result:
tool_spans = trace_dict.get("toolSpans", [])
for span in tool_spans:
if isinstance(span, dict):
try:
tool_input = span.get("input")
tool_output = span.get("output")
result.append(
ToolCall(
name=span.get("name", "unknown_tool"),
input_parameters=(
tool_input
if isinstance(tool_input, dict)
else None
),
output=(
_truncate(str(tool_output))
if tool_output
else None
),
)
)
except Exception:
pass
return result if result else None
def _extract_token_cost(trace_dict: Dict[str, Any]) -> Optional[float]:
"""Extract total token count from trace."""
llm_spans = trace_dict.get("llmSpans", [])
if not llm_spans:
return None
total_tokens = 0
has_token_data = False
for span in llm_spans:
if not isinstance(span, dict):
continue
input_tokens = span.get("inputTokenCount")
output_tokens = span.get("outputTokenCount")
if input_tokens is not None:
total_tokens += input_tokens
has_token_data = True
if output_tokens is not None:
total_tokens += output_tokens
has_token_data = True
return float(total_tokens) if has_token_data else None
def _extract_completion_time(trace_dict: Dict[str, Any]) -> Optional[float]:
"""Extract completion time from trace timestamps."""
start_time_str = trace_dict.get("startTime")
end_time_str = trace_dict.get("endTime")
if not start_time_str or not end_time_str:
return None
try:
start_time = dateutil_parser.isoparse(start_time_str)
end_time = dateutil_parser.isoparse(end_time_str)
duration = (end_time - start_time).total_seconds()
return duration if duration >= 0 else None
except (ValueError, TypeError):
return None
def _extract_tags(
nodeid: str, item: pytest.Item, trace_dict: Dict[str, Any]
) -> Optional[List[str]]:
"""Extract tags from trace or test markers."""
tags = []
trace_tags = trace_dict.get("tags")
if trace_tags and isinstance(trace_tags, list):
tags.extend(trace_tags)
marker = item.get_closest_marker("tags")
if marker and marker.args:
marker_tags = marker.args[0]
if isinstance(marker_tags, list):
tags.extend(marker_tags)
seen = set()
unique_tags = []
for tag in tags:
if tag not in seen:
seen.add(tag)
unique_tags.append(tag)
return unique_tags if unique_tags else None
def _get_environment_info() -> Dict[str, str]:
"""Collect environment info for debugging."""
info = {
"python_version": sys.version.split()[0],
}
try:
import langchain_core
info["langchain_core_version"] = getattr(
langchain_core, "__version__", "unknown"
)
except ImportError:
pass
return info
# =============================================================================
# TEST CASE CREATION
# =============================================================================
def _add_test_case_to_run(
item: pytest.Item,
nodeid: str,
passed: bool,
trace_uuid: str,
trace_dict: Dict[str, Any],
):
"""Add a test case to the current TestRun."""
from deepeval.test_run import global_test_run_manager
from deepeval.test_run.api import LLMApiTestCase
test_run = global_test_run_manager.test_run
if test_run is None:
return
parts = nodeid.split("::")
test_file = parts[0] if parts else nodeid
test_name = parts[-1] if parts else nodeid
input_str = _extract_input_from_trace(trace_dict)
output_str = _extract_output_from_trace(trace_dict)
tools_called = _extract_tools_called_from_trace(trace_dict)
token_cost = _extract_token_cost(trace_dict)
completion_time = _extract_completion_time(trace_dict)
tags = _extract_tags(nodeid, item, trace_dict)
additional_metadata = {
"trace_uuid": trace_uuid,
"pytest_nodeid": nodeid,
"test_file": test_file,
"test_name": test_name,
"trace_name": trace_dict.get("name"),
**_get_environment_info(),
}
order = len(test_run.test_cases)
api_test_case = LLMApiTestCase(
name=f"{nodeid} [{trace_uuid}]",
input=input_str or f"LangChain test: {test_name}",
actualOutput=output_str or ("PASSED" if passed else "FAILED"),
expectedOutput=None,
context=None,
retrievalContext=None,
toolsCalled=tools_called,
expectedTools=None,
tokenCost=token_cost,
completionTime=completion_time,
tags=tags,
metadata=additional_metadata,
success=passed,
metricsData=None,
trace=None,
order=order,
runDuration=completion_time or 0,
evaluationCost=None,
)
_logger.debug("[DEBUG] trace keys: %s", list(trace_dict.keys()))
_logger.debug(
"[DEBUG] toolsCalled top-level: %s", bool(trace_dict.get("toolsCalled"))
)
_logger.debug("[DEBUG] toolSpans: %d", len(trace_dict.get("toolSpans", [])))
_logger.debug("[DEBUG] baseSpans: %d", len(trace_dict.get("baseSpans", [])))
_logger.debug(
"[DEBUG] output: %s %s",
type(trace_dict.get("output")),
trace_dict.get("output"),
)
_logger.debug(
"[DEBUG] added api_test_case fields: tokenCost=%s completionTime=%s tags=%s",
token_cost is not None,
completion_time is not None,
tags is not None,
)
if completion_time is not None:
_logger.debug("[DEBUG] completionTime=%.3fs", completion_time)
if tags:
_logger.debug("[DEBUG] tags=%s", tags)
test_run.add_test_case(api_test_case)
_logger.debug(
"[DEBUG] after add_test_case, test_cases: %d", len(test_run.test_cases)
)
# =============================================================================
# SESSION FINISH
# =============================================================================
def pytest_sessionfinish(session: pytest.Session, exitstatus):
"""Upload the TestRun at the end of the session."""
if not _upload_enabled():
return
_logger.debug("Running teardown with pytest sessionfinish...")
from deepeval.confident.api import is_confident
from deepeval.test_run import global_test_run_manager
if not is_confident():
return
test_run = global_test_run_manager.test_run
if test_run is None:
_logger.debug(
"[DEBUG] sessionfinish: test_run is None, skipping upload"
)
return
if (
len(test_run.test_cases) == 0
and len(test_run.conversational_test_cases) == 0
):
_logger.debug(
"[DEBUG] sessionfinish: no test cases found, skipping upload"
)
return
test_run.test_passed = sum(1 for tc in test_run.test_cases if tc.success)
test_run.test_failed = sum(
1 for tc in test_run.test_cases if not tc.success
)
try:
result = global_test_run_manager.post_test_run(test_run)
if result:
link, run_id = result
_logger.debug("TEST RUN LINK: %s", link)
except Exception:
_logger.exception("Failed to upload test run")