c# - 直接或通过 HTTP 客户端测试我的 Web API Controller 是最佳实践吗?

标签 c# unit-testing asp.net-web-api

我正在为我的 ASP.NET Core Web API 添加一些单元测试,我想知道是直接对 Controller 进行单元测试还是通过 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 的方法,但我想知道最佳实践是什么。第二种方法似乎更好一些,因为它在不知道实际响应对象类型的情况下天真地测试来自 Web API 的实际 JSON 响应,但是以这种方式注入(inject)模拟存储库更加困难 - 测试必须连接到单独的本地 Web API服务器本身以某种方式配置为使用模拟对象......我想?

最佳答案

编辑:TL;DR
结论你应该同时做,因为每个测试都有不同的目的。
答:
这是一个很好的问题,我经常问自己。
首先,您必须了解单元测试的目的和集成测试的目的。
Unit 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.


  • 过滤器、路由和模型绑定(bind)等 不会工作。

  • Integration Test :

    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.


  • 过滤器、路由和模型绑定(bind)等 工作。

  • “最佳实践”应该被认为是“有值(value)且有意义”。
    你应该问问自己编写测试有什么值(value),还是我只是为了编写测试而创建这个测试?
    假设您的 GetGroups()方法看起来像这样。
    [HttpGet]
    [Authorize]
    public async Task<ActionResult<Group>> GetGroups()
    {            
        var groups  = await _repository.ListAllAsync();
        return Ok(groups);
    }
    
    为它编写单元测试没有任何值(value)!因为你正在做的是测试 mock _repository的实现!那么这有什么意义呢?!
    该方法没有逻辑,并且存储库只会与您模拟的完全一样,该方法中没有任何其他建议。
    存储库将有自己的一组单独的单元测试,您将在其中涵盖存储库方法的实现。
    现在假设您的 GetGroups()方法不仅仅是 _repository 的包装器并且有一些逻辑。
    [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() 编写单元测试是有值(value)的。方法,因为结果可能会根据 mock HttpContext.User值(value)。
    属性如 [Authorize][ServiceFilter(….)] 不会在单元测试中触发。
    .
    编写集成测试是 几乎总是值得的因为您想测试当它构成实际应用程序/系统/进程的一部分时该进程将做什么。
    问问自己,应用程序/系统是否正在使用它?
    如果 是的 ,编写集成测试,因为结果取决于环境和标准的组合。
    现在即使你的GetGroups()方法只是第一个实现中的包装器,_repository将指向一个实际的数据存储,没有什么是 mock !
    所以现在,测试不仅涵盖了数据存储有数据(或没有数据)这一事实,它还依赖于建立的实际连接,HttpContext设置是否正确以及信息的序列化是否按预期工作。
    过滤器、路由和模型绑定(bind)等 也工作。
    因此,如果您的 GetGroups() 上有一个属性方法,例如 [Authorize][ServiceFilter(….)] , 它按预期触发。
    我使用 xUnit 进行测试,因此对于 Controller 上的单元测试,我使用它。
    Controller 单元测试:
    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 mocker。
    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)
    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);
        }
    }
    
    添加编辑:
    总之,您应该两者都做,因为每个测试都有不同的目的。
    查看其他答案,您会发现共识是两者都做。

    关于c# - 直接或通过 HTTP 客户端测试我的 Web API Controller 是最佳实践吗?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/62815739/

    相关文章:

    c# - WPF 使用 MVVM 禁用列表框项目

    c# - 用户控件在运行时动态创建,但存在内存泄漏

    android - Robolectric 3.0,未能测试启动 HandlerThread 的函数

    c# - 仅基于 WPF、WCF 和 ADO.net 的 .Net 项目同时将 DB 作为 Sybase 是否可能?

    c# - 如何为给定的附图设置交替行颜色

    java - Maven 使用关键字 "Test"运行所有测试

    c++ - 在已在共享库中实现的可执行文件中实现静态方法是否安全?

    rest - 网络 API/REST : Request list of items

    c# - 这是获取 HttpContext 请求正文的安全方法吗

    azure - 我可以导入从 Azure 外部托管的 APIM 吗?