Coverage for app/backend/src/tests/test_experimentation.py: 100%
172 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-06 06:09 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-06 06:09 +0000
1import json
3import pytest
4from growthbook.common_types import FeatureResult
5from sqlalchemy import select
7from couchers import experimentation
8from couchers.config import config
9from couchers.context import make_background_user_context, make_logged_out_context
10from couchers.db import session_scope
11from couchers.experimentation import (
12 GrowthBookUnavailableError,
13 _load_local_flags,
14 _record_feature_usage,
15 setup_experimentation,
16)
17from couchers.i18n import LocalizationContext
18from couchers.metrics import feature_flag_evaluations_counter
19from couchers.models.logging import ExperimentExposure, ExposureSource, FeatureUsage
20from couchers.proto import bugs_pb2
21from tests.fixtures.sessions import bugs_session
24def _flag_eval_count(flag_key: str, source: str, value: str) -> float:
25 return sum(
26 s.value
27 for m in feature_flag_evaluations_counter.collect()
28 for s in m.samples
29 if s.name == "couchers_feature_flag_evaluations_total"
30 and s.labels.get("flag_key") == flag_key
31 and s.labels.get("source") == source
32 and s.labels.get("value") == value
33 )
36# Raw GrowthBook feature definitions for exercising the framework's own bucketing/exposure mechanics.
37# Most tests just need feature_flags.set(key, value); these go through feature_flags.set_definition().
38# A rollout with explicit coverage: bucketing needs a hash, so anonymous (logged-out) users are
39# excluded even at 100% coverage and get the feature's default value instead.
40_ROLLOUT_FLAG = {"defaultValue": "control", "rules": [{"force": "treatment", "coverage": 1.0}]}
41# A global force with no coverage: applies to everyone, including anonymous users.
42_GLOBAL_FORCE_FLAG = {"defaultValue": False, "rules": [{"force": True}]}
43# An actual experiment: a logged-in user gets bucketed (coverage 1), which fires the exposure callback.
44_EXPERIMENT_FLAG = {
45 "defaultValue": "control",
46 "rules": [{"key": "my_experiment", "variations": ["control", "treatment"], "coverage": 1.0}],
47}
50def test_logged_in_user_is_bucketed_into_rollout(db, feature_flags):
51 feature_flags.set_definition("rollout_flag", _ROLLOUT_FLAG)
52 context = make_background_user_context(123)
53 assert context.get_string_value("rollout_flag", "fallback") == "treatment"
56def test_anonymous_user_excluded_from_rollout_gets_feature_default(feature_flags):
57 feature_flags.set_definition("rollout_flag", _ROLLOUT_FLAG)
58 context = make_logged_out_context(LocalizationContext.en_utc())
59 # Previously this raised NotLoggedInContextException via context.user_id.
60 assert context.get_string_value("rollout_flag", "fallback") == "control"
63def test_anonymous_user_still_gets_global_force_on_flag(feature_flags):
64 feature_flags.set_definition("global_flag", _GLOBAL_FORCE_FLAG)
65 context = make_logged_out_context(LocalizationContext.en_utc())
66 assert context.get_boolean_value("global_flag", default=False) is True
69def test_unknown_feature_returns_in_code_default(feature_flags):
70 context = make_logged_out_context(LocalizationContext.en_utc())
71 assert context.get_string_value("does_not_exist", "my_default") == "my_default"
74def test_evaluating_an_experiment_flag_records_exactly_one_exposure(db, feature_flags):
75 # Evaluating an experiment-backed flag for a bucketed user records exactly one exposure - this is
76 # the whole point of per-flag evaluation: exposure is logged only for flags the user actually hits.
77 feature_flags.set_definition("experiment_flag", _EXPERIMENT_FLAG)
78 context = make_background_user_context(123)
79 assert context.get_object_value("experiment_flag", "control") in {"control", "treatment"}
81 with session_scope() as session:
82 rows = session.execute(select(ExperimentExposure).where(ExperimentExposure.user_id == 123)).scalars().all()
83 assert len(rows) == 1
84 assert rows[0].experiment_key == "my_experiment"
85 assert rows[0].source == ExposureSource.backend
88def test_evaluate_feature_flag_servicer_returns_value(feature_flags, db):
89 feature_flags.set("global_flag", True)
90 with bugs_session() as bugs:
91 res = bugs.EvaluateFeatureFlag(bugs_pb2.EvaluateFeatureFlagReq(flag_key="global_flag"))
92 assert res.value.bool_value is True
95def test_evaluate_feature_flag_servicer_unknown_leaves_value_unset(feature_flags, db):
96 with bugs_session() as bugs:
97 res = bugs.EvaluateFeatureFlag(bugs_pb2.EvaluateFeatureFlagReq(flag_key="does_not_exist"))
98 assert not res.HasField("value")
101def _get_usage(session, user_id):
102 return (
103 session.execute(select(FeatureUsage).where(FeatureUsage.user_id == user_id).order_by(FeatureUsage.id))
104 .scalars()
105 .all()
106 )
109def test_record_feature_usage_appends_a_row(db):
110 _record_feature_usage(1, "my_feature", FeatureResult(value=True, source="defaultValue"))
112 with session_scope() as session:
113 rows = _get_usage(session, 1)
114 assert len(rows) == 1
115 assert rows[0].feature_key == "my_feature"
116 assert rows[0].value is True
117 assert rows[0].time is not None
120def test_record_feature_usage_appends_a_row_per_check(db):
121 # every check appends - the log is append-only, not deduplicated per (user, feature)
122 _record_feature_usage(1, "my_feature", FeatureResult(value="first", source="force"))
123 _record_feature_usage(1, "my_feature", FeatureResult(value="second", source="force"))
125 with session_scope() as session:
126 rows = _get_usage(session, 1)
127 assert len(rows) == 2
128 assert [row.value for row in rows] == ["first", "second"]
131def test_record_feature_usage_records_each_user_and_feature(db):
132 _record_feature_usage(1, "feature_a", FeatureResult(value=1, source="force"))
133 _record_feature_usage(1, "feature_b", FeatureResult(value=2, source="force"))
134 _record_feature_usage(2, "feature_a", FeatureResult(value=3, source="force"))
136 with session_scope() as session:
137 assert {row.feature_key for row in _get_usage(session, 1)} == {"feature_a", "feature_b"}
138 assert len(_get_usage(session, 2)) == 1
141def test_record_feature_usage_none_value(db):
142 # unknown features evaluate to a None value - must persist without violating NOT NULL
143 _record_feature_usage(1, "unknown_feature", FeatureResult(value=None, source="unknownFeature"))
145 with session_scope() as session:
146 rows = _get_usage(session, 1)
147 assert len(rows) == 1
148 assert rows[0].value is None
151def test_global_evaluation_excluded_from_rollout_gets_feature_default(feature_flags):
152 # global (no-user) evaluation can't bucket into a rollout, so it gets the feature default
153 feature_flags.set_definition("rollout_flag", _ROLLOUT_FLAG)
154 assert experimentation.get_global_string_value("rollout_flag", "fallback") == "control"
157def test_global_evaluation_gets_global_force_on_flag(feature_flags):
158 feature_flags.set_definition("global_flag", _GLOBAL_FORCE_FLAG)
159 assert experimentation.get_global_boolean_value("global_flag", default=False) is True
162def test_global_evaluation_unknown_feature_returns_in_code_default(feature_flags):
163 assert experimentation.get_global_string_value("does_not_exist", "my_default") == "my_default"
166def test_evaluation_increments_metric_with_source_and_value(feature_flags):
167 feature_flags.set("metric_flag", "yes")
168 before = _flag_eval_count("metric_flag", "defaultValue", "yes")
169 assert experimentation.get_global_string_value("metric_flag", "no") == "yes"
170 assert _flag_eval_count("metric_flag", "defaultValue", "yes") == before + 1
173def test_unknown_feature_increments_metric_with_unknown_source(feature_flags):
174 before = _flag_eval_count("metric_unknown_flag", "unknownFeature", "fallback")
175 assert experimentation.get_global_string_value("metric_unknown_flag", "fallback") == "fallback"
176 assert _flag_eval_count("metric_unknown_flag", "unknownFeature", "fallback") == before + 1
179@pytest.fixture
180def setup_isolation(monkeypatch, tmp_path):
181 """Run setup_experimentation() against a clean module state and a tmp cache path, and make sure the
182 background refresh thread it starts is stopped afterwards."""
183 monkeypatch.setattr(experimentation, "_initialized", False)
184 monkeypatch.setattr(experimentation, "_last_fetch_time", None)
185 monkeypatch.setattr(experimentation, "_state", {"features": {}, "savedGroups": {}})
186 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", "")
187 monkeypatch.setitem(config, "GROWTHBOOK_CACHE_PATH", str(tmp_path / "cache.json"))
188 yield tmp_path / "cache.json"
189 experimentation._refresh_stop.set()
190 if experimentation._refresh_thread is not None:
191 experimentation._refresh_thread.join(timeout=5)
192 experimentation._refresh_stop.clear()
193 experimentation._refresh_thread = None
196def test_setup_writes_cache_and_records_fetch_time(setup_isolation, monkeypatch):
197 cache = setup_isolation
198 payload = {"features": {"f": {"defaultValue": True}}, "savedGroups": {}}
199 monkeypatch.setattr(experimentation, "_fetch_features", lambda: payload)
201 setup_experimentation()
203 assert experimentation._state["features"] == {"f": {"defaultValue": True}}
204 assert experimentation.seconds_since_last_fetch() is not None
205 written = json.loads(cache.read_text())
206 assert written["response"] == payload
207 assert "fetched_at" in written
210def test_setup_falls_back_to_disk_cache_when_fetch_fails(setup_isolation, monkeypatch):
211 cache = setup_isolation
212 cached_payload = {"features": {"cached": {"defaultValue": "x"}}, "savedGroups": {}}
213 cache.write_text(json.dumps({"fetched_at": 1000.0, "response": cached_payload}))
214 monkeypatch.setattr(experimentation, "_fetch_features", lambda: None)
216 setup_experimentation()
218 assert experimentation._state["features"] == {"cached": {"defaultValue": "x"}}
219 # fetch time reflects the cached pull time, so staleness is large immediately
220 staleness = experimentation.seconds_since_last_fetch()
221 assert staleness is not None and staleness > 0
224def test_setup_raises_when_fetch_fails_and_no_cache(setup_isolation, monkeypatch):
225 monkeypatch.setattr(experimentation, "_fetch_features", lambda: None)
227 with pytest.raises(GrowthBookUnavailableError):
228 setup_experimentation()
231def test_setup_raises_on_corrupt_cache(setup_isolation, monkeypatch):
232 cache = setup_isolation
233 cache.write_text("this is not json")
234 monkeypatch.setattr(experimentation, "_fetch_features", lambda: None)
236 with pytest.raises(json.JSONDecodeError):
237 setup_experimentation()
240def test_seconds_since_last_fetch_none_when_never_fetched(setup_isolation):
241 assert experimentation.seconds_since_last_fetch() is None
244def test_flags_value_returned(flags):
245 flags.set_string("my_flag", "from_file")
246 context = make_logged_out_context(LocalizationContext.en_utc())
247 assert context.get_string_value("my_flag", "fallback") == "from_file"
250def test_flags_missing_key_returns_in_code_default(flags):
251 context = make_logged_out_context(LocalizationContext.en_utc())
252 assert context.get_string_value("missing_flag", "fallback") == "fallback"
255def test_flags_boolean_value(flags):
256 flags.set_boolean("bool_flag", False)
257 context = make_logged_out_context(LocalizationContext.en_utc())
258 assert context.get_boolean_value("bool_flag", default=True) is False
261def test_load_local_flags_from_file(tmp_path):
262 path = tmp_path / "flags.json"
263 path.write_text(json.dumps({"flag_a": True, "flag_b": "hello", "flag_c": 42}))
264 assert _load_local_flags(str(path)) == {"flag_a": True, "flag_b": "hello", "flag_c": 42}
267def test_load_local_flags_rejects_non_object(tmp_path):
268 path = tmp_path / "flags.json"
269 path.write_text(json.dumps(["not", "an", "object"]))
270 with pytest.raises(ValueError, match="must contain a JSON object"):
271 _load_local_flags(str(path))
274def test_setup_in_local_file_mode_loads_file_and_skips_growthbook(monkeypatch, tmp_path):
275 path = tmp_path / "flags.json"
276 path.write_text(json.dumps({"flag_x": "from_file"}))
277 monkeypatch.setattr(experimentation, "_initialized", False)
278 monkeypatch.setitem(config, "FEATURE_FLAGS_FILE_OVERRIDE_PATH", str(path))
279 # If GrowthBook were touched, this would blow up.
280 monkeypatch.setattr(experimentation, "_fetch_features", lambda: pytest.fail("GrowthBook should not be touched"))
282 setup_experimentation()
284 assert _load_local_flags(str(path)) == {"flag_x": "from_file"}
285 assert experimentation._refresh_thread is None