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 trong sự cô lập với cơ sở hạ tầng và các phụ thuộc của nó. Khi unit test logic controller, chỉ 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 phụ thuộc hay của bản thân framework.
Unit test controller (Kiểm thử đơn vị controller)
Thiết lập unit test của các controller action để tập trung vào hành vi của controller. Unit test controller tránh các tình huống như filter (bộ lọc), routing (định tuyến) và model binding (ràng buộc model). Các test bao gồm tương tác giữa các thành phần cùng nhau phản hồi một request được xử lý bởi integration test. Để biết thêm thông tin về integration test, xem Integration tests trong ASP.NET Core.
Nếu bạn đang viết filter và route tùy chỉnh, hãy unit test chúng riêng lẻ, không phải như một phần của các test trên một controller action cụ thể.
Để minh họa unit test controller, hãy xem xét controller sau trong ứng dụng mẫu.
Controller Home hiển thị danh sách các phiên brainstorming (tư duy tập thể) và cho phép tạo phiên brainstorming mới với 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 Phụ thuộc Tường minh).
- Mong đợi dependency injection (DI) (tiêm phụ thuộc) cung cấp instance của
IBrainstormSessionRepository. - Có thể được kiểm thử với service
IBrainstormSessionRepositoryđược mock (giả lập) bằng framework mock object, chẳng hạn như Moq. Mock object là đối tượng giả tạo với tập hợp các hành vi property và method được xác định trước để kiểm thử.
Phương thức HTTP GET Index không có vòng lặp hay nhánh và chỉ gọi một phương thức. Unit test cho action này:
- Mock service
IBrainstormSessionRepositorybằng phương thứcGetTestSessions.GetTestSessionstạo hai phiên brainstorming mock với ngày và tên phiên. - Thực thi phương thức
Index. - Đưa ra các assertion (xác nhận) về kết quả trả về:
- Một ViewResult được trả về.
- ViewDataDictionary.Model là
StormSessionViewModel. - Có hai phiên brainstorming đượ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 phương thức HTTP POST Index của controller Home 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ố chính xác.
Trạng thái model không hợp lệ được kiểm thử bằng cách thêm lỗi với 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();
}Khi ModelState không hợp lệ, cùng ViewResult được trả về như với GET request. Test không cố gắng truyền model không hợp lệ. Truyền model không hợp lệ không phải là cách tiếp cận hợp lệ, vì model binding không chạy (mặc dù integration test có sử dụng model binding). Trong trường hợp này, model binding không được kiểm thử. Các unit test này chỉ kiểm thử mã trong action method.
Test thứ hai xác minh rằng khi ModelState hợp lệ:
- Một
BrainstormSessionmới được thêm (thông 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 lệnh setup cho phép xác thực mock trong test. Điều này được thực hiện với lệnh gọi mockRepo.Verify, lệnh này fail test 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 pha trộn mock có thể xác minh ("strict") với mock không thể xác minh ("loose" hoặc stub).
SessionController trong ứng dụng mẫu hiển thị thông tin liên quan đến một phiên brainstorming cụ thể. Controller bao gồm logic để xử lý các giá trị id không hợp lệ (có hai tình huống return trong ví dụ sau):
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);
}
}Unit test bao gồm một test cho mỗi tình huống return trong action Index của Session controller:
[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 hiển thị chức năng dưới dạng web API trên route api/ideas:
- Danh sách các ý tưởng (
IdeaDTO) liên kết với phiên brainstorming được trả về bởi phương thứcForSession. - Phương thức
Createthêm ý tưởng mới vào phiên.
[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 domain entity (thực thể miền) trực tiếp qua API call. Domain entity:
- Thường bao gồm nhiều dữ liệu hơn client yêu cầu.
- Gắn kết không cần thiết domain model nội bộ của ứng dụng với API công khai.
Việc ánh xạ giữa domain entity và các kiểu trả về cho client có thể được thực hiện:
- Thủ công với LINQ
Select. - Tự động với thư viện như AutoMapper.
Ứng dụng mẫu chứa hai test ForSession. Test đầu tiên xác định liệu ForSession trả về NotFoundObjectResult (HTTP Not Found) cho phiên 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 liệu ForSession trả về danh sách ý tưởng phiên (<List<IdeaDTO>>) cho phiên hợp lệ. Các kiểm tra cũng kiểm tra ý tưởng đầ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 lỗi model vào controller như một phần của test. Không cố gắng kiểm thử xác thực model hoặc model binding trong unit test — chỉ kiểm thử hành vi của action method khi gặp 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, vì vậy mock repository được cấu hình để trả về null. Không cần tạo cơ sở dữ liệu test (trong bộ nhớ hoặc cách khác) và xây dựng truy vấn trả về kết quả này:
[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 Create thứ ba, 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);
}Kiểm thử 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 id phiên nhất định. Nếu id phiên 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 controller ForSessionActionResult được bao gồm trong ApiIdeasControllerTests.
Test đầu tiên xác nhận rằng controller trả về ActionResult nhưng không trả về danh sách ý tưởng không tồn tại cho id phiên 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);
}Đối với id phiên hợp lệ, test thứ hai xác nhận rằng phương thức trả về:
ActionResultvới kiểuList<IdeaDTO>.- ActionResult<T>.Value là kiểu
List<IdeaDTO>. - Mục đầu tiên trong danh sách là ý tưởng hợp lệ khớp với ý tưởng được lưu trong mock phiên.
[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 Idea mới cho phiên nhất định. Controller trả về:
- BadRequest cho model không hợp lệ.
- NotFound nếu phiên không tồn tại.
- CreatedAtAction khi phiên được cập nhật với ý tưởng 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 rằng 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 rằng NotFound được trả về nếu phiên 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);
}Đối với id phiên hợp lệ, test cuối cùng xác nhận rằng:
- Phương thức trả về
ActionResultvới kiểuBrainstormSession. - ActionResult<T>.Result là CreatedAtActionResult.
CreatedAtActionResulttương tự như response 201 Created với headerLocation. - ActionResult<T>.Value là kiểu
BrainstormSession. - Mock call để cập nhật phiên,
UpdateAsync(testSession), đã được gọi. Lệnh gọi phương thứcVerifiableđược kiểm tra bằng cách thực thimockRepo.Verify()trong các assertion. - Hai đối tượng
Ideađược trả về cho phiên. - Mục cuối cùng (ý tưởng
Ideađược thêm bởi mock call đếnUpdateAsync) khớp vớinewIdeađược thêm vào phiên 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);
}