Тестирование фича-флагов и A/B-экспериментов: как не отправить в прод кашу из вариантов
Однажды мы «выкатили» безобидный флаг: новый экран онбординга на 10% аудитории. QA прогнал новый экран — работает. Через день в саппорт посыпалось: часть людей видела то старый, то новый онбординг при каждом перезапуске, теряя прогресс. Флаг не был «липким»: пользователь при каждом заходе заново попадал в случайный бакет. Экран мы протестировали, а поведение флага — нет.
Фича-флаги и A/B — это уже не «спрятать кнопку за if». Это ветвление кода прямо в проде, целый слой, который живёт своей жизнью. И тестировать надо не только новую фичу, но и сам механизм переключения.
Флаг — это не «вкл/выкл», это ветка кода в проде
Как только в коде появился if (flag.isOn("new_checkout")), у вас в проде одновременно живут две версии приложения. Обе доедут до пользователя. Значит, тестировать надо обе — и старую ветку тоже, потому что её легко сломать «заодно», пока пилишь новую.
Минимальная матрица для одного булева флага:
- ON — новая фича работает.
- OFF — старое поведение осталось нетронутым (регресс!).
- Переключение на лету — что будет с юзером, который сидит в приложении в момент смены флага: подхватит без перезапуска, сломается, или увидит полуразобранный экран.
- Флаг недоступен — сервис флагов не ответил/таймаут. Приложение обязано взять дефолт, а не упасть и не зависнуть на белом экране.
Последний пункт забывают чаще всего, а он самый опасный: сеть флагов лежит → лежит всё приложение.
Что реально ломается (а не то, что вы думали протестировать)
Баги фича-флагов почти никогда не в самой фиче. Они в стыках:
- Комбинации флагов. Пять булевых флагов — это 32 состояния. Никто не тестирует все, но два-три флага, которые трогают один и тот же экран, обязаны быть проверены вместе. Классика: флаг A прячет старую кнопку, флаг B рассчитывает на неё — включили оба, кнопки нет, платёж не начать.
- Дефолты. Что отдаёт SDK, когда нет сети / нет юзера / ещё не загрузилась конфигурация? Дефолт должен быть безопасным старым поведением, и это надо проверить руками, выключив сеть.
- Кэш и момент оценки. Флаг вычислился при старте и закэшировался, а вы меняете его в дашборде и ждёте эффекта — а его нет до перезапуска/рефетча. Это не баг, это дизайн; но знать, когда флаг переоценивается, критично для проверки.
- Гонки. Флаг ещё не приехал, а код уже спрашивает его значение → ветка выбирается по дефолту, потом «прыгает». Мелькание UI, двойные события аналитики.
Таргетинг и сегменты — где злые баги
Как только флаг не «100% или 0%», а «10% / только iOS / только премиум / только Германия» — появляется таргетинг, и вот тут прячется самое интересное.
- Пограничные сегменты. Юзер ровно на границе: подписка истекает в эту секунду, версия приложения
minVersion − 1, регион по IP vs по настройкам. Проверьте, в какой бакет он попадает и не «мигает» ли между ними. - Sticky / липкость. Один и тот же пользователь должен стабильно видеть один вариант между сессиями и устройствами. Если бакетинг не привязан к устойчивому ключу (user id, а не сессия), человек будет прыгать между вариантами — как в истории выше. Проверяется просто: убить приложение, зайти заново несколько раз — вариант не меняется.
- Раскатка процентами. 10% — это примерно 10% по хэшу, а не «первые 10% списком». Проверьте, что увеличение с 10% до 50% не перекидывает тех, кто уже был в фиче, обратно (иначе теряется консистентность опыта).
- Кто побеждает. Если на юзера подходят два правила (он и «премиум», и «Германия»), какое сработает? Приоритет правил — отдельный тест-кейс.
A/B-эксперимент: тестируем механику, а не гипотезу
Важная развилка: фича-флаг отвечает на вопрос «показывать ли фичу», A/B-эксперимент — «какой из вариантов лучше по метрике». QA не проверяет гипотезу («вырастет ли конверсия») — это дело аналитики. QA проверяет, что эксперимент устроен так, что данным можно верить:
- Экспозиция считается один раз и в нужный момент. Событие «пользователь увидел вариант B» должно улетать тогда, когда он реально его увидел, а не при загрузке конфига. Иначе в выборку попадут те, кто до экрана не дошёл, и результат смажется.
- Сплит сходится. Задумано 50/50 — по факту тоже примерно 50/50. Сильный перекос = сломанный бакетинг или двойная экспозиция.
- Никто не попал в два варианта сразу. Один юзер — один вариант эксперимента, навсегда, на всех устройствах.
- Метрика привязана к варианту. Событие покупки несёт правильный
variant/experiment_id. Проверяется перехватом трафика (Proxyman/Charles) — смотрим, что уходит на самом деле, а не что «должно». - Взаимоисключающие эксперименты не пересекаются, если так задумано (mutual exclusion groups).
Kill switch: главный тест — «выключить»
У любого рискового флага должна быть возможность мгновенно откатить его из дашборда, без релиза. И этот сценарий надо отрепетировать до, а не во время инцидента:
- Выключаю флаг в дашборде → как быстро эффект доходит до клиента (мгновенно / со следующим рефетчем / после перезапуска)?
- Юзеры, которые были в фиче, корректно возвращаются на старое поведение, ничего не ломая по дороге (незавершённые транзакции, открытые экраны)?
- Аналитика перестаёт слать событие экспозиции.
Если «выключить» на самом деле требует релиза — это не kill switch, и релиз-риск вы не сняли.
Флаги-зомби
Каждый флаг — это технический долг и лишняя ветка в матрице. Флаг, который «уже раскатан на 100% и давно живёт», надо удалять вместе с мёртвой веткой кода. Пока он висит:
- кто-то случайно выключит давно-дефолтное поведение;
- старая (OFF) ветка тихо гниёт без тестов и однажды всплывёт;
- матрица состояний растёт впустую.
QA полезно вести список активных флагов и на ретро спрашивать: «этот ещё эксперимент или уже пора чистить?». GrowthBook и LaunchDarkly умеют подсвечивать stale-флаги — пользуйтесь.
Чек-лист перед релизом флага
- Протестированы обе ветки: ON и OFF (OFF = регресс старого поведения).
- Безопасный дефолт при недоступности сервиса флагов (проверено с выключенной сетью).
- Известно, когда флаг переоценивается (старт / рефетч / перезапуск) — и это проверено.
- Липкость: вариант стабилен между сессиями и устройствами.
- Проверены комбинации с другими флагами, трогающими тот же экран.
- Таргетинг: пограничные сегменты, приоритет правил, корректность процентной раскатки.
- Для A/B: экспозиция считается один раз в нужный момент; сплит сходится;
variantв событиях (проверено перехватом трафика). - Kill switch реально выключает без релиза — отрепетировано.
- Заведена задача на удаление флага после раскатки.
Коротко — что забрать с собой
- Флаг — это две версии приложения в проде одновременно. Тестируй обе ветки, а не только новую фичу.
- Самый забываемый и самый злой кейс — недоступность сервиса флагов: должен быть безопасный дефолт, а не белый экран.
- Липкость и «когда флаг переоценивается» решают больше, чем сама фича. Проверяй перезапуском.
- A/B тестирует QA как механику: экспозиция один раз, сплит сходится, вариант в событиях — данным должно быть можно верить.
- Kill switch без релиза надо отрепетировать заранее. Флаги-зомби — удалять.
Почитать: GrowthBook — фича-флаги · LaunchDarkly — testing feature flags · Martin Fowler — Feature Toggles