DEV Community

Daniel Jonathan
Daniel Jonathan

Posted on

Unit Testing ASP.NET Core Web API with Moq and xUnit (Controllers + Services)

What Is Moq?

Moq allows you to replace real dependencies with lightweight test doubles so you can test logic in isolation.

Core methods:

  • Setup() → define mocked behavior
  • ReturnsAsync() → return values for async methods
  • ThrowsAsync() → simulate failures
  • Verify() → assert that a dependency was called

Everything in this guide is based on the pattern: Mock → Execute → Validate.


Why Test Web API Controllers?

Controllers handle HTTP requests and return responses. Testing ensures:

  • ✅ Correct HTTP status codes (200, 404, 400, 201)
  • ✅ Services are called with right parameters
  • ✅ Validation works properly
  • ✅ Errors are handled gracefully

Rule: Controllers should be THIN – they orchestrate, not implement business logic.


Setup (2 minutes)

dotnet new xunit -n YourApi.Tests
cd YourApi.Tests
dotnet add package Moq --version 4.20.72
dotnet add package FluentAssertions --version 6.12.2
dotnet add reference ../YourApi/YourApi.csproj
Enter fullscreen mode Exit fullscreen mode

The Pattern: Mock → Execute → Validate

Example Controller (ProductsController)

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(IProductService productService, ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ProductResponse>> GetById(int id)
    {
        var product = await _productService.GetProductAsync(id);

        if (product == null)
            return NotFound(new { message = $"Product with ID {id} not found" });

        var response = new ProductResponse
        {
            Id = product.Id,
            Name = product.Name,
            SKU = product.SKU,
            Description = product.Description,
            Price = product.Price,
            IsActive = product.IsActive,
            Category = product.Category != null
                ? new CategoryResponse { Id = product.Category.Id, Name = product.Category.Name }
                : new CategoryResponse()
        };

        return Ok(response);
    }

    [HttpPost]
    public async Task<ActionResult<ProductResponse>> Create([FromBody] CreateProductRequest request)
    {
        try
        {
            var product = await _productService.CreateProductAsync(request);

            var response = new ProductResponse
            {
                Id = product.Id,
                Name = product.Name,
                SKU = product.SKU,
                Price = product.Price,
                IsActive = product.IsActive
            };

            return CreatedAtAction(nameof(GetById), new { id = product.Id }, response);
        }
        catch (InvalidOperationException ex)
        {
            return Conflict(new { message = ex.Message });
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Testing Controllers

Controllers are tested by mocking the service, calling the action, and asserting on the ActionResult<T> (status code + response body).

Test Class Setup

using CommonComps.Models;
using CommonComps.Models.Requests;
using CommonComps.Models.Responses;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Moq;
using ProductWebAPI.Controllers;
using ProductWebAPI.Services;
using Xunit;

public class ProductsControllerTests
{
    private readonly Mock<IProductService> _mockService;
    private readonly Mock<ILogger<ProductsController>> _mockLogger;
    private readonly ProductsController _controller;

    public ProductsControllerTests()
    {
        _mockService = new Mock<IProductService>();
        _mockLogger = new Mock<ILogger<ProductsController>>();
        _controller = new ProductsController(_mockService.Object, _mockLogger.Object);
    }
}
Enter fullscreen mode Exit fullscreen mode

GET Endpoint Tests

[Fact]
public async Task GetById_ReturnsOk_WhenProductExists()
{
    // Arrange
    var product = new Product
    {
        Id = 1,
        Name = "Laptop",
        SKU = "LAP-001",
        Price = 999.99m,
        IsActive = true,
        Category = new Category { Id = 1, Name = "Electronics" }
    };

    _mockService.Setup(s => s.GetProductAsync(1))
        .ReturnsAsync(product);

    // Act
    var result = await _controller.GetById(1);

    // Assert
    var okResult = result.Result.Should().BeOfType<OkObjectResult>().Subject;
    var response = okResult.Value.Should().BeOfType<ProductResponse>().Subject;
    response.Id.Should().Be(1);
    response.Name.Should().Be("Laptop");
    _mockService.Verify(s => s.GetProductAsync(1), Times.Once);
}

[Fact]
public async Task GetById_ReturnsNotFound_WhenProductDoesNotExist()
{
    // Arrange
    _mockService.Setup(s => s.GetProductAsync(999))
        .ReturnsAsync((Product?)null);

    // Act
    var result = await _controller.GetById(999);

    // Assert
    result.Result.Should().BeOfType<NotFoundObjectResult>();
}
Enter fullscreen mode Exit fullscreen mode

POST Endpoint Tests

[Fact]
public async Task Create_ReturnsCreated_WhenSuccessful()
{
    // Arrange
    var request = new CreateProductRequest
    {
        Name = "New Product",
        SKU = "NEW-001",
        Price = 49.99m,
        CategoryId = 1
    };

    var createdProduct = new Product
    {
        Id = 42,
        Name = "New Product",
        SKU = "NEW-001",
        Price = 49.99m,
        IsActive = true
    };

    _mockService.Setup(s => s.CreateProductAsync(It.IsAny<CreateProductRequest>()))
        .ReturnsAsync(createdProduct);

    // Act
    var result = await _controller.Create(request);

    // Assert
    var createdResult = result.Result.Should().BeOfType<CreatedAtActionResult>().Subject;
    createdResult.StatusCode.Should().Be(201);
    createdResult.ActionName.Should().Be(nameof(ProductsController.GetById));

    var response = createdResult.Value.Should().BeOfType<ProductResponse>().Subject;
    response.Id.Should().Be(42);
    _mockService.Verify(s => s.CreateProductAsync(It.IsAny<CreateProductRequest>()), Times.Once);
}

[Fact]
public async Task Create_ReturnsConflict_WhenDuplicateSKU()
{
    // Arrange
    var request = new CreateProductRequest { Name = "Test", SKU = "DUP-001" };

    _mockService.Setup(s => s.CreateProductAsync(It.IsAny<CreateProductRequest>()))
        .ThrowsAsync(new InvalidOperationException("Product with SKU DUP-001 already exists"));

    // Act
    var result = await _controller.Create(request);

    // Assert
    result.Result.Should().BeOfType<ConflictObjectResult>();
}
Enter fullscreen mode Exit fullscreen mode


Key Moq Techniques

Setup Return Values

// Return specific value
_mockService.Setup(s => s.GetProductAsync(1)).ReturnsAsync(product);

// Return null
_mockService.Setup(s => s.GetProductAsync(999)).ReturnsAsync((Product?)null);

// Return for any argument
_mockService.Setup(s => s.CreateProductAsync(It.IsAny<CreateProductRequest>())).ReturnsAsync(product);

// Throw exception
_mockService.Setup(s => s.CreateProductAsync(It.IsAny<CreateProductRequest>()))
    .ThrowsAsync(new InvalidOperationException("Product already exists"));
Enter fullscreen mode Exit fullscreen mode

Verify Method Calls

// Verify called once
_mockService.Verify(s => s.GetProductAsync(1), Times.Once);

// Verify never called
_mockService.Verify(s => s.DeleteProductAsync(It.IsAny<int>()), Times.Never);

// Verify with specific argument check
_mockService.Verify(s => s.CreateProductAsync(It.Is<CreateProductRequest>(r =>
    r.SKU == "LAP-001")), Times.Once);
Enter fullscreen mode Exit fullscreen mode

Testing Services (Business Logic Layer)

Services contain business logic and depend on repositories. Mock the repository to test service logic in isolation.

Example Service (ProductService)

public class ProductService : IProductService
{
    private readonly IProductRepository _productRepository;
    private readonly ILogger<ProductService> _logger;

    public ProductService(IProductRepository productRepository, ILogger<ProductService> logger)
    {
        _productRepository = productRepository;
        _logger = logger;
    }

    public async Task<Product?> GetProductAsync(int id)
    {
        return await _productRepository.GetByIdAsync(id);
    }

    public async Task<Product> CreateProductAsync(CreateProductRequest request)
    {
        // Business logic: Check for duplicate SKU
        if (await _productRepository.ExistsAsync(request.SKU))
            throw new InvalidOperationException($"Product with SKU {request.SKU} already exists");

        var product = new Product
        {
            Name = request.Name,
            SKU = request.SKU,
            Description = request.Description,
            Price = request.Price,
            CategoryId = request.CategoryId,
            IsActive = true
        };

        product.Id = await _productRepository.CreateAsync(product);
        return product;
    }
}
Enter fullscreen mode Exit fullscreen mode

Unit Testing Services with Mocked Repository

using CommonComps.Models;
using CommonComps.Models.Requests;
using CommonComps.Repositories;
using FluentAssertions;
using Microsoft.Extensions.Logging;
using Moq;
using ProductWebAPI.Services;
using Xunit;

public class ProductServiceTests
{
    private readonly Mock<IProductRepository> _productRepositoryMock;
    private readonly Mock<ILogger<ProductService>> _loggerMock;
    private readonly ProductService _productService;

    public ProductServiceTests()
    {
        _productRepositoryMock = new Mock<IProductRepository>();
        _loggerMock = new Mock<ILogger<ProductService>>();
        _productService = new ProductService(_productRepositoryMock.Object, _loggerMock.Object);
    }

    [Fact]
    public async Task GetProductAsync_WithValidId_ReturnsProduct()
    {
        // Arrange
        var expectedProduct = new Product
        {
            Id = 1,
            Name = "Test Product",
            SKU = "TEST-001",
            Price = 99.99m,
            IsActive = true
        };
        _productRepositoryMock.Setup(r => r.GetByIdAsync(1))
            .ReturnsAsync(expectedProduct);

        // Act
        var result = await _productService.GetProductAsync(1);

        // Assert
        result.Should().NotBeNull();
        result.Id.Should().Be(1);
        result.Name.Should().Be("Test Product");
        _productRepositoryMock.Verify(r => r.GetByIdAsync(1), Times.Once);
    }

    [Fact]
    public async Task CreateProductAsync_WithDuplicateSKU_ThrowsException()
    {
        // Arrange
        var request = new CreateProductRequest
        {
            Name = "Duplicate Product",
            SKU = "EXISTING-001",
            Price = 29.99m,
            CategoryId = 1
        };
        _productRepositoryMock.Setup(r => r.ExistsAsync("EXISTING-001"))
            .ReturnsAsync(true);

        // Act & Assert
        var action = () => _productService.CreateProductAsync(request);
        await action.Should().ThrowAsync<InvalidOperationException>()
            .WithMessage("*EXISTING-001*already exists*");
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Arrange-Act-Assert (AAA) - Clear test structure
  2. Test naming: MethodName_Scenario_ExpectedResult
  3. Mock only dependencies - Not the thing you're testing
  4. Verify important interactions - Did service get called?
  5. One test = one scenario - Keep tests focused
  6. Repository tests - Use integration tests for critical queries

Quick Reference

// 1. Create mocks
var mockService = new Mock<IProductService>();
var mockLogger = new Mock<ILogger<ProductsController>>();

// 2. Setup behavior
mockService.Setup(s => s.GetProductAsync(1)).ReturnsAsync(product);

// 3. Inject into controller
var controller = new ProductsController(mockService.Object, mockLogger.Object);

// 4. Execute
var result = await controller.GetById(1);

// 5. Validate (note: use .Result for ActionResult<T>)
result.Result.Should().BeOfType<OkObjectResult>();
mockService.Verify(s => s.GetProductAsync(1), Times.Once);
Enter fullscreen mode Exit fullscreen mode

Running Tests

# Run all tests
dotnet test

# Run with details
dotnet test --logger "console;verbosity=detailed"

# Run specific test
dotnet test --filter "FullyQualifiedName~GetById_ReturnsOk"
Enter fullscreen mode Exit fullscreen mode


Summary

Testing controllers:

  1. Mock service
  2. Call controller
  3. Verify HTTP result + service calls

Testing services:

  1. Mock repository
  2. Execute service logic
  3. Validate business rules

Next: Learn integration testing with Testcontainers in Quick Guide #2: Repository + Database Testing!

Top comments (0)