Coverage for app / backend / src / tests / test_experimentation.py: 100%
86 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-23 19:04 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-23 19:04 +0000
1import pytest
2from growthbook.common_types import FeatureResult
3from sqlalchemy import select
5from couchers import experimentation
6from couchers.config import config
7from couchers.context import make_background_user_context, make_logged_out_context
8from couchers.db import session_scope
9from couchers.experimentation import _record_feature_usage
10from couchers.i18n import LocalizationContext
11from couchers.models.logging import ExperimentExposure, FeatureUsage
12from couchers.proto import bugs_pb2
13from tests.fixtures.sessions import bugs_session
16@pytest.fixture
17def experimentation_snapshot(monkeypatch):
18 """Enable experimentation with an in-memory feature snapshot for evaluation."""
19 monkeypatch.setattr(experimentation, "_initialized", True)
20 features = {
21 # A rollout with explicit coverage: bucketing needs a hash, so anonymous (logged-out) users
22 # are excluded even at 100% coverage and get the feature's default value instead.
23 "rollout_flag": {"defaultValue": "control", "rules": [{"force": "treatment", "coverage": 1.0}]},
24 # A global force with no coverage: applies to everyone, including anonymous users.
25 "global_flag": {"defaultValue": False, "rules": [{"force": True}]},
26 # An actual experiment: a logged-in user gets bucketed (coverage 1), which fires the exposure
27 # tracking callback unless it's explicitly suppressed.
28 "experiment_flag": {
29 "defaultValue": "control",
30 "rules": [{"key": "my_experiment", "variations": ["control", "treatment"], "coverage": 1.0}],
31 },
32 }
33 monkeypatch.setattr(experimentation, "_state", {"features": features, "savedGroups": {}})
34 monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", True)
35 monkeypatch.setitem(config, "EXPERIMENTATION_PASS_ALL_GATES", False)
38def test_logged_in_user_is_bucketed_into_rollout(db, experimentation_snapshot):
39 context = make_background_user_context(123)
40 assert context.get_string_value("rollout_flag", "fallback") == "treatment"
43def test_anonymous_user_excluded_from_rollout_gets_feature_default(experimentation_snapshot):
44 context = make_logged_out_context(LocalizationContext.en_utc())
45 # Previously this raised NotLoggedInContextException via context.user_id.
46 assert context.get_string_value("rollout_flag", "fallback") == "control"
49def test_anonymous_user_still_gets_global_force_on_flag(experimentation_snapshot):
50 context = make_logged_out_context(LocalizationContext.en_utc())
51 assert context.get_boolean_value("global_flag", default=False) is True
54def test_unknown_feature_returns_in_code_default(experimentation_snapshot):
55 context = make_logged_out_context(LocalizationContext.en_utc())
56 assert context.get_string_value("does_not_exist", "my_default") == "my_default"
59def test_value_method_returns_in_code_default_when_disabled(monkeypatch, experimentation_snapshot):
60 monkeypatch.setitem(config, "EXPERIMENTATION_ENABLED", False)
61 context = make_background_user_context(123)
62 assert context.get_string_value("global_flag", "off") == "off"
65def test_evaluating_an_experiment_flag_records_exactly_one_exposure(db, experimentation_snapshot):
66 # Evaluating an experiment-backed flag for a bucketed user records exactly one exposure - this is
67 # the whole point of per-flag evaluation: exposure is logged only for flags the user actually hits.
68 context = make_background_user_context(123)
69 assert context.get_object_value("experiment_flag", "control") in {"control", "treatment"}
71 with session_scope() as session:
72 rows = session.execute(select(ExperimentExposure).where(ExperimentExposure.user_id == 123)).scalars().all()
73 assert len(rows) == 1
74 assert rows[0].experiment_key == "my_experiment"
77def test_evaluate_feature_flag_servicer_returns_value(experimentation_snapshot, db):
78 with bugs_session() as bugs:
79 res = bugs.EvaluateFeatureFlag(bugs_pb2.EvaluateFeatureFlagReq(flag_key="global_flag"))
80 assert res.value.bool_value is True
83def test_evaluate_feature_flag_servicer_unknown_leaves_value_unset(experimentation_snapshot, db):
84 with bugs_session() as bugs:
85 res = bugs.EvaluateFeatureFlag(bugs_pb2.EvaluateFeatureFlagReq(flag_key="does_not_exist"))
86 assert not res.HasField("value")
89def _get_usage(session, user_id):
90 return (
91 session.execute(select(FeatureUsage).where(FeatureUsage.user_id == user_id).order_by(FeatureUsage.id))
92 .scalars()
93 .all()
94 )
97def test_record_feature_usage_appends_a_row(db):
98 _record_feature_usage(1, "my_feature", FeatureResult(value=True, source="defaultValue"))
100 with session_scope() as session:
101 rows = _get_usage(session, 1)
102 assert len(rows) == 1
103 assert rows[0].feature_key == "my_feature"
104 assert rows[0].value is True
105 assert rows[0].time is not None
108def test_record_feature_usage_appends_a_row_per_check(db):
109 # every check appends - the log is append-only, not deduplicated per (user, feature)
110 _record_feature_usage(1, "my_feature", FeatureResult(value="first", source="force"))
111 _record_feature_usage(1, "my_feature", FeatureResult(value="second", source="force"))
113 with session_scope() as session:
114 rows = _get_usage(session, 1)
115 assert len(rows) == 2
116 assert [row.value for row in rows] == ["first", "second"]
119def test_record_feature_usage_records_each_user_and_feature(db):
120 _record_feature_usage(1, "feature_a", FeatureResult(value=1, source="force"))
121 _record_feature_usage(1, "feature_b", FeatureResult(value=2, source="force"))
122 _record_feature_usage(2, "feature_a", FeatureResult(value=3, source="force"))
124 with session_scope() as session:
125 assert {row.feature_key for row in _get_usage(session, 1)} == {"feature_a", "feature_b"}
126 assert len(_get_usage(session, 2)) == 1
129def test_record_feature_usage_none_value(db):
130 # unknown features evaluate to a None value - must persist without violating NOT NULL
131 _record_feature_usage(1, "unknown_feature", FeatureResult(value=None, source="unknownFeature"))
133 with session_scope() as session:
134 rows = _get_usage(session, 1)
135 assert len(rows) == 1
136 assert rows[0].value is None
139def test_global_evaluation_excluded_from_rollout_gets_feature_default(experimentation_snapshot):
140 # global (no-user) evaluation can't bucket into a rollout, so it gets the feature default
141 assert experimentation.get_global_string_value("rollout_flag", "fallback") == "control"
144def test_global_evaluation_gets_global_force_on_flag(experimentation_snapshot):
145 assert experimentation.get_global_boolean_value("global_flag", default=False) is True
148def test_global_evaluation_unknown_feature_returns_in_code_default(experimentation_snapshot):
149 assert experimentation.get_global_string_value("does_not_exist", "my_default") == "my_default"