Skip to content

Commit 87b6ef0

Browse files
Add tests for multi_experiment
1 parent 6db403f commit 87b6ef0

1 file changed

Lines changed: 202 additions & 0 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
from pathlib import Path
2+
from types import SimpleNamespace
3+
4+
import pytest
5+
from pydantic import PrivateAttr
6+
7+
from src.deployments.experiments.multi_experiment import Config, Multiple, dict_to_namespace
8+
from src.deployments.registry import registry as experiment_registry
9+
10+
# --------------------------------------------------------------------------- #
11+
# Helper Functions
12+
# --------------------------------------------------------------------------- #
13+
14+
15+
class ConcreteMultiple(Multiple):
16+
"""Multiple.get_params_list is abstract; fill it with an injectable param list."""
17+
18+
_param_sets: list = PrivateAttr(default_factory=list)
19+
20+
def get_params_list(self):
21+
return self._param_sets
22+
23+
24+
def _make_multiple(mocker, param_sets: list, output_folder: Path, **config_kwargs):
25+
"""Create a ConcreteMultiple instance, bypassing full pydantic validation."""
26+
exp = ConcreteMultiple.model_construct(
27+
api_client=mocker.Mock(),
28+
config=Config(**config_kwargs),
29+
namespace="ns",
30+
output_folder=output_folder,
31+
skip_check=False,
32+
dry_run=False,
33+
)
34+
exp._param_sets = param_sets
35+
return exp
36+
37+
38+
@pytest.fixture
39+
def registered_target(mocker):
40+
"""Register a fake experiment factory under `dummy-multi-target` for the registry lookup."""
41+
run_mock = mocker.AsyncMock()
42+
fake_experiment = SimpleNamespace(run=run_mock)
43+
factory = mocker.Mock(return_value=fake_experiment)
44+
experiment_registry.add("dummy-multi-target", factory, module_path="dummy-module")
45+
yield factory, run_mock
46+
info = experiment_registry.get("dummy-multi-target")
47+
if info:
48+
experiment_registry._experiments.remove(info)
49+
50+
51+
# --------------------------------------------------------------------------- #
52+
# dict_to_namespace Tests
53+
# --------------------------------------------------------------------------- #
54+
class TestDictToNamespace:
55+
"""Tests for the dict_to_namespace function."""
56+
57+
def test_dict_to_namespace_converts_nested_dicts(self):
58+
"""Should recursively convert nested dicts into SimpleNamespace objects."""
59+
result = dict_to_namespace({"a": 1, "b": {"c": 2, "d": {"e": 3}}})
60+
assert result.a == 1
61+
assert result.b.c == 2
62+
assert result.b.d.e == 3
63+
64+
@pytest.mark.parametrize("value", [5, "str", None, [1, 2]])
65+
def test_dict_to_namespace_passes_through_non_dict(self, value):
66+
"""Should return non-dict values unchanged."""
67+
assert dict_to_namespace(value) == value
68+
69+
70+
# --------------------------------------------------------------------------- #
71+
# Config Tests
72+
# --------------------------------------------------------------------------- #
73+
class TestConfig:
74+
"""Tests for the Config class."""
75+
76+
def test_get_raw_input_returns_original_data(self):
77+
"""Should return the exact kwargs passed to the constructor."""
78+
cfg = Config(name="exp", delay=5, extra_field="value")
79+
assert cfg.get_raw_input() == {"name": "exp", "delay": 5, "extra_field": "value"}
80+
81+
def test_get_extra_fields_returns_only_extras(self):
82+
"""Should return only the fields not declared on the model."""
83+
cfg = Config(name="exp", foo="bar", nested={"a": 1})
84+
assert cfg.get_extra_fields() == {"foo": "bar", "nested": {"a": 1}}
85+
86+
def test_get_extra_fields_empty_when_no_extra(self):
87+
"""Should return an empty dict when no extra fields were provided."""
88+
cfg = Config(name="exp")
89+
assert cfg.get_extra_fields() == {}
90+
91+
92+
# --------------------------------------------------------------------------- #
93+
# Multiple.get_name_from_params Tests
94+
# --------------------------------------------------------------------------- #
95+
class TestMultipleGetNameFromParams:
96+
"""Tests for the Multiple.get_name_from_params method."""
97+
98+
def test_get_name_from_params_joins_key_value_pairs(self, mocker, tmp_path):
99+
"""Should join each key/value pair with `_` and separate pairs with `__`."""
100+
exp = _make_multiple(mocker, [], tmp_path, name="dummy-multi-target", delay=1)
101+
result = exp.get_name_from_params({"lr": 0.1, "batch": 32})
102+
assert result == "lr_0.1__batch_32"
103+
104+
105+
# --------------------------------------------------------------------------- #
106+
# Multiple._run Tests
107+
# --------------------------------------------------------------------------- #
108+
class TestMultipleRun:
109+
"""Tests for the Multiple._run method."""
110+
111+
@pytest.mark.asyncio
112+
async def test_run_raises_if_delay_not_set(self, mocker, tmp_path):
113+
"""Should raise AssertionError when delay is falsy."""
114+
exp = _make_multiple(mocker, [{}], tmp_path, name="dummy-multi-target", delay=0)
115+
with pytest.raises(AssertionError):
116+
await exp._run()
117+
118+
@pytest.mark.asyncio
119+
async def test_run_raises_if_no_experiment_name(self, mocker, tmp_path):
120+
"""Should raise ValueError when config.name is not set."""
121+
mocker.patch("src.deployments.experiments.multi_experiment.asyncio.sleep")
122+
exp = _make_multiple(mocker, [{}], tmp_path, name=None, delay=0.01)
123+
with pytest.raises(ValueError):
124+
await exp._run()
125+
126+
@pytest.mark.asyncio
127+
async def test_run_merges_params_into_a_fresh_copy_of_raw_input_each_iteration(
128+
self, mocker, tmp_path, registered_target
129+
):
130+
"""Should merge each param set onto a fresh deep copy of the raw config, not a
131+
previously mutated copy."""
132+
factory, run_mock = registered_target
133+
mocker.patch("src.deployments.experiments.multi_experiment.asyncio.sleep")
134+
exp = _make_multiple(
135+
mocker,
136+
[{"model.lr": 0.2}, {"model.batch": 8}],
137+
tmp_path,
138+
name="dummy-multi-target",
139+
delay=0.01,
140+
model={"lr": 0.1, "batch": 4},
141+
)
142+
143+
await exp._run()
144+
145+
assert factory.call_count == 2
146+
first_config = factory.call_args_list[0].kwargs["config"]
147+
assert first_config["model"] == {"lr": 0.2, "batch": 4}
148+
second_config = factory.call_args_list[1].kwargs["config"]
149+
assert second_config["model"] == {"lr": 0.1, "batch": 8}
150+
assert run_mock.await_count == 2
151+
152+
@pytest.mark.asyncio
153+
async def test_run_passes_experiment_context_to_child_experiment(
154+
self, mocker, tmp_path, registered_target
155+
):
156+
"""Should forward namespace, skip_check, dry_run, and a nested output_folder."""
157+
factory, _ = registered_target
158+
mocker.patch("src.deployments.experiments.multi_experiment.asyncio.sleep")
159+
exp = _make_multiple(
160+
mocker,
161+
[{"model.lr": 0.2}],
162+
tmp_path,
163+
name="dummy-multi-target",
164+
delay=0.01,
165+
model={"lr": 0.1},
166+
)
167+
168+
await exp._run()
169+
170+
kwargs = factory.call_args_list[0].kwargs
171+
assert kwargs["namespace"] == "ns"
172+
assert kwargs["skip_check"] is False
173+
assert kwargs["dry_run"] is False
174+
assert Path(kwargs["output_folder"]).parent == tmp_path
175+
176+
@pytest.mark.asyncio
177+
async def test_run_sleeps_delay_between_each_experiment(
178+
self, mocker, tmp_path, registered_target
179+
):
180+
"""Should sleep for config.delay seconds after every experiment run."""
181+
sleep_mock = mocker.patch("src.deployments.experiments.multi_experiment.asyncio.sleep")
182+
exp = _make_multiple(mocker, [{}, {}, {}], tmp_path, name="dummy-multi-target", delay=42)
183+
184+
await exp._run()
185+
186+
assert sleep_mock.await_count == 3
187+
sleep_mock.assert_awaited_with(42)
188+
189+
@pytest.mark.asyncio
190+
async def test_run_continues_after_child_experiment_raises(
191+
self, mocker, tmp_path, registered_target
192+
):
193+
"""Should log the exception and continue the loop if a child experiment raises."""
194+
factory, run_mock = registered_target
195+
run_mock.side_effect = [RuntimeError("boom"), None]
196+
mocker.patch("src.deployments.experiments.multi_experiment.asyncio.sleep")
197+
exp = _make_multiple(mocker, [{}, {}], tmp_path, name="dummy-multi-target", delay=0.01)
198+
199+
await exp._run()
200+
201+
assert factory.call_count == 2
202+
assert run_mock.await_count == 2

0 commit comments

Comments
 (0)