Привет! Меня зовут Гриша и я бэкенд разработчик на .net 

С друзьями мы часто обсуждаем процессы разработки, в том числе как пишутся автотесты. Я часто слышу о подходах, которые несут боль разработчикам и одновременно с этим приносят мало пользы. 

При этом я строю весь процесс разработки микросервиса на тестах. Я бы хотел поделиться своим взглядом на то, как писать компонентные тесты и в чём они могут быть полезны помимо проверки работы сервиса.

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

Введение

Все знают что любая статья про тестирование должна начинаться с пирамиды. Я не верю в пирамиду тестирования в микросервисах, поэтому вместо неё будет табличка.

еnd2еnd 

Компонентные 

Интеграционные

Модульные

Что тестируют

группу сервисов

сервис в изоляции 

часть сервиса, либо весь сервис с замокаными зависимостями

класс или метод

Скорость

медленно

медленно

медленно

быстро

Кто пишет

скорее тестировщики но могут и разработчики

скорее разработчики но могут и тестировщики

разработчики

разработчики

Где живут

в отдельном проекте

в проекте с кодом

в проекте с кодом

в проекте с кодом

Блокируют ли пайплайн в ci

нет

да

да

да

Удобно ли дебажить

нет, только remote debug

да

да

да

Когда требуются правки

поменялся контракт

поменялся контракт

поменялась реализация

поменялась реализация 

Могут ли проверять негативные сценарии

нет

да

да

да

Генерируют тестовые данные на машине разработчика

нет

да

нет

нет

Полезны для воспроизведения багов

нет

да 

скорее нет 

скорее нет

Поднимают окружение для разработки

нет

да

нет

нет

  1. Модульные тесты быстрые, простые, но ломаются при рефакторинге. Обычно их пишут для алгоритмов или каких-то других сложных участков кода.

    Я их пишу на доменные объекты, если сервис на ДДД. Если сервис - это перекладывание json из одного места в другое, то могу и не писать вообще.

  2. Интеграционные тесты - что-то что большее чем Unit тест, но меньше чем тест, покрывающий весь микросервис. Это тесты на репозитории в связке с БД, тесты на сервис, но с замоканными зависимостями - обычно они проверяют взаимодействие нескольких частей проекта, пайплайн запроса.  

    Я такие тесты не пишу за редкими исключениями - например, когда в БД идут сложные запросы и их надо потестить отдельно.

  3. Компонентные тесты тестируют сервис целиком, на реальных с точки зрения сервиса зависимостях (Мы не вмешиваемся в конфигурацию DI сервиса и не подсовываем туда заглушки - сервис шлёт настоящие запросы и получает настоящие ответы). В идеале такие тесты также документируют требования, которые ставились перед его разработкой, и гарантируют что сервис соответствует им. 

    Я стараюсь писать их всегда.

  4. End2End тесты проверяют связку сервисов - обычно простые позитивные сценарии. Если все сервисы в связке покрыты компонетными тестами, то e2e по сути проверяет лишь, что контракт не изменился, и связка сервисов может работать вместе.

    Я редко пишу эти тесты - обычно, когда сомневаюсь, что вторая сторона не поменяет наш контракт.

На практике в сервис добавляются только те тесты, которые нужны - в моём случае это был бы скорее ромб тестирования. Есть алгоритмы - есть юнит тесты. Есть сложная структура из микросервисов - неплохо бы написать е2е сценарий. Вот только компонентные тесты я стараюсь писать всегда и много. И главная причина для этого - последние 3 пункта в табличке. Я пробегусь по каждому:

  1. Поднимают окружение для разработки - запустил тесты, они прошли или упали - у тебя уже все что нужно для сервиса поднято локально в докере - база, RabbitMq или Kafka, http моки - все что вообще можно поднять там. Это удобно.

  2. Генерируют тестовые данные на машине разработчика - нужно подебажить как работает код для сущности, которая зависит от другой, которая зависит от третей и так далее - запусти подходящий тест, который в своих шагах эти сущности сгенерит. И да - он будет - потому что на все CRUD операции такие тесты пишутся. Это супер удобно.

  3. Полезны для воспроизведения багов - тестировщик вместо текстового пошагового описания бага делает мерж реквест с тестом, который не проходит. Ты запускаешь его и дебажишь. Это супер мега удобно - по крайней мере для разработчика ????

Часть 1. Схема тестового фреймворка

Ближе к делу! Давайте покроем типовой микросервис компонентными тестами и на практике посмотрим, удобно это или нет. Схема теста у нас будет следующая

Схема тестового фреймворка
Схема тестового фреймворка
  1. Перед запуском тесткейсов поднимем всё необходимое сервису окружение в докере - контейнер с базой данных, брокером сообщений и прочим. Если у нас есть какие-то синхронные взаимодействия - поднимем контейнер для динамического создания заглушек сервисов. В нашем случае это Mountebank - а заглушки там называются imposter.

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

  3. Каждый тесткейс будет состоять из 3 этапов:

    • Given - конфигурация окружения. Этот этап не обязательный. Обычно там формируются импостеры с требуемым поведением для прохождения тесткейса.

    • When - инициирующее действие. Обычно это запрос в сервис, получение ответа и запись ответа в общую переменную.

    • Then - проверка результата. Обычно это проверка http ответа + проверка изменений, требуемых по контракту. Например, проверка того, что в RabbitMQ выгрузилось сообщение и оно корректно.
      Также в этом методе извлекаются необходимы для дальнейших шагов данные и помещаются в общие переменные. Например, можно извлечь id созданной сущности чтобы потом проверить её обновление и удаление.

Часть 2. Пишем фреймворк

Реализуем эту схему в коде на примере сервиса часто задаваемых вопросов FAQ:

  1. Сервис должен предоставлять API для создания категорий и вопросов. 

  2. Он должен уметь искать по тексту вопроса.

  3. Сервис должен быть закрыт авторизацией через jwt токен.

  4. При создании вопроса мы должны отправить его в сервис модерации через RabbitMQ.

Вот код контроллеров сервиса.

CategoryController
[ApiController]
[Authorize]
[Route("api/v1/categories")]
public class CategoryController : ControllerBase
{
    private readonly FaqDbContext _dbContext;
    
    public CategoryController(FaqDbContext dbContext) => _dbContext = dbContext;

    [HttpGet]
    public async Task<List<CategoryDto>> GetAllCategoriesAsync() 
        => await _dbContext.Categories.Include(x=>x.Questions)
            .Select(x=>new CategoryDto(x)).ToListAsync();

    [HttpGet("{id}")]
    public async Task<CategoryDto> GetCategoryById([FromRoute] Guid id)
    {
        var category = await _dbContext.Categories.Include(x=>x.Questions)
            .FirstAsync(x => x.Id == id);
        
        return new CategoryDto(category);
    }

    [HttpPost]
    public async Task<CategoryDto> CreateCategory([FromBody] CategoryDto categoryDto)
    {
        var category = new Category(categoryDto.Id, categoryDto.Name);
        
        await _dbContext.Categories.AddAsync(category);
        await _dbContext.SaveChangesAsync();
        
        return new CategoryDto(category);
    }

    [HttpPut("{id}")]
    public async Task<CategoryDto> UpdateCategory([FromRoute] Guid id, [FromBody] CategoryDto categoryDto)
    {
        var category = await _dbContext.Categories.FirstAsync(x => x.Id == id);
        category.Name = categoryDto.Name;
        
        await _dbContext.SaveChangesAsync();
        
        return new CategoryDto(category);
    }

    [HttpDelete("{id}")]
    public async Task DeleteCategory([FromRoute] Guid id)
    {
        var category = await _dbContext.Categories.FirstAsync(x => x.Id == id);
        
        _dbContext.Categories.Remove(category);
        await _dbContext.SaveChangesAsync();
    }
}

QuestionController
[ApiController]
[Authorize]
[Route("api/v1/questions")]
public class QuestionController : ControllerBase
{
    private readonly FaqDbContext _dbContext;

    public QuestionController(FaqDbContext dbContext) => _dbContext = dbContext;

    [HttpGet("{id}")]
    public async Task<QuestionDto?> GetQuestionById([FromRoute] Guid id)
    {
        var question = await _dbContext.Questions.FirstAsync(x => x.Id == id);
        return new QuestionDto(question);
    }
    
    [HttpGet]
    public async Task<List<QuestionDto>> Search([FromQuery]string? search)
    {
        var questionQuery = _dbContext.Questions.AsQueryable();
        
        if (search != null)
        {
            questionQuery = questionQuery.Where(x =>
                EF.Functions.ILike(x.Title, $"%{search}%") ||
                EF.Functions.ILike(x.Answer, $"%{search}%")
            );
        }
        var questions = await questionQuery.ToListAsync();
        return questions.Select(x => new QuestionDto(x)).ToList();
    }

    [HttpPost]
    public async Task<QuestionDto> CreateQuestion([FromBody] QuestionDto questionDto)
    {
        var question = new Question(questionDto.Id, questionDto.Title, questionDto.Answer, questionDto.CategoryId);

        _dbContext.Questions.Add(question);
        await _dbContext.SaveChangesAsync();
        
        var factory = new ConnectionFactory
        {
            UserName = "guest",
            Password = "guest",
            VirtualHost = "/",
            HostName = "localhost",
            Port = 5672
        };
        
        using (var connection = factory.CreateConnection())
        using (var channel = connection.CreateModel())
        {
            channel.ExchangeDeclare("FaqExchange", ExchangeType.Direct, durable: true);
            channel.QueueDeclare("FaqQueue", true, false, false);
            channel.QueueBind("FaqQueue", "FaqExchange", "FaqRoutingKey");

            var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(question));
            
            channel.BasicPublish(exchange:"FaqExchange",
                routingKey:"FaqRoutingKey",
                basicProperties:null,
                body: body);
        }
        
        return new QuestionDto(question);
    }

    [HttpPut("{id}")]
    public async Task<QuestionDto> UpdateQuestion([FromRoute] Guid id, [FromBody] QuestionDto questionDto)
    {
        var question = await _dbContext.Questions.FirstAsync(x => x.Id == id);
        
        question.Title = questionDto.Title;
        question.Answer = questionDto.Answer;
        question.CategoryId = questionDto.CategoryId;
        
        await _dbContext.SaveChangesAsync();
        return new QuestionDto(question);
    }

    [HttpDelete("{id}")]
    public async Task DeleteQuestion([FromRoute] Guid id)
    {
        var question = await _dbContext.Questions.FirstAsync(x => x.Id == id);
        
        _dbContext.Questions.Remove(question);
        await _dbContext.SaveChangesAsync();
    }
}

Вам кажется что код плохой и его надо переписать? Вам не кажется. Но прежде чем рефакторить микросервис, мы же должны покрыть его тестами, чтобы быть уверенными, что мы ничего не сломаем ????

Создадим проект с для компонентных тестов со следующими зависимостями

csproj файл проекта с тестами
<ItemGroup>
   <!-- клиент для mountebank -->
   <PackageReference Include="MbDotNet" Version="5.0.0" />


   <!-- позволяет генерировать jwt токен -->
   <PackageReference Include="jose-jwt" Version="4.1.0" />


   <!-- необходимо для работы с открытым/закрытым RSA ключами -->
   <PackageReference Include="Portable.BouncyCastle" Version="1.9.0" />


   <!-- клиент для работы с докер -->
   <PackageReference Include="Docker.DotNet" Version="3.125.15" />


   <!-- позволяет выразительно описывать проверяемое поведение системы в тесте  -->
   <PackageReference Include="FluentAssertions" Version="6.12.0" />
  
   <!-- необходимо для описания тестовых сценариев в BDD стиле -->
   <PackageReference Include="SpecFlow" Version="3.9.74" />
   <PackageReference Include="SpecFlow.xUnit" Version="3.9.74" />


   <!-- стандартная тествоая инфраструктура -->
   <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.10" />
   <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
   <PackageReference Include="xunit.runner.visualstudio" Version="2.5.0">
     <PrivateAssets>all</PrivateAssets>
     <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
   </PackageReference>
</ItemGroup>

Теперь давайте создадим наш микрофреймворк, который будет состоять из 4 файлов:

ScenarioBeforeAndAfter
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace ComponentTests;

[Binding]
public static class ScenarioBeforeAndAfter
{
    /// <summary>
    /// Перед запуском тестов поднимем все необходимое окружение в докере
    /// </summary>
    [BeforeTestRun]
    public static async Task BeforeTestRun()
    {
        await ExtEnvironment.Start();
    }

    /// <summary>
    /// Перед каждым тестом почистим состояние
    /// </summary>
    [BeforeScenario]
    public static async Task BeforeScenario()
    {
        await ExtEnvironment.PostgresContainer.DeleteAllData();
        await ExtEnvironment.MountebankClient.DeleteImposterAsync(4501);
        await ExtEnvironment.RabbitMqClient.ClearQueue("FaqQueue");
        
        Common.ClearState();
    }
}

При запуске тестов мы первым делом попадаем сюда. Сначала мы запускаем внешнее окружение для сервиса, затем перед каждым сценарием очищаем состояние в зависимостях.

ExtEnvironment
/// <summary>
/// Класс для работы с внешними зависимостями
/// </summary>
public static class ExtEnvironment
{
    /// <summary>
    /// Postgres контейнер
    /// </summary>
    public static PostgresContainer PostgresContainer { get; set; }    
    
    /// <summary>
    /// MountebankContainer контейнер
    /// </summary>
    public static MountebankContainer MountebankContainer { get; set; }
    
    /// <summary>
    /// Postgres контейнер
    /// </summary>
    public static RabbitmqContainer RabbitmqContainer { get; set; }

    /// <summary>
    /// Тестовый сервер приложения
    /// </summary>
    public static TestServer TestServer { get; set;}
    
    /// <summary>
    /// Клиент для маунтбанка
    /// </summary>
    public static MountebankClient MountebankClient { get; private set; } = new(new Uri("http://localhost:2525"));
    
    /// <summary>
    /// Клиент для RabbitMq
    /// </summary>
    public static RabbitMqClient RabbitMqClient { get; private set; } = new();

    /// <summary>
    /// ctor
    /// </summary>
    public static async Task Start()
    {
        var dockerClient = new DockerClientConfiguration(new Uri(DockerApiUri())).CreateClient();
        
        // Создаем зависимости
        PostgresContainer = new PostgresContainer(dockerClient);
        RabbitmqContainer = new RabbitmqContainer(dockerClient);
        MountebankContainer = new MountebankContainer(dockerClient);
        
        // Готовим окружение - удаляем лишнее и запускаем нужные контейнеры
        await RemoveAllContainers(dockerClient);
        await Task.WhenAll(PostgresContainer.StartContainer(), RabbitmqContainer.StartContainer(), MountebankContainer.StartContainer());

        // Стартуем сервер с приложением
        TestServer = CreateServer();
    }

    /// <summary>
    /// Старт сервера с приложением
    /// </summary>
    private static TestServer CreateServer()
    {
        // Конфигурируем наше тестовое прилоежние через переменные окружения
        Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Development");
        
        Environment.SetEnvironmentVariable("DBSETTINGS__USER", "faq");
        Environment.SetEnvironmentVariable("DBSETTINGS__PASSWORD", "faq");
        Environment.SetEnvironmentVariable("DBSETTINGS__HOST", "127.0.0.1");
        Environment.SetEnvironmentVariable("DBSETTINGS__PORT", "5432");
        Environment.SetEnvironmentVariable("DBSETTINGS__DBNAME", "faq");
        
        Environment.SetEnvironmentVariable("AUTHENTICATION_AUTHORITY", "http://localhost:4501/auth/realms/myrealm");

        return new WebApplicationFactory<Program>().Server;
    }

    /// <summary>
    /// Получение Url API Docker
    /// </summary>
    /// <returns>Url</returns>
    /// <exception cref="Exception">Ошибка если не удалось определить ОС</exception>
    private static string DockerApiUri()
    {
        var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
        if (isWindows)
        {
            return "npipe://./pipe/docker_engine";
        }

        var isLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
        if (isLinux)
        {
            return "tcp://127.0.0.1:2375";
        }
            
        var isOsx = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
        if (isOsx)
        {
            return "unix:///var/run/docker.sock";
        }

        throw new Exception("Was unable to determine what OS this is running on");
    }
    
    /// <summary>
    /// Остановить и удалить все контейнеры
    /// </summary>
    private static async Task RemoveAllContainers(DockerClient dockerClient)
    {
        IList<ContainerListResponse> containers = await dockerClient.Containers.ListContainersAsync(new ContainersListParameters());
        foreach (var container in containers)
        {
            await dockerClient.Containers.KillContainerAsync(container.ID, new ContainerKillParameters());
            await dockerClient.Containers.RemoveContainerAsync(container.ID, new ContainerRemoveParameters());
        }
    }
}

Класс представляет собой внешнее окружение. При запуске тестов удаляет все запущенные контейнеры, запускает нужные сервису и дожидается пока они поднимутся. В нашем случае это 3 контейнера:

  • PostgresContainer для базы данных сервиса

  • RabbitmqContainer для шины данных, в которую отправляет сообщение сервис

  • MountebankContainer для создания http моков - нам он пригодится чтобы замокать keyCloak

Также здесь устанавливаются переменные окружения и запускается сервер с приложением.

BaseContainer
/// <summary>
///  Базовый контейнер для работы с Docker Api
/// </summary>
public abstract class BaseContainer
{
    protected readonly DockerClient DockerClient;
    protected string? ContainerId;
        
    protected readonly string Image;
    protected readonly string Tag;

    protected string ImageFull => $"{Image}:{Tag}";
        
    protected BaseContainer(string image, string tag, DockerClient dockerClient)
    {
        Image = image;
        Tag = tag;
        DockerClient = dockerClient;
    }

    /// <summary>
    /// Скачать образ
    /// </summary>
    /// <param name="image">Image</param>
    /// <param name="tag">tag</param>
    protected async Task PullImage(string image,string tag)
    {
        await DockerClient.Images
            .CreateImageAsync(new ImagesCreateParameters
                {
                    FromImage = image,
                    Tag = tag
                },
                new AuthConfig(),
                new Progress<JSONMessage>());
    }

    /// <summary>
    /// Запустить контейнер
    /// </summary>
    public abstract Task StartContainer();
        
    /// <summary>
    /// Ожидание готовности контейнера
    /// </summary>
    protected abstract Task WaitContainer();
}

Базовый класс для контейнеров зависимостей. Содержит данные об образе и декларирует методы для запуска и конфигурации контейнера.

Common
/// <summary>
/// Класс содержит данные, которые должны передаваться между шагами в сценарии
/// </summary>
public static class Common
{
    /// <summary>
    /// http ответ
    /// </summary>
    public static HttpResponseMessage? HttpResponseMessage { get; set; }

    /// <summary>
    /// Созданная категория
    /// </summary>
    public static CategoryDto? Category { get; set; }
    
    /// <summary>
    /// Созданный вопрос
    /// </summary>
    public static QuestionDto? Question { get; set; }
    
    /// <summary>
    /// Токен авторизации
    /// </summary>
    public static string? AuthToken { get; set; }   

    /// <summary>
    /// Почистить состояние между тесткейсами
    /// </summary>
    public static void ClearState()
    {
        HttpResponseMessage = null;
        Category = null;
        Question = null;
        AuthToken = null;
    }
}

Класс содержит данные, которые должны передаваться между шагами в сценарии, а также метод для их очистки - чтобы один сценарий никак не мог влиять на другой.

Часть 3. Базовый тестовый сценарий

Теперь давайте напишем наш первый сценарий.

  Scenario: Администратор может создать/отредактировать/получить список категорий/удалить категории
    When администратор создаёт категорию с названием "SomeCategoryName"
    Then категория успешно создана
    And название категории "SomeCategoryName"
    When администратор обновляет название категории на "SomeCategoryNameUpdated"
    Then категория успешно обновлена
    And название категории "SomeCategoryNameUpdated"
    When администратор запрашивает список всех категорий
    Then список категорий получен c количеством элементов 1    
    When администратор удаляет категорию
    Then категория успешно удалена
    When администратор запрашивает список всех категорий
    Then список категорий получен c количеством элементов 0

Сами шаги опишем в соответствующих файлах

When
[Binding]
public class WhenCategoryStepDefinitions
{
    [When(@"администратор создаёт категорию с названием ""(.*)""")]
    public async Task ClientCreateCategoryWithName(string name)
    {
        // создаём объект представляющий DTO категории
        var category = new CategoryDto
        (
            Guid.NewGuid(),
            name
        );

        // сериализуем в json этот объект
        var body = JsonSerializer.Serialize(category);
            
        // создаём объект представляющий http запрос
        var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/categories")
        {
            // задаём контент запроса
            Content = new StringContent(body, Encoding.UTF8, "application/json"),
        };
           
        // устанавливаем хэдер авторизации
        request.Headers.Authorization = AuthenticationHeaderValue.Parse("Bearer " + Common.AuthToken);
        
        // отправляем запрос на сервер и сохраняем его результат
        Common.HttpResponseMessage = await ExtEnvironment.TestServer.CreateClient().SendAsync(request);
    }
        
    [When(@"администратор обновляет название категории на ""(.*)""")]
    public async Task ClientUpdateCategoryNameTo(string name)
    {
        // создаём объект представляющий DTO категории
        var category = new CategoryDto
        (
            Guid.NewGuid(),
            name
        );

        // сериализуем в json этот объект
        var body = JsonSerializer.Serialize(category);
            
        // создаём объект представляющий http запрос
        var request = new HttpRequestMessage(HttpMethod.Put, $"/api/v1/categories/{Common.Category!.Id}")
        {
            // задаём контент запроса
            Content = new StringContent(body, Encoding.UTF8, "application/json")
        };
            
        // устанавливаем хэдер авторизации
        request.Headers.Authorization = AuthenticationHeaderValue.Parse("Bearer " + Common.AuthToken);
        
        // отправляем запрос на сервер и сохраняем его результат
        Common.HttpResponseMessage = await ExtEnvironment.TestServer.CreateClient().SendAsync(request);
    }
        
    [When("администратор запрашивает список всех категорий")]
    public async Task ClientRequestAllCategories()
    {
        // создаём объект представляющий http запрос на получение всех категрий
        var request = new HttpRequestMessage(HttpMethod.Get, $"/api/v1/categories");
        
        // устанавливаем хэдер авторизации
        request.Headers.Authorization = AuthenticationHeaderValue.Parse("Bearer " + Common.AuthToken);
        
        // отправляем запрос на сервер и сохраняем его результат
        Common.HttpResponseMessage = await ExtEnvironment.TestServer.CreateClient().SendAsync(request);
    }
        
    [When(@"администратор удаляет категорию")]
    public async Task ClientDeleteCategory()
    {
        // создаём объект представляющий http запрос на получение категории по id
        var request = new HttpRequestMessage(HttpMethod.Delete, $"/api/v1/categories/{Common.Category!.Id}");
        
        // устанавливаем хэдер авторизации
        request.Headers.Authorization = AuthenticationHeaderValue.Parse("Bearer " + Common.AuthToken);
        
        // отправляем запрос на сервер и сохраняем его результат
        Common.HttpResponseMessage = await ExtEnvironment.TestServer.CreateClient().SendAsync(request);
    }
}

Then
[Binding]
public class ThenCategoryStepDefinitions
{
    [Then(@"категория успешно создана")]
    public void CategorySuccessfullyCreated()
    {
        // проверим, что ответ на запрос не пустой
        Common.HttpResponseMessage.Should().NotBeNull();
        
        // проверим, что код ответа 200
        Common.HttpResponseMessage!.StatusCode.Should().Be(HttpStatusCode.OK);
            
        // десериализуем и сохраним полученную в ответе категорию
        Common.Category = Common.HttpResponseMessage.Content.ReadAs<CategoryDto>();
    }
        
    [Then(@"название категории ""(.*)""")]
    public void CategoryNameShouldBe(string name)
    {
        // название категории должно быть {name}
        Common.Category!.Name.Should().Be(name);
    }
        
    [Then(@"категория успешно обновлена")]
    public void CategorySuccessfullyUpdated()
    {
        // проверим, что ответ на запрос не пустой
        Common.HttpResponseMessage.Should().NotBeNull();
                
        // проверим, что код ответа 200
        Common.HttpResponseMessage!.StatusCode.Should().Be(HttpStatusCode.OK);
            
        // десериализуем и сохраним полученную в ответе категорию
        Common.Category = Common.HttpResponseMessage.Content.ReadAs<CategoryDto>();
    }        
        
    [Then(@"категория успешно удалена")]
    public void CategorySuccessfullyDeleted()
    {
        // проверим, что ответ на запрос не пустой
        Common.HttpResponseMessage.Should().NotBeNull();
        
        // проверим, что код ответа 200
        Common.HttpResponseMessage!.StatusCode.Should().Be(HttpStatusCode.OK);
    }
        
    [Then(@"список категорий получен c количеством элементов (.*)")]
    public void ClientGotCategoryListWithCount(int count)
    {
        // проверим, что ответ на запрос не пустой
        Common.HttpResponseMessage.Should().NotBeNull();
        
        // проверим, что код ответа 200
        Common.HttpResponseMessage!.StatusCode.Should().Be(HttpStatusCode.OK);
            
        // десериализуем полученный список категорий
        var result = Common.HttpResponseMessage.Content.ReadAs<List<CategoryDto>>();
           
        // количество категорий в списке должно быть {count}
        result!.Count.Should().Be(count);
    }
}

Я подробно прокомментировал шаги и не думаю, что тут есть что добавить. Но хотел бы обратить внимание на тот факт, что благодаря тому, что наши тесты живут в том же репозитории и написаны на том же языке программирования, мы можем переиспользовать DTO модели из проекта сервиса. Это удобнее, чем работать с json как текстом или писать свои модельки для тестов.

Наш первый сценарий готов. Ура!

Часть 4. Авторизация

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

Давайте вспомним как работает JWT авторизация.

JWT flow
JWT flow

KeyCloak не так просто поднять в контейнере и корректно настроить. Мы не будем это делать - вместо этого мы его замокаем. Как видно из схемы, наша заглушка keyCloak должна уметь обрабатывать 3 запроса:

  1. Получение токена.

  2. Получение настроек проверки токена.

  3. Получение сертификата для проверки токена.

Создадим шаг с типом Given - как мы помним, именно такие шаги нужны для настройки окружения. В нём с помощью Mountebank сгенерируем нужный нам импостер.

[Given("кейклоак работает и настроен")]
[Binding]
public class GivenCommonStepDefinitions
{
    [Given("кейклоак работает и настроен")]
    public async Task KeyCloakIsWorking()
    {
        // удалим импостер, если он уже есть
        await ExtEnvironment.MountebankClient.DeleteImposterAsync(4501);
        
        // созданим импостер кейклоака
        await ExtEnvironment.MountebankClient.CreateHttpImposterAsync(new HttpImposter(4501, "keyCloak",
            new HttpImposterOptions()
            {
                // разрешим cors, чтобы можно было удобно получить токен из UI сваггера
                AllowCORS = true
            }));

        // мокаем запрос на получение настроек для проверки токена
        await ExtEnvironment.MountebankClient.AddHttpImposterStubAsync(4501,
            new HttpStub()
                .OnPathAndMethodEqual($"/auth/realms/myrealm/.well-known/openid-configuration", Method.Get)
                .ReturnsJson(HttpStatusCode.OK, KeyCloakResponseGenerator.GetOpenidConfiguration("myrealm")), 0);

        // мокаем запрос на получение сертификата для проверки токена
        await ExtEnvironment.MountebankClient.AddHttpImposterStubAsync(4501,
            new HttpStub()
                .OnPathAndMethodEqual($"/auth/realms/myrealm/protocol/openid-connect/certs", Method.Get)
                .ReturnsJson(HttpStatusCode.OK, KeyCloakResponseGenerator.GetCertificates()), 1);
        
        // мокаем запрос на получение токена 
        await ExtEnvironment.MountebankClient.AddHttpImposterStubAsync(4501,
            new HttpStub()
                .OnPathAndMethodEqual($"/auth/realms/myrealm/protocol/openid-connect/token", Method.Post)
                .ReturnsJson(HttpStatusCode.OK, KeyCloakResponseGenerator.GetToken("myrealm", new Dictionary<string, string>())));
    }
}

Также нам нужен шаг, в котором клиент получит токен и сохранит его для дальнейших запросов.

[When("клиент является администратором приложения")]
[Binding]
public class WhenCommonStepDefinitions
{
    [When("клиент является администратором приложения")]
    public async Task ClientIsAdmin()
    {
        // создаём http клиент для отпавки запроса на получение токена
        using var httpClient = new HttpClient();
        
        // задаём базовый адрес кейклоака
        httpClient.BaseAddress = new Uri($"http://localhost:4501");
        
        // создаём запрос на получение токена
        var request = new HttpRequestMessage(HttpMethod.Post, $"/auth/realms/myrealm/protocol/openid-connect/token");
        
        // отправляем запрос
        var response = await httpClient.SendAsync(request);
        
        // десериализуем полученный ответ и прихраниваем AccessToken для дальнейшего использования в запросах к сервису
        Common.AuthToken = response.Content.ReadAs<Token>()!.AccessToken;
    }
}

Теперь добавим блок Background, который будет выполняться перед каждым сценарием.

#noinspection SpellCheckingInspection,CucumberUndefinedStep,NonAsciiCharacters
Feature: Category
  Background:
    Given кейклоак работает и настроен
    When клиент является администратором приложения
  
  Scenario: Администратор может создать/отредактировать/получить список категорий/удалить категории
    When администратор создаёт категорию с названием "SomeCategoryName"
    Then категория успешно создана
    And название категории "SomeCategoryName"
    When администратор обновляет название категории на "SomeCategoryNameUpdated"
    Then категория успешно обновлена
    And название категории "SomeCategoryNameUpdated"
    When администратор запрашивает список всех категорий
    Then список категорий получен c количеством элементов 1    
    When администратор удаляет категорию
    Then категория успешно удалена
    When администратор запрашивает список всех категорий
    Then список категорий получен c количеством элементов 0

Вот теперь наш сценарий полностью готов!

Mountebank поддерживает и другие протоколы - grpc, websockets и т.д. Таким образом этот подход позволяет замокать любое синхронное взаимодействие - главное, чтобы мы могли реализовать нужную логику генерации ответов.

Как видно из кода шага Given - в нашем случае логика генерации ответов keyCloak инкапсулирована в класс KeyCloakResponseGenerator.

KeyCloakResponseGenerator
/// <summary>
/// Генератор ответов от KeyCloak
/// </summary>
internal static class KeyCloakResponseGenerator
{
    /// <summary>
    /// Эмулируем ответ от кейклоак на запрос конфигурации
    /// </summary>
    public static OpenidConfiguration GetOpenidConfiguration(string realm)
    {
        return new OpenidConfiguration
        {
            Issuer = $"http://localhost:4501/auth/realms/{realm}",
            AuthorizationEndpoint = $"http://localhost:4501/auth/realms/{realm}/protocol/openid-connect/auth",
            TokenEndpoint = $"http://localhost:4501/auth/realms/{realm}/protocol/openid-connect/token",
            UserinfoEndpoint = $"http://localhost:4501/auth/realms/{realm}/protocol/openid-connect/userinfo",
            JwksUri = $"http://localhost:4501/auth/realms/{realm}/protocol/openid-connect/certs",
            CheckSessionIframe = $"http://localhost:4501/auth/realms/{realm}/protocol/openid-connect/login-status-iframe.html",
            GrantTypesSupported = new List<string>
            {
                "authorization_code",
                "implicit",
                "refresh_token",
                "password",
                "client_credentials"
            },
            ResponseTypesSupported = new List<string>
            {
                "code",
                "none",
                "id_token",
                "token",
                "id_token token",
                "code id_token",
                "code token",
                "code id_token token"
            },
            SubjectTypesSupported = new List<string>
            {
                "public",
                "pairwise"
            },
            IdTokenSigningAlgValuesSupported = new List<string>
            {
                "RS256"
            },
            IdTokenEncryptionAlgValuesSupported = new List<string>
            {
                "RSA-OAEP",
                "RSA1_5"
            },
            IdTokenEncryptionEncValuesSupported = new List<string>
            {
                "A128GCM",
                "A128CBC-HS256"
            },
            UserinfoSigningAlgValuesSupported = new List<string>
            {
                "RS256"
            },
            RequestObjectSigningAlgValuesSupported = new List<string>
            {
                "RS256"
            },
            ResponseModesSupported = new List<string>
            {
                "query",
                "fragment",
                "form_post"
            },
            RegistrationEndpoint =
                $"http://localhost:4501/auth/realms/{realm}/clients-registrations/openid-connect",
            TokenEndpointAuthMethodsSupported = new List<string>
            {
                "private_key_jwt",
                "client_secret_basic",
                "client_secret_post",
                "tls_client_auth",
                "client_secret_jwt"
            },
            TokenEndpointAuthSigningAlgValuesSupported = new List<string>
            {
                "RS256"
            },
            ClaimsSupported = new List<string>
            {
                "aud",
                "sub",
                "iss",
                "auth_time",
                "name",
                "given_name",
                "family_name",
                "preferred_username",
                "clientId",
                "email",
                "acr"
            },
            ClaimTypesSupported = new List<string>
            {
                "normal"
            },
            ClaimsParameterSupported = false,
            ScopesSupported = new List<string>
            {
                "openid",
                "profile",
                "email",
                "address",
                "phone",
                "offline_access",
                "roles",
                "web-origins",
                "microprofile-jwt",
                "aud-fins",
                "aud-my-app"
            },
            RequestParameterSupported = true,
            RequestUriParameterSupported = true,
            CodeChallengeMethodsSupported = new List<string>
            {
                "plain",
                "S256"
            },
            TlsClientCertificateBoundAccessTokens = true,
            IntrospectionEndpoint = $"http://localhost:4501/auth/realms/{realm}/protocol/openid-connect/token/introspect"

        };
    }

    /// <summary>
    /// Эмулируем ответ от кейклоака на запрос сертификата для проверки токена
    /// </summary>
    public static Certificates GetCertificates()
    {
        return new Certificates()
        {
            Keys = new List<Key>()
            {
                new()
                {
                    Kid = "0-CDYkLdNn_158pWcWIH3H_sO5m-eu2uBXH0KZE1pZM",
                    Kty = "RSA",
                    Alg = "RS256",
                    Use = "sig",
                    N = "jWrUiboTNuiejJM9jY0K_oCiuhoiZje18VQH7KlPWnihA7S8SvpVclr5nxuT75Rg2iBpR6RmfDfubkuMDOXlT86PRcgMAkDAFfoZ8OGlhKGLGgVSf-Tduv8fJ7lN28OgEzEFgE8G-B84FwOeoUV7VxMk39MxAsN6ajXM-IU-gLohj4c5UKrWHjor3tdqrYSjIZmK7-tcCR-_0U9GXPu-mHYKEi9B5WmyO-EoQ31OUjghFwCXJYMsvSiLIoDbX_P9I6NUkZkJRPYcL96ixLpRHKC5hGL61tEOFBlvFu1mDvkX6swU5EfD1sxlME7Ytnzw98S0yPzYcaYXI_WGWb2QsQ",
                    E = "AQAB",
                    X5C = new[]
                    {
                        "MIICmzCCAYMCBgFhUT+/kjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZCcm9rZXIwHhcNMTgwMjAxMTIwMTI3WhcNMjgwMjAxMTIwMzA3WjARMQ8wDQYDVQQDDAZCcm9rZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCNatSJuhM26J6Mkz2NjQr+gKK6GiJmN7XxVAfsqU9aeKEDtLxK+lVyWvmfG5PvlGDaIGlHpGZ8N+5uS4wM5eVPzo9FyAwCQMAV+hnw4aWEoYsaBVJ/5N26/x8nuU3bw6ATMQWATwb4HzgXA56hRXtXEyTf0zECw3pqNcz4hT6AuiGPhzlQqtYeOive12qthKMhmYrv61wJH7/RT0Zc+76YdgoSL0HlabI74ShDfU5SOCEXAJclgyy9KIsigNtf8/0jo1SRmQlE9hwv3qLEulEcoLmEYvrW0Q4UGW8W7WYO+RfqzBTkR8PWzGUwTti2fPD3xLTI/Nhxphcj9YZZvZCxAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEP7uuF7qtcxgyUPH1NlvsWIaOvDCmK2/xjnnr2UKzhiwXPvr+7QVNmH+oM+AeNUgXEy4B7C1mHQ4dYJd/QD7XGYE4nEadEnr/heo3Zj9RPX+ldq9ttFutUVTTW/7bESkaxEmoK018LZDlnTkiA8Q6ZPN1K/eqUJVfZ60aoNUnDG6UPFROWtmG8uB9fnos1SUBnroy+cEjtSTEnear1DpDk9DXwBeeCU91YYLzjOO/RmBLxgG2WuLkL4bWSLz/RDTRyVeCDpi7SoTzrg/NxoKtGvBUyxTAdyI6TBRo56I4O8lzD7pOUDjSN+khfM18JW9kUO6lsfecg5EJznf5GONG4="
                    },
                    X5T = "n5fx46hNQ83PWi9k8gp3ULhVU9c",
                    Hash = "Gk6c2paGOti48FHFNfG2VkhDhePaQ7gaAXQazyJ1ZFs"
                }
            }
        };
    }
        
    /// <summary>
    /// Эмулируем ответ от кейклоака на запрос токена
    /// </summary>
    public static Token GetToken(string realm, Dictionary<string, string> claims)
    {
        return new Token
        {
            AccessToken =  new JwtTokenBuilder().WithIssuer(realm).WithDefaultClaims().WithClaims(claims).CreateToken(),
            ExpiresIn = 100000,
            NotBeforePolicy = 1518534968,
            SessionState = Guid.NewGuid(),
            Scope = "email profile",
            RefreshToken =  new JwtTokenBuilder().WithIssuer(realm).CreateToken(),
            RefreshExpiresIn = 18000000,
            TokenType =  "Bearer"
        };
    }
}

Методы GetOpenidConfiguration и GetCertificates содержат  статический контент. Они создавались просто - я перехватил ответы из тестового KeyCloak и по ним сгенерил модельки. Обычно сервисы-зависимости на определённый запрос генерируют определённый статический ответ - поэтому проблем в генерации заглушек на такие зависимости не возникает.

В нашем случае все немного сложнее. Нам необходимо чтобы мы могли добавлять разные клеймы в токен и подписать его. К тому же если бы мы просто перехватили токен с теста и захардкодили его - он бы быстро протух и перестал работать. Поэтому в нашем случае токен генерируется руками с помощью JwtTokenBuilder

JwtTokenBuilder
/// <summary>
/// Билдер для создания jwt
/// </summary>
public class JwtTokenBuilder
{
    /// <summary>
    /// Список клеймов, которые будут добавлены в токен
    /// </summary>
    private readonly List<Claim> _claims = new();

    /// <summary>
    /// Приватный rsa ключ - используется для подписи токена
    /// </summary>
    private static string PrivateKey => 
        """
        -----BEGIN RSA PRIVATE KEY-----
        MIIEogIBAAKCAQEAjWrUiboTNuiejJM9jY0K/oCiuhoiZje18VQH7KlPWnihA7S8SvpVclr5nxuT75Rg2iBpR6RmfDfubkuMDOXlT86PRcgMAkDAFfoZ8OGlhKGLGgVSf+Tduv8fJ7lN28OgEzEFgE8G+B84FwOeoUV7VxMk39MxAsN6ajXM+IU+gLohj4c5UKrWHjor3tdqrYSjIZmK7+tcCR+/0U9GXPu+mHYKEi9B5WmyO+EoQ31OUjghFwCXJYMsvSiLIoDbX/P9I6NUkZkJRPYcL96ixLpRHKC5hGL61tEOFBlvFu1mDvkX6swU5EfD1sxlME7Ytnzw98S0yPzYcaYXI/WGWb2QsQIDAQABAoIBAF6JUwm7FYs4WH07FQPijL3z+lSUkfhpN7zbYuzHhl/Bkknq8ZDh5msq/AJsKioXs+M9lYOqGETkEwUyha49pV0Dhe2tPLHo3UAT0HGiNscCQv4jHrKWqc+PKyGgE7ddAE60D6xlqBAItrNT3SCMVVaxWo4yHWpuiRAlZR+h21WrmbKeem4ZfiUUPlzCSrkEOAiz9GMrFt58oFPyg2cnX2B8DcZRMhe3nYCaEdojJFH5S+1sGvYE5aTyqXnQvRlNUV+tup5RIOMe411j4xG6goRDF7w9KuKsk4LgslnCs4Awyxlby8jmLuvNWaXA/q6VgR3x4kofdeMtmZN4wezcZK0CgYEAw80DgGoX0SRIPc48OEKD/YdRZ/Kwq5KYl/HrC0CxzlPkaD0vzU0WAE+b+/dDFYMskNtu3la8PwwZJtuJthQqF4eDNIxAALuEz13756Fn3XinX5ZkPLa0j8YJur8z/HTJe9pDvsLpqHYOWp3/Ig3Z+thOMuD+giiCVdxXGSlYFH8CgYEAuOVtV5tOtEb8hu5R4+Hp5JIAfd53qepHzFKUwF/Tp97UOH42sIkxzX2B6UqpVcdx/yhHxd9g75vw0rHlbeqGWbjDR4nqcTTE1rDCNeDbci5YX+9Dfna8OWd1++KYa3kHi+C3tQPsak8ckMAMgGUHWcoEQJ0FEwUb/Z92/OakAs8CgYBUYHrL0exlki8Xg1JsJC3hCXlJREpiBZCAmh3iAYUeFwTs7sE0xa1fgO8FS+66zIZd/lHuuo3w1XPZTO4xassgzKL7+Bx0tFptSmEN1n598EqgZJzZlRqGgp8avN7YQjO5jbt372Ll18ojvsZ9lF6FPMWmI1NKH87a1VMrYqe0XQKBgA0zXB4gGXtnggoEI9aYP4GxJtXVt0drUZr13mbpsIvQregmorLx6JtaNZc5XGOibLIh5xXqf9o7kPMJ/m5diyAGv/Jwl0tj0BXf4s3D8wbw5iBbTb9OrNuQVm0YXXd22aIT9im3UP66DTkMbRgRnne7o5gVXdJg0AHIi888jEMjAoGAStEkWhJONuHawMFSqJM+dcMVDSyx6wRDjleeZTzMqXBbDH0I1AT6v+zMsCeAsirA8LTa0kfvMT+E6HqRO9daaH/hhw1Qtxwdgh6EGus3oMz4Yk9Kvl/yIQWLtonEWMO76ZCFe4x40ZCodLp/CZuvH+ybhDRYHv60L63tP6XYf+M=
        -----END RSA PRIVATE KEY-----
        """;
    
    /// <summary>
    /// Создать токен
    /// </summary>
    public string CreateToken()
    { 
        RSAParameters rsaParams;
        using (var pk = new StringReader(PrivateKey))
        {
            var pemReader = new PemReader(pk);
            var keyPair = pemReader.ReadObject() as AsymmetricCipherKeyPair;
            if (keyPair == null)
            {
                throw new EncryptionException("Could not read RSA private key");
            } 
            var privateRsaParams = keyPair.Private as RsaPrivateCrtKeyParameters;
            rsaParams = DotNetUtilities.ToRSAParameters(privateRsaParams);
        }

        using var rsa = new RSACryptoServiceProvider();
        rsa.ImportParameters(rsaParams);
            
        var payload = _claims.ToDictionary(k => k.Type, v => (object)v.Value);
        return Jose.JWT.Encode(payload, rsa, Jose.JwsAlgorithm.RS256);
    }
    
    /// <summary>
    /// Добавить стандартные клеймы
    /// </summary>
    public JwtTokenBuilder WithDefaultClaims()
    {
        _claims.Add(new Claim("exp", "1883314464"));
        _claims.Add(new Claim("iat", "1883314404"));
        _claims.Add(new Claim("jti", "1a35839e-bb3e-4010-a9ca-7fd96f73037c"));
        _claims.Add(new Claim("aud", "account"));
        _claims.Add(new Claim("typ", "Bearer"));
        _claims.Add(new Claim("azp", "localhost"));
        _claims.Add(new Claim("session_state", "657f0fdc-d21e-4d4e-ada4-0eb91acf9d9c"));
        _claims.Add(new Claim("acr", "1"));
        _claims.Add(new Claim("scope", "profile email"));
        _claims.Add(new Claim("sid", "657f0fdc-d21e-4d4e-ada4-0eb91acf9d9c"));
        return this;
    }
    
    /// <summary>
    /// Добавить клейм iss
    /// </summary>
    public JwtTokenBuilder WithIssuer(string issuer)
    {
        _claims.Add(new Claim("iss", $"http://localhost:4501/auth/realms/{issuer}"));
        return this;
    }
    
    /// <summary>
    /// Добавить клеймы
    /// </summary>
    public JwtTokenBuilder WithClaims(Dictionary<string, string> withClaims)
    {
        foreach (var claim in withClaims)
        {
            _claims.Add(new Claim(claim.Key, claim.Value));
        }

        return this;
    }
}

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

Ухх, это было сложно, но мы справились, осталось совсем немного.

Часть 5. Продвинутые сценарии

Давайте напишем сценарий для вопросов. Поскольку вопрос нельзя создать без категории, добавим блок Background, который сгенерирует нужные данные в сервисе (про авторизацию тоже не забудем).

#noinspection SpellCheckingInspection,CucumberUndefinedStep,NonAsciiCharacters
Feature: Question
  Background:
    Given кейклоак работает и настроен
    When клиент является администратором приложения
    When администратор создаёт категорию с названием "SomeCategoryName"
    Then категория успешно создана
  
  Scenario: Администратор может создать/отредактировать/получить список вопросов/удалить вопрос
    When администратор создаёт вопрос с названием "SomeQuestionName" и телом "SomeQuestionBody"
    Then вопрос успешно создан
    And название вопроса "SomeQuestionName"
    And тело вопроса "SomeQuestionBody"
    When администратор обновляет название вопроса на "SomeQuestionNameUpdated" и тело на "SomeQuestionBodyUpdated"
    Then вопрос успешно обновлен
    And название вопроса "SomeQuestionNameUpdated"
    And тело вопроса "SomeQuestionBodyUpdated"
    When администратор запрашивает список всех вопросов
    Then список вопросов получен c количеством элементов 1    
    When администратор удаляет вопрос
    Then вопрос успешно удален
    When администратор запрашивает список всех вопросов
    Then список вопросов получен c количеством элементов 0

Здесь я бы хотел заострить внимание на удобстве переиспользования шагов для создания нужного состояния сервиса. У нас в блоке Background всего ничего - 4 строки. Но в реальных системах со сложными схемами данных, таких зависимостей может быть намного больше. И это очень удобно, когда ты в любой момент с помощью теста можешь сгенерировать любые нужные тебе данные в сервисе - удобно не только для написания тестов, но и для воспроизведения и разбора различных багов.

Напишем чуть более продвинутый сценарий на проверку поиска вопросов, чтобы показать удобство и возможности языка Gherkin. Он позволяет как передавать табличные значения в метод, так и выполнять проверку по таблице результатов.

  Scenario Outline: Поиск вопросов работает верно
    When администратор создаёт вопросы
      | Name   | Answer    |
      | First  | AbcXXX    |
      | Second | XXXcda    |
      | Third  | SecondYYY |
    When администратор выполняет поиск вопросов "<SearchQuery>"
    Then список вопросов получен c количеством элементов <Count>
  Examples: 
    | SearchQuery | Count |
    | First       | 1     |
    | Fir         | 1     |
    | ZZZ         | 0     |
    | first       | 1     |
    | XXX         | 2     |
    | Second      | 2     |

Вот код метода, который принимает табличные данные

[When("администратор создаёт вопросы")]
[When("администратор создаёт вопросы")]
    public async Task AdministratorCreateQuestions(Table table)
    {
        // переберём в цикле все переданные значения с данными вопросов, которые требуется создать
        foreach (var row in table.Rows)
        {
            // создаём объёкт представляющий DTO вопроса
            var question = new QuestionDto
            (
                Guid.NewGuid(),
                row[0],
                row[1],
                Common.Category!.Id
            );

            // сериализуем вопрос в json
            var body = JsonSerializer.Serialize(question);
            
            // создаём объект представляющий запрос на создание вопроса
            var request = new HttpRequestMessage(HttpMethod.Post, "/api/v1/questions")
            {
                Content = new StringContent(body, Encoding.UTF8, "application/json")
            };
            
            // устанавливаем хэдер авторизации
            request.Headers.Authorization = AuthenticationHeaderValue.Parse("Bearer " + Common.AuthToken);
            
            // отправляем запрос на сервер и сохраняем его результат
            Common.HttpResponseMessage = await ExtEnvironment.TestServer.CreateClient().SendAsync(request);
        }
    }

Таким образом мы довольно компактно написали тест на проверку разнообразных вариантов поиска вопросов. Я слышал мнение что Grerkin не удобен для разработчиков, многословен и что проще писать все тесты просто в коде. Я не согласен с этим мнением - плохо себе представляю как этот тест можно было бы столь же компактно и наглядно реализовать в коде. Знакомство с языком Grerkin и specflow можно продолжить тут https://specflow.org/

Часть 6. Тестируем отправку в RabbitMQ

Перейдём к финальной части нашей статьи - тестированию отправки сообщения в RabbitMQ.

Напишем вот такой простой сценарий

  Scenario: Вопрос корректно отправляется в rabbitMq
    When администратор создаёт вопрос с названием "SomeQuestionName" и телом "SomeQuestionBody"
    Then вопрос успешно создан
    And вопрос успешно отправлен

Чтобы проверить, что вопрос успешно отправлен, нам нужно прочитать его из очереди RabbitMQ - это поможет сделать самописный клиент для REST API

RabbitMqClient
/// <summary>
/// Клиент для REST API RabbitMQ. Позволяет работать с реббитом по pull модели - что удобно для тестов
/// </summary>
public class RabbitMqClient
{
    private readonly HttpClient _httpClient;
    const string Vhost = "%2F";

    public RabbitMqClient()
    {
        var httpClientHandler = new HttpClientHandler { Credentials = new NetworkCredential("guest", "guest") };
        _httpClient = new HttpClient(httpClientHandler);
    }
        
    /// <summary>
    /// Получить сообщение из очереди
    /// </summary>
    public async Task<ModelMessageRabbit?> GetMessageFromQueue(string queue)
    {
        var content = new StringContent("{\"count\":1,\"ackmode\":\"ack_requeue_false\",\"encoding\":\"auto\",\"truncate\":50000}");
        var httpResponseMessage = await _httpClient.PostAsync($"http://localhost:15672/api/queues/{Vhost}/{queue}/get", content);

        var payload = httpResponseMessage.Content.ReadAs<ModelMessageRabbit[]>();
        return payload!.FirstOrDefault();
    }
        
    /// <summary>
    /// Очистить очередь
    /// </summary>
    public async Task ClearQueue(string queue) =>
        await _httpClient.DeleteAsync($"http://localhost:15672/api/queues/{Vhost}/{queue}/contents");
}

А сам шаг проверки будет выглядеть так

[Then(@"вопрос успешно отправлен")]
[Then(@"вопрос успешно отправлен")]
    public async Task QuestionSuccessfullySent()
    {
        // прочитаем последнее сообщение из RabbitMQ, которое должно содержать отправленный нами вопрос
        var messagesFromQueue = await ExtEnvironment.RabbitMqClient.GetMessageFromQueue("FaqQueue");
        
        // проверим, что сообщение было отправлено
        messagesFromQueue.Should().NotBeNull();

        // десериализуем вопрос из сообщения из RabbitMQ
        var question = messagesFromQueue!.Payload!.ReadAs<QuestionDto>();
        
        // проверим, что поля вопроса выгружены верно
        question.Should().NotBeNull();
        question!.Id.Should().Be(Common.Question!.Id);
        question.Title.Should().Be(Common.Question.Title);
        question.Answer.Should().Be(Common.Question.Answer);
        question.CategoryId.Should().Be(Common.Question.CategoryId);
    }

Ну вот мы и добрались до конца статьи. Мне кажется получилось хоть и объёмно, но достаточно просто. Я специально написал комментарии почти на каждую строку, чтобы любой тестировщик, даже без знаний c#, мог освоить такой подход к написанию тестов.

Весь проект с тестами можно посмотреть вот здесь https://gitlab.com/grisha0088/ComponentTestsExample

Выводы

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

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

Мне не нравится складывающаяся в последнее время в индустрии практика, когда сервисы покрываются End2End тестами, обычно написанными на Python, которые живут в отдельном репозитории и полностью разрабатываются силами тестировщиков. Такие тесты могут проверять качество сервиса, но на этом их польза заканчивается. Да и эксплуатировать их - дебажить если тест упал, следить чтобы на них точно не забили - дело не простое. Я не против End2End тестов, но, как мне кажется, не они должны лежать в основе - их роль, базовые сценарии в интеграции микросервисов.

Я написал эту статью, чтобы показать, что есть отличная альтернатива End2End тестам и ничего сложного в таком подходе нет. Надеюсь, эта статься поможет вам писать действительно полезные тесты и получать радость от работы с ними ????

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


  1. jdev
    05.09.2023 02:36
    +1

    Спасибо за пост - сам работаю примерно так же, но за другими топиками не доходили руки расписать этот подход, чтобы новым разработчикам на пальцах не объяснять. Теперь буду ваш пост скидывать:)


    1. grisha0088 Автор
      05.09.2023 02:36

      Рад, что статья принесёт пользу)


      1. ArkadiyShuvaev
        05.09.2023 02:36
        +1

        Статья и правда отличная. Не думали для привлечения более массовой аудитории изменить язык текста на английский в Readme и ComponentTests.csproj на GitHub?

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


        1. grisha0088 Автор
          05.09.2023 02:36
          +1

          Идея хорошая - нужно будет сделать перевод, заодно английский подтянуть)


  1. SviatoslavGusev
    05.09.2023 02:36

    Ещё бы весь процесс автоматизировать. Ибо сейчас с этими ChatGPT, контент все пилят просто десятками постов. И в ручную если везде размещать, толком ничего не выйдет...


  1. ritorichesky_echpochmak
    05.09.2023 02:36
    +1

    Я правильно понимаю, что это всё пролетает мимо test coverage?


    1. grisha0088 Автор
      05.09.2023 02:36

      Да, кажется что coverage можно корректно считать только на юнит тестах.


  1. Sing303
    05.09.2023 02:36
    +1

    А что если у сервиса есть парочка своих же микросервисов, как их лучше поднимать (и нужно ли, или мокать)? Инициализировать БД/Кафку для другого сервиса тоже в тестируемом сервисе?


    1. grisha0088 Автор
      05.09.2023 02:36

      Я их всегда мокаю. В таком случае можно проверить что будет если сервис-зависимость вернёт 500ку или вообще любой нужный ответ.
      К тому же у сервиса-зависимости могут быть свои сервисы и так далее. Все локально не поднимешь. Лучше написать простой end2end тест в дополнение, который прямо на тестовом стенде проверит базовые сценарии.


  1. ritorichesky_echpochmak
    05.09.2023 02:36
    +1

    Немного не очевидно, как дебажится вся эта история поднятая где-то в изолированном окружении


    1. grisha0088 Автор
      05.09.2023 02:36

      Дебажится локально без проблем. Запускаешь тест в дебаге, ставишь брейкпоинт в коде сервиса и он сработает. Никакого remote debug не нужно. Удобный дебаг - это один из главных плюсов этого подхода.