Skip to content

Commit 28a0679

Browse files
committed
add tests
1 parent 68db60c commit 28a0679

6 files changed

Lines changed: 597 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Unit tests for the chaos _context module."""
2+
3+
from strands_evals.chaos import ChaosScenario
4+
from strands_evals.chaos._context import _current_scenario
5+
6+
7+
class TestContextVar:
8+
"""Tests for the _current_scenario ContextVar."""
9+
10+
def test_set_and_get(self):
11+
scenario = ChaosScenario(name="test_scenario")
12+
token = _current_scenario.set(scenario)
13+
try:
14+
assert _current_scenario.get() is scenario
15+
assert _current_scenario.get().name == "test_scenario"
16+
finally:
17+
_current_scenario.reset(token)
18+
19+
def test_nested_set_and_reset(self):
20+
s1 = ChaosScenario(name="outer")
21+
s2 = ChaosScenario(name="inner")
22+
23+
token1 = _current_scenario.set(s1)
24+
try:
25+
assert _current_scenario.get().name == "outer"
26+
token2 = _current_scenario.set(s2)
27+
try:
28+
assert _current_scenario.get().name == "inner"
29+
finally:
30+
_current_scenario.reset(token2)
31+
assert _current_scenario.get().name == "outer"
32+
finally:
33+
_current_scenario.reset(token1)
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Unit tests for chaos effect classes."""
2+
3+
import random
4+
5+
import pytest
6+
7+
from strands_evals.chaos.effects import (
8+
CorruptValues,
9+
RemoveFields,
10+
ToolCallFailure,
11+
TruncateFields,
12+
)
13+
14+
15+
class TestToolCallFailure:
16+
"""Tests for the ToolCallFailure pre-hook effect."""
17+
18+
@pytest.mark.parametrize(
19+
"error_type,expected_message",
20+
[
21+
("timeout", "Tool call timed out"),
22+
("network_error", "Network unreachable"),
23+
("execution_error", "Tool execution failed"),
24+
("validation_error", "Tool input validation failed"),
25+
],
26+
)
27+
def test_apply_returns_default_message(self, error_type, expected_message):
28+
effect = ToolCallFailure(error_type=error_type)
29+
assert effect.apply() == expected_message
30+
31+
def test_apply_returns_custom_message_when_provided(self):
32+
effect = ToolCallFailure(error_type="timeout", error_message="Custom timeout msg")
33+
assert effect.apply() == "Custom timeout msg"
34+
35+
def test_apply_rate_defaults_to_one(self):
36+
effect = ToolCallFailure()
37+
assert effect.apply_rate == 1.0
38+
39+
40+
class TestTruncateFields:
41+
"""Tests for the TruncateFields post-hook effect."""
42+
43+
def test_truncates_long_strings(self):
44+
effect = TruncateFields(max_length=5)
45+
response = {"name": "hello world", "short": "hi"}
46+
result = effect.apply(response)
47+
assert result["name"] == "hello"
48+
assert result["short"] == "hi"
49+
50+
def test_preserves_non_string_values(self):
51+
effect = TruncateFields(max_length=3)
52+
response = {"count": 42, "flag": True, "items": [1, 2, 3]}
53+
result = effect.apply(response)
54+
assert result["count"] == 42
55+
assert result["flag"] is True
56+
assert result["items"] == [1, 2, 3]
57+
58+
def test_truncates_nested_dicts(self):
59+
effect = TruncateFields(max_length=3)
60+
response = {"nested": {"deep_value": "abcdef"}}
61+
result = effect.apply(response)
62+
assert result["nested"]["deep_value"] == "abc"
63+
64+
def test_empty_dict_returns_empty(self):
65+
effect = TruncateFields(max_length=5)
66+
assert effect.apply({}) == {}
67+
68+
def test_non_dict_input_returned_as_is(self):
69+
effect = TruncateFields(max_length=5)
70+
assert effect.apply("not a dict") == "not a dict"
71+
assert effect.apply(None) is None
72+
73+
def test_zero_max_length_truncates_all_strings(self):
74+
effect = TruncateFields(max_length=0)
75+
response = {"text": "hello"}
76+
result = effect.apply(response)
77+
assert result["text"] == ""
78+
79+
80+
class TestRemoveFields:
81+
"""Tests for the RemoveFields post-hook effect."""
82+
83+
def test_removes_at_least_one_field(self):
84+
random.seed(42)
85+
effect = RemoveFields(remove_ratio=0.1)
86+
response = {"a": 1, "b": 2, "c": 3, "d": 4}
87+
result = effect.apply(response)
88+
assert len(result) < len(response)
89+
90+
def test_removes_half_fields(self):
91+
random.seed(42)
92+
effect = RemoveFields(remove_ratio=0.5)
93+
response = {"a": 1, "b": 2, "c": 3, "d": 4}
94+
result = effect.apply(response)
95+
assert len(result) == 2
96+
97+
def test_removes_all_fields_at_ratio_one(self):
98+
random.seed(42)
99+
effect = RemoveFields(remove_ratio=1.0)
100+
response = {"a": 1, "b": 2, "c": 3}
101+
result = effect.apply(response)
102+
assert len(result) == 0
103+
104+
def test_empty_dict_returns_empty(self):
105+
effect = RemoveFields(remove_ratio=0.5)
106+
assert effect.apply({}) == {}
107+
108+
def test_non_dict_input_returned_as_is(self):
109+
effect = RemoveFields(remove_ratio=0.5)
110+
assert effect.apply("not a dict") == "not a dict"
111+
assert effect.apply(None) is None
112+
113+
def test_single_field_always_removed(self):
114+
random.seed(42)
115+
effect = RemoveFields(remove_ratio=0.5)
116+
response = {"only_key": "value"}
117+
result = effect.apply(response)
118+
assert len(result) == 0
119+
120+
121+
class TestCorruptValues:
122+
"""Tests for the CorruptValues post-hook effect."""
123+
124+
def test_corrupts_at_least_one_field(self):
125+
random.seed(42)
126+
effect = CorruptValues(corrupt_ratio=0.1)
127+
response = {"a": "original_a", "b": "original_b", "c": "original_c", "d": "original_d"}
128+
result = effect.apply(response)
129+
corrupted_count = sum(1 for k in response if result[k] != response[k])
130+
assert corrupted_count >= 1
131+
132+
def test_corrupted_values_come_from_corruption_pool(self):
133+
random.seed(42)
134+
effect = CorruptValues(corrupt_ratio=1.0)
135+
response = {"a": "original", "b": "data"}
136+
result = effect.apply(response)
137+
corruption_pool = [None, 99999, "", True, [], "CORRUPTED_DATA"]
138+
for key in response:
139+
assert result[key] in corruption_pool
140+
141+
def test_corrupts_nested_dicts_recursively(self):
142+
random.seed(42)
143+
effect = CorruptValues(corrupt_ratio=1.0)
144+
response = {"top": "value", "nested": {"inner": "deep_value"}}
145+
result = effect.apply(response)
146+
# The nested dict should also be processed
147+
assert "nested" in result or "top" in result
148+
149+
def test_empty_dict_returns_empty(self):
150+
effect = CorruptValues(corrupt_ratio=0.5)
151+
assert effect.apply({}) == {}
152+
153+
def test_non_dict_input_returned_as_is(self):
154+
effect = CorruptValues(corrupt_ratio=0.5)
155+
assert effect.apply("not a dict") == "not a dict"
156+
assert effect.apply(None) is None
157+
158+
def test_corrupted_value_differs_from_original(self):
159+
random.seed(42)
160+
effect = CorruptValues(corrupt_ratio=1.0)
161+
response = {"key": "unique_original_value"}
162+
result = effect.apply(response)
163+
assert result["key"] != "unique_original_value"
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Unit tests for ChaosExperiment."""
2+
3+
import pytest
4+
5+
from strands_evals import Case
6+
from strands_evals.chaos import ChaosExperiment, ChaosScenario
7+
from strands_evals.chaos._context import _current_scenario
8+
from strands_evals.chaos.effects import CorruptValues, ToolCallFailure
9+
from strands_evals.evaluators.evaluator import Evaluator
10+
from strands_evals.types import EvaluationData, EvaluationOutput
11+
12+
13+
class MockChaosEvaluator(Evaluator):
14+
"""Simple evaluator that always passes."""
15+
16+
def evaluate(self, evaluation_case: EvaluationData) -> list[EvaluationOutput]:
17+
return [EvaluationOutput(score=1.0, test_pass=True, reason="Mock pass")]
18+
19+
20+
@pytest.fixture
21+
def cases():
22+
return [
23+
Case(name="case_a", input="hello"),
24+
Case(name="case_b", input="world"),
25+
]
26+
27+
28+
@pytest.fixture
29+
def scenarios():
30+
return [
31+
ChaosScenario(
32+
name="search_timeout",
33+
effects={"search_tool": [ToolCallFailure(error_type="timeout")]},
34+
),
35+
ChaosScenario(
36+
name="db_corrupt",
37+
effects={"db_tool": [CorruptValues(corrupt_ratio=0.8)]},
38+
),
39+
]
40+
41+
42+
@pytest.fixture
43+
def evaluator():
44+
return MockChaosEvaluator()
45+
46+
47+
class TestChaosExperiment:
48+
"""Tests for ChaosExperiment initialization and execution."""
49+
50+
def test_expanded_cases_count_with_baseline(self, cases, scenarios, evaluator):
51+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=True)
52+
# 2 cases × (2 scenarios + 1 baseline) = 6
53+
assert len(experiment._expanded_cases) == 6
54+
55+
def test_expanded_cases_count_without_baseline(self, cases, scenarios, evaluator):
56+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=False)
57+
# 2 cases × 2 scenarios = 4
58+
assert len(experiment._expanded_cases) == 4
59+
60+
def test_expanded_case_names_include_scenario(self, cases, scenarios, evaluator):
61+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=True)
62+
names = [c.name for c in experiment._expanded_cases]
63+
assert "case_a|baseline" in names
64+
assert "case_a|search_timeout" in names
65+
assert "case_a|db_corrupt" in names
66+
assert "case_b|baseline" in names
67+
assert "case_b|search_timeout" in names
68+
assert "case_b|db_corrupt" in names
69+
70+
def test_each_expanded_case_has_unique_session_id(self, cases, scenarios, evaluator):
71+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator])
72+
session_ids = [c.session_id for c in experiment._expanded_cases]
73+
assert len(session_ids) == len(set(session_ids))
74+
75+
def test_get_scenario_for_session(self, cases, scenarios, evaluator):
76+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=True)
77+
# Pick an expanded case and verify its scenario maps correctly
78+
for expanded_case in experiment._expanded_cases:
79+
scenario = experiment.get_scenario_for_session(expanded_case.session_id)
80+
assert scenario is not None
81+
assert scenario.name in expanded_case.name
82+
83+
def test_get_scenario_for_unknown_session(self, cases, scenarios, evaluator):
84+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator])
85+
assert experiment.get_scenario_for_session("nonexistent-id") is None
86+
87+
def test_get_original_case_name(self, cases, scenarios, evaluator):
88+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=True)
89+
for expanded_case in experiment._expanded_cases:
90+
original_name = experiment.get_original_case_name(expanded_case.session_id)
91+
assert original_name in ("case_a", "case_b")
92+
93+
def test_get_original_case_name_unknown_session(self, cases, scenarios, evaluator):
94+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator])
95+
assert experiment.get_original_case_name("nonexistent-id") is None
96+
97+
def test_context_var_set_and_reset(self, cases, scenarios, evaluator):
98+
"""Verify the ContextVar is set to the correct scenario during task execution and reset after."""
99+
observed_scenarios = []
100+
101+
def capturing_task(case: Case):
102+
scenario = _current_scenario.get()
103+
observed_scenarios.append((case.name, scenario.name if scenario else None))
104+
return "output"
105+
106+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=True)
107+
experiment.run_evaluations(task=capturing_task)
108+
109+
# Should have 6 observations (2 cases × 3 scenarios)
110+
assert len(observed_scenarios) == 6
111+
112+
# Verify baseline scenarios observed
113+
baseline_obs = [(name, sn) for name, sn in observed_scenarios if sn == "baseline"]
114+
assert len(baseline_obs) == 2
115+
116+
# Verify chaos scenarios observed
117+
timeout_obs = [(name, sn) for name, sn in observed_scenarios if sn == "search_timeout"]
118+
assert len(timeout_obs) == 2
119+
120+
# After all runs, the ContextVar should be back to None
121+
assert _current_scenario.get() is None
122+
123+
def test_context_var_reset_on_task_exception(self, evaluator):
124+
"""Verify the ContextVar is reset even if the task raises."""
125+
cases = [Case(name="failing", input="x")]
126+
scenarios_list = [ChaosScenario(name="chaos", effects={"t": [ToolCallFailure()]})]
127+
128+
call_count = [0]
129+
130+
def failing_task(case: Case):
131+
call_count[0] += 1
132+
if call_count[0] == 1:
133+
raise RuntimeError("Task failed")
134+
return "output"
135+
136+
experiment = ChaosExperiment(
137+
cases=cases, scenarios=scenarios_list, evaluators=[evaluator], include_baseline=True
138+
)
139+
140+
# The base Experiment should handle the exception internally
141+
# ContextVar should still be reset
142+
try:
143+
experiment.run_evaluations(task=failing_task)
144+
except Exception:
145+
pass
146+
147+
assert _current_scenario.get() is None
148+
149+
def test_returns_evaluation_reports(self, cases, scenarios, evaluator):
150+
"""Verify run_evaluations returns reports."""
151+
152+
def task(case: Case):
153+
return "output"
154+
155+
experiment = ChaosExperiment(cases=cases, scenarios=scenarios, evaluators=[evaluator], include_baseline=True)
156+
reports = experiment.run_evaluations(task=task)
157+
158+
assert len(reports) >= 1
159+
report = reports[0]
160+
# 2 cases × 3 scenarios = 6 scores
161+
assert len(report.scores) == 6

0 commit comments

Comments
 (0)