Because DI is central to how ASP.NET Core wires everything together, controllers are naturally testable in isolation by constructing them directly with mocked dependencies, without spinning up the actual web server:
[Fact]
public async Task GetById_ReturnsNotFound_WhenOrderDoesNotExist()
{
var mockService = new Mock<IOrderService>();
mockService.Setup(s => s.GetByIdAsync(1)).ReturnsAsync((Order?)null);
var controller = new OrdersController(mockService.Object);
var result = await controller.GetById(1);
Assert.IsType<NotFoundResult>(result.Result);
}
This is exactly why the "controllers thin, logic in injected services" guideline (mentioned again in the mistakes below) matters beyond code organization — a controller that calls _orderService.GetByIdAsync can be tested by mocking IOrderService, in complete isolation from any real database. A controller with business logic and raw DbContext calls inline can't be unit tested the same way; it needs a much heavier integration test (via WebApplicationFactory, which spins up the whole app in-memory) just to exercise a single branch of logic.
For true end-to-end coverage — verifying routing, model binding, middleware, and the real pipeline together — WebApplicationFactory<TEntryPoint> hosts the actual app in-memory and issues real HTTP requests against it within a test, typically swapping the real database for an in-memory or test one via its service-overriding hooks.