ASP.NET Core 中文文档 第四章 MVC(4.5)测试控制器逻辑

原文: Testing Controller Logic
作者: Steve Smith
翻译: 姚阿勇(Dr.Yao)
校对: 高嵩(Jack)

ASP.NET MVC 应用程序的控制器应当小巧并专注于用户界面。涉及了非 UI 事务的大控制器更难于测试和维护。

章节:

  • 为什么要测试控制器
  • 单元测试
  • 集成测试

在 GitHub 上查看或下载示例

为什么要测试控制器

控制器是所有 ASP.NET Core MVC 应用程序的核心部分。因此,你应当确保它们的行为符合应用的预期。 自动化测试可以为你提供这样的保障并能够在进入生产环境之前将错误检测出来。重要的一点是,避免将非必要的职责加入你的控制器并且确保测试只关注在控制器的职责上。

控制器的逻辑应当最小化并且不要去关心业务逻辑或基础事务(如,数据访问)。要测试控制器的逻辑,而不是框架。根据有效或无效的输入去测试控制器的 行为 如何。根据其执行业务操作的返回值去测试控制器的响应。

典型的控制器职责:

  • 验证 ModelState.IsValid
  • 如果 ModelState 无效则返回一个错误响应
  • 从持久层获取一个业务实体
  • 在业务实体上执行一个操作
  • 将业务实体保存到持久层
  • 返回一个合适的 IActionResult

单元测试

单元测试 包括对应用中独立于基础结构和依赖项之外的某一部分的测试。对控制器逻辑进行单元测试的时候,只测试一个操作的内容,而不测试其依赖项或框架本身的行为。就是说对你的控制器操作进行测试时,要确保只聚焦于操作本身的行为。控制器单元测试避开诸如 过滤器, 路由,or 模型绑定 这些内容。由于只专注于测试某一项内容,单元测试通常编写简单而运行快捷。一组编写良好的单元测试可以无需过多开销地频繁运行。然而,单元测试并不检测组件之间交互的问题,那是集成测试的目的。

如果你在编写自定义的过滤器,路由,诸如此类,你应该对它们进行单元测试,但不是作为某个控制器操作测试的一部分。它们应该单独进行测试。

使用 Visual Studio 创建并运行单元测试

为演示单元测试,请查看下面的控制器。它显示一个头脑风暴讨论会的列表,并且可以用 POST 请求创建新的头脑风暴讨论会:

using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;

namespace TestingControllersSample.Controllers
{
    public class HomeController : Controller                                   // 手动高亮
    {
        private readonly IBrainstormSessionRepository _sessionRepository;

        public HomeController(IBrainstormSessionRepository sessionRepository) // 手动高亮
        {
            _sessionRepository = sessionRepository;
        }

        public async Task<IActionResult> Index()                              // 手动高亮
        {
            var sessionList = await _sessionRepository.ListAsync();

            var model = sessionList.Select(session => new StormSessionViewModel()
            {
                Id = session.Id,
                DateCreated = session.DateCreated,
                Name = session.Name,
                IdeaCount = session.Ideas.Count
            });

            return View(model);
        }

        public class NewSessionModel
        {
            [Required]
            public string SessionName { get; set; }
        }

        [HttpPost]                                                     // 手动高亮
        public async Task<IActionResult> Index(NewSessionModel model)  // 手动高亮
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            await _sessionRepository.AddAsync(new BrainstormSession()
            {
                DateCreated = DateTimeOffset.Now,
                Name = model.SessionName
            });

            return RedirectToAction("Index");
        }
    }
}

这个控制器遵循显式依赖原则,期望依赖注入为其提供一个 IBrainstormSessionRepository 的实例。这样就非常容易用一个 Mock 对象框架来进行测试,比如 Moq 。HTTP GET Index 方法没有循环或分支,只是调用了一个方法。要测试这个 Index 方法,我们需要验证是否返回了一个 ViewResult ,其中包含一个来自存储库的 List 方法的 ViewModel

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Controllers;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
using Xunit;

namespace TestingControllersSample.Tests.UnitTests
{
    public class HomeControllerTests
    {
        [Fact]                                                                       // 手动高亮
        public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()   // 手动高亮
        {
            // Arrange
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
            var controller = new HomeController(mockRepo.Object);

            // Act
            var result = await controller.Index();

            // Assert
            var viewResult = Assert.IsType<ViewResult>(result);
            var model = Assert.IsAssignableFrom<IEnumerable<StormSessionViewModel>>(
                viewResult.ViewData.Model);
            Assert.Equal(2, model.Count());
        }

        [Fact]
        public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
        {
            // Arrange
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
            var controller = new HomeController(mockRepo.Object);
            controller.ModelState.AddModelError("SessionName", "Required");
            var newSession = new HomeController.NewSessionModel();

            // Act
            var result = await controller.Index(newSession);

            // Assert
            var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);
            Assert.IsType<SerializableError>(badRequestResult.Value);
        }
        [Fact]
        public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
        {
            // Arrange
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
                .Returns(Task.CompletedTask)
                .Verifiable();
            var controller = new HomeController(mockRepo.Object);
            var newSession = new HomeController.NewSessionModel()
            {
                SessionName = "Test Name"
            };

            // Act
            var result = await controller.Index(newSession);

            // Assert
            var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result);
            Assert.Null(redirectToActionResult.ControllerName);
            Assert.Equal("Index", redirectToActionResult.ActionName);
            mockRepo.Verify();
        }
        
        private List<BrainstormSession> GetTestSessions()
        {
            var sessions = new List<BrainstormSession>();
            sessions.Add(new BrainstormSession()
            {
                DateCreated = new DateTime(2016, 7, 2),
                Id = 1,
                Name = "Test One"
            });
            sessions.Add(new BrainstormSession()
            {
                DateCreated = new DateTime(2016, 7, 1),
                Id = 2,
                Name = "Test Two"
            });
            return sessions;
        }
    }
}

HTTP POST Index 方法(下面所示)应当验证:

  • ModelState.IsValidfalse 时,操作方法返回一个包含适当数据的 ViewResult
  • ModelState.IsValidtrue 时,存储库的 Add 方法被调用,然后返回一个包含正确变量内容的 RedirectToActionResult
    [Fact]
    public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
    {
        // Arrange
        var mockRepo = new Mock<IBrainstormSessionRepository>();
        mockRepo.Setup(repo => repo.ListAsync()).Returns(Task.FromResult(GetTestSessions()));
        var controller = new HomeController(mockRepo.Object);
        controller.ModelState.AddModelError("SessionName", "Required");         // 手动高亮
        var newSession = new HomeController.NewSessionModel();

        // Act
        var result = await controller.Index(newSession);

        // Assert
        var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);   // 手动高亮
        Assert.IsType<SerializableError>(badRequestResult.Value);               // 手动高亮
    }

    [Fact]
    public async Task IndexPost_ReturnsARedirectAndAddsSession_WhenModelStateIsValid()
    {
        // Arrange
        var mockRepo = new Mock<IBrainstormSessionRepository>();
        mockRepo.Setup(repo => repo.AddAsync(It.IsAny<BrainstormSession>()))
            .Returns(Task.CompletedTask)
            .Verifiable();
        var controller = new HomeController(mockRepo.Object);
        var newSession = new HomeController.NewSessionModel()
        {
            SessionName = "Test Name"
        };

        // Act
        var result = await controller.Index(newSession);

        // Assert
        var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); // 手动高亮
        Assert.Null(redirectToActionResult.ControllerName);                         // 手动高亮
        Assert.Equal("Index", redirectToActionResult.ActionName);                   // 手动高亮
        mockRepo.Verify();
    }

第一个测试确定当 ModelState 无效时,返回一个与 GET 请求一样的 ViewResult 。注意,测试不会尝试传递一个无效模型进去。那样是没有作用的,因为模型绑定并没有运行 - 我们只是直接调用了操作方法。然而,我们并不想去测试模型绑定 —— 我们只是在测试操作方法里的代码行为。最简单的方法就是在 ModelState 中添加一个错误。

第二个测试验证当 ModelState 有效时,新的 BrainstormSession 被添加(通过存储库),并且该方法返回一个带有预期属性值的 RedirectToActionResult 。未被执行到的 mock 调用通常就被忽略了,但是在设定过程的最后调用 Verifiable 则允许其在测试中被验证。这是通过调用 mockRepo.Verify 实现的。

这个例子中所采用的 Moq 库能够简单地混合可验证的,“严格的”及带有不可验证mock(也称为 “宽松的” mock 或 stub)的mock。了解更多关于 使用 Moq 自定义 Mock 行为。

应用程序里的另外一个控制器显示指定头脑风暴讨论会的相关信息。它包含一些处理无效 id 值的逻辑:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.ViewModels;

namespace TestingControllersSample.Controllers
{
    public class SessionController : Controller
    {
        private readonly IBrainstormSessionRepository _sessionRepository;

        public SessionController(IBrainstormSessionRepository sessionRepository)
        {
            _sessionRepository = sessionRepository;
        }

        public async Task<IActionResult> Index(int? id)
        {
            if (!id.HasValue)                              // 手动高亮
            {                                                // 手动高亮
                return RedirectToAction("Index", "Home");    // 手动高亮
            }                                                // 手动高亮

            var session = await _sessionRepository.GetByIdAsync(id.Value);
            if (session == null)                        // 手动高亮
            {                                           // 手动高亮
                return Content("Session not found.");   // 手动高亮
            }                                           // 手动高亮

            var viewModel = new StormSessionViewModel()
            {
                DateCreated = session.DateCreated,
                Name = session.Name,
                Id = session.Id
            };

            return View(viewModel);
        }
    }
}

这个控制器操作有三种情况要测试,每条 return 语句一种:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Controllers;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.ViewModels;
using Xunit;

namespace TestingControllersSample.Tests.UnitTests
{
    public class SessionControllerTests
    {
        [Fact]
        public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
        {
            // Arrange
            var controller = new SessionController(sessionRepository: null);

            // Act
            var result = await controller.Index(id: null);

            // Arrange
            var redirectToActionResult = Assert.IsType<RedirectToActionResult>(result); // 手动高亮
            Assert.Equal("Home", redirectToActionResult.ControllerName);                // 手动高亮
            Assert.Equal("Index", redirectToActionResult.ActionName);                   // 手动高亮
        }
        [Fact]
        public async Task IndexReturnsContentWithSessionNotFoundWhenSessionNotFound()
        {
            // Arrange
            int testSessionId = 1;
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
                .Returns(Task.FromResult((BrainstormSession)null));
            var controller = new SessionController(mockRepo.Object);

            // Act
            var result = await controller.Index(testSessionId);

            // Assert
            var contentResult = Assert.IsType<ContentResult>(result);   // 手动高亮
            Assert.Equal("Session not found.", contentResult.Content);  // 手动高亮
        }

       [Fact]
        public async Task IndexReturnsViewResultWithStormSessionViewModel()
        {
            // Arrange
            int testSessionId = 1;
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
                .Returns(Task.FromResult(GetTestSessions().FirstOrDefault(s => s.Id == testSessionId)));
            var controller = new SessionController(mockRepo.Object);

            // Act
            var result = await controller.Index(testSessionId);

            // Assert
            var viewResult = Assert.IsType<ViewResult>(result); // 手动高亮
            var model = Assert.IsType<StormSessionViewModel>(viewResult.ViewData.Model);    // 手动高亮
            Assert.Equal("Test One", model.Name);   // 手动高亮
            Assert.Equal(2, model.DateCreated.Day); // 手动高亮
            Assert.Equal(testSessionId, model.Id);  // 手动高亮
        }

      private List<BrainstormSession> GetTestSessions()
        {
            var sessions = new List<BrainstormSession>();
            sessions.Add(new BrainstormSession()
            {
                DateCreated = new DateTime(2016, 7, 2),
                Id = 1,
                Name = "Test One"
            });
            sessions.Add(new BrainstormSession()
            {
                DateCreated = new DateTime(2016, 7, 1),
                Id = 2,
                Name = "Test Two"
            });
            return sessions;
        }
    }
}

这个应用程序以 Web API (一个头脑风暴讨论会的意见列表以及一个给讨论会添加新意见的方法)的形式公开功能:

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;

namespace TestingControllersSample.Api
{
    [Route("api/ideas")]
    public class IdeasController : Controller
    {
        private readonly IBrainstormSessionRepository _sessionRepository;

        public IdeasController(IBrainstormSessionRepository sessionRepository)
        {
            _sessionRepository = sessionRepository;
        }

        [HttpGet("forsession/{sessionId}")]                         // 手动高亮
        public async Task<IActionResult> ForSession(int sessionId)  // 手动高亮
        {
            var session = await _sessionRepository.GetByIdAsync(sessionId);
            if (session == null)
            {
                return NotFound(sessionId); // 手动高亮
            }

            var result = session.Ideas.Select(idea => new IdeaDTO()// 手动高亮
            {                                                      // 手动高亮
                Id = idea.Id,                                      // 手动高亮
                Name = idea.Name,                                  // 手动高亮
                Description = idea.Description,                    // 手动高亮
                DateCreated = idea.DateCreated                     // 手动高亮
            }).ToList();                                           // 手动高亮
            
            return Ok(result);
        }

        [HttpPost("create")]                                                    // 手动高亮
        public async Task<IActionResult> Create([FromBody]NewIdeaModel model)   // 手动高亮
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);                                  // 手动高亮
            }

            var session = await _sessionRepository.GetByIdAsync(model.SessionId);
            if (session == null)
            {
                return NotFound(model.SessionId);                               // 手动高亮
            }

            var idea = new Idea()
            {
                DateCreated = DateTimeOffset.Now,
                Description = model.Description,
                Name = model.Name
            };
            session.AddIdea(idea);

            await _sessionRepository.UpdateAsync(session);

            return Ok(session);                                                 // 手动高亮
        }
    }
}

ForSession 方法返回一个 IdeaDTO 类型的列表,该类型有着符合 JavaScript 惯例的驼峰命名法的属性名。从而避免直接通过 API 调用返回你业务领域的实体,因为通常它们都包含了 API 客户端并不需要的更多数据,而且它们将你的应用程序的内部领域模型与外部公开的 API 不必要地耦合起来。可以手动将业务领域实体与你想要返回的类型连接映射起来(使用这里展示的 LINQ Select),或者使用诸如 AutoMapper的类库。

CreateForSession API 方法的单元测试:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Moq;
using TestingControllersSample.Api;
using TestingControllersSample.ClientModels;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using Xunit;

namespace TestingControllersSample.Tests.UnitTests
{
    public class ApiIdeasControllerTests
    {
        [Fact]
        public async Task Create_ReturnsBadRequest_GivenInvalidModel()  // 手动高亮
        {
            // Arrange & Act
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            var controller = new IdeasController(mockRepo.Object);
            controller.ModelState.AddModelError("error","some error");  // 手动高亮

            // Act
            var result = await controller.Create(model: null);

            // Assert
            Assert.IsType<BadRequestObjectResult>(result);              // 手动高亮
        }
       [Fact]
        public async Task Create_ReturnsHttpNotFound_ForInvalidSession()// 手动高亮
        {
            // Arrange
            int testSessionId = 123;
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))    // 手动高亮
                .Returns(Task.FromResult((BrainstormSession)null));     // 手动高亮 
            var controller = new IdeasController(mockRepo.Object);

            // Act
            var result = await controller.Create(new NewIdeaModel());   // 手动高亮

            // Assert
            Assert.IsType<NotFoundObjectResult>(result);
        }
        
        [Fact]
        public async Task Create_ReturnsNewlyCreatedIdeaForSession()    // 手动高亮
        {
            // Arrange
            int testSessionId = 123;
            string testName = "test name";
            string testDescription = "test description";
            var testSession = GetTestSession();
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))    // 手动高亮
                .Returns(Task.FromResult(testSession));                 // 手动高亮
            var controller = new IdeasController(mockRepo.Object);

            var newIdea = new NewIdeaModel()
            {
                Description = testDescription,
                Name = testName,
                SessionId = testSessionId
            };
            mockRepo.Setup(repo => repo.UpdateAsync(testSession))       // 手动高亮
                .Returns(Task.CompletedTask)                            // 手动高亮
                .Verifiable();                                          // 手动高亮

            // Act
            var result = await controller.Create(newIdea);

            // Assert
            var okResult = Assert.IsType<OkObjectResult>(result);       // 手动高亮
            var returnSession = Assert.IsType<BrainstormSession>(okResult.Value);// 手动高亮
            mockRepo.Verify();                                          // 手动高亮
            Assert.Equal(2, returnSession.Ideas.Count());
            Assert.Equal(testName, returnSession.Ideas.LastOrDefault().Name);
            Assert.Equal(testDescription, returnSession.Ideas.LastOrDefault().Description);
        }

        [Fact]
        public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
        {
            // Arrange
            int testSessionId = 123;
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
                .Returns(Task.FromResult((BrainstormSession)null));
            var controller = new IdeasController(mockRepo.Object);

            // Act
            var result = await controller.ForSession(testSessionId);

            // Assert
            var notFoundObjectResult = Assert.IsType<NotFoundObjectResult>(result);
            Assert.Equal(testSessionId, notFoundObjectResult.Value);
        }

        [Fact]
        public async Task ForSession_ReturnsIdeasForSession()
        {
            // Arrange
            int testSessionId = 123;
            var mockRepo = new Mock<IBrainstormSessionRepository>();
            mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId)).Returns(Task.FromResult(GetTestSession()));
            var controller = new IdeasController(mockRepo.Object);

            // Act
            var result = await controller.ForSession(testSessionId);

            // Assert
            var okResult = Assert.IsType<OkObjectResult>(result);
            var returnValue = Assert.IsType<List<IdeaDTO>>(okResult.Value);
            var idea = returnValue.FirstOrDefault();
            Assert.Equal("One", idea.Name);
        }
        
        private BrainstormSession GetTestSession()
        {
            var session = new BrainstormSession()
            {
                DateCreated = new DateTime(2016, 7, 2),
                Id = 1,
                Name = "Test One"
            };

            var idea = new Idea() { Name = "One" };
            session.AddIdea(idea);
            return session;
        }
    }
}

如前所述,要测试这个方法在 ModelState 无效时的行为,可以将一个模型错误作为测试的一部分添加到控制器。不要在单元测试尝试测试模型验证或者模型绑定 —— 仅仅测试应对特定 ModelState 值的时候,你的操作方法的行为。

第二项测试需要存储库返回 null ,因此将模拟的存储库配置为返回 null 。没有必要去创建一个测试数据库(内存中的或其他的)并构建一条能返回这个结果的查询 —— 就像展示的那样,一行代码就可以了。.

最后一项测试验证存储库的 Update 方法是否被调用。像我们之前做过的那样,在调用 mock 时调用了 Verifiable ,然后模拟存储库的 Verify 方法被调用,用以确认可验证的方法已被执行。确保 Update 保存了数据并不是单元测试的职责;那是集成测试做的事。

集成测试

集成测试是为了确保你应用程序里各独立模块能够正确地一起工作。通常,能进行单元测试的东西,都能进行集成测试,但反之则不行。不过,集成测试往往比单元测试慢得多。因此,最好尽量采用单元测试,在涉及到多方合作的情况下再进行集成测试。

尽管 mock 对象仍然有用,但在集成测试中很少用到它们。在单元测试中,mock 对象是一种有效的方式,根据测试目的去控制测试单元外的合作者应当有怎样的行为。在集成测试中,则采用真实的合作者来确定整个子系统能够正确地一起工作。

应用程序状态

在执行集成测试的时候,一个重要的考虑因素就是如何设置你的应用程序的状态。各个测试需要独立地运行,所以每个测试都应该在已知状态下随应用程序启动。如果你的应用没有使用数据库或者任何持久层,这可能不是个问题。然而,大多数真实的应用程序都会将它们的状态持久化到某种数据存储中,所以某个测试对其有任何改动都可能影响到其他测试,除非重置了数据存储。使用内置的 TestServer ,它可以直接托管我们集成测试中的 ASP.NET Core 应用程序,但又无须对我们将使用的数据授权访问。如果你正在使用真实的数据库,一种方法是让应用程序连接到测试数据库,你的测试可以访问它并且确保在每个测试执行之前会重置到一个已知的状态。

在这个示例应用程序里,我采用了 Entity Framework Core 的 InMemoryDatabase 支持,因此我可以直接把我的测试项目连接到它。实际上,我在应用程序的 Startup 类里公开了一个 InitializeDatabase 方法,我可以在开发( Development )环境中启动应用程序的时候调用这个方法。我的集成测试只要把环境设置为 Development ,就能自动从中受益。我不需要担心重置数据库,因为 InMemoryDatabase 会在应用程序每次重启的时候重置。

The Startup class:

using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TestingControllersSample.Core.Interfaces;
using TestingControllersSample.Core.Model;
using TestingControllersSample.Infrastructure;

namespace TestingControllersSample
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<AppDbContext>(                        // 手动高亮
                optionsBuilder => optionsBuilder.UseInMemoryDatabase());// 手动高亮

            services.AddMvc();

            services.AddScoped<IBrainstormSessionRepository,
                EFStormSessionRepository>();
        }

        public void Configure(IApplicationBuilder app,
            IHostingEnvironment env,
            ILoggerFactory loggerFactory)
        {
            loggerFactory.AddConsole(LogLevel.Warning);

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();

                var repository = app.ApplicationServices.GetService<IBrainstormSessionRepository>();// 手动高亮
                InitializeDatabaseAsync(repository).Wait();                                         // 手动高亮
            }

            app.UseStaticFiles();

            app.UseMvcWithDefaultRoute();
        }

        public async Task InitializeDatabaseAsync(IBrainstormSessionRepository repo)    // 手动高亮
        {
            var sessionList = await repo.ListAsync();
            if (!sessionList.Any())
            {
                await repo.AddAsync(GetTestSession());
            }
        }

        public static BrainstormSession GetTestSession()                                // 手动高亮 
        {
            var session = new BrainstormSession()
            {
                Name = "Test Session 1",
                DateCreated = new DateTime(2016, 8, 1)
            };
            var idea = new Idea()
            {
                DateCreated = new DateTime(2016, 8, 1),
                Description = "Totally awesome idea",
                Name = "Awesome idea"
            };
            session.AddIdea(idea);
            return session;
        }
    }
}

在下面的集成测试中,你会看到 GetTestSession 方法被频繁使用。

访问视图

每一个集成测试类都会配置 TestServer 来运行 ASP.NET Core 应用程序。默认情况下,TestServer 在其运行的目录下承载 Web 应用程序 —— 在本例中,就是测试项目文件夹。因此,当你尝试测试返回 ViewResult 的控制器操作的时候,你会看见这样的错误:

   未找到视图 “Index”。已搜索以下位置:
  (位置列表)

要修正这个问题,你需要配置服务器使其采用 Web 项目的 ApplicationBasePathApplicationName 。这在所示的集成测试类中调用 UseServices 完成的:

using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Xunit;

namespace TestingControllersSample.Tests.IntegrationTests
{
    public class HomeControllerTests : IClassFixture<TestFixture<TestingControllersSample.Startup>>
    {
        private readonly HttpClient _client;

        public HomeControllerTests(TestFixture<TestingControllersSample.Startup> fixture)
        {
            _client = fixture.Client;
        }

        [Fact]
        public async Task ReturnsInitialListOfBrainstormSessions()
        {
            // Arrange
            var testSession = Startup.GetTestSession();

            // Act
            var response = await _client.GetAsync("/");

            // Assert
            response.EnsureSuccessStatusCode();
            var responseString = await response.Content.ReadAsStringAsync();
            Assert.True(responseString.Contains(testSession.Name));
        }
        
        [Fact]
        public async Task PostAddsNewBrainstormSession()
        {
            // Arrange
            string testSessionName = Guid.NewGuid().ToString();
            var data = new Dictionary<string, string>();
            data.Add("SessionName", testSessionName);
            var content = new FormUrlEncodedContent(data);

            // Act
            var response = await _client.PostAsync("/", content);

            // Assert
            Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
            Assert.Equal("/", response.Headers.Location.ToString());
        }
    }
}

在上面的测试中,responseString 从视图获取真实渲染的 HTML ,可以用来检查确认其中是否包含期望的结果。

API 方法

如果你的应用程序有公开的 Web API,采用自动化测试来确保它们按期望执行是个好主意。内置的 TestServer 便于测试 Web API。如果你的 API 方法使用了模型绑定,那么你应该始终检查 ModelState.IsValid ,另外确认你的模型验证工作是否正常应当在集成测试里进行。

下面一组测试针对上文所示的 ideasController里的 Create 方法:

        [Fact]
        public async Task CreatePostReturnsBadRequestForMissingNameValue()
        {
            // Arrange
            var newIdea = new NewIdeaDto("", "Description", 1);

            // Act
            var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);

            // Assert
            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }

        [Fact]
        public async Task CreatePostReturnsBadRequestForMissingDescriptionValue()
        {
            // Arrange
            var newIdea = new NewIdeaDto("Name", "", 1);

            // Act
            var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);

            // Assert
            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }

        [Fact]
        public async Task CreatePostReturnsBadRequestForSessionIdValueTooSmall()
        {
            // Arrange
            var newIdea = new NewIdeaDto("Name", "Description", 0);

            // Act
            var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);

            // Assert
            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }

        [Fact]
        public async Task CreatePostReturnsBadRequestForSessionIdValueTooLarge()
        {
            // Arrange
            var newIdea = new NewIdeaDto("Name", "Description", 1000001);

            // Act
            var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);

            // Assert
            Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        }

        [Fact]
        public async Task CreatePostReturnsNotFoundForInvalidSession()
        {
            // Arrange
            var newIdea = new NewIdeaDto("Name", "Description", 123);

            // Act
            var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);

            // Assert
            Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
        }

        [Fact]
        public async Task CreatePostReturnsCreatedIdeaWithCorrectInputs()
        {
            // Arrange
            var testIdeaName = Guid.NewGuid().ToString();
            var newIdea = new NewIdeaDto(testIdeaName, "Description", 1);

            // Act
            var response = await _client.PostAsJsonAsync("/api/ideas/create", newIdea);

            // Assert
            response.EnsureSuccessStatusCode();
            var returnedSession = await response.Content.ReadAsJsonAsync<BrainstormSession>();
            Assert.Equal(2, returnedSession.Ideas.Count);
            Assert.True(returnedSession.Ideas.Any(i => i.Name == testIdeaName));
        }

        [Fact]
        public async Task ForSessionReturnsNotFoundForBadSessionId()
        {
            // Arrange & Act
            var response = await _client.GetAsync("/api/ideas/forsession/500");

            // Assert
            Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
        }

        public async Task ForSessionReturnsIdeasForValidSessionId()
        {
            // Arrange
            var testSession = Startup.GetTestSession();

            // Act
            var response = await _client.GetAsync("/api/ideas/forsession/1");

            // Assert
            response.EnsureSuccessStatusCode();
            var ideaList = JsonConvert.DeserializeObject<List<IdeaDTO>>(
                await response.Content.ReadAsStringAsync());
            var firstIdea = ideaList.First();
            Assert.Equal(testSession.Ideas.First().Name, firstIdea.Name);
        }
    }

不同于对返回 HTML 视图的操作的集成测试,有返回值的 Web API 方法通常能够反序列化为强类型对象,就像上面所示的最后一个测试。在此例中,该测试将返回值反序列化为一个 BrainstormSession 实例,然后再确认意见是否被正确添加到了意见集合里。

你可以在sample project这篇文章里找到更多的集成测试示例。

返回目录



你可能感兴趣的:(ASP.NET Core 中文文档 第四章 MVC(4.5)测试控制器逻辑)