ASP.NET Core 中的测试控制器逻辑Test controller logic in ASP.NET Core

作者:Steve SmithBy Steve Smith

控制器在任何 ASP.NET Core MVC 应用中起着核心作用。Controllers play a central role in any ASP.NET Core MVC app. 因此,应该对控制器表现达到预期怀有信心。As such, you should have confidence that controllers behave as intended. 在将应用部署到生产环境之前,自动测试可以检测到错误。Automated tests can detect errors before the app is deployed to a production environment.

查看或下载示例代码如何下载View or download sample code (how to download)

控制器逻辑的单元测试Unit tests of controller logic

单元测试涉及通过基础结构和依赖项单独测试应用的一部分。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 behavior of its dependencies or of the framework itself.

将控制器操作的单元测试设置为专注于控制器的行为。Set up unit tests of controller actions to focus on the controller's behavior. 控制器单元测试将避开筛选器路由模型绑定等方案。A controller unit test avoids scenarios such as filters, routing, and model binding. 涵盖共同响应请求的组件之间的交互的测试由集成测试处理。Tests that cover the interactions among components that collectively respond to a request are handled by integration tests. 有关集成测试的详细信息,请参阅ASP.NET Core 中的集成测试For more information on integration tests, see ASP.NET Core 中的集成测试.

如果编写自定义筛选器和路由,应对其单独进行单元测试,而不是在测试特定控制器操作时进行。If you're writing custom filters and routes, unit test them in isolation, not as part of tests on a particular controller action.

要演示控制器单元测试,请查看以下示例应用中的控制器。To demonstrate controller unit tests, review the following controller in the sample app. 主控制器显示集体讨论会话列表并允许使用 POST 请求创建新的集体讨论会话:The Home controller displays a list of brainstorming sessions and allows the creation of new brainstorming sessions with a POST request:

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);
        }
        else
        {
            await _sessionRepository.AddAsync(new BrainstormSession()
            {
                DateCreated = DateTimeOffset.Now,
                Name = model.SessionName
            });
        }

        return RedirectToAction(actionName: nameof(Index));
    }
}

前面的控制器:The preceding controller:

HTTP GET Index 方法没有循环或分支,且仅调用一个方法。The HTTP GET Index method has no looping or branching and only calls one method. 此操作的单元测试:The unit test for this action:

  • 使用 GetTestSessions 方法模拟 IBrainstormSessionRepository 服务。Mocks the IBrainstormSessionRepository service using the GetTestSessions method. GetTestSessions 使用日期和会话名称创建两个 mock 集体讨论会话。GetTestSessions creates two mock brainstorm sessions with dates and session names.
  • 执行 Index 方法。Executes the Index method.
  • 根据该方法返回的结果进行断言:Makes assertions on the result returned by the method:
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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());
}
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 方法测试验证:The Home controller's HTTP POST Index method tests verifies that:

通过使用 AddModelError 添加错误来测试无效模型状态,如下第一个测试所示:An invalid model state is tested by adding errors using AddModelError as shown in the first test below:

[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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 请求相同的 ViewResultWhen ModelState isn't valid, the same ViewResult is returned as for a GET request. 测试不会尝试传入无效模型。The test doesn't attempt to pass in an invalid model. 传入无效模型不是有效的方法,因为不会运行模型绑定(尽管集成测试使用模型绑定)。Passing an invalid model isn't a valid approach, since model binding isn't running (although an integration test does use model binding). 在本例中,不测试模型绑定。In this case, model binding isn't tested. 这些单元测试仅测试操作方法中的代码。These unit tests are only testing the code in the action method.

第二个测试验证 ModelState 有效时的情况:The second test verifies that when the ModelState is valid:

  • 已通过存储库添加新的 BrainstormSessionA new BrainstormSession is added (via the repository).
  • 该方法将返回带有所需属性的 RedirectToActionResultThe method returns a RedirectToActionResult with the expected properties.

通常会忽略未调用的模拟调用,但在设置调用末尾调用 Verifiable 就可以在测试中进行 mock 验证。Mocked calls that aren't called are normally ignored, but calling Verifiable at the end of the setup call allows mock validation in the test. 这通过对 mockRepo.Verify 的调用来完成,进行这种调用时,如果未调用所需方法,则测试将失败。This is performed with the call to mockRepo.Verify, which fails the test if the expected method wasn't called.

备注

通过此示例中使用的 Moq 库,可以混合可验证(或称“严格”)mock 和非可验证 mock(也称为“宽松”mock 或存根)。The Moq library used in this sample makes it possible to mix verifiable, or "strict", mocks with non-verifiable mocks (also called "loose" mocks or stubs). 详细了解使用 Moq 自定义 Mock 行为Learn more about customizing Mock behavior with Moq.

示例应用中的 SessionController 显示与特定集体讨论会话相关的信息。SessionController in the sample app displays information related to a particular brainstorming session. 该控制器包含用于处理无效 id 值的逻辑(以下示例中有两个 return 方案可用来应对这些情况)。The controller includes logic to deal with invalid id values (there are two return scenarios in the following example to cover these scenarios). 最后的 return 语句向视图 (Controllers/SessionController.cs) 返回一个新的 StormSessionViewModelThe final return statement returns a new StormSessionViewModel to the view (Controllers/SessionController.cs):

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(actionName: nameof(Index), 
                controllerName: "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);
    }
}

单元测试包括对会话控制器 Index 操作中的每个 return 方案执行一个测试:The unit tests include one test for each return scenario in the Session controller Index action:

[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
    // Arrange
    var controller = new SessionController(sessionRepository: null);

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

    // Assert
    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))
        .ReturnsAsync((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))
        .ReturnsAsync(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);
}

移动到想法控制器,应用会将功能公开为 api/ideas 路由上的 Web API:Moving to the Ideas controller, the app exposes functionality as a web API on the api/ideas route:

  • ForSession 方法将返回与集体讨论会话关联的想法列表 (IdeaDTO)。A list of ideas (IdeaDTO) associated with a brainstorming session is returned by the ForSession method.
  • Create 方法会向会话中添加新想法。The Create method adds new ideas to a session.
[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);
}

避免直接通过 API 调用返回企业域实体。Avoid returning business domain entities directly via API calls. 域实体:Domain entities:

  • 包含的数据通常比客户端所需的数据更多。Often include more data than the client requires.
  • 无需将应用的内部域模型与公开的 API 结合。Unnecessarily couple the app's internal domain model with the publicly exposed API.

可以执行域实体与返回到客户端的类型之间的映射:Mapping between domain entities and the types returned to the client can be performed:

接着,示例应用会演示想法控制器的 CreateForSession API 方法的单元测试。Next, the sample app demonstrates unit tests for the Create and ForSession API methods of the Ideas controller.

示例应用包含两个 ForSession 测试。The sample app contains two ForSession tests. 第一个测试可确定 ForSession 是否返回无效会话的 NotFoundObjectResult(找不到 HTTP):The first test determines if ForSession returns a NotFoundObjectResult (HTTP Not Found) for an invalid session:

[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((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);
}

第二个 ForSession 测试可确定 ForSession 是否返回有效会话的会话想法列表 (<List<IdeaDTO>>)。The second ForSession test determines if ForSession returns a list of session ideas (<List<IdeaDTO>>) for a valid session. 这些测试还会检查第一个想法,以确认其 Name 属性正确:The checks also examine the first idea to confirm its Name property is correct:

[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(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);
}

若要测试 Create 方法在 ModelState 无效时的行为,示例应用会在测试中将模型错误添加到控制器。To test the behavior of the Create method when the ModelState is invalid, the sample app adds a model error to the controller as part of the test. 请勿在单元测试中尝试测试模型有效性或模型绑定—仅测试操作方法在遇到无效 ModelState 时的行为:Don't try to test model validation or model binding in unit tests—just test the action method's behavior when confronted with an invalid ModelState:

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

Create 的第二个测试依赖存储库返回 null,所以 mock 存储库配置为返回 nullThe second test of Create depends on the repository returning null, so the mock repository is configured to return null. 无需创建测试数据库(在内存中或其他位置)并构建将返回此结果的查询。There's no need to create a test database (in memory or otherwise) and construct a query that returns this result. 该测试可以在单个语句中完成,如示例代码所示:The test can be accomplished in a single statement, as the sample code illustrates:

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

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

    // Assert
    Assert.IsType<NotFoundObjectResult>(result);
}

第三个 Create 测试 Create_ReturnsNewlyCreatedIdeaForSession 验证调用了存储库的 UpdateAsync 方法。The third Create test, Create_ReturnsNewlyCreatedIdeaForSession, verifies that the repository's UpdateAsync method is called. 使用 Verifiable 调用 mock,然后调用模拟存储库的 Verify 方法,以确认执行了可验证的方法。The mock is called with Verifiable, and the mocked repository's Verify method is called to confirm the verifiable method is executed. 确保 UpdateAsync 方法保存了数据不是单元测试的职责—这可以通过集成测试完成。It's not the unit test's responsibility to ensure that the UpdateAsync method saved the data—that can be performed with an integration test.

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

测试 ActionResult<T>Test ActionResult<T>

在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult 派生的类型或返回特定类型。In ASP.NET Core 2.1 or later, ActionResult<T> (ActionResult<TValue>) enables you to return a type deriving from ActionResult or return a specific type.

示例应用包含将返回给定会话 idList<IdeaDTO> 的方法。The sample app includes a method that returns a List<IdeaDTO> for a given session id. 如果会话 id 不存在,控制器将返回 NotFoundIf the session id doesn't exist, the controller returns NotFound:

[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(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 result;
}

ApiIdeasControllerTests 中包含 ForSessionActionResult 控制器的两个测试。Two tests of the ForSessionActionResult controller are included in the ApiIdeasControllerTests.

第一个测试可确认控制器将返回 ActionResult,而不是不存在会话 id 的不存在想法列表:The first test confirms that the controller returns an ActionResult but not a nonexistent list of ideas for a nonexistent session id:

[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    var nonExistentSessionId = 999;

    // Act
    var result = await controller.ForSessionActionResult(nonExistentSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

对于有效会话 id,第二个测试可确认该方法将返回:For a valid session id, the second test confirms that the method returns:

  • 类型为 List<IdeaDTO>ActionResultAn ActionResult with a List<IdeaDTO> type.
  • ActionResult<T>.ValueList<IdeaDTO> 类型。The ActionResult<T>.Value is a List<IdeaDTO> type.
  • 列表中的第一项是与 mock 会话中存储的想法匹配的有效想法(通过调用 GetTestSession 获取)。The first item in the list is a valid idea matching the idea stored in the mock session (obtained by calling GetTestSession).
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

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

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

示例应用还包含用于为给定会话创建新的 Idea 的方法。The sample app also includes a method to create a new Idea for a given session. 控制器将返回:The controller returns:

[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([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 CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}

ApiIdeasControllerTests 中包含 CreateActionResult 的三个测试。Three tests of CreateActionResult are included in the ApiIdeasControllerTests.

第一个测试可确认将返回 BadRequest(对于无效模型)。The first text confirms that a BadRequest is returned for an invalid model.

[Fact]
public async Task CreateActionResult_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.CreateActionResult(model: null);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}

第二个测试可确认将返回 NotFound(如果会话不存在)。The second test checks that a NotFound is returned if the session doesn't exist.

[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var nonExistentSessionId = 999;
    string testName = "test name";
    string testDescription = "test description";
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = nonExistentSessionId
    };

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

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

对于有效会话 id,最后一个测试可确认:For a valid session id, the final test confirms that:

  • 该方法将返回类型为 BrainstormSessionActionResultThe method returns an ActionResult with a BrainstormSession type.
  • ActionResult<T>.ResultCreatedAtActionResultThe ActionResult<T>.Result is a CreatedAtActionResult. CreatedAtActionResult 类似于包含 Location 标头的 201 Created 响应。CreatedAtActionResult is analogous to a 201 Created response with a Location header.
  • ActionResult<T>.ValueBrainstormSession 类型。The ActionResult<T>.Value is a BrainstormSession type.
  • 调用了用于更新会话 UpdateAsync(testSession) 的 mock 调用。The mock call to update the session, UpdateAsync(testSession), was invoked. 通过执行断言中的 mockRepo.Verify() 来检查 Verifiable 方法调用。The Verifiable method call is checked by executing mockRepo.Verify() in the assertions.
  • 将返回该会话的两个 Idea 对象。Two Idea objects are returned for the session.
  • 最后一项(通过对 UpdateAsync 的 mock 调用而添加的 Idea)与添加到测试中的会话的 newIdea 匹配。The last item (the Idea added by the mock call to UpdateAsync) matches the newIdea added to the session in the test.
[Fact]
public async Task CreateActionResult_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))
        .ReturnsAsync(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.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
    var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnValue.Ideas.Count());
    Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}

控制器在任何 ASP.NET Core MVC 应用中起着核心作用。Controllers play a central role in any ASP.NET Core MVC app. 因此,应该对控制器表现达到预期怀有信心。As such, you should have confidence that controllers behave as intended. 在将应用部署到生产环境之前,自动测试可以检测到错误。Automated tests can detect errors before the app is deployed to a production environment.

查看或下载示例代码如何下载View or download sample code (how to download)

控制器逻辑的单元测试Unit tests of controller logic

单元测试涉及通过基础结构和依赖项单独测试应用的一部分。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 behavior of its dependencies or of the framework itself.

将控制器操作的单元测试设置为专注于控制器的行为。Set up unit tests of controller actions to focus on the controller's behavior. 控制器单元测试将避开筛选器路由模型绑定等方案。A controller unit test avoids scenarios such as filters, routing, and model binding. 涵盖共同响应请求的组件之间的交互的测试由集成测试处理。Tests that cover the interactions among components that collectively respond to a request are handled by integration tests. 有关集成测试的详细信息,请参阅ASP.NET Core 中的集成测试For more information on integration tests, see ASP.NET Core 中的集成测试.

如果编写自定义筛选器和路由,应对其单独进行单元测试,而不是在测试特定控制器操作时进行。If you're writing custom filters and routes, unit test them in isolation, not as part of tests on a particular controller action.

要演示控制器单元测试,请查看以下示例应用中的控制器。To demonstrate controller unit tests, review the following controller in the sample app. 主控制器显示集体讨论会话列表并允许使用 POST 请求创建新的集体讨论会话:The Home controller displays a list of brainstorming sessions and allows the creation of new brainstorming sessions with a POST request:

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);
        }
        else
        {
            await _sessionRepository.AddAsync(new BrainstormSession()
            {
                DateCreated = DateTimeOffset.Now,
                Name = model.SessionName
            });
        }

        return RedirectToAction(actionName: nameof(Index));
    }
}

前面的控制器:The preceding controller:

HTTP GET Index 方法没有循环或分支,且仅调用一个方法。The HTTP GET Index method has no looping or branching and only calls one method. 此操作的单元测试:The unit test for this action:

  • 使用 GetTestSessions 方法模拟 IBrainstormSessionRepository 服务。Mocks the IBrainstormSessionRepository service using the GetTestSessions method. GetTestSessions 使用日期和会话名称创建两个 mock 集体讨论会话。GetTestSessions creates two mock brainstorm sessions with dates and session names.
  • 执行 Index 方法。Executes the Index method.
  • 根据该方法返回的结果进行断言:Makes assertions on the result returned by the method:
[Fact]
public async Task Index_ReturnsAViewResult_WithAListOfBrainstormSessions()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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());
}
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 方法测试验证:The Home controller's HTTP POST Index method tests verifies that:

通过使用 AddModelError 添加错误来测试无效模型状态,如下第一个测试所示:An invalid model state is tested by adding errors using AddModelError as shown in the first test below:

[Fact]
public async Task IndexPost_ReturnsBadRequestResult_WhenModelStateIsInvalid()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.ListAsync())
        .ReturnsAsync(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 请求相同的 ViewResultWhen ModelState isn't valid, the same ViewResult is returned as for a GET request. 测试不会尝试传入无效模型。The test doesn't attempt to pass in an invalid model. 传入无效模型不是有效的方法,因为不会运行模型绑定(尽管集成测试使用模型绑定)。Passing an invalid model isn't a valid approach, since model binding isn't running (although an integration test does use model binding). 在本例中,不测试模型绑定。In this case, model binding isn't tested. 这些单元测试仅测试操作方法中的代码。These unit tests are only testing the code in the action method.

第二个测试验证 ModelState 有效时的情况:The second test verifies that when the ModelState is valid:

  • 已通过存储库添加新的 BrainstormSessionA new BrainstormSession is added (via the repository).
  • 该方法将返回带有所需属性的 RedirectToActionResultThe method returns a RedirectToActionResult with the expected properties.

通常会忽略未调用的模拟调用,但在设置调用末尾调用 Verifiable 就可以在测试中进行 mock 验证。Mocked calls that aren't called are normally ignored, but calling Verifiable at the end of the setup call allows mock validation in the test. 这通过对 mockRepo.Verify 的调用来完成,进行这种调用时,如果未调用所需方法,则测试将失败。This is performed with the call to mockRepo.Verify, which fails the test if the expected method wasn't called.

备注

通过此示例中使用的 Moq 库,可以混合可验证(或称“严格”)mock 和非可验证 mock(也称为“宽松”mock 或存根)。The Moq library used in this sample makes it possible to mix verifiable, or "strict", mocks with non-verifiable mocks (also called "loose" mocks or stubs). 详细了解使用 Moq 自定义 Mock 行为Learn more about customizing Mock behavior with Moq.

示例应用中的 SessionController 显示与特定集体讨论会话相关的信息。SessionController in the sample app displays information related to a particular brainstorming session. 该控制器包含用于处理无效 id 值的逻辑(以下示例中有两个 return 方案可用来应对这些情况)。The controller includes logic to deal with invalid id values (there are two return scenarios in the following example to cover these scenarios). 最后的 return 语句向视图 (Controllers/SessionController.cs) 返回一个新的 StormSessionViewModelThe final return statement returns a new StormSessionViewModel to the view (Controllers/SessionController.cs):

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(actionName: nameof(Index), 
                controllerName: "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);
    }
}

单元测试包括对会话控制器 Index 操作中的每个 return 方案执行一个测试:The unit tests include one test for each return scenario in the Session controller Index action:

[Fact]
public async Task IndexReturnsARedirectToIndexHomeWhenIdIsNull()
{
    // Arrange
    var controller = new SessionController(sessionRepository: null);

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

    // Assert
    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))
        .ReturnsAsync((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))
        .ReturnsAsync(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);
}

移动到想法控制器,应用会将功能公开为 api/ideas 路由上的 Web API:Moving to the Ideas controller, the app exposes functionality as a web API on the api/ideas route:

  • ForSession 方法将返回与集体讨论会话关联的想法列表 (IdeaDTO)。A list of ideas (IdeaDTO) associated with a brainstorming session is returned by the ForSession method.
  • Create 方法会向会话中添加新想法。The Create method adds new ideas to a session.
[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);
}

避免直接通过 API 调用返回企业域实体。Avoid returning business domain entities directly via API calls. 域实体:Domain entities:

  • 包含的数据通常比客户端所需的数据更多。Often include more data than the client requires.
  • 无需将应用的内部域模型与公开的 API 结合。Unnecessarily couple the app's internal domain model with the publicly exposed API.

可以执行域实体与返回到客户端的类型之间的映射:Mapping between domain entities and the types returned to the client can be performed:

接着,示例应用会演示想法控制器的 CreateForSession API 方法的单元测试。Next, the sample app demonstrates unit tests for the Create and ForSession API methods of the Ideas controller.

示例应用包含两个 ForSession 测试。The sample app contains two ForSession tests. 第一个测试可确定 ForSession 是否返回无效会话的 NotFoundObjectResult(找不到 HTTP):The first test determines if ForSession returns a NotFoundObjectResult (HTTP Not Found) for an invalid session:

[Fact]
public async Task ForSession_ReturnsHttpNotFound_ForInvalidSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync((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);
}

第二个 ForSession 测试可确定 ForSession 是否返回有效会话的会话想法列表 (<List<IdeaDTO>>)。The second ForSession test determines if ForSession returns a list of session ideas (<List<IdeaDTO>>) for a valid session. 这些测试还会检查第一个想法,以确认其 Name 属性正确:The checks also examine the first idea to confirm its Name property is correct:

[Fact]
public async Task ForSession_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(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);
}

若要测试 Create 方法在 ModelState 无效时的行为,示例应用会在测试中将模型错误添加到控制器。To test the behavior of the Create method when the ModelState is invalid, the sample app adds a model error to the controller as part of the test. 请勿在单元测试中尝试测试模型有效性或模型绑定—仅测试操作方法在遇到无效 ModelState 时的行为:Don't try to test model validation or model binding in unit tests—just test the action method's behavior when confronted with an invalid ModelState:

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

Create 的第二个测试依赖存储库返回 null,所以 mock 存储库配置为返回 nullThe second test of Create depends on the repository returning null, so the mock repository is configured to return null. 无需创建测试数据库(在内存中或其他位置)并构建将返回此结果的查询。There's no need to create a test database (in memory or otherwise) and construct a query that returns this result. 该测试可以在单个语句中完成,如示例代码所示:The test can be accomplished in a single statement, as the sample code illustrates:

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

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

    // Assert
    Assert.IsType<NotFoundObjectResult>(result);
}

第三个 Create 测试 Create_ReturnsNewlyCreatedIdeaForSession 验证调用了存储库的 UpdateAsync 方法。The third Create test, Create_ReturnsNewlyCreatedIdeaForSession, verifies that the repository's UpdateAsync method is called. 使用 Verifiable 调用 mock,然后调用模拟存储库的 Verify 方法,以确认执行了可验证的方法。The mock is called with Verifiable, and the mocked repository's Verify method is called to confirm the verifiable method is executed. 确保 UpdateAsync 方法保存了数据不是单元测试的职责—这可以通过集成测试完成。It's not the unit test's responsibility to ensure that the UpdateAsync method saved the data—that can be performed with an integration test.

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

测试 ActionResult<T>Test ActionResult<T>

在 ASP.NET Core 2.1 或更高版本中,ActionResult<T> (ActionResult<TValue>) 支持返回从 ActionResult 派生的类型或返回特定类型。In ASP.NET Core 2.1 or later, ActionResult<T> (ActionResult<TValue>) enables you to return a type deriving from ActionResult or return a specific type.

示例应用包含将返回给定会话 idList<IdeaDTO> 的方法。The sample app includes a method that returns a List<IdeaDTO> for a given session id. 如果会话 id 不存在,控制器将返回 NotFoundIf the session id doesn't exist, the controller returns NotFound:

[HttpGet("forsessionactionresult/{sessionId}")]
[ProducesResponseType(200)]
[ProducesResponseType(404)]
public async Task<ActionResult<List<IdeaDTO>>> ForSessionActionResult(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 result;
}

ApiIdeasControllerTests 中包含 ForSessionActionResult 控制器的两个测试。Two tests of the ForSessionActionResult controller are included in the ApiIdeasControllerTests.

第一个测试可确认控制器将返回 ActionResult,而不是不存在会话 id 的不存在想法列表:The first test confirms that the controller returns an ActionResult but not a nonexistent list of ideas for a nonexistent session id:

[Fact]
public async Task ForSessionActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);
    var nonExistentSessionId = 999;

    // Act
    var result = await controller.ForSessionActionResult(nonExistentSessionId);

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

对于有效会话 id,第二个测试可确认该方法将返回:For a valid session id, the second test confirms that the method returns:

  • 类型为 List<IdeaDTO>ActionResultAn ActionResult with a List<IdeaDTO> type.
  • ActionResult<T>.ValueList<IdeaDTO> 类型。The ActionResult<T>.Value is a List<IdeaDTO> type.
  • 列表中的第一项是与 mock 会话中存储的想法匹配的有效想法(通过调用 GetTestSession 获取)。The first item in the list is a valid idea matching the idea stored in the mock session (obtained by calling GetTestSession).
[Fact]
public async Task ForSessionActionResult_ReturnsIdeasForSession()
{
    // Arrange
    int testSessionId = 123;
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    mockRepo.Setup(repo => repo.GetByIdAsync(testSessionId))
        .ReturnsAsync(GetTestSession());
    var controller = new IdeasController(mockRepo.Object);

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

    // Assert
    var actionResult = Assert.IsType<ActionResult<List<IdeaDTO>>>(result);
    var returnValue = Assert.IsType<List<IdeaDTO>>(actionResult.Value);
    var idea = returnValue.FirstOrDefault();
    Assert.Equal("One", idea.Name);
}

示例应用还包含用于为给定会话创建新的 Idea 的方法。The sample app also includes a method to create a new Idea for a given session. 控制器将返回:The controller returns:

[HttpPost("createactionresult")]
[ProducesResponseType(201)]
[ProducesResponseType(400)]
[ProducesResponseType(404)]
public async Task<ActionResult<BrainstormSession>> CreateActionResult([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 CreatedAtAction(nameof(CreateActionResult), new { id = session.Id }, session);
}

ApiIdeasControllerTests 中包含 CreateActionResult 的三个测试。Three tests of CreateActionResult are included in the ApiIdeasControllerTests.

第一个测试可确认将返回 BadRequest(对于无效模型)。The first text confirms that a BadRequest is returned for an invalid model.

[Fact]
public async Task CreateActionResult_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.CreateActionResult(model: null);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<BadRequestObjectResult>(actionResult.Result);
}

第二个测试可确认将返回 NotFound(如果会话不存在)。The second test checks that a NotFound is returned if the session doesn't exist.

[Fact]
public async Task CreateActionResult_ReturnsNotFoundObjectResultForNonexistentSession()
{
    // Arrange
    var nonExistentSessionId = 999;
    string testName = "test name";
    string testDescription = "test description";
    var mockRepo = new Mock<IBrainstormSessionRepository>();
    var controller = new IdeasController(mockRepo.Object);

    var newIdea = new NewIdeaModel()
    {
        Description = testDescription,
        Name = testName,
        SessionId = nonExistentSessionId
    };

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

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    Assert.IsType<NotFoundObjectResult>(actionResult.Result);
}

对于有效会话 id,最后一个测试可确认:For a valid session id, the final test confirms that:

  • 该方法将返回类型为 BrainstormSessionActionResultThe method returns an ActionResult with a BrainstormSession type.
  • ActionResult<T>.ResultCreatedAtActionResultThe ActionResult<T>.Result is a CreatedAtActionResult. CreatedAtActionResult 类似于包含 Location 标头的 201 Created 响应。CreatedAtActionResult is analogous to a 201 Created response with a Location header.
  • ActionResult<T>.ValueBrainstormSession 类型。The ActionResult<T>.Value is a BrainstormSession type.
  • 调用了用于更新会话 UpdateAsync(testSession) 的 mock 调用。The mock call to update the session, UpdateAsync(testSession), was invoked. 通过执行断言中的 mockRepo.Verify() 来检查 Verifiable 方法调用。The Verifiable method call is checked by executing mockRepo.Verify() in the assertions.
  • 将返回该会话的两个 Idea 对象。Two Idea objects are returned for the session.
  • 最后一项(通过对 UpdateAsync 的 mock 调用而添加的 Idea)与添加到测试中的会话的 newIdea 匹配。The last item (the Idea added by the mock call to UpdateAsync) matches the newIdea added to the session in the test.
[Fact]
public async Task CreateActionResult_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))
        .ReturnsAsync(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.CreateActionResult(newIdea);

    // Assert
    var actionResult = Assert.IsType<ActionResult<BrainstormSession>>(result);
    var createdAtActionResult = Assert.IsType<CreatedAtActionResult>(actionResult.Result);
    var returnValue = Assert.IsType<BrainstormSession>(createdAtActionResult.Value);
    mockRepo.Verify();
    Assert.Equal(2, returnValue.Ideas.Count());
    Assert.Equal(testName, returnValue.Ideas.LastOrDefault().Name);
    Assert.Equal(testDescription, returnValue.Ideas.LastOrDefault().Description);
}

其他资源Additional resources