Вызов Тестирования Feature Flags

Feature flags (также известные как feature toggles) стали незаменимыми для современной доставки программного обеспечения, обеспечивая прогрессивные rollouts, A/B тестирование, canary развертывания и возможности мгновенного отката. Однако, feature flags вносят сложность в тестирование - QA команды должны валидировать все комбинации флагов, тестировать сценарии постепенного rollout, проверять правила таргетинга и убеждаться, что флаги не создают технический долг, когда оставлены в коде неопределенно долго.

Эта статья исследует стратегии тестирования для feature flags используя LaunchDarkly, Flagsmith и open-source решения, охватывая тестирование комбинаций, валидацию A/B и интеграцию с CI/CD пайплайнами.

Управление Feature Flags с LaunchDarkly

Интеграция SDK LaunchDarkly

# app/feature_flags.py
import ldclient
from ldclient.config import Config
import os

class FeatureFlagManager:
    def __init__(self):
        sdk_key = os.getenv('LAUNCHDARKLY_SDK_KEY')
        ldclient.set_config(Config(sdk_key))
        self.client = ldclient.get()

    def is_feature_enabled(self, flag_key: str, user: dict, default: bool = False):
        """Проверить включен ли feature для пользователя"""
        return self.client.variation(flag_key, user, default)

    def get_flag_value(self, flag_key: str, user: dict, default):
        """Получить значение feature flag"""
        return self.client.variation(flag_key, user, default)

# Использование в приложении
feature_flags = FeatureFlagManager()

def process_payment(user_id: str, amount: float):
    user = {
        "key": user_id,
        "custom": {"subscription_tier": "premium"}
    }

    if feature_flags.is_feature_enabled('new-payment-processor', user):
        result = new_payment_processor(amount)
    else:
        result = legacy_payment_processor(amount)

    return result

Тестирование Feature Flags с LaunchDarkly

# tests/test_feature_flags.py
import pytest
from unittest.mock import Mock, patch

class TestFeatureFlags:
    @pytest.fixture
    def mock_ld_client(self):
        """Mock LaunchDarkly клиента"""
        with patch('ldclient.get') as mock_get:
            mock_client = Mock()
            mock_client.is_initialized.return_value = True
            mock_get.return_value = mock_client
            yield mock_client

    def test_feature_enabled_for_user(self, mock_ld_client):
        """Тестировать feature flag включен для конкретного пользователя"""
        mock_ld_client.variation.return_value = True

        ff_manager = FeatureFlagManager()
        user = {"key": "user123"}

        result = ff_manager.is_feature_enabled('new-payment-processor', user)
        assert result == True

    def test_flag_targeting_by_attribute(self, mock_ld_client):
        """Тестировать таргетинг флага на основе атрибутов пользователя"""
        def variation_side_effect(flag_key, user, default):
            if user.get('custom', {}).get('subscription_tier') == 'premium':
                return True
            return False

        mock_ld_client.variation.side_effect = variation_side_effect

        ff_manager = FeatureFlagManager()

        premium_user = {"key": "premium1", "custom": {"subscription_tier": "premium"}}
        assert ff_manager.is_feature_enabled('premium-feature', premium_user) == True

        free_user = {"key": "free1", "custom": {"subscription_tier": "free"}}
        assert ff_manager.is_feature_enabled('premium-feature', free_user) == False

Интеграция и Тестирование Flagsmith

# app/flagsmith_manager.py
from flagsmith import Flagsmith
import os

class FlagsmithManager:
    def __init__(self):
        self.flagsmith = Flagsmith(
            environment_key=os.getenv('FLAGSMITH_ENVIRONMENT_KEY')
        )

    def is_feature_enabled(self, feature_name: str, identifier: str = None):
        """Проверить включен ли feature"""
        flags = self.flagsmith.get_identity_flags(identifier) if identifier else self.flagsmith.get_environment_flags()
        return flags.is_feature_enabled(feature_name)

    def get_feature_value(self, feature_name: str, identifier: str = None, default=None):
        """Получить значение feature"""
        flags = self.flagsmith.get_identity_flags(identifier) if identifier else self.flagsmith.get_environment_flags()
        value = flags.get_feature_value(feature_name)
        return value if value is not None else default

Тестирование Всех Комбинаций Флагов

# tests/test_flag_combinations.py
import pytest
from itertools import product

class FlagCombinationTester:
    """Тестировать все возможные комбинации feature flags"""

    def __init__(self, flags: dict):
        self.flags = flags

    def get_all_combinations(self):
        """Генерировать все возможные комбинации флагов"""
        flag_names = list(self.flags.keys())
        flag_values = [self.flags[name] for name in flag_names]

        for combination in product(*flag_values):
            yield dict(zip(flag_names, combination))

    def test_all_combinations(self):
        """Тестировать поведение со всеми комбинациями флагов"""
        for combination in self.get_all_combinations():
            try:
                self._test_with_flags(combination)
                print(f"✓ Протестирована комбинация: {combination}")
            except Exception as e:
                pytest.fail(f"✗ Провалилось с комбинацией {combination}: {str(e)}")

# Оптимизированное тестирование (только критические пути)
@pytest.mark.parametrize("new_checkout,express_payment", [
    (True, True),
    (True, False),
    (False, True),
    (False, False)
])
def test_critical_checkout_paths(new_checkout, express_payment):
    """Тестировать критические пути checkout с ключевыми комбинациями флагов"""
    with patch_feature_flags({
        'new-checkout-ui': new_checkout,
        'express-payment': express_payment
    }):
        response = complete_checkout()
        assert response.success == True

Валидация A/B Тестирования

# tests/test_ab_testing.py
from scipy import stats
import numpy as np

class ABTestValidator:
    """Валидировать статистическую значимость A/B тестов"""

    def __init__(self, alpha: float = 0.05):
        self.alpha = alpha

    def calculate_sample_size(self, baseline_rate: float, minimum_detectable_effect: float, power: float = 0.8):
        """Вычислить требуемый размер выборки для A/B теста"""
        z_alpha = stats.norm.ppf(1 - self.alpha / 2)
        z_beta = stats.norm.ppf(power)

        p1 = baseline_rate
        p2 = baseline_rate * (1 + minimum_detectable_effect)
        p_avg = (p1 + p2) / 2

        n = (2 * p_avg * (1 - p_avg) * (z_alpha + z_beta) ** 2) / ((p2 - p1) ** 2)
        return int(np.ceil(n))

    def test_statistical_significance(self, control_conversions: int, control_total: int,
                                     treatment_conversions: int, treatment_total: int):
        """Тестировать статистически значима ли разница между вариантами"""
        observed = np.array([
            [control_conversions, control_total - control_conversions],
            [treatment_conversions, treatment_total - treatment_conversions]
        ])

        chi2, p_value, dof, expected = stats.chi2_contingency(observed)
        is_significant = p_value < self.alpha

        control_rate = control_conversions / control_total
        treatment_rate = treatment_conversions / treatment_total
        lift = ((treatment_rate - control_rate) / control_rate) * 100

        return {
            'is_significant': is_significant,
            'p_value': p_value,
            'control_rate': control_rate,
            'treatment_rate': treatment_rate,
            'lift_percentage': lift
        }

def test_ab_test_new_checkout():
    """Тестировать A/B эксперимент для нового checkout flow"""
    validator = ABTestValidator(alpha=0.05)

    result = validator.test_statistical_significance(
        control_conversions=450, control_total=5000,
        treatment_conversions=520, treatment_total=5000
    )

    print(f"Конверсия контроль: {result['control_rate']:.2%}")
    print(f"Конверсия treatment: {result['treatment_rate']:.2%}")
    print(f"Lift: {result['lift_percentage']:.2f}%")

    assert result['is_significant'] == True
    assert result['lift_percentage'] > 0

Тестирование Жизненного Цикла Флагов

# tests/test_flag_lifecycle.py
from datetime import datetime, timedelta

class FlagLifecycleTester:
    """Тестировать управление жизненным циклом feature flags"""

    def test_flag_age_tracking(self):
        """Тестировать что флаги не остаются в коде неопределенно долго"""
        flags = self.get_all_flags()
        old_flags = []
        max_age_days = 90

        for flag in flags:
            created_date = datetime.fromisoformat(flag['created_at'])
            age = datetime.now() - created_date

            if age > timedelta(days=max_age_days):
                old_flags.append({
                    'key': flag['key'],
                    'age_days': age.days,
                    'permanent': flag.get('permanent', False)
                })

        temporary_old_flags = [f for f in old_flags if not f['permanent']]

        assert len(temporary_old_flags) == 0, \
            f"Найдено {len(temporary_old_flags)} флагов старше {max_age_days} дней"

    def test_unused_flags_detection(self):
        """Обнаружить флаги которые больше не используются в коде"""
        all_flags = self.get_all_flags()
        code_references = self.scan_codebase_for_flag_references()

        unused_flags = [flag['key'] for flag in all_flags if flag['key'] not in code_references]

        assert len(unused_flags) == 0, f"Найдено {len(unused_flags)} флагов без ссылок"

Интеграция CI/CD

# .github/workflows/feature-flags-test.yml
name: Feature Flag Testing

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test-flags:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Install dependencies
        run: |
          pip install pytest scipy numpy launchdarkly-server-sdk

      - name: Run flag combination tests
        env:
          LAUNCHDARKLY_SDK_KEY: ${{ secrets.LAUNCHDARKLY_SDK_KEY }}
        run: pytest tests/test_flag_combinations.py -v

      - name: Validate A/B tests
        run: pytest tests/test_ab_testing.py -v

      - name: Check flag lifecycle
        run: pytest tests/test_flag_lifecycle.py -v

Заключение

Тестирование feature flags требует комплексной стратегии, которая валидирует все комбинации флагов, обеспечивает статистическую валидность A/B тестов и управляет жизненным циклом флагов для предотвращения технического долга. Внедряя систематическое тестирование комбинаций, валидацию A/B со статистической строгостью, управление жизненным циклом и интеграцию CI/CD, команды могут уверенно использовать feature flags для прогрессивной доставки при сохранении качества кода.

Ключ - относиться к feature flags как к временным по умолчанию, тестировать все критические комбинации, правильно валидировать эксперименты и автоматизировать очистку флагов.