Kiểm thử logic Controller trong ASP.NET Core
Bởi Steve Smith
Unit test (kiểm thử đơn vị) liên quan đến việc kiểm thử một phần của ứng dụng một cách độc lập với cơ sở hạ tầng và các dependency (phụ thuộc) của nó. Khi unit test logic của controller, chỉ có nội dung của một action duy nhất được kiểm thử, không phải hành vi của các dependency hay của bản thân framework.
Unit testing controllers
Thiết lập unit test cho các controller action để tập trung vào hành vi của controller. Một unit test cho controller tránh các kịch bản như filters, routing, và model binding. Các test bao gồm tương tác giữa các component cùng nhau phản hồi một request được xử lý bởi integration tests. Để biết thêm về integration tests, xem Integration tests in ASP.NET Core.
Nếu bạn đang viết custom filters và routes, hãy unit test chúng một cách độc lập, không phải như một phần của test trên một controller action cụ thể.
Để minh họa unit test cho controller, hãy xem controller sau trong ứng dụng mẫu.
Home controller hiển thị danh sách các brainstorming session (phiên động não) và cho phép tạo các phiên mới thông qua 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));
}
}Controller trên:
- Tuân theo Explicit Dependencies Principle (Nguyên tắc Dependency Rõ ràng).
- Kỳ vọng dependency injection (DI) cung cấp một instance của
IBrainstormSessionRepository. - Có thể được kiểm thử với service
IBrainstormSessionRepositoryđược mock bằng framework mock object như Moq. Một mocked object (đối tượng giả lập) là đối tượng được tạo ra với tập hợp các hành vi thuộc tính và phương thức được xác định trước dùng cho testing.
Phương thức HTTP GET Index không có vòng lặp hay phân nhánh và chỉ gọi một phương thức. Unit test cho action này:
- Mock service
IBrainstormSessionRepositorysử dụng phương thứcGetTestSessions.GetTestSessionstạo hai mock brainstorm session với ngày và tên phiên. - Thực thi phương thức
Index. - Kiểm tra (assert) kết quả trả về:
- Một ViewResult được trả về.
- ViewDataDictionary.Model là
StormSessionViewModel. - Có hai brainstorming session được lưu trong
ViewDataDictionary.Model.
[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;
}Các test cho phương thức HTTP POST Index của Home controller xác minh rằng:
- Khi ModelState.IsValid là
false, action method trả về 400 Bad Request ViewResult với dữ liệu phù hợp. - Khi
ModelState.IsValidlàtrue: - Phương thức
Addtrên repository được gọi. - Một RedirectToActionResult được trả về với các đối số đúng.
Trạng thái model không hợp lệ được kiểm thử bằng cách thêm lỗi sử dụng AddModelError như trong test đầu tiên:
[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();
}Khi ModelState không hợp lệ, cùng ViewResult được trả về như đối với GET request. Test không cố gắng truyền một model không hợp lệ. Truyền model không hợp lệ không phải cách tiếp cận hợp lệ vì model binding không chạy (mặc dù integration test sử dụng model binding). Trong trường hợp này, model binding không được kiểm thử. Những unit test này chỉ kiểm thử code trong action method.
Test thứ hai xác minh rằng khi ModelState hợp lệ:
- Một
BrainstormSessionmới được thêm (qua repository). - Phương thức trả về
RedirectToActionResultvới các thuộc tính mong đợi.
Các mock call không được gọi thường bị bỏ qua, nhưng gọi Verifiable ở cuối setup call cho phép xác thực mock trong test. Điều này được thực hiện với lời gọi mockRepo.Verify, sẽ thất bại nếu phương thức mong đợi không được gọi.
Lưu ý: Thư viện Moq được sử dụng trong mẫu này cho phép kết hợp các mock có thể xác minh ("strict" mocks) với các mock không thể xác minh ("loose" mocks hay stubs).
SessionController trong ứng dụng mẫu hiển thị thông tin liên quan đến một brainstorming session cụ thể. Controller bao gồm logic để xử lý các giá trị id không hợp lệ (có hai kịch bản return để bao phủ các trường hợp này). Câu lệnh return cuối cùng trả về một StormSessionViewModel mới cho view:
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);
}
}Các unit test bao gồm một test cho mỗi kịch bản return trong 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);
}Chuyển sang Ideas controller, ứng dụng cung cấp chức năng như một web API trên route api/ideas:
- Một danh sách ideas (
IdeaDTO) liên kết với một brainstorming session được trả về bởi phương thứcForSession. - Phương thức
Createthêm các ideas mới vào một 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);
}Tránh trả về các business domain entity (thực thể miền nghiệp vụ) trực tiếp qua API calls. Domain entities:
- Thường bao gồm nhiều dữ liệu hơn client cần.
- Kết hợp không cần thiết domain model nội bộ của ứng dụng với API được công khai.
Việc ánh xạ giữa domain entities và các kiểu trả về cho client có thể được thực hiện:
- Thủ công với LINQ
Select, như ứng dụng mẫu sử dụng. - Tự động với một thư viện như AutoMapper.
Tiếp theo, ứng dụng mẫu minh họa unit tests cho các phương thức API Create và ForSession của Ideas controller.
Ứng dụng mẫu chứa hai test ForSession. Test đầu tiên xác định nếu ForSession trả về NotFoundObjectResult (HTTP Not Found) cho một session không hợp lệ:
[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);
}Test ForSession thứ hai xác định nếu ForSession trả về danh sách ideas của session (<List<IdeaDTO>>) cho một session hợp lệ. Các kiểm tra cũng xem xét idea đầu tiên để xác nhận thuộc tính Name của nó đúng:
[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);
}Để kiểm thử hành vi của phương thức Create khi ModelState không hợp lệ, ứng dụng mẫu thêm model error vào controller như một phần của test. Không cố gắng kiểm thử model validation hay model binding trong unit tests—chỉ kiểm thử hành vi của action method khi đối mặt với ModelState không hợp lệ:
[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);
}Test thứ hai của Create phụ thuộc vào repository trả về null, nên mock repository được cấu hình để trả về null. Không cần tạo database test (trong bộ nhớ hay cách khác) và xây dựng query để trả về kết quả này. Test có thể được thực hiện trong một câu lệnh duy nhất:
[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);
}Test thứ ba Create, Create_ReturnsNewlyCreatedIdeaForSession, xác minh rằng phương thức UpdateAsync của repository được gọi. Mock được gọi với Verifiable, và phương thức Verify của mock repository được gọi để xác nhận phương thức có thể xác minh được thực thi. Không phải trách nhiệm của unit test để đảm bảo phương thức UpdateAsync đã lưu dữ liệu—điều đó có thể được thực hiện với 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);
}Test ActionResult<T>
ActionResult<T> (ActionResult<TValue>) có thể trả về kiểu dẫn xuất từ ActionResult hoặc trả về kiểu cụ thể.
Ứng dụng mẫu bao gồm một phương thức trả về List<IdeaDTO> cho một session id nhất định. Nếu session id không tồn tại, controller trả về 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;
}Hai test của ForSessionActionResult controller được bao gồm trong ApiIdeasControllerTests.
Test đầu tiên xác nhận controller trả về ActionResult nhưng không phải danh sách ideas không tồn tại cho session id không tồn tại:
- Kiểu
ActionResultlàActionResult<List<IdeaDTO>>. - Result là NotFoundObjectResult.
[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);
}Với session id hợp lệ, test thứ hai xác nhận phương thức trả về:
- Một
ActionResultvới kiểuList<IdeaDTO>. - ActionResult<T>.Value là kiểu
List<IdeaDTO>. - Item đầu tiên trong danh sách là một idea hợp lệ khớp với idea được lưu trong mock session (thu được bằng cách gọi
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);
}Ứng dụng mẫu cũng bao gồm phương thức tạo một Idea mới cho một session nhất định. Controller trả về:
- BadRequest cho model không hợp lệ.
- NotFound nếu session không tồn tại.
- CreatedAtAction khi session được cập nhật với idea mới.
[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);
}Ba test của CreateActionResult được bao gồm trong ApiIdeasControllerTests.
Test đầu tiên xác nhận BadRequest được trả về cho model không hợp lệ.
[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);
}Test thứ hai kiểm tra NotFound được trả về nếu session không tồn tại.
[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);
}Với session id hợp lệ, test cuối cùng xác nhận:
- Phương thức trả về
ActionResultvới kiểuBrainstormSession. - ActionResult<T>.Result là CreatedAtActionResult.
CreatedAtActionResulttương tự như response 201 Created vớiLocationheader. - ActionResult<T>.Value là kiểu
BrainstormSession. - Mock call để cập nhật session,
UpdateAsync(testSession), đã được gọi. Lời gọi phương thứcVerifiableđược kiểm tra bằng cách thực thimockRepo.Verify()trong các assertions. - Hai đối tượng
Ideađược trả về cho session. - Item cuối cùng (
Ideađược thêm bởi mock call tớiUpdateAsync) khớp vớinewIdeađược thêm vào session trong 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);
}