Лучше всего тестировать контроллеры веб-API напрямую или через HTTP-клиент? - PullRequest
17 голосов
/ 09 июля 2020

Я добавляю несколько модульных тестов для своего ASP. NET Core Web API, и мне интересно, нужно ли модульное тестирование контроллеров напрямую или через HTTP-клиент. Непосредственно будет выглядеть примерно так:

[TestMethod]
public async Task GetGroups_Succeeds()
{
    var controller = new GroupsController(
        _groupsLoggerMock.Object,
        _uowRunnerMock.Object,
        _repoFactoryMock.Object
    );

    var groups = await controller.GetGroups();

    Assert.IsNotNull(groups);
}

... тогда как через HTTP-клиент будет выглядеть примерно так:

[TestMethod]
public void GetGroups_Succeeds()
{
    HttpClient.Execute();

    dynamic obj = JsonConvert.DeserializeObject<dynamic>(HttpClient.ResponseContent);
    Assert.AreEqual(200, HttpClient.ResponseStatusCode);
    Assert.AreEqual("OK", HttpClient.ResponseStatusMsg);
    string groupid = obj[0].id;
    string name = obj[0].name;
    string usercount = obj[0].userCount;
    string participantsjson = obj[0].participantsJson;
    Assert.IsNotNull(name);
    Assert.IsNotNull(usercount);
    Assert.IsNotNull(participantsjson);
}

При поиске в Интернете это похоже на оба способа тестирования Кажется, что API используется, но мне интересно, что лучше всего. Второй метод кажется немного лучше, потому что он наивно проверяет фактический ответ JSON от веб-API, не зная фактический тип объекта ответа, но таким образом внедрить фиктивные репозитории сложнее - тесты должны будут подключаться к отдельному локальному Сервер веб-API, который сам каким-то образом был настроен на использование фиктивных объектов ... Я думаю?

Ответы [ 8 ]

15 голосов
/ 11 июля 2020

Edit: TL; DR

Заключение, которое вы должны сделать и то, и другое, потому что каждый тест служит своей цели.

Ответ:

Это хороший вопрос, который я часто задаю себе.

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

Модульный тест :

Модульные тесты включают тестирование части приложения в изоляции от его инфраструктуры и зависимостей. При модульном тестировании контроллера logi c проверяется только содержимое одного действия, а не поведение его зависимостей или самого фреймворка.

  • Такие вещи, как фильтры, маршрутизация и привязка модели не будет работать.

Тест интеграции :

Интеграционные тесты гарантируют, что компоненты приложения работают правильно уровень, который включает в себя вспомогательные инфраструктуры приложения, такие как база данных, файловая система и сеть. ASP. NET Core поддерживает интеграционные тесты с использованием инфраструктуры модульного тестирования с тестовым веб-хостом и тестовым сервером в памяти.

  • Такие вещи, как фильтры, маршрутизация и привязка модели будет работать.

« Лучшая практика » следует понимать как «Имеет ценность и имеет смысл».

Вы должны спросить себя Есть ли смысл в написании теста, или я просто создаю этот тест для написания теста?

Допустим, ваш метод GetGroups() выглядит так.

[HttpGet]
[Authorize]
public async Task<ActionResult<Group>> GetGroups()
{            
    var groups  = await _repository.ListAllAsync();
    return Ok(groups);
}

Нет смысла писать для него модульный тест! потому что то, что вы делаете, - это тестирование имитации реализации _repository! Так какой в ​​этом смысл ?! У метода нет logi c, и репозиторий будет только таким, каким вы его издевались, ничто в методе не говорит об обратном.

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

Теперь предположим, что ваш метод GetGroups() - это больше, чем просто оболочка для _repository и имеет некоторые логики c в нем.

[HttpGet]
[Authorize]
public async Task<ActionResult<Group>> GetGroups()
{            
   List<Group> groups;
   if (HttpContext.User.IsInRole("Admin"))
      groups = await _repository.FindByExpressionAsync(g => g.IsAdminGroup == true);
   else
      groups = await _repository.FindByExpressionAsync(g => g.IsAdminGroup == false);

    //maybe some other logic that could determine a response with a different outcome...
    
    return Ok(groups);
}

Теперь есть смысл в написании модульного теста для метода GetGroups(), потому что результат может измениться в зависимости от высмеянного HttpContext.User значения.

Такие атрибуты, как [Authorize] или [ServiceFilter(….)] , не будут активированы в модульном тесте.

.

Запись интеграционные тесты почти всегда того стоят , потому что вы хотите проверить, что будет делать процесс, когда он является частью реального приложения / системы / процесса.

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

Теперь, даже если ваш GetGroups() метод является просто оболочкой, как в первой реализации, _repository будет указывать на реальное хранилище данных, ничего не имитируется !

Итак, теперь тест не только охватывает тот факт, что в хранилище данных есть данные (или нет), он также полагается на при фактическом подключении, HttpContext правильно настроен и работает ли сериализация информации должным образом.

Такие вещи, как фильтры, маршрутизация и привязка модели также будут Итак, если у вас есть атрибут в вашем методе GetGroups(), например [Authorize] или [ServiceFilter(….)], он будет сработать, как ожидалось.

Я использую xUnit для тестирования, поэтому для модульного теста на контроллере я использую это.

Контроллер Unit Test:

public class MyEntityControllerShould
{
    private MyEntityController InitializeController(AppDbContext appDbContext)
    {
        var _controller = new MyEntityController (null, new MyEntityRepository(appDbContext));            
        var httpContext = new DefaultHttpContext();
        var context = new ControllerContext(new ActionContext(httpContext, new RouteData(), new ActionDescriptor()));
        _controller.ControllerContext = context;
        return _controller;
    }

    [Fact]
    public async Task Get_All_MyEntity_Records()
    {
      // Arrange
      var _AppDbContext = AppDbContextMocker.GetAppDbContext(nameof(Get_All_MeetUp_Records));
      var _controller = InitializeController(_AppDbContext);
    
     //Act
     var all = await _controller.GetAllValidEntities();
     
     //Assert
     Assert.True(all.Value.Count() > 0);
    
     //clean up otherwise the other test will complain about key tracking.
     await _AppDbContext.DisposeAsync();
    }
}

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

public class AppDbContextMocker
{
    /// <summary>
    /// Get an In memory version of the app db context with some seeded data
    /// </summary>
    /// <param name="dbName"></param>
    /// <returns></returns>
    public static AppDbContext GetAppDbContext(string dbName)
    {
        //set up the options to use for this dbcontext
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(dbName)                
            .Options;
        var dbContext = new AppDbContext(options);
        dbContext.SeedAppDbContext();
        return dbContext;
    }
}

Расширение Seed.

public static class AppDbContextExtensions
{
   public static void SeedAppDbContext(this AppDbContext appDbContext)
   {
       var myEnt = new MyEntity()
       {
          Id = 1,
          SomeValue = "ABCD",
       }
       appDbContext.MyENtities.Add(myEnt);
       //add more seed records etc....

        appDbContext.SaveChanges();
        //detach everything
        foreach (var entity in appDbContext.ChangeTracker.Entries())
        {
           entity.State = EntityState.Detached;
        }
    }
}

и для тестирования интеграции: (это код из учебника, но я не могу вспомнить, где я его видел, на YouTube или Pluralsight)

настройка для TestFixture

public class TestFixture<TStatup> : IDisposable
{
    /// <summary>
    /// Get the application project path where the startup assembly lives
    /// </summary>    
    string GetProjectPath(string projectRelativePath, Assembly startupAssembly)
    {
        var projectName = startupAssembly.GetName().Name;

        var applicationBaseBath = AppContext.BaseDirectory;

        var directoryInfo = new DirectoryInfo(applicationBaseBath);

        do
        {
            directoryInfo = directoryInfo.Parent;
            var projectDirectoryInfo = new DirectoryInfo(Path.Combine(directoryInfo.FullName, projectRelativePath));
            if (projectDirectoryInfo.Exists)
            {
                if (new FileInfo(Path.Combine(projectDirectoryInfo.FullName, projectName, $"{projectName}.csproj")).Exists)
                    return Path.Combine(projectDirectoryInfo.FullName, projectName);
            }
        } while (directoryInfo.Parent != null);

        throw new Exception($"Project root could not be located using application root {applicationBaseBath}");
    }

    /// <summary>
    /// The temporary test server that will be used to host the controllers
    /// </summary>
    private TestServer _server;

    /// <summary>
    /// The client used to send information to the service host server
    /// </summary>
    public HttpClient HttpClient { get; }

    public TestFixture() : this(Path.Combine(""))
    { }

    protected TestFixture(string relativeTargetProjectParentDirectory)
    {
        var startupAssembly = typeof(TStatup).GetTypeInfo().Assembly;
        var contentRoot = GetProjectPath(relativeTargetProjectParentDirectory, startupAssembly);

        var configurationBuilder = new ConfigurationBuilder()
            .SetBasePath(contentRoot)
            .AddJsonFile("appsettings.json")
            .AddJsonFile("appsettings.Development.json");


        var webHostBuilder = new WebHostBuilder()
            .UseContentRoot(contentRoot)
            .ConfigureServices(InitializeServices)
            .UseConfiguration(configurationBuilder.Build())
            .UseEnvironment("Development")
            .UseStartup(typeof(TStatup));

        //create test instance of the server
        _server = new TestServer(webHostBuilder);

        //configure client
        HttpClient = _server.CreateClient();
        HttpClient.BaseAddress = new Uri("http://localhost:5005");
        HttpClient.DefaultRequestHeaders.Accept.Clear();
        HttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

    }

    /// <summary>
    /// Initialize the services so that it matches the services used in the main API project
    /// </summary>
    protected virtual void InitializeServices(IServiceCollection services)
    {
        var startupAsembly = typeof(TStatup).GetTypeInfo().Assembly;
        var manager = new ApplicationPartManager
        {
            ApplicationParts = {
                new AssemblyPart(startupAsembly)
            },
            FeatureProviders = {
                new ControllerFeatureProvider()
            }
        };
        services.AddSingleton(manager);
    }

    /// <summary>
    /// Dispose the Client and the Server
    /// </summary>
    public void Dispose()
    {
        HttpClient.Dispose();
        _server.Dispose();
        _ctx.Dispose();
    }

    AppDbContext _ctx = null;
    public void SeedDataToContext()
    {
        if (_ctx == null)
        {
            _ctx = _server.Services.GetService<AppDbContext>();
            if (_ctx != null)
                _ctx.SeedAppDbContext();
        }
    }
}

и используйте его так же в тесте интеграции.

public class MyEntityControllerShould : IClassFixture<TestFixture<MyEntityApp.Api.Startup>>
{
    private HttpClient _HttpClient;
    private const string _BaseRequestUri = "/api/myentities";

    public MyEntityControllerShould(TestFixture<MyEntityApp.Api.Startup> fixture)
    {
        _HttpClient = fixture.HttpClient;
        fixture.SeedDataToContext();
    }

    [Fact]
    public async Task Get_GetAllValidEntities()
    {
        //arrange
        var request = _BaseRequestUri;

        //act
        var response = await _HttpClient.GetAsync(request);

        //assert
        response.EnsureSuccessStatusCode(); //if exception is not thrown all is good

        //convert the response content to expected result and test response
        var result = await ContentHelper.ContentTo<IEnumerable<MyEntities>>(response.Content);
        Assert.NotNull(result);
    }
}

Добавлено редактирование: В заключение, вы должны сделать и то, и другое, потому что каждый тест служит своей цели.

Глядя на другие ответы, вы увидите, что консенсус состоит в том, чтобы делать и то, и другое.

3 голосов
/ 18 июля 2020

Если мы ограничим область обсуждения сравнением тестирования Контроллера и HttpClient, я бы сказал, что лучше использовать HttpClient. Потому что , если вы пишете тесты для своих контроллеров, вы уже пишете интеграционные тесты и почти нет смысла писать «более слабые» интеграционные тесты, в то время как вы можете писать более сильные, более реалистичные c и также надмножество более слабых.

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

Я понимаю, как вводить фиктивные зависимости. Это требует больше усилий по сравнению с непосредственным тестированием контроллера. Однако. NET Core уже предоставляет хороший набор инструментов, которые помогут вам в этом. Вы можете настроить тестовый хост внутри самого теста, настроить его и получить от него HttpClient. Затем вы можете использовать этот HttpClient для своих целей тестирования.

Другая проблема заключается в том, что создание запроса HttpClient для каждого теста является довольно утомительной задачей. В любом случае, Refit может вам в этом очень помочь. Декларативный синтаксис Refit довольно легко понять (и в конечном итоге поддерживать). Хотя я бы также рекомендовал Refit для всех удаленных вызовов API, он также подходит для ASP. NET тестирования интеграции ядра.

Объединяя все доступные решения, я не понимаю, почему вы должны ограничиваться контроллером test, в то время как вы можете go для более «реального» интеграционного теста, приложив немного больше усилий.

3 голосов
/ 12 июля 2020

TL; DR

Лучше всего тестировать [...] напрямую или через HTTP-клиент?

Не "или" , а "и" . Если вы серьезно относитесь к лучшим методам тестирования - вам понадобятся оба теста.

Первый тест - это модульный тест. Но второй - это интеграционный тест.

Существует общий консенсус (пирамида тестов), что вам нужно больше модульных тестов по сравнению с количеством интеграционных тестов. Но вам нужны оба.

Есть много причин, по которым вы должны предпочесть модульные тесты интеграционным тестам, большинство из них сводятся к тому, что модульные тесты малы (во всех смыслах), а интеграционные тесты - нет. . Но основные 4:

  1. Локальность

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

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

  2. Speed ​​

    В небольшом проекте с небольшим количеством тестов вы этого не заметите. Но на небольшом проекте это станет проблемой. (Сетевые задержки, задержки ввода-вывода, инициализация, очистка и т. Д. c., И c.)

  3. Простота

    Вы сами это заметили.

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

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

Напишите больше тестов. (Опять же, это означает - оба). Научитесь лучше писать тесты. Удалите их в последнюю очередь.

Практика добивается совершенства.

3 голосов
/ 11 июля 2020

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

Мне нравится использовать конечные точки с помощью прямых вызовов Http. Сегодня существуют инструменты fantasti c, такие как Cypress, которые позволяют перехватывать и изменять запросы клиентов. Мощь этой функции вместе с простым взаимодействием GUI на основе браузера размывает традиционные определения тестов, потому что один тест в Cypress может быть всех этих типов: Unit, Functional, Integration и E2E.

Если конечная точка является пуленепробиваемой, тогда внесение ошибок извне становится невозможным. Но даже ошибки изнутри легко смоделировать. Запустите те же тесты Cypress с выключенным Db. Или внедрите имитацию прерывистой сети из Cypress. Это внешнее издевательство над проблемами, которое ближе к производственной среде.

2 голосов
/ 11 июля 2020

При выполнении модульного тестирования важно знать, что вы собираетесь тестировать, и писать тесты в соответствии с вашими требованиями. Тем не менее, второй тест может выглядеть как интеграционный тест вместо модульного, но меня сейчас это не волнует! вариант второй , потому что во втором модульном тесте вы тестируете свой WebApi как WebApi, а не как класс. Например, предположим, что у вас есть класс с методом с именем X(). Итак, насколько вероятно написать для него модульный тест с использованием Reflection? Если это совершенно маловероятно, то написание модульного теста на основе Reflection - пустая трата времени. Если это возможно, вам следует также написать свой тест, используя Reflection.

Более того, используя второй подход, вы можете изменить технический стек (для замены. Net на php), используемый для создания WebApi, без изменения ваших тестов (это то, чего мы ожидаем и от WebApi).

Наконец, вы должны принять решение! как вы собираетесь использовать этот WebApi? Насколько вероятно вызов вашего WebApi с использованием прямого создания экземпляра класса?

Примечание:

Это может не иметь отношения к вашему вопросу, но вы также должны сосредоточиться на своих утверждениях . Например, утверждение ResponseStatusCode и ResponseStatusMsg может не понадобиться, и вы можете утверждать только один.

Или что произойдет, если obj имеет значение null? или obj имеет более одного участника?

2 голосов
/ 11 июля 2020

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

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

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

Что касается хороших практик, если вы хотите провести настоящий модульный тест, тогда у вас нет другого выбора, кроме выбора первого варианта, поскольку внешние зависимости недопустимы (HttpClient - внешняя зависимость).

Затем, если позволяют время и ресурсы, вы можете провести интеграционное тестирование для наиболее важных и / или сложные пути.

1 голос
/ 14 июля 2020

Вы можете использовать Swagger (также известный как OpenAPI).

Установите Swashbuckle.AspNetCore из nuget.

using Microsoft.OpenApi.Models;

//in  Startup.ConfigureServices

public void ConfigureServices(IServiceCollection services)
{
  services.AddSwaggerGen(c =>
  {
  c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
  });
}

//in Startup.Configure

public void Configure(IApplicationBuilder app)
{
  app.UseSwagger();
  app.UseSwaggerUI(c =>
  {
    c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
  });
}

Наконец, добавьте "launchUrl": "swagger", в launchSettings. json

1 голос
/ 13 июля 2020

Если вы ищете не программирование, вы можете использовать Postman , и можете создать коллекцию запросов и можете тестировать несколько запросов один за другим.

...