Skip to content

Commit f9f6871

Browse files
authored
test: ConfigurationFixture for plugin-override singleton reset (#50)
The Agentic QA platform suites' conftest had two near-identical ~20-line functions (_configure_httpx_plugin, _configure_llm_plugin) that hand-wrote the same env-set + Configuration._instance reset + ServiceLocator cache pop + get_client + type-validate sequence. Pattern: Test Fixture (Test Helper Class). - src/test/python/suites/agentic/_fixtures.py (new): ConfigurationFixture with three classmethods: * set_env(overrides) — apply env vars * reset_singletons(plugin_interface) — clear caches for one plugin without disturbing others * resolve(iface, expected_cls, env_overrides) — composes the above and validates the resolved class - conftest.py: both _configure_*_plugin functions are now 6-line wrappers calling ConfigurationFixture.resolve. Imports reduced (no longer reach into Configuration / ServiceLocator privates). The cache reset is now exhaustively unit-tested instead of being buried in fixture code that's only exercised by E2E suites. Coverage: 10 new unit tests covering set_env (writes to os.environ, empty-dict no-op), reset_singletons (clears Configuration, pops only target plugin's caches, no-op when target absent), and resolve (raises on None / wrong type, returns class on success, applies env before resolving, resets caches on each call). flake8 + mypy clean. 287 unit tests pass (was 277; +10).
1 parent 89d52c2 commit f9f6871

3 files changed

Lines changed: 322 additions & 35 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright (c) 2017-2026 Wesley Peng
2+
#
3+
# Licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0).
4+
# You may obtain a copy of the License at
5+
#
6+
# https://www.gnu.org/licenses/lgpl-3.0.html
7+
#
8+
# This software is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU Lesser General Public License for more details.
12+
13+
"""Test-suite-private helpers for plugin configuration overrides.
14+
15+
The Agentic QA platform suites need to swap which concrete plugin
16+
ServiceLocator returns for a given plugin interface at fixture time
17+
(e.g. ``HttpxRESTPlugin`` vs the default REST plugin, or enabling
18+
the optional LLM judge). Each switch requires invalidating two
19+
singleton caches before re-resolving:
20+
21+
* ``taf.foundation.conf.configuration.Configuration._instance``
22+
and ``._settings`` — the YAML config singleton.
23+
* ``taf.foundation.servicelocator.ServiceLocator._plugins`` and
24+
``._clients`` — per-plugin discovery caches.
25+
26+
Without the cache reset the new env-var override is ignored and the
27+
tests resolve a stale client class. The pattern was duplicated for
28+
each plugin in ``conftest.py``; this module pulls it into a single
29+
:class:`ConfigurationFixture` helper.
30+
"""
31+
32+
import os
33+
from typing import Any, Type
34+
35+
from taf.foundation import ServiceLocator
36+
from taf.foundation.conf.configuration import Configuration
37+
38+
39+
class ConfigurationFixture:
40+
"""Centralized helper for plugin-override fixtures.
41+
42+
Encapsulates the env-set, singleton-reset, ServiceLocator-resolve,
43+
type-validate sequence that used to be hand-written per plugin.
44+
"""
45+
46+
@staticmethod
47+
def set_env(overrides: dict[str, str]) -> None:
48+
"""Apply a batch of environment variable overrides.
49+
50+
The keys are typically TAF_PLUGIN_<NAME>_<FIELD> for plugin
51+
configuration; values are forwarded to ``os.environ``.
52+
"""
53+
for key, value in overrides.items():
54+
os.environ[key] = value
55+
56+
@staticmethod
57+
def reset_singletons(plugin_interface: Type[Any]) -> None:
58+
"""Invalidate Configuration + ServiceLocator caches for one plugin.
59+
60+
We pop only the cache entries for the specific plugin interface
61+
so that other plugins resolved earlier in the session keep
62+
their cached client classes.
63+
"""
64+
Configuration._instance = None
65+
Configuration._settings = None
66+
ServiceLocator._plugins.pop(plugin_interface, None)
67+
ServiceLocator._clients.pop(plugin_interface, None)
68+
69+
@classmethod
70+
def resolve(
71+
cls,
72+
plugin_interface: Type[Any],
73+
expected_client_cls: Type[Any],
74+
env_overrides: dict[str, str] | None = None,
75+
) -> Type[Any]:
76+
"""Apply env overrides, reset singletons, and resolve the plugin.
77+
78+
Args:
79+
plugin_interface: The plugin interface (e.g. ``RESTPlugin``).
80+
expected_client_cls: The concrete client class the test
81+
expects ServiceLocator to resolve to.
82+
env_overrides: Optional environment variable overrides
83+
applied before resetting the singletons.
84+
85+
Returns:
86+
The resolved client class.
87+
88+
Raises:
89+
AssertionError: If ServiceLocator returns ``None`` or a
90+
client class other than ``expected_client_cls``.
91+
"""
92+
if env_overrides:
93+
cls.set_env(env_overrides)
94+
cls.reset_singletons(plugin_interface)
95+
96+
client_cls = ServiceLocator.get_client(plugin_interface)
97+
assert client_cls is not None, (
98+
f'ServiceLocator failed to resolve {plugin_interface.__name__}'
99+
)
100+
assert client_cls is expected_client_cls, (
101+
f'Expected {expected_client_cls.__name__}, '
102+
f'got {client_cls.__name__}. '
103+
f'ServiceLocator did not resolve to the expected plugin.'
104+
)
105+
return client_cls

src/test/python/suites/agentic/conftest.py

Lines changed: 19 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@
3232
import pytest
3333
import yaml
3434

35-
from taf.foundation import ServiceLocator
3635
from taf.foundation.api.plugins import LLMPlugin, RESTPlugin
37-
from taf.foundation.conf.configuration import Configuration
36+
37+
from ._fixtures import ConfigurationFixture
3838

3939

4040
_HAS_LANGCHAIN = (
@@ -59,28 +59,21 @@ def _load_config():
5959

6060

6161
def _configure_httpx_plugin():
62-
"""Switch REST plugin to httpx via env overrides, then resolve via ServiceLocator."""
63-
os.environ['TAF_PLUGIN_REST_NAME'] = 'HttpxRESTPlugin'
64-
os.environ['TAF_PLUGIN_REST_LOCATION'] = '../plugins/svc/httpx'
65-
66-
# Reset singletons so config reload picks up the override
67-
Configuration._instance = None
68-
Configuration._settings = None
69-
ServiceLocator._plugins.pop(RESTPlugin, None)
70-
ServiceLocator._clients.pop(RESTPlugin, None)
71-
72-
# Resolve through ServiceLocator — this proves the chain:
73-
# config.yml + env override → ServiceLocator → HttpxRESTPlugin → HttpClient
74-
client_cls = ServiceLocator.get_client(RESTPlugin)
75-
assert client_cls is not None, 'ServiceLocator failed to resolve REST plugin'
62+
"""Switch REST plugin to httpx via env overrides, then resolve via ServiceLocator.
7663
64+
Uses :class:`ConfigurationFixture` to handle the env-set +
65+
singleton-reset + resolve + type-validate sequence.
66+
"""
7767
from taf.foundation.plugins.svc.httpx import HttpClient
78-
assert client_cls is HttpClient, (
79-
f'Expected HttpClient, got {client_cls}. '
80-
'ServiceLocator did not resolve to httpx plugin.'
81-
)
8268

83-
return client_cls
69+
return ConfigurationFixture.resolve(
70+
plugin_interface=RESTPlugin,
71+
expected_client_cls=HttpClient,
72+
env_overrides={
73+
'TAF_PLUGIN_REST_NAME': 'HttpxRESTPlugin',
74+
'TAF_PLUGIN_REST_LOCATION': '../plugins/svc/httpx',
75+
},
76+
)
8477

8578

8679
@pytest.fixture(scope='session')
@@ -152,22 +145,13 @@ def _configure_llm_plugin():
152145
TAF_PLUGIN_LLM_ENABLED=true
153146
→ Configuration → ServiceLocator → LLMJudgePlugin → LLMClient
154147
"""
155-
os.environ['TAF_PLUGIN_LLM_ENABLED'] = 'true'
156-
157-
Configuration._instance = None
158-
Configuration._settings = None
159-
ServiceLocator._plugins.pop(LLMPlugin, None)
160-
ServiceLocator._clients.pop(LLMPlugin, None)
161-
162-
client_cls = ServiceLocator.get_client(LLMPlugin)
163-
assert client_cls is not None, 'ServiceLocator failed to resolve LLM plugin'
164-
165148
from taf.foundation.plugins.llm.judge.llmclient import LLMClient
166-
assert client_cls is LLMClient, (
167-
f'Expected LLMClient, got {client_cls}. '
168-
'ServiceLocator did not resolve to LLM judge plugin.'
149+
150+
return ConfigurationFixture.resolve(
151+
plugin_interface=LLMPlugin,
152+
expected_client_cls=LLMClient,
153+
env_overrides={'TAF_PLUGIN_LLM_ENABLED': 'true'},
169154
)
170-
return client_cls
171155

172156

173157
@pytest.fixture(scope='session')
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
# Copyright (c) 2017-2026 Wesley Peng
2+
#
3+
# Licensed under the GNU Lesser General Public License v3.0 (LGPL-3.0).
4+
# You may obtain a copy of the License at
5+
#
6+
# https://www.gnu.org/licenses/lgpl-3.0.html
7+
#
8+
# This software is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU Lesser General Public License for more details.
12+
13+
"""Unit tests for ConfigurationFixture.
14+
15+
Locks the singleton-reset semantics that the platform suites rely on
16+
when swapping plugins at fixture time.
17+
"""
18+
19+
import os
20+
import sys
21+
from pathlib import Path
22+
23+
import pytest
24+
25+
# The fixture lives alongside the agentic suite's conftest. The suite
26+
# directory isn't on sys.path by default for unit tests, so we add it.
27+
_SUITE_DIR = Path(__file__).parent.parent / 'suites' / 'agentic'
28+
if str(_SUITE_DIR) not in sys.path:
29+
sys.path.insert(0, str(_SUITE_DIR))
30+
31+
from _fixtures import ConfigurationFixture # noqa: E402
32+
33+
from taf.foundation import ServiceLocator # noqa: E402
34+
from taf.foundation.api.plugins import RESTPlugin # noqa: E402
35+
from taf.foundation.conf.configuration import Configuration # noqa: E402
36+
37+
38+
class _FakePlugin:
39+
"""Marker class used as a plugin interface in tests."""
40+
41+
42+
class TestSetEnv:
43+
44+
def test_set_env_writes_to_os_environ(
45+
self, monkeypatch: pytest.MonkeyPatch,
46+
) -> None:
47+
monkeypatch.delenv('TAF_TEST_KEY_1', raising=False)
48+
monkeypatch.delenv('TAF_TEST_KEY_2', raising=False)
49+
50+
ConfigurationFixture.set_env({
51+
'TAF_TEST_KEY_1': 'value-1',
52+
'TAF_TEST_KEY_2': 'value-2',
53+
})
54+
assert os.environ['TAF_TEST_KEY_1'] == 'value-1'
55+
assert os.environ['TAF_TEST_KEY_2'] == 'value-2'
56+
57+
def test_empty_dict_is_noop(self) -> None:
58+
# Should not raise, should not mutate os.environ
59+
before = dict(os.environ)
60+
ConfigurationFixture.set_env({})
61+
assert dict(os.environ) == before
62+
63+
64+
class TestResetSingletons:
65+
66+
def test_clears_configuration_singleton(self) -> None:
67+
# Force-prime the Configuration singleton.
68+
Configuration._instance = object() # type: ignore[assignment]
69+
Configuration._settings = {'sentinel': True} # type: ignore[assignment]
70+
71+
ConfigurationFixture.reset_singletons(_FakePlugin)
72+
assert Configuration._instance is None
73+
assert Configuration._settings is None
74+
75+
def test_pops_only_target_plugin_caches(self) -> None:
76+
# Stash sentinel entries for two plugin interfaces.
77+
ServiceLocator._plugins[_FakePlugin] = 'fake-plugin'
78+
ServiceLocator._clients[_FakePlugin] = 'fake-client'
79+
ServiceLocator._plugins[RESTPlugin] = 'rest-plugin-sentinel'
80+
ServiceLocator._clients[RESTPlugin] = 'rest-client-sentinel'
81+
82+
ConfigurationFixture.reset_singletons(_FakePlugin)
83+
84+
# Target plugin caches were popped.
85+
assert _FakePlugin not in ServiceLocator._plugins
86+
assert _FakePlugin not in ServiceLocator._clients
87+
88+
# Other plugin caches are preserved.
89+
assert ServiceLocator._plugins.get(RESTPlugin) == 'rest-plugin-sentinel'
90+
assert ServiceLocator._clients.get(RESTPlugin) == 'rest-client-sentinel'
91+
92+
# Cleanup
93+
ServiceLocator._plugins.pop(RESTPlugin, None)
94+
ServiceLocator._clients.pop(RESTPlugin, None)
95+
96+
def test_no_op_if_target_not_in_caches(self) -> None:
97+
# Should not raise even when nothing to pop.
98+
ServiceLocator._plugins.pop(_FakePlugin, None)
99+
ServiceLocator._clients.pop(_FakePlugin, None)
100+
ConfigurationFixture.reset_singletons(_FakePlugin)
101+
102+
103+
class TestResolve:
104+
105+
def test_raises_when_service_locator_returns_none(
106+
self, monkeypatch: pytest.MonkeyPatch,
107+
) -> None:
108+
monkeypatch.setattr(
109+
ServiceLocator, 'get_client', lambda iface: None,
110+
)
111+
112+
with pytest.raises(AssertionError, match='failed to resolve'):
113+
ConfigurationFixture.resolve(
114+
plugin_interface=_FakePlugin,
115+
expected_client_cls=str,
116+
)
117+
118+
def test_raises_on_unexpected_client_class(
119+
self, monkeypatch: pytest.MonkeyPatch,
120+
) -> None:
121+
class _ExpectedCls:
122+
pass
123+
124+
class _ActualCls:
125+
pass
126+
127+
monkeypatch.setattr(
128+
ServiceLocator, 'get_client', lambda iface: _ActualCls,
129+
)
130+
131+
with pytest.raises(AssertionError, match='did not resolve to the expected plugin'):
132+
ConfigurationFixture.resolve(
133+
plugin_interface=_FakePlugin,
134+
expected_client_cls=_ExpectedCls,
135+
)
136+
137+
def test_returns_client_cls_on_success(
138+
self, monkeypatch: pytest.MonkeyPatch,
139+
) -> None:
140+
class _ExpectedCls:
141+
pass
142+
143+
monkeypatch.setattr(
144+
ServiceLocator, 'get_client', lambda iface: _ExpectedCls,
145+
)
146+
147+
result = ConfigurationFixture.resolve(
148+
plugin_interface=_FakePlugin,
149+
expected_client_cls=_ExpectedCls,
150+
)
151+
assert result is _ExpectedCls
152+
153+
def test_applies_env_overrides_before_resolving(
154+
self, monkeypatch: pytest.MonkeyPatch,
155+
) -> None:
156+
seen_env: dict[str, str] = {}
157+
158+
def fake_get_client(_iface: object) -> type:
159+
# Capture the env state ServiceLocator would see at this point
160+
seen_env['TAF_PROBE'] = os.environ.get('TAF_PROBE', '')
161+
return str
162+
163+
monkeypatch.setattr(ServiceLocator, 'get_client', fake_get_client)
164+
monkeypatch.delenv('TAF_PROBE', raising=False)
165+
166+
ConfigurationFixture.resolve(
167+
plugin_interface=_FakePlugin,
168+
expected_client_cls=str,
169+
env_overrides={'TAF_PROBE': 'set-by-fixture'},
170+
)
171+
172+
# The override was visible to ServiceLocator at resolution time
173+
assert seen_env['TAF_PROBE'] == 'set-by-fixture'
174+
175+
def test_resets_caches_on_each_call(
176+
self, monkeypatch: pytest.MonkeyPatch,
177+
) -> None:
178+
# Pre-populate the cache; resolve should clear it.
179+
ServiceLocator._plugins[_FakePlugin] = 'stale'
180+
ServiceLocator._clients[_FakePlugin] = 'stale'
181+
182+
called: list[bool] = []
183+
184+
def fake_get_client(iface: type) -> type:
185+
# By the time get_client runs, the cache should already be
186+
# cleared by reset_singletons().
187+
called.append(True)
188+
assert iface not in ServiceLocator._plugins
189+
assert iface not in ServiceLocator._clients
190+
return str
191+
192+
monkeypatch.setattr(ServiceLocator, 'get_client', fake_get_client)
193+
194+
ConfigurationFixture.resolve(
195+
plugin_interface=_FakePlugin,
196+
expected_client_cls=str,
197+
)
198+
assert called == [True]

0 commit comments

Comments
 (0)