Unit Testing ASP.NET Core Web API with Moq and xUnit (Controllers + Services)
Source: Dev.to
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 behaviorReturnsAsync()→ return values for async methodsThrowsAsync()→ simulate failuresVerify()→ assert that a dependency was called
Everything in this guide follows 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 the 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
The Pattern: Mock → Execute → Validate
Example Controller (ProductsController)
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _productService;
private readonly ILogger _logger;
public ProductsController(IProductService productService, ILogger logger)
{
_productService = productService;
_logger = logger;
}
[HttpGet("{id}")]
public async Task> 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> 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 });
}
}
}
Testing Controllers
Controllers are tested by mocking the service, calling the action, and asserting on the ActionResult (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 _mockService;
private readonly Mock> _mockLogger;
private readonly ProductsController _controller;
public ProductsControllerTests()
{
_mockService = new Mock();
_mockLogger = new Mock>();
_controller = new ProductsController(_mockService.Object, _mockLogger.Object);
}
}
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().Subject;
var response = okResult.Value.Should().BeOfType().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();
}
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()))
.ReturnsAsync(createdProduct);
// Act
var result = await _controller.Create(request);
// Assert
var createdResult = result.Result.Should().BeOfType().Subject;
createdResult.StatusCode.Should().Be(201);
createdResult.ActionName.Should().Be(nameof(ProductsController.GetById));
var response = createdResult.Value.Should().BeOfType().Subject;
response.Id.Should().Be(42);
_mockService.Verify(s => s.CreateProductAsync(It.IsAny()), 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()))
.ThrowsAsync(new InvalidOperationException("Product with SKU DUP-001 already exists"));
// Act
var result = await _controller.Create(request);
// Assert
result.Result.Should().BeOfType();
}

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.GetProductAsync(It.IsAny())).ReturnsAsync(product);