最佳实践是直接测试Web API控制器还是通过HTTP客户端测试? [英] Is it best practice to test my Web API controllers directly or through an HTTP client?

查看:59
本文介绍了最佳实践是直接测试Web API控制器还是通过HTTP客户端测试?的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我正在为我的ASP.NET Core Web API添加一些单元测试,并且我想知道是否要直接或通过HTTP客户端对控制器进行单元测试.直接看起来大概是这样的:

I'm adding some unit tests for my ASP.NET Core Web API, and I'm wondering whether to unit test the controllers directly or through an HTTP client. Directly would look roughly like this:

[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客户端看起来大致如下:

... whereas through an HTTP client would look roughly like this:

[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的方法,但是我想知道最佳实践是什么.第二种方法似乎更好一些,因为它可以天真地测试Web API的实际JSON响应而不知道实际的响应对象类型,但是以这种方式注入模拟存储库更加困难-测试必须连接到单独的本地Web API服务器本身以某种方式配置为使用模拟对象...我想吗?

Searching online, it looks like both ways of testing an API seem to be used, but I'm wondering what the best practice is. The second method seems a bit better because it naively tests the actual JSON response from the Web API without knowing the actual response object type, but it's more difficult to inject mock repositories this way - the tests would have to connect to a separate local Web API server that itself was somehow configured to use mock objects... I think?

推荐答案

TL; DR

您应该同时执行两项结论,因为每种测试的目的都不相同.

The conclusion you should do both because each test serves a different purpose.

答案:

这是一个好问题,我经常问自己一个问题.

This is a good question, one I often ask myself.

首先,您必须查看单元测试的目的和集成测试的目的.

First, you must look at the purpose of a unit test and the purpose of an integration test.

单元测试:

单元测试涉及对应用程序的一部分进行测试,使其与应用程序隔离基础架构和依赖项.当单元测试控制器逻辑时,仅测试单个操作的内容,而不测试它的依赖关系或框架本身的依赖关系.

Unit tests involve testing a part of an app in isolation from its infrastructure and dependencies. When unit testing controller logic, only the contents of a single action are tested, not the behaviour of its dependencies or of the framework itself.

  • 过滤器,路由和模型绑定之类的东西不起作用.
  • 集成测试:

    集成测试可确保应用程序的组件正常运行包含应用程序支持基础架构的级别,例如数据库,文件系统和网络.ASP.NET Core支持使用带有测试Web主机的单元测试框架进行集成测试,以及内存中的测试服务器.

    Integration tests ensure that an app's components function correctly at a level that includes the app's supporting infrastructures, such as the database, file system, and network. ASP.NET Core supports integration tests using a unit test framework with a test web host and an in-memory test server.

    • 过滤器,路由和模型绑定之类的东西将起作用.
    • "最佳实践"应被视为具有价值并有意义".

      "Best practice" should be thought of as "Has value and makes sense".

      您应该问自己 编写测试是否有任何价值,或者我只是为了编写测试而创建此测试?

      You should ask yourself Is there any value in writing the test, or am I just creating this test for the sake of writing a test?

      假设您的 GetGroups()方法看起来像这样.

      Let's say your GetGroups() method looks like this.

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

      为此编写单元测试没有任何价值!因为您正在做的是测试 _repository 模拟实现!那么,这有什么意义呢?该方法没有逻辑,存储库将仅是您要嘲笑的样子,该方法中没有其他建议.

      There is no value in writing a unit test for it! because what you are doing is testing a mocked implementation of _repository! So what is the point of that?! The method has no logic and the repository is only going to be exactly what you mocked it to be, nothing in the method suggests otherwise.

      存储库将具有自己的一组单独的单元测试,您将在其中介绍存储库方法的实现.

      现在,让我们说您的 GetGroups()方法不仅是 _repository 的包装,而且还包含一些逻辑.

      Now let's say your GetGroups() method is more than just a wrapper for the _repository and has some logic in it.

      [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()方法编写单元测试很有用,因为结果可能会根据 mocked HttpContext.User 值.

      Now there is value in writing a unit test for the GetGroups() method because the outcome could change depending on the mocked HttpContext.User value.

      诸如 [Authorize] [ServiceFilter(….)] 之类的属性不会在单元测试中触发.

      .

      写作集成测试几乎总是值得的. ,因为您要测试该过程成为实际应用程序/系统/过程的一部分时将执行的操作.

      Writing integration tests is almost always worth it because you want to test what the process will do when it forms part of an actual application/system/process.

      问问自己,应用程序/系统是否正在使用它?如果,请编写一个集成测试,因为结果取决于情况和条件的组合.

      Ask yourself, is this being used by the application/system? If yes, write an integration test because the outcome depends on a combination of circumstances and criteria.

      现在,即使您的 GetGroups()方法只是第一个实现中的包装器, _repository 仍将指向实际的数据存储,没有任何模拟

      Now even if your GetGroups() method is just a wrapper like in the first implementation, the _repository will point to an actual datastore, nothing is mocked!

      因此,现在,测试不仅涵盖了数据存储中是否有数据这一事实,还取决于建立了实际的连接,正确设置了 HttpContext 以及是否序列化了信息按预期工作.

      So now, not only does the test cover the fact that the datastore has data (or not), it also relies on an actual connection being made, HttpContext being set up properly and whether serialisation of the information works as expected.

      过滤器,路由和模型绑定之类的东西也将起作用.因此,如果您在 GetGroups()方法上具有属性,例如 [Authorize] [ServiceFilter(….]] ,则<.

      Things like filters, routing, and model binding will also work. So if you had an attribute on your GetGroups() method, for example [Authorize] or [ServiceFilter(….)], it will be triggered as expected.

      我使用xUnit进行测试,因此对于在控制器上进行的单元测试,我会使用它.

      I use xUnit for testing so for a unit test on a controller I use this.

      控制器单元测试:

      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();
          }
      }
      

      用于单元测试的Context模拟程序.

      The Context mocker used for unit testing.

      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;
          }
      }
      

      种子扩展名.

      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)

      and for Integration Testing: (this is some code from a tutorial, but I can't remember where I saw it, either youtube or Pluralsight)

      TestFixture的设置

      setup for the 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();
              }
          }
      }
      

      并在集成测试中像这样使用它.

      and use it like this in the integration test.

      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);
          }
      }
      

      添加了修改:总之,您应该同时执行这两项操作,因为每种测试的目的都不相同.

      Added In conclusion, you should do both, because each test serves a different purpose.

      看看其他答案,您会发现共识是两者都做.

      Looking at the other answers you will see that the consensus is to do both.

      这篇关于最佳实践是直接测试Web API控制器还是通过HTTP客户端测试?的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆