Просто картинка
Просто картинка

Эта статья не перепечатанная документация, так что, если тебя интересует интеграционное тестирование .net rest api, то залетай.

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

Предыстория

Когда я начинал свой путь в карьере разработчика, мне вынужденно пришлось начинать карьеру как full stack разработчик. И именно, это сформировало, то как я видел процесс разработки. Делал базу, api, frontend и осуществлял тестирования с ui. Но, разработка backend части, мне всегда нравилась больше(ну и другие причины). И вот я начал разрабатывать api для frontend. 


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

Проблема

Ещё одна случайная картинка
Ещё одна случайная картинка

Очевидным решением, для этой проблемы, являются тесты. Unit тесты, прекрасно подходят для тестирования, зубодробительной бизнес логики. Но, как бы мне не хотелось писать только такой код, 90% моего времени, занимает написания кода, api, работы с базой, работы с внешними api и так далее. 

После непродолжительного времени, я осознал потребность в интеграционных тестах. Давайте же посмотрим, что нам предлагает майкрософт на этом поприще. Предлагает она нам замечательный Microsoft.AspNetCore.TestHost который поможет нам запустить наш сервер из program.cs со всеми зависимостями и предоставит нам http client к нему. А дальше…

А дальше нам предлагается, через http client вручную формировать запросы и обрабатывать ответы от них. Я попробовал такой подход на одном из своих проектов, и он дал свои плоды.

Но очевидна встало 2 проблемы, которые такой подход не решал:

  • Не было строгой типизации. 

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

Очевидно, я решил поискать как же решить эту проблему. И не нашёл. Пришлось брать всё в свои руки.

Решение

Костыли и велосипеды
Костыли и велосипеды

Небольшое отступление. До этого шла речь о asp net core rest api, и далее пойдёт речь о нём же. Но у нас большинство проектов использует graphql+hotchocolate и начинал я путь именно с него. И там тоже ничего не нашёл. И я решил написать библиотеку, с названием, graph ql integration test, сокращённо GAIT. Я подумал что перевод этой аббревиатуры просто идеально подходил для описания того что она делаем. Но об этом позже. 

Поскольку у меня уже была библиотека GAIT для graphql, то для rest api, я назвал библиотеку RAIT. Увы, перевод уже не так подходил к названию, но аббревиатуру решено было оставить. 

Проще всего мне кажется объяснить, что она делает, приведя пример кода:

var model = new Model
{
    Id = 10
};
var responseModel = await _httpClient.Rait<RaitTestController>().Call(n => n.ActionName(model));

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

Помимо этого, я получил 2 огромных бонуса:

  • В коде методы контроллера, не висят как неиспользуемые. Видно что, есть вызовы откуда то. Так наглядно видно, что покрыто тестами, а что нет. 

  • Облегчает навигацию по коду. Вы легко можете перейти как в контроллер, так и обратно.

Немного о процессе разработки

Немного маркетинга
Немного маркетинга

Настраиваем проект, что бы у него были test.json конфигурации.  Делаем, чтобы в тестах, база переиздавалась и накатывались миграции. 

Далее, применяется tdd и обычно, процесс разработки начинается, с регистрации первого пользователя, ну или авторизации администратора:

[Test]
public async Task Register()
{
    
    var authResult = await Context.UserOneClient.Rait<AuthController>().Call(n => n.RegisterUser(new RegisterModel
    {
        Email = "e1ektr0.xyz@gmail.com",
        Password = "Password"
    }));
    
    
    var chessDbContext = Context.Services.GetRequiredService<ChessDbContext>();
    Assert.That(chessDbContext.Users.Count(), Is.Not.Zero);
    
    TestContext.UserOneClient.DefaultRequestHeaders.Authorization =
        new AuthenticationHeaderValue("Bearer", authResult!.Token);
}

Тут можно обратить внимание на то, что я не гнушаюсь использовать dbcontext, для того чтобы, проверить изменения в базе. А так, же, использование класса TestContext(раскрою чуть позже). 

Далее напишем еще один тест, например, наш пользователь хочет создать лобби:

[Test]
public async Task CreateLobby()
{
    await new AuthTests().Register();

    var lobby = await TestContext.UserOneClient.Rait<LobbyController>().Call(n => n.CreateLobby());

    Assert.That(lobby, Is.Not.Null);
    Assert.That(Context.DbContext.Lobbies.ToList(), Is.Not.Empty);
}

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

[Test]
public async Task JoinLobby()
{
    await CreateLobby();
    await new AuthTests().RegisterUserTwo();

    var lobbies = await Context.UserTwoClient.Rait<LobbyController>().Call(n => n.Get());
    var lobby = lobbies!.First();
    await TestContext.UserTwoClient.Rait<LobbyController>().Call(n => n.Join(lobby.Id));

    var dbLobby = Context.DbContext.Lobbies.First();
    await Context.DbContext.Entry(dbLobby).ReloadAsync();
    
    Assert.That(dbLobby.OpponentUserId, Is.Not.Null);
}

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

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

Заключение

Учебный пример использования RAIT: https://github.com/e1ektr0/Chess

RAIT: https://github.com/e1ektr0/RAIT

А где же GAIT? Увы, взял деньги за разработку, и лежит в закрытом репо внутри компании. Ну и не то, чтобы он кому то был нужен, я думаю. Аудитория значительно меньше. 

RAIT - не дописан полностью, по мере того, что мне требуется на проекте, дописывается. Также, он написан плохо, процесс рефакторинга отложен, до того, как я доберусь до copilot. Хочу поиграться.  

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

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

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


  1. E1ektr0 Автор
    25.04.2023 12:00

    Вижу минусы, но не вижу за что. За подачу материала? Тогда не обидно. За плохое раскрытие? Отвечу на вопросы. За что то ещё? Отпишите хоть.


  1. dopusteam
    25.04.2023 12:00

    А дальше нам предлагается, через http client вручную формировать запросы и обрабатывать ответы от них. Я попробовал такой подход на одном из своих проектов, и он дал свои плоды.

    Но очевидна встало 2 проблемы, которые такой подход не решал:

    - Не было строгой типизации. 

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

    А можно подробнее? Какой типизации нет и сколько там доп затрат?

     full steck

    ни будь,

    тобиш

    Без комментариев

    Рандомные знаки препинания ещё портят впечатлени


    1. E1ektr0 Автор
      25.04.2023 12:00
      +1

      using var client = application.CreateClient(); 
      
      var response = await client.GetAsync("/weatherforecast");
      
      response.StatusCode.Should().Be(HttpStatusCode.OK);

      Вот пример того что предлагается делать в оригинале.
      Соответственно, имя контроллера текстом, если ответ json, то, тоже отсутствует строгая типизация.
      Так же, если потребуется передать кучу query params и так же, сам тип запроса, не строго типизирован(ну post, get, etc).


      1. dopusteam
        25.04.2023 12:00

        Соответственно, имя контроллера текстом, если ответ json, то, тоже отсутствует строгая типизация.

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

        И возвращаем мы тоже json

        Так же, если потребуется передать кучу query params и так же, сам тип запроса, не строго типизирован

        Что тоже логично, мы проверяем web сервис, запросы\ответы нетипизированы, там что угодно прилететь может, это просто текст


        1. E1ektr0 Автор
          25.04.2023 12:00
          +1

          >>Да и это правильно, внешний клиент ходит к нам с json который проходит через биндинг и его тоже желательно проверить. И возвращаем мы тоже json

          В моём подходе всё это остаётся и никуда не девается. Просто спрятано за фасадом.

          >>Что тоже логично, мы проверяем web сервис, запросы\ответы нетипизированы, там что угодно прилететь может, это просто текст
          Я никуда это не убрал. Я упростил написание этой проверки.


          1. lair
            25.04.2023 12:00

            В моём подходе всё это остаётся и никуда не девается. Просто спрятано за фасадом.

            А фасад вы написали сами, поэтому точно знаете, что он сгенерит. Когда у вас поменяется DTO-шка, ваш фасад сам все перегенерит, и все продолжит работать, а вот реальный клиент - сломается (у него-то никто схему не менял).

            Это, понятное дело, не во всех проектах проблема, но во многих. И именно для этого тестируют через голый HttpClient, или независимо сгенеренный API-клиент - так точно известно, что контракт стабилен.


        1. E1ektr0 Автор
          25.04.2023 12:00
          +1

          Обратите внимание, запрос идёт через http client.

          await _httpClient.Rait<RaitTestController>().Call(n => n.ActionName(model));

          Видимо в примерах я совсем не понятно это написал, но я пытался показать там другое.


          1. dopusteam
            25.04.2023 12:00

            Вообще, есть вопросы и по коду.
            Нет тестов.
            Есть вот такое https://github.com/e1ektr0/RAIT/blob/master/RAIT.Core/RaitHttpRequester.cs#L28
            и глушится компилятор

            var methodInfo = methodBody!.Method;  

            Поэтому с одной стороны - продвигаете антипаттерн зависимых тестов, во второй - проект написан некачественно, с третьей - непонятно зачем.


  1. dopusteam
    25.04.2023 12:00

    Assert.That(chessDbContext.Users.Count(), Is.Not.Zero);

    Лучше проверять конкретное количество, а не "не ноль"

    var lobby = lobbies!.First();

    Глушить компилятор - не лучшая идея

    [Test] public async Task JoinLobby()

    Непонятно что проверяет код, рекомендую посмотреть подходы именования

    TestContext.UserOneClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult!.Token);

    Что за код после Assert?

    await new AuthTests().Register();

    Создаётся сущность, и на ней вызывается метод. Непонятно, что она реально делает, там какое то состояние есть? Или она в статике что то делает?

    1. TestContext я использую для передачи данных между тестами.

    Тесты должны быть независимыми

    В целом, тесты нечитабельные, я вообще не понял, что там проверяется толком

    Ещё сейчас тесты не включают в себя прогон миддлварок, например, что в принципе делает их неприменимыми.


    1. E1ektr0 Автор
      25.04.2023 12:00

      >>Непонятно что проверяет код, рекомендую посмотреть подходы именования
      Справедливо.

      >>TestContext.UserOneClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authResult!.Token);
      Сохранение результатов авторизации, в текущий httpclient.

      >>await new AuthTests().Register();
      Это проблема именования. Это собственно, вызов первого теста.

      >>Тесты должны быть независимыми
      Да. Но мне нравятся тесты, которые зависимы друг от друга.
      Если процесс авторизации поменялся, и другие методы зависят от этого, этот подход выявит проблемы. Если же он поменялся, но при этом изменения не повлияли на другие тесты, значит всё замечательно.

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

      >>В целом, тесты нечитабельные, я вообще не понял, что там проверяется толком
      Да.


      1. dopusteam
        25.04.2023 12:00

        Да. Но мне нравятся тесты, которые зависимы друг от друга.

        Я правильно понимаю, что каждый тест запускает сам следующий? Оо
        И что будет, если натравить dotnet test на них?

        Вообще я б на вашем месте почитал, почему зависимые тесты считаются антипаттерном и почему так никто не пишет

        Про миддлварки не увидел, если прогоняются - отлично.


        1. E1ektr0 Автор
          25.04.2023 12:00

          >>Я правильно понимаю, что каждый тест запускает сам следующий? ОоИ что будет, если натравить dotnet test на них?
          Не совсем так. Обычно это минимально необходимый набор.

          Например, что бы пользователю сделать изменение в блоге, надо зарегистрироваться, потом создать пост, потом внести изменения. То, в рамках этого сценария, я вызову тест создания поста который вызовет регистрацию. И только после этого я напишу, редактирование поста. Но!
          Я не буду вызывать код на депозит денег и ещё чего либо не связанного. Т.е. Длительность теста, будет равна максимально длинному пути пользовательского сценария. Но ведь, это всё равно бы пришлось сделать?

          >>И что будет, если натравить dotnet test на них?
          Всё будет хорошо. А как я по вашему из запускаю? Но да, такие тесты, дорого на ci cd гонять. В моём случае, они часто включают запуск и прогонку solidity на локальной ноде.

          >>Вообще я б на вашем месте почитал, почему зависимые тесты считаются антипаттерном и почему так никто не пишет
          Да , спасибо за наводку. Читал , но лет 10 назад.


          1. dopusteam
            25.04.2023 12:00

            Всё будет хорошо. А как я по вашему из запускаю?

            Он ж их всех запустит без учёта порядка (по алфавиту). Что если запустится вначале зависимый? Или тесты как то особо помечаются?


            1. E1ektr0 Автор
              25.04.2023 12:00

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


              1. dopusteam
                25.04.2023 12:00

                Так, давайте ещё раз.
                Есть тесты, который запускают другие тесты? Т.е. условно TestB зависит от TestA и TestA запускает TestB.
                При этом dotnet test может запустить сразу TestB и всё будет ок?


                1. E1ektr0 Автор
                  25.04.2023 12:00

                  Конечно. В момент запуска testb будет запущен testhost, который запустит program.cs где происходит инициализация базы данных. В процессе инициализации базы данных, проект поймёт что это тесты, грохнет базу, создаст заново, накатит миграции. Сделает seed.
                  После чего начнётся работа testb. Он в свою очередь вызовет код testa на пустой базе. После чего продолжится исполнение testb.


                  1. dopusteam
                    25.04.2023 12:00

                    Выше было вот что

                    Например, что бы пользователю сделать изменение в блоге, надо зарегистрироваться, потом создать пост, потом внести изменения. То, в рамках этого сценария, я вызову тест создания поста который вызовет регистрацию. И только после этого я напишу, редактирование поста

                    Почему тест редактирования не вызовется при выполнении dotnet test сам без ожидания регистрации? А если он вызовется, то он ж развалится


                    1. E1ektr0 Автор
                      25.04.2023 12:00

                      Вызовется. Но в тесте, редактирования поста, мы же не можем полагаться на это? Мы не знаем состояние базы и прочее. Потому, надо обнулить базу и сделать всё поэтапно с нуля.


                      1. dopusteam
                        25.04.2023 12:00

                        Короче я окончательно запутался, если честно. Вы сначала предлагаете добавить зависимость между тестами (первый регается, второй полагается, что первый зарегался), потом говорите, что второй должен пройти всё с нуля поэтапно. Может кодом покажете тогда?


                      1. E1ektr0 Автор
                        25.04.2023 12:00

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

                        https://github.com/e1ektr0/Chess/blob/master/Chess.Api.Tests/ChessLobbyTest.cs


                      1. dopusteam
                        25.04.2023 12:00

                        Зачем вызывать второй из первого если второй и так вызовется?


  1. ArtemIllarionov
    25.04.2023 12:00
    +1

    Не стоит вызывать тесты внутри тестов. Общая логика создания тестовых данных должна быть вынесена.

    Например, тест RegisterUserTwo() ничего нового не проверяет относительно теста RegisterUserOne(). Если нужен будет третий пользователь, то создадите новый тест RegisterUserThree()?

    Для каждого сервиса создается c# client (руками или генерируется) со строгой типизацией для его api. Этот клиент используют другие сервисы и этот же клиент используется в тестах. Т.е. для меня область применения библиотеки RAIT выглядит, как около нулевая.


    1. E1ektr0 Автор
      25.04.2023 12:00

      >>Для каждого сервиса создается c# client (руками или генерируется) со строгой типизацией для его api. Этот клиент используют другие сервисы и этот же клиент используется в тестах. Т.е. для меня область применения библиотеки RAIT выглядит, как около нулевая.

      Блин, хорошая идея. Чем пользуетесь для генерации клиента? О таком подходе я не подумал, потому что начинал с grapqhl и там удобной генерации не нашёл. Нашёл какую то генерацию, и она была избыточна и не удобна.

      >>Например, тест RegisterUserTwo() ничего нового не проверяет относительно теста RegisterUserOne(). Если нужен будет третий пользователь, то создадите новый тест RegisterUserThree()?
      Справедливо.


      >>Не стоит вызывать тесты внутри тестов.
      Тогда избыточно выходит.


  1. lair
    25.04.2023 12:00

    Зачем вам интеграционный тест через апи-слой, если вы формируете запросы автоматически (и, как следствие, не проверяете стабильность внешнего контракта)? Что мешает сразу вызывать контроллер?


    1. E1ektr0 Автор
      25.04.2023 12:00

      Ну а авторизацию\аутентификацию как делать? Мидлваров то на контроллере нет. Да и контроллер просто так не зарезолвишь.


      1. lair
        25.04.2023 12:00

        Ну а авторизацию\аутентификацию как делать?

        Задать User в контексте?

        Да и контроллер просто так не зарезолвишь.

        Гм, а в чем проблема? у меня они прекрасно резолвились.