ASP.NET Core 中的單元測試控制器邏輯

作者:Steve Smith

單元測試包括將應用程式的一部分與其基礎結構和相依性隔離測試。 對控制器邏輯進行單元測試時,只會測試單一動作的內容,而不會測試其相依性或架構本身的行為。

單元測試控制器

設定單元測試的控制器動作,以專注於控制器的行為。 控制器單元測試會避開篩選路由模型繫結等情況。 涵蓋共同回應要求之元件之間互動的測試,都由整合測試處理。 如需整合測試的詳細資訊,請參閱 ASP.NET Core 中的整合測試

如果您想要撰寫自訂篩選和路由,請單獨對其進行單元測試,而不是當作特定控制器動作測試的一部分。

若要進行控制器單元測試,請檢閱下列範例應用程式中的控制器。

檢視或下載範例程式碼 \(英文\) (如何下載)

Home 控制器會顯示一份腦力激盪工作階段清單,並允許使用 POST 要求來建立新的腦力激盪工作階段:

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

上述控制器會:

  • 遵循明確相依性準則
  • 預期相依性插入 (DI) 以提供 IBrainstormSessionRepository 的執行個體。
  • 可以使用模擬物件架構 (例如 Moq) 透過模擬 IBrainstormSessionRepository 服務進行測試。 「模擬物件」是製作出來的物件,具有一組用於測試的預定屬性和方法行為。 如需詳細資訊,請參閱整合測試簡介

HTTP GET Index 方法有沒有迴圈或分支,而且只會呼叫一個方法。 此動作的單元測試:

  • 使用 IBrainstormSessionRepository 方法來模擬 GetTestSessions 服務。 GetTestSessions 會建立兩個具有日期和工作階段名稱的模擬腦力激盪工作階段。
  • 執行 Index 方法。
  • 對方法傳回的結果進行判斷提示:
[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;
}

Home 控制器的 HTTP POST Index 方法測試會驗證:

您可以使用 AddModelError 新增錯誤,來測試無效的模型狀態,如下列第一項測試中所示:

[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 要求相同的 ViewResult。 此測試不會嘗試傳入無效的模型。 傳遞無效模型不是有效的方法,因為模型繫結並未執行 (儘管整合測試確實使用模型繫結)。 在此情況下,不會測試模型繫結。 這些單元測試只會測試動作方法中的程式碼。

第二項測試會驗證 ModelState 何時有效:

  • 新增 BrainstormSession (透過存放庫)。
  • 此方法會以預期屬性傳回 RedirectToActionResult

通常會忽略未呼叫的模擬呼叫,但在安裝程式呼叫結束時呼叫 Verifiable 即可在測試中對其進行模擬驗證。 可透過呼叫 mockRepo.Verify 來執行,如果未呼叫預期的方法,則測試會失敗。

注意

此範例中所使用的 Moq 程式庫,可讓您混合可驗證 (或「嚴格」) 的模擬,以及無法驗證的模擬 (也稱為「鬆散」的模擬或虛設常式)。 如需詳細資訊,請參閱 Customizing Mock Behavior with Moq (使用 Moq 自訂模擬行為)。

範例應用程式中的 SessionController會顯示與特定腦力激盪工作階段相關的資訊。 控制器包含處理無效 id 值的邏輯 (下列範例中有兩個 return 案例來說明這些案例)。 最終 return 陳述式會將新的 StormSessionViewModel 傳回至檢視 (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 案例的一項測試:

[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:

  • ForSession 方法會傳回與腦力激盪工作階段建立關聯的構想清單 (IdeaDTO)。
  • Create 方法會將新構想新增至工作階段。
[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 呼叫來傳回商務網域實體。 領域實體:

  • 通常會包含超過用戶端所需的資料。
  • 會不必要地將應用程式內部網域模型與公用公開的 API 結合在一起。

可以執行網域實體與傳回給用戶端之類型間的對應:

接下來,範例應用程式會示範構想控制器 CreateForSession API 方法的單元測試。

範例應用程式包含兩項 ForSession 測試。 第一項測試會判斷 ForSession 是否會為無效的工作階段傳回 NotFoundObjectResult (找不到 HTTP):

[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>>)。 檢查也會查驗第一個構想,以確認其 Name 屬性是否正確:

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

為了測試 ModelState 無效時的 Create 方法行為,範例應用程式會將模型錯誤新增至控制器作為測試的一部分。 請勿嘗試在單元測試中測試模型驗證或模型繫結 - 只要測試當遇到無效 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,因此會設定模擬儲存機制傳回 null。 不需要建立測試資料庫 (在記憶體中或其他位置),也不需要建構傳回此結果的查詢。 測試可在單一陳述式中完成,如範例程式碼所示:

[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 方法。 會使用 Verifiable 呼叫模擬,再呼叫模擬存放庫的 Verify 方法,確認是否已執行可驗證的方法。 單元測試無須負責確保 UpdateAsync 方法已儲存資料 - 而是透過整合測試來完成。

[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>

ActionResult<T> (ActionResult<TValue>) 可傳回衍生自 ActionResult 的類型或傳回特定類型。

範例應用程式包含為指定工作階段 id 傳回 List<IdeaDTO> 的方法。 如果工作階段 id 不存在,則控制器會傳回 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;
}

ForSessionActionResult 控制器的兩項測試會包含於 ApiIdeasControllerTests

第一項測試會確認控制器傳回 ActionResult,而不是為不存在之工作階段 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,第二項測試會確認此方法傳回:

  • 具有 ActionResult 類型的 List<IdeaDTO>
  • ActionResult<T>.ValueList<IdeaDTO> 類型。
  • 清單中的第一個項目,是與儲存在模擬工作階段中構想相符的有效構想 (透過呼叫 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。 控制器會傳回:

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

CreateActionResult 的三項測試都包含在 ApiIdeasControllerTests 中。

第一項測試會確認為無效的模型傳回 BadRequest

[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

[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,最終測試會確認:

  • 此方法會以 BrainstormSession 類型傳回 ActionResult
  • ActionResult<T>.ResultCreatedAtActionResult. CreatedAtActionResult 類似於具有 Location 標頭尸的「201 已建立」回應。
  • ActionResult<T>.ValueBrainstormSession 類型。
  • 已叫用更新工作階段 (UpdateAsync(testSession)) 的模擬呼叫。 Verifiable 方法呼叫會透過在判斷提示中執行 mockRepo.Verify() 來檢查。
  • 會為工作階段傳回兩個 Idea 物件。
  • 最後一個項目 (模擬呼叫 UpdateAsync 新增的 Idea) 會與在測試中新增至工作階段的 newIdea 相符。
[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 應用程式中都扮演重要角色。 因此,您應該確信控制器的行為符合預期。 在將應用程式部署至生產環境前,自動化測試可以偵測錯誤。

檢視或下載範例程式碼 \(英文\) (如何下載)

控制器邏輯的單元測試

單元測試包括將應用程式的一部分與其基礎結構和相依性隔離測試。 對控制器邏輯進行單元測試時,只會測試單一動作的內容,而不會測試其相依性或架構本身的行為。

設定單元測試的控制器動作,以專注於控制器的行為。 控制器單元測試會避開篩選路由模型繫結等情況。 涵蓋共同回應要求之元件之間互動的測試,都由整合測試處理。 如需整合測試的詳細資訊,請參閱 ASP.NET Core 中的整合測試

如果您想要撰寫自訂篩選和路由,請單獨對其進行單元測試,而不是當作特定控制器動作測試的一部分。

若要進行控制器單元測試,請檢閱下列範例應用程式中的控制器。 Home 控制器會顯示一份腦力激盪工作階段清單,並允許使用 POST 要求來建立新的腦力激盪工作階段:

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

上述控制器會:

  • 遵循明確相依性準則
  • 預期相依性插入 (DI) 以提供 IBrainstormSessionRepository 的執行個體。
  • 可以使用模擬物件架構 (例如 Moq) 透過模擬 IBrainstormSessionRepository 服務進行測試。 「模擬物件」是製作出來的物件,具有一組用於測試的預定屬性和方法行為。 如需詳細資訊,請參閱整合測試簡介

HTTP GET Index 方法有沒有迴圈或分支,而且只會呼叫一個方法。 此動作的單元測試:

  • 使用 IBrainstormSessionRepository 方法來模擬 GetTestSessions 服務。 GetTestSessions 會建立兩個具有日期和工作階段名稱的模擬腦力激盪工作階段。
  • 執行 Index 方法。
  • 對方法傳回的結果進行判斷提示:
[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;
}

Home 控制器的 HTTP POST Index 方法測試會驗證:

您可以使用 AddModelError 新增錯誤,來測試無效的模型狀態,如下列第一項測試中所示:

[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 要求相同的 ViewResult。 此測試不會嘗試傳入無效的模型。 傳遞無效模型不是有效的方法,因為模型繫結並未執行 (儘管整合測試確實使用模型繫結)。 在此情況下,不會測試模型繫結。 這些單元測試只會測試動作方法中的程式碼。

第二項測試會驗證 ModelState 何時有效:

  • 新增 BrainstormSession (透過存放庫)。
  • 此方法會以預期屬性傳回 RedirectToActionResult

通常會忽略未呼叫的模擬呼叫,但在安裝程式呼叫結束時呼叫 Verifiable 即可在測試中對其進行模擬驗證。 可透過呼叫 mockRepo.Verify 來執行,如果未呼叫預期的方法,則測試會失敗。

注意

此範例中所使用的 Moq 程式庫,可讓您混合可驗證 (或「嚴格」) 的模擬,以及無法驗證的模擬 (也稱為「鬆散」的模擬或虛設常式)。 如需詳細資訊,請參閱 Customizing Mock Behavior with Moq (使用 Moq 自訂模擬行為)。

範例應用程式中的 SessionController會顯示與特定腦力激盪工作階段相關的資訊。 控制器包含處理無效 id 值的邏輯 (下列範例中有兩個 return 案例來說明這些案例)。 最終 return 陳述式會將新的 StormSessionViewModel 傳回至檢視 (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 案例的一項測試:

[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:

  • ForSession 方法會傳回與腦力激盪工作階段建立關聯的構想清單 (IdeaDTO)。
  • Create 方法會將新構想新增至工作階段。
[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 呼叫來傳回商務網域實體。 領域實體:

  • 通常會包含超過用戶端所需的資料。
  • 會不必要地將應用程式內部網域模型與公用公開的 API 結合在一起。

可以執行網域實體與傳回給用戶端之類型間的對應:

接下來,範例應用程式會示範構想控制器 CreateForSession API 方法的單元測試。

範例應用程式包含兩項 ForSession 測試。 第一項測試會判斷 ForSession 是否會為無效的工作階段傳回 NotFoundObjectResult (找不到 HTTP):

[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>>)。 檢查也會查驗第一個構想,以確認其 Name 屬性是否正確:

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

為了測試 ModelState 無效時的 Create 方法行為,範例應用程式會將模型錯誤新增至控制器作為測試的一部分。 請勿嘗試在單元測試中測試模型驗證或模型繫結 - 只要測試當遇到無效 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,因此會設定模擬儲存機制傳回 null。 不需要建立測試資料庫 (在記憶體中或其他位置),也不需要建構傳回此結果的查詢。 測試可在單一陳述式中完成,如範例程式碼所示:

[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 方法。 會使用 Verifiable 呼叫模擬,再呼叫模擬存放庫的 Verify 方法,確認是否已執行可驗證的方法。 單元測試無須負責確保 UpdateAsync 方法已儲存資料 - 而是透過整合測試來完成。

[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>

在 ASP.NET Core 2.1 或更新版本中,ActionResult<T> (ActionResult<TValue>) 可讓您傳回衍生自 ActionResult 的類型或傳回特定類型。

範例應用程式包含為指定工作階段 id 傳回 List<IdeaDTO> 的方法。 如果工作階段 id 不存在,則控制器會傳回 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;
}

ForSessionActionResult 控制器的兩項測試會包含於 ApiIdeasControllerTests

第一項測試會確認控制器傳回 ActionResult,而不是為不存在之工作階段 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,第二項測試會確認此方法傳回:

  • 具有 ActionResult 類型的 List<IdeaDTO>
  • ActionResult<T>.ValueList<IdeaDTO> 類型。
  • 清單中的第一個項目,是與儲存在模擬工作階段中構想相符的有效構想 (透過呼叫 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。 控制器會傳回:

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

CreateActionResult 的三項測試都包含在 ApiIdeasControllerTests 中。

第一項測試會確認為無效的模型傳回 BadRequest

[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

[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,最終測試會確認:

  • 此方法會以 BrainstormSession 類型傳回 ActionResult
  • ActionResult<T>.ResultCreatedAtActionResult. CreatedAtActionResult 類似於具有 Location 標頭尸的「201 已建立」回應。
  • ActionResult<T>.ValueBrainstormSession 類型。
  • 已叫用更新工作階段 (UpdateAsync(testSession)) 的模擬呼叫。 Verifiable 方法呼叫會透過在判斷提示中執行 mockRepo.Verify() 來檢查。
  • 會為工作階段傳回兩個 Idea 物件。
  • 最後一個項目 (模擬呼叫 UpdateAsync 新增的 Idea) 會與在測試中新增至工作階段的 newIdea 相符。
[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);
}

其他資源