Unit Testing Controllers in ASP.NET Core Web API

Unit Testing Controllers in ASP.NET Core Web API

Unit testing controllers in ASP.NET Core Web API is crucial for building robust, reliable, and maintainable web applications. Controllers serve as the main entry point for handling client requests, processing input, coordinating with the business logic, and generating HTTP responses. By thoroughly testing controllers, developers can ensure that their APIs respond correctly to various scenarios, handle errors effectively, and deliver a consistent experience to clients.

In this post, we will explore the role of controllers, the importance of isolating them with mocking, and provide practical examples of how to effectively unit test controller actions using popular testing frameworks. We will continue to work with the same example that we have used so far. So, read the following two articles before proceeding with this article.

What are Controllers in ASP.NET Core Web API

Controllers are classes in ASP.NET Core Web API that serve as the primary entry point for all HTTP requests coming into your application. Think of them as the reception desk of your API; every client (browser, mobile app, or even another backend service) interacts with your system through Controllers. These classes act as intermediaries between the client and the server-side logic. Their job is to process HTTP requests and return appropriate HTTP responses.

Responsibilities of Controllers

Let us understand the Role and Responsibilities of a Controller in ASP.NET Core Web API. For a better understanding, please look at the following diagram:

What are Controllers in ASP.NET Core Web API

Receiving HTTP Requests

Controllers receive various HTTP method requests like:

  • GET – Retrieve data
  • POST – Create new resources
  • PUT – Update existing resources
  • DELETE – Remove resources

The framework routes incoming requests to the appropriate controller method (called an action method) based on the URL and HTTP method.

Parsing and Validating Input

When the client sends data (e.g., JSON in the body of a POST request or query parameters in a GET request), controllers are responsible for:

  • Binding incoming data to strongly typed parameters or models.
  • Validating that the data is well-formed and meets business or technical rules (e.g., required fields, value ranges).
Calling Business Logic / Service Layer

Controllers typically do not contain business logic themselves. Instead, they delegate the main processing to the service layer (e.g., services, repositories, domain logic). This separation promotes:

  • Clean architecture
  • Easier testing
  • Reusability and maintainability
Formatting and Returning HTTP Responses

After processing, controllers prepare an HTTP response for the client. They set:

  • HTTP status codes (e.g., 200 OK, 201 Created, 400 Bad Request, 404 Not Found, 500 Internal Server Error)
  • Response body content (e.g., data in JSON or XML format)
  • HTTP headers, if necessary
Why Are Controllers Important?

Controllers are important because:

  • They directly affect the client experience, as the client receives responses and status codes from controllers.
  • They manage the entire request flow, from accepting input and validation to service calls and response generation.
  • Any bug or inefficiency in controllers can result in incorrect or slow responses, which can harm your API’s reliability and usability.
Why Unit Test Controllers in ASP.NET Core Web API?

Let us understand why Unit Test a Controller in ASP.NET Core Web API. For a better understanding, please look at the following diagram:

Why Unit Test Controllers in ASP.NET Core Web API?

Given their critical role, unit testing controllers ensures:

  • They correctly parse and validate input.
  • They properly call the service layer.
  • They return the right HTTP status codes and data based on various scenarios.
  • Error and edge cases are handled correctly.

This leads to more reliable APIs and faster bug detection.

Why Mock the Service Layer in Controller Unit Tests?

Mocking means creating fake versions of the real dependencies (such as services or repositories) that a controller uses. This way, when running unit tests, we avoid calling the actual database or executing complex business logic. Instead, we simulate specific behaviours or responses that fit our test needs.

Why Mock the Service Layer in Controller Unit Tests?

Mocking the service layer in controller unit tests offers several benefits:

  • Focus on Controller Logic: Tests only check the controller’s behaviour, without being influenced by how the service or database works. This isolation makes it easier to find issues in the controller itself.
  • Faster Tests: Since no real database or external system is involved, tests run significantly faster, allowing for quicker feedback during development.
  • More Reliable Tests: External dependencies can cause flaky or inconsistent test results. Mocks eliminate this by providing stable, predictable responses.
  • Complete Control Over Scenarios: You can program mocks to return specific data or throw exceptions, allowing you to easily test all possible edge cases and error conditions.
Order API Controller:

The following is our OrdersController that manages HTTP requests related to order operations in our API. It provides two main endpoints:

CreateOrder (POST)

Accepts an OrderCreateDTO object in the request body to create a new order. First, it checks if the incoming data is valid. If the validation fails, it returns a 400 Bad Request with validation errors. If the input is valid, it calls the service layer to create the order. Depending on the outcome, it returns appropriate HTTP responses:

  • 200 OK with the created order data on success
  • 404 Not Found if the related resources needed for creating the order are missing
  • 400 Bad Request for invalid inputs or business rule violations
  • 500 Internal Server Error for any unexpected errors
GetOrder (GET)

Retrieves an order by its unique ID provided in the URL.

  • If the order exists, it returns 200 OK with the order details.
  • If no order is found with that ID, it returns a 404 Not Found error.

The controller uses dependency injection to get an instance of IOrderService, which does the real work (like talking to the database or applying business rules).

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.API.DTOs;
using ProductCatalog.API.Services;
using ProductCatalog.API.Exceptions;

namespace ProductCatalog.API.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class OrdersController : ControllerBase
    {
        private readonly IOrderService _service;

        public OrdersController(IOrderService service)
        {
            _service = service;
        }

        [HttpPost]
        public async Task<IActionResult> CreateOrder([FromBody] OrderCreateDTO orderCreateDto)
        {
            if (!ModelState.IsValid)
                return BadRequest(ModelState);

            try
            {
                var order = await _service.CreateOrderAsync(orderCreateDto);
                return Ok(order);
            }
            catch (NotFoundException ex)
            {
                return NotFound(new { message = ex.Message });
            }
            catch (ArgumentException ex)
            {
                return BadRequest(new { message = ex.Message });
            }
            catch (InvalidOperationException ex)
            {
                return BadRequest(new { message = ex.Message });
            }
            catch (Exception)
            {
                return StatusCode(500, new { message = "An unexpected error occurred." });
            }
        }

        [HttpGet("{id}")]
        public async Task<IActionResult> GetOrder(int id)
        {
            var order = await _service.GetOrderByIdAsync(id);
            if (order == null) 
                return NotFound();

            return Ok(order);
        }
    }
}
Unit Testing Orders API Controller:

The following example demonstrates how to write unit tests for the OrdersController in an ASP.NET Core Web API project. We use the Moq framework to create a mock of the IOrderService dependency. This approach ensures we test the controller’s behaviour independently of the actual service implementation. The tests cover key scenarios such as:

  • Retrieving an existing order (should return 200 OK with the order data).
  • Handling requests for orders that don’t exist (should return 404 Not Found).
  • Creating orders with valid input (should return 200 OK with the created order).
  • Handling invalid order creation requests by returning 400 Bad Request.

These tests confirm the controller behaves correctly in different situations while isolating it from the real service logic.

In your ProductCatalog.Tests project, create a folder named Controllers at the project root. Add a new class file named OrdersControllerTests.cs inside the Controllers folder and copy and paste the following code:

using Moq;
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.API.Services;
using ProductCatalog.API.DTOs;
using ProductCatalog.API.Controllers;

namespace ProductCatalog.Tests.Controllers
{
    public class OrdersControllerTests
    {
        // Mock object for IOrderService interface to fake service behaviour during tests
        private readonly Mock<IOrderService> _mockOrderService;

        // Instance of the controller being tested, injected with the mock service
        private readonly OrdersController _controller;

        // Constructor runs before each test method, initializes mock order service and controller
        public OrdersControllerTests()
        {
            // Create a new Mock<IOrderService> instance
            _mockOrderService = new Mock<IOrderService>();

            // Instantiate OrdersController passing in the mocked service object
            // The .Object property returns the actual IOrderService proxy from the mock
            _controller = new OrdersController(_mockOrderService.Object);
        }

        // Test method: GET /order/{id} with an existing order ID returns 200 OK and the order data
        [Fact]
        public async Task GetOrder_ExistingId_ReturnsOkWithOrder()
        {
            // Arrange: setup test data and mock behavior
            var orderId = 1;  // The order ID to test
            var orderResponse = new OrderResponseDTO { OrderId = orderId, CustomerId = 123 };  // Mock order data to be returned

            // Setup the mock service to return orderResponse when GetOrderByIdAsync is called with orderId
            _mockOrderService.Setup(s => s.GetOrderByIdAsync(orderId))
                             .ReturnsAsync(orderResponse);

            // Act: invoke the GetOrder controller action with the orderId
            var result = await _controller.GetOrder(orderId);

            // Assert: verify the result is OkObjectResult containing the expected orderResponse
            var okResult = Assert.IsType<OkObjectResult>(result);  // Checks that result is 200 OK with an object
            Assert.Equal(orderResponse, okResult.Value);  // Checks that the returned object matches the mocked data
        }

        // Test method: GET /order/{id} with a non-existing order ID returns 404 NotFound
        [Fact]
        public async Task GetOrder_NonExistingId_ReturnsNotFound()
        {
            // Arrange: setup the mock to return null for any integer argument (simulate not found)
            _mockOrderService.Setup(s => s.GetOrderByIdAsync(It.IsAny<int>()))
                             .ReturnsAsync((OrderResponseDTO?)null);

            // Act: call GetOrder with a non-existing ID (999)
            var result = await _controller.GetOrder(999);

            // Assert: verify that the result is NotFoundResult (HTTP 404)
            Assert.IsType<NotFoundResult>(result);
        }

        // Test method: POST /order with valid model returns 201 Created with location header
        [Fact]
        public async Task CreateOrder_ValidModel_ReturnsOkResult()
        {
            // Arrange: create input DTO for creating an order
            var orderDto = new OrderCreateDTO { CustomerId = 1 };

            // Expected response DTO after successful creation
            var createdOrder = new OrderResponseDTO { OrderId = 1, CustomerId = 1 };

            // Setup mock to return createdOrder when CreateOrderAsync is called with orderDto
            _mockOrderService.Setup(s => s.CreateOrderAsync(orderDto))
                             .ReturnsAsync(createdOrder);

            // Act: call CreateOrder action with the valid DTO
            var result = await _controller.CreateOrder(orderDto);

            // Assert: verify the result is OkObjectResult (HTTP 200)
            var okResult = Assert.IsType<OkObjectResult>(result);

            // Verify that the returned object is the expected created order DTO
            Assert.Equal(createdOrder, okResult.Value);
        }

        // Test method: POST /order with invalid model returns 400 BadRequest
        [Fact]
        public async Task CreateOrder_InvalidModel_ReturnsBadRequest()
        {
            // Arrange: manually add a model validation error to simulate invalid input
            _controller.ModelState.AddModelError("CustomerId", "Required");

            // Act: call CreateOrder with an empty DTO (which is invalid due to missing CustomerId)
            var result = await _controller.CreateOrder(new OrderCreateDTO());

            // Assert: verify the response is BadRequestObjectResult (HTTP 400)
            var badRequestResult = Assert.IsType<BadRequestObjectResult>(result);

            // Verify that BadRequest contains some error details (not null)
            Assert.NotNull(badRequestResult.Value);
        }
    }
}
Code Explanation
  • Mock<IOrderService> creates a fake version of the service to simulate its behavior without connecting to a real database.
  • The controller is instantiated with the mocked service, isolating the controller’s logic during tests.
  • Tests follow the Arrange-Act-Assert pattern:
    • Arrange: Set up mock behavior and test data.
    • Act: Call the controller method.
    • Assert: Verify that the returned HTTP response type and data are as expected.
  • The Setup method configures how the mock responds to specific calls.
  • Assert.IsType<T>(…) checks that the controller returns the expected HTTP status code.
  • Model validation errors are manually added in tests to simulate invalid inputs.

Unit testing ASP.NET Core Web API controllers is essential to guarantee that your API behaves as expected, processes inputs correctly, and returns proper HTTP responses in all situations. By mocking the service layer and isolating controllers, tests become fast, stable, and focused solely on controller logic. This approach not only improves code quality but also facilitates the early detection of bugs, enables the maintenance of code over time, and allows for the confident delivery of robust APIs that provide a seamless client experience.

In the next article, I will discuss Unit Testing the Repositories of our ASP.NET Core Web API Project. In this article, I explain Unit Testing the Controllers of our ASP.NET Core Web API. I hope you enjoy this article on Unit Testing Controllers in ASP.NET Core Web API.

1 thought on “Unit Testing Controllers in ASP.NET Core Web API”

  1. blank

    📺 Watch the Complete Video Tutorial
    To get a hands-on understanding of how to perform Unit Testing for Controllers in ASP.NET Core Web API using xUnit and Moq, we recommend watching the following step-by-step video tutorial by Pranaya Rout on our official YouTube channel:
    👉 Watch it here: https://www.youtube.com/watch?v=brSFKUuMOFk

    This video covers real-time examples, best practices, and clearly explains how to mock service layers to isolate controller logic effectively.

    Don’t forget to Like, Share, and Subscribe to stay updated with the latest .NET tutorials!

Leave a Reply

Your email address will not be published. Required fields are marked *