Контекст

Использование переключателей фичей (feature‑flags, feature‑toggles — здесь и далее FT) является довольно распространённой практикой как для разработки непосредственно фич (например, в Яндекс, Ситимобил, QIWI, Лемана Тех, SimbirSoft), так и для рефакторинга, исследований или менеджмента веток.

Но о feature‑toggles редко говорят в контексте автоматизации тестирования (как минимум, тестирования API).

Для .NET стека в части автоматизации тестирования широко используется xUnit — популярный фреймворк, о котором можно найти достаточно много статей. Предлагаю вспомнить основные особенности, а подробнее можно прочитать здесь или в документации. А если вспоминать не нужно или не хочется — давайте перейдем к проблеме.

Пару слов об xUnit

Fixture — это класс общего (разделяемого) контекста. Разделяемый контекст на практике — это некоторое состояние, общее для группы тестов. Например, токен авторизации или идентификатор проекта.

Контекст можно делить:

  1. Между тестами внутри одного класса в случае, если класс реализует интерфейс IClassFixture<T>, где T — тип класса‑контекста.

  2. Между тестами внутри одной коллекции в случае, когда несколько тестовых классов отмечены атрибутом [TestCollection].

По умолчанию, тесты выполняются:

  1. Внутри класса — последовательно;

  2. Между классами — параллельно;

  3. Внутри коллекции — последовательно.

Другими словами, тесты из разных классов, но в одной коллекции с общим контекстом будут выполняться последовательно, согласно пункту 3.

Проблема

Решая задачу обновления legacy системы на новый стек или разделяя домены при переходе из MVP в отраслевое решение, вы можете столкнуться с паттерном Anti‑Corraption Layer в том или ином виде, часто в сочетании с feature‑toggles.

Если кратко, то feature‑toogle — это именованный логический флаг. Обычно имя флага — это имя фичи, с которой флаг сопоставлен. Флаг имеет два состояния: true (фича активирована) или false (фича деактивирована).

В статье я хочу поделиться опытом интеграции обновленного 3D движка в платформу Bimeister в далеком 2023 году. Суть задачи сводилась к тому, чтобы в зависимости от состояния переключателя настроить прокси либо в одну группу контейнеров (текущая реализация), либо в другую (новая реализация).

Рисунок 1 - Использование feature-toggle
Рисунок 1 - Использование feature-toggle

Менеджмент переключателей был построен на основе инструментария Microsoft, флаги и их состояния хранились в БД, переключение осуществлялось через отдельный контроллер.

При разработке новой версии 3D движка команда старалась максимально сохранять текущие контракты между backend и frontend, но не всегда это было возможно из‑за особенностей новой реализации. В итоге появились такие группы конечных точек в API:

  1. Новые конечные точки, отмеченные атрибутом‑фильтром [FeatureGate], которые работают только при активированном FT. Для прохождения тестов нужно активировать FT.

  2. Старые конечные точки, которые зависят от FT, при разном состоянии FT должны сохранить одинаковое поведение. Прохождение тестов не зависит от FT.

  3. Старые конечные точки, которые не зависят от FT. Прохождение тестов не зависит от FT.

  4. Старые конечные точки, которые при активации FT становятся obsolete и выбрасывают соответствующее исключение. Для прохождения тестов в текущем режиме нужно деактивировать FT, для прохождения тестов с фичей — нужно активировать FT.

Пункты 1 и 4 приводят к конфликту — старые конечные точки будут возвращать ошибку 405, их тесты перестанут быть успешными, если FT активирован, а при деактивированном FT мы не можем проверить поведение для версий с фичей.

Собственно, проблема следующая — организация автоматических API тестов для всех перечисленных категорий. Для существующих конечных точек уже настроен запуск тестов в CI/CD перед влитием в основную ветку и по расписанию, и наши изыскания при решении проблемы не должны ничего сломать и влиться «бесшовно».

Поиск решений

В ходе мозгового штурма, были предложены следующие варианты:

  1. Тесты сами управляют FT.
    ? Плюсы: решается проблема ошибок 405.
    ? Минусы: очень сложно поддерживать при кросс‑командной разработке, и такие тесты нельзя параллелить.

  2. Кастомизируется Gitlab job под FT. Тестовый проект остается один, но job запускается с параметрами (уже есть механизм запускать весь проект под списком FT).
    ? Плюсы: есть возможность запуска тестов как с FT, так и без FT.
    ? Минусы: не решает проблему 405 ошибок, сложно поддерживать.

  3. Под FT создается отдельный проект. Под проект — отдельная Gitlab job. В проект помещаются только те тесты, которым требуется активация FT. Остальные тесты по прежнему живут в основном тестовом проекте. Тестовый проект запускается в отдельной job'е. После влития фичи, проект уничтожается, тесты переезжают в основной проект, устаревшие тесты из основного проекта уничтожаются.
    ? Плюсы: решается проблема с 405. Тесты по каждой фиче можно смотреть отдельно, не засоряя основной проект.
    ? Минусы: лишние проекты, сложности со слиянием.

  4. Один FT = один встроенный атрибут категории.
    ? Плюсы: нет отдельных тестовых проектов.
    ? Минусы: сложность и неопределенность с реализацией.

  5. Один FT = один кастомный атрибут.
    ? Плюсы: нет отдельных тестовых проектов.
    ? Минусы: сложность с реализацией.

Чтобы решить проблему с тестированием приложения в разных состояниях переключателей, можно пойти в кастомизацию фреймворка. Возможные варианты:

  1. Реализовать интерфейс IAsyncLifetime для каждого тестового класса.
    Минусы: на каждый тест будут дополнительные вызовы к API, дублирование кода, сложность поддержки для нескольких FT.

  2. Сделать на каждый тестовый метод свою отдельную тестовую коллекцию. В момент помещения теста в новую коллекцию, «обернуть» тестовый метод, добавив активацию FT перед его вызовом, и деактивацию после.
    Минусы: на каждый тест будут дополнительные вызовы к API, сложность в реализации и поддержке.

  3. Сделать кастомный атрибут [FeatureToggle], который принимает имя фичи в качестве аргумента. Все тесты с одинаковым FT помещаются в одну коллекцию. Активация/деактивация FT происходит для всей коллекции, а не для каждого теста.

Мы остановились на последнем варианте, поскольку он предполагает меньшее число вызовов к API, и, как следствие, меньшее количество случайных состояний при параллельном выполнении. Это особенно важно для проекта с ~3к тестов. Из минусов — тесты внутри коллекции с этим FT будут выполняться последовательно. Нас это устроило, поскольку FT не живут вечно, а использование или неиспользование FT‑кастомизации при прогоне можно задать в настройках.

А что на практике?

Чтобы не быть голословным и не переводить статью в теоретическую плоскость, давайте рассмотрим предложенный подход на практическом примере. В статье код сильно утрирован для наглядности. Полный пример здесь.

Допустим, у нас имеется сервис прогноза погоды WeatherForecast. По умолчанию, прогноз строится на основе показаний датчиков давления и температуры. Но в какой‑то момент этого становится недостаточно, и для более подробного прогноза погоды мы решаем перевести наш сервис на целую метеостанцию — отличный повод для внедрения фича‑менеджмента!

Тогда наш контроллер API примет вид:

public sealed class WeatherForecastController(IWeatherForecastProvider weatherForecastProvider) : ControllerBase
{
    /// <summary>
    /// Состояние фичи 'WeatherStation' НЕ ВЛИЯЕТ на работу роута: в ответе будет краткое сообщение о погоде.
    /// </summary>
    [HttpGet("Summary")]
    [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(SummaryView))]
    public Task<SummaryView> GetSummary()
    {
        return weatherForecastProvider.GetSummary();
    }
 
    /// <summary>
    /// Роут для работы только с НЕактивированой фичей 'WeatherStation':
    /// <br/> Если фича 'WeatherStation' НЕ активирована, то в ответе будет прогноз погоды по умолчанию.
    /// <br/> Если фича 'WeatherStation' АКТИВИРОВАНА, то будет ошибка 405.
    /// </summary>
    [HttpGet("Default")]
    [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(DefaultWeatherForecastView))]
    [ProducesResponseType((int)HttpStatusCode.MethodNotAllowed, Type = typeof(ErrorView))]
    public Task<DefaultWeatherForecastView> DefaultToday()
    {
        return weatherForecastProvider.GetDefaultWeatherForecast();
    }
 
    /// <summary>
    /// Роут для работы только с АКТИВИРОВАННОЙ фичей 'WeatherStation':
    /// <br/> Если фича 'WeatherStation' АКТИВИРОВАНА, то в ответе будет подробный прогноз погоды с метеостанции.
    /// <br/> Если фича 'WeatherStation' НЕ активирована, то будет ошибка 405.
    /// </summary>
    [FeatureGate(Features.WeatherStation)]
    [HttpGet("Advanced")]
    [ProducesResponseType((int)HttpStatusCode.OK, Type = typeof(AdvancedWeatherForecastView))]
    [ProducesResponseType((int)HttpStatusCode.MethodNotAllowed, Type = typeof(ErrorView))]
    public Task<AdvancedWeatherForecastView> AdvancedToday()
    {
        return weatherForecastProvider.GetAdvancedWeatherForecast();
    }
}

Таким образом, GET запрос «WeatherForecast/Default» может возвращать либо результат, либо ошибку — в зависимости от состояния фича‑флага «WeatherStation».

Тогда наши API тесты на этот контроллер могут выглядеть примерно так:

[Collection(nameof(WeatherForecastFixture))]
public sealed class WeatherForecastTests(WeatherForecastFixture fixture)
{
    private readonly ApiFacade _api = fixture.Api;
 
    /// <summary>
    /// Тест будет запускаться дважды: без фичей и с активированной фичей 'WeatherStation'
    /// </summary>
    [Fact]
    [FeatureToggle(FeatureToggles.Off)]
    [FeatureToggle(FeatureToggles.WeatherStation)]
    public async Task GetSummary_ValidModel_Ok()
    {
        // Arrange
 
        // Act
        var result = await _api.WeatherForecast.GetSummary();
 
        // Assert
        // ...
    }
 
    /// <summary>
    /// Тест будет запускаться без фичей
    /// </summary>
    [Fact]
    public async Task DefaultToday_ValidModel_Ok()
    {
        // Arrange
 
        // Act
        var result = await _api.WeatherForecast.DefaultToday();
 
        // Assert
        // ...
    }
 
    /// <summary>
    /// Тест будет запускаться только с активированной фичей 'WeatherStation'
    /// </summary>
    [Fact]
    [FeatureToggle(FeatureToggles.WeatherStation)]
    public async Task AdvancedToday_ValidModel_Ok()
    {
        // Arrange
 
        // Act
        var result = await _api.WeatherForecast.AdvancedToday();
 
        // Assert
        // ...
    }
}

Три теста, в одной тестовой коллекции WeatherForecastFixture. Но наши доработки с кастомным атрибутом [FeatureToggle] приведут к тому, что при запуске тестов на выполнение произойдет некая «магия», о которой поговорим дальше, и для тестов, отмеченных [FeatureToggle(FeatureToggles.WeatherStation)], будет создана отдельная коллекция "WeatherForecastFixture:{guid}.toggle:WeatherStation". Перед запуском коллекции будет вызов к API для активации фичи, а после — для деактивации. Подробный вывод в консоль на рисунке ниже.

Рисунок 2 - Перераспределение тестов по коллекциям с учетом фича-флагов
Рисунок 2 - Перераспределение тестов по коллекциям с учетом фича-флагов

Теперь пару слов о «магии» — это и есть наша кастомизация xUnit. За основу взят этот пример. Создаем класс CustomTestFramework, который наследуем от XunitTestFramework. В нем переопределяем метод CreateExecutor— в зависимости от настроек проекта с тестами, возвращаем либо оригинальный ITestFrameworkExecutor, либо наш собственный:

internal sealed class CustomTestFramework : XunitTestFramework
{
    public CustomTestFramework(IMessageSink messageSink) : base(messageSink)
    {
        messageSink.OnMessage(new DiagnosticMessage($"Using {nameof(CustomTestFramework)}"));
    }
 
    protected override ITestFrameworkExecutor CreateExecutor(AssemblyName assemblyName)
    {
        return ApiTestsSettings.UseFeaturedTestFramework
            ? new CustomTestFrameworkExecutor(assemblyName, SourceInformationProvider, DiagnosticMessageSink)
            : base.CreateExecutor(assemblyName);
    }
}

Чтобы xUnit «понял», что нужно использовать кастомный фреймворк, его нужно зарегистрировать в сборке с тестами:

[assembly: Xunit.TestFramework(
    typeName: "WeatherForecast.Api.TestFramework.CustomTestFramework",
    assemblyName: "WeatherForecast.Api.TestFramework")]

А дальше последовательно создать кастомные CustomTestFrameworkExecutor, CustomTestAssemblyRunner, CustomTestCollectionRunner и CustomTestClassRunner, в которых реализовать нужную вам логику. В данном конкретном примере — управление фича‑флагами. Чтобы не загромождать статью, здесь код каждого из этих классов не приводится, их можно посмотреть на github, проект WeatherForecast.Api.TestFramework (выводов в консоль там быть, конечно же, не должно, но для статьи очень был нужен рисунок 2).

Поскольку группировка по коллекциям возможна как через фиктивный класс с атрибутом [CollectionDefinition], так и через реализацию интерфейса IClassFixture, то следует эти нюансы также учесть. На рисунке ниже небольшая шпаргалка с порядком вызовов в xUnit при запуске тестового метода.

Рисунок 3 - Порядок вызовов при запуске теста в xUnit
Рисунок 3 - Порядок вызовов при запуске теста в xUnit

Вместо заключения

Рассмотренный вариант позволяет решить обозначенную проблему и не сильно дорог в поддержке и использовании.

Следует отметить, что необдуманное использование подхода с кастомным атрибутом для FT в сочетании с глобальной параллелизацией (1 тест = 1 коллекция) может приводить к появлению побочных эффектов, поскольку несколько тестовых классов (в одних есть FT, а в других нет) будут выполняться одновременно. Пожалуй, это тоже можно отнести к минусам выбранного подхода.

А какое решение используете вы?

Комментарии (0)