Why Unit Test Controllers and Services
Unit testing a controller means testing its action methods in isolation from the ASP.NET Core hosting pipeline, routing, filters, and model binding. You instantiate the controller directly in a test project, inject mocked versions of its dependencies (such as IProductService), and call an action method like a plain C# method. This is fast — no Kestrel server, no HTTP round-trip — and lets you pinpoint exactly which line of business logic broke when a test fails, rather than debugging through middleware.
Cricket analogy: Testing a controller in isolation is like a batsman practicing straight drives in the nets against a bowling machine rather than in a full T20 match — you isolate one skill (like Virat Kohli drilling cover drives) without worrying about fielders, crowd noise, or match pressure.
Mocking Dependencies with Moq
Moq lets you create a mock of an interface like IOrderService and configure its behavior with mock.Setup(s => s.GetOrderAsync(id)).ReturnsAsync(order) so that when the controller under test calls that method, it receives a predictable fake result instead of hitting a real database. You then inject mock.Object into the controller's constructor. After the action runs, mock.Verify(s => s.GetOrderAsync(id), Times.Once) confirms the controller actually called the dependency the expected number of times, which catches bugs where logic is accidentally skipped or duplicated.
Cricket analogy: Setting up a mock's Returns value is like a coach at nets programming a bowling machine to always deliver a yorker at leg stump, so the batsman (the controller) faces a predictable, repeatable scenario every time.
public class ProductsControllerTests
{
[Fact]
public async Task GetById_ReturnsOkWithProduct_WhenProductExists()
{
// Arrange
var mockService = new Mock<IProductService>();
var product = new ProductDto { Id = 1, Name = "Widget" };
mockService.Setup(s => s.GetByIdAsync(1)).ReturnsAsync(product);
var controller = new ProductsController(mockService.Object);
// Act
var result = await controller.GetById(1);
// Assert
var okResult = Assert.IsType<OkObjectResult>(result.Result);
var returned = Assert.IsType<ProductDto>(okResult.Value);
Assert.Equal("Widget", returned.Name);
mockService.Verify(s => s.GetByIdAsync(1), Times.Once);
}
}Asserting Action Results and Status Codes
Controller action methods often return IActionResult or ActionResult<T>, and a good unit test asserts both the concrete result type and its payload — for example Assert.IsType<OkObjectResult>(result) followed by casting Value to the expected DTO and checking its properties. When testing validation failures, you can manually add an error to ModelState before calling the action and assert that a BadRequestObjectResult is returned, verifying the controller respects [ApiController]'s automatic model validation behavior or your own explicit check.
Cricket analogy: Asserting the correct ActionResult type is like a third umpire in the DRS system checking not just whether the ball hit the stumps but confirming exactly how — bowled, LBW, or run out — before confirming the decision.
The [ApiController] attribute automatically returns a 400 Bad Request with validation error details when ModelState is invalid, before your action method body even runs — so you don't need to manually check ModelState.IsValid in most cases, but your unit tests should still verify this behavior.
Testing Services and Business Logic
Service classes that contain business logic — calculating a discount, validating a shipping address, orchestrating a multi-step order process — should be tested directly, independent of any controller. Following the Arrange-Act-Assert pattern, you construct the service with mocked repositories or HTTP clients, call the method under test, and assert both the return value and any side effects, such as verifying a repository's SaveAsync was called exactly once. Testing services this way keeps controllers thin and pushes the bulk of your test coverage onto the layer where the actual logic lives.
Cricket analogy: Testing the service layer directly is like evaluating a bowler's raw pace and swing in isolated fitness testing at the National Cricket Academy, separate from how a captain might deploy them tactically in a match.
Avoid mocking types you don't own deeply, like EF Core's DbContext — mocking DbSet<T> and LINQ queries is brittle and often doesn't reflect real query behavior. Prefer wrapping data access behind a repository interface, or use an in-memory/SQLite provider in integration tests instead.
- Mock dependencies with Moq to isolate controller logic from the HTTP pipeline
- Use Assert.IsType<T> to verify both the ActionResult type and its payload
- Verify mock calls with Times.Once to catch skipped or duplicated logic
- Test ModelState validation failures by manually adding errors before invoking the action
- Test service classes directly and independently from controllers
- Follow Arrange-Act-Assert for consistent, readable tests
- Avoid deeply mocking EF Core's DbContext; use a repository abstraction instead
Practice what you learned
1. In a unit test using Moq, which method configures a mocked dependency's return value?
2. Which assertion correctly checks that a controller action returned a 200 OK with a specific payload type?
3. Why should business logic live in a service class rather than directly in a controller action?
4. What does mockService.Verify(s => s.GetByIdAsync(1), Times.Once) confirm?
5. What does the [ApiController] attribute do automatically when ModelState is invalid?
Was this page helpful?
You May Also Like
Integration Testing with WebApplicationFactory
Use Microsoft.AspNetCore.Mvc.Testing's WebApplicationFactory to spin up an in-memory ASP.NET Core app and test real HTTP requests through the full middleware pipeline.
Logging and Observability with Serilog
Configure structured logging with Serilog and pair it with metrics and distributed tracing to build real observability into an ASP.NET Core API.
ASP.NET Core Interview Questions
A focused review of the ASP.NET Core fundamentals, common pitfalls, and system-design questions that come up most often in technical interviews.
Related Reading
Related Study Notes in Microsoft Technologies
Browse all study notesWindows 10 / UWP Development Study Notes
.NET · 30 topics
Microsoft TechnologiesWindows Batch Scripting Study Notes
Batch · 30 topics
Microsoft TechnologiesMFC (Microsoft Foundation Classes) Study Notes
C++ · 30 topics
Microsoft TechnologiesSilverlight Study Notes
.NET · 30 topics
Microsoft TechnologiesXAML Study Notes
.NET · 30 topics
Microsoft TechnologiesWPF (Windows Presentation Foundation) Study Notes
.NET · 30 topics