Unit Testing
Unit tests check the API’s logic without HTTP or a database — in milliseconds. The best targets are services (business rules) and the mapping from results to status codes. Controllers deserve a few tests too, especially their error paths.
On this page · 7 min · Advanced
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Advanced
- Updated
- Oct 29, 2026
Related tutorials
In simple words#
Unit tests check the kitchen’s recipes one by one, without opening the restaurant.
- xUnit
- Test framework.
- Moq
- Fake dependencies.
- Test services
- Most logic is tested at service level.
Learning objectives#
- Test service rules with Facts and Theories
- Test controller actions that return ActionResult<T>
- Mock dependencies with Moq and verify calls
- Decide what belongs in unit vs integration tests
Explanation#
Services are plain classes: create them with fakes or an in-memory store, call a method, assert on the Result. Theories with InlineData cover many rule combinations cheaply.
ActionResult<T> has two halves: result.Result (an IActionResult such as NotFoundObjectResult) and result.Value (the T when you returned the object directly). Assert on whichever you expect.
Problem(...) inside a controller returns an ObjectResult whose Value is ProblemDetails — assert on StatusCode and Title. Controllers created with new need ProblemDetailsFactory for Problem(); give them a ControllerContext or test the mapping through a helper.
Moq: new Mock<IStockService>(); Setup(...).ReturnsAsync(...); Verify(..., Times.Once). Mock boundaries (repositories, external services), not everything.
Leave routing, model binding, validation attributes, filters and serialization to integration tests — unit tests cannot see them.
Syntax#
var result = await controller.Get(7);
var dto = Assert.IsType<ProductDto>(result.Value); // returned the object
Assert.IsType<NotFoundResult>(result.Result); // returned NotFound()
Code example: Tests for a pricing service and a products controller#
// Services/Pricing.cs
public interface IProductRepository
{
Task<ProductRecord?> FindAsync(int id);
}
public record ProductRecord(int Id, string Name, decimal Price, bool IsDiscontinued);
public record ProductDto(int Id, string Name, decimal Price);
public class PricingService
{
public decimal PriceFor(decimal listPrice, int quantity, string? customerTier) =>
Math.Round(listPrice * quantity * (1 - (customerTier, quantity) switch
{
("gold", _) => 0.15m,
(_, >= 10) => 0.05m,
_ => 0m,
}), 2);
}
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/products")]
public class ProductsController(IProductRepository products) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> Get(int id)
{
var product = await products.FindAsync(id);
if (product is null) return NotFound();
if (product.IsDiscontinued) return StatusCode(StatusCodes.Status410Gone);
return new ProductDto(product.Id, product.Name, product.Price);
}
}
// Tests/PricingServiceTests.cs
using Xunit;
public class PricingServiceTests
{
private readonly PricingService _pricing = new();
[Theory]
[InlineData(100, 1, null, 100)] // no discount
[InlineData(100, 10, null, 950)] // 5% bulk discount from 10 items
[InlineData(100, 2, "gold", 170)] // gold customers: 15% on any order
[InlineData(100, 10, "gold", 850)] // gold beats bulk
public void PriceFor_applies_the_best_rule(int listPrice, int quantity, string? tier, int expected) =>
Assert.Equal(expected, _pricing.PriceFor(listPrice, quantity, tier));
}
// Tests/ProductsControllerTests.cs
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
public class ProductsControllerTests
{
private readonly Mock<IProductRepository> _repository = new();
[Fact]
public async Task Get_returns_the_dto_for_an_active_product()
{
_repository.Setup(r => r.FindAsync(7)).ReturnsAsync(new ProductRecord(7, "Wireless Mouse", 799m, false));
var result = await new ProductsController(_repository.Object).Get(7);
var dto = Assert.IsType<ProductDto>(result.Value);
Assert.Equal("Wireless Mouse", dto.Name);
_repository.Verify(r => r.FindAsync(7), Times.Once);
}
[Fact]
public async Task Get_returns_404_for_unknown_products()
{
var result = await new ProductsController(_repository.Object).Get(99);
Assert.IsType<NotFoundResult>(result.Result);
}
[Fact]
public async Task Get_returns_410_for_discontinued_products()
{
_repository.Setup(r => r.FindAsync(3)).ReturnsAsync(new ProductRecord(3, "Old Webcam", 999m, true));
var result = await new ProductsController(_repository.Object).Get(3);
Assert.Equal(410, Assert.IsType<StatusCodeResult>(result.Result).StatusCode);
}
}
dotnet test runs 7 tests (4 Theory cases + 3 Facts): Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7.
This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.
Step-by-step explanation#
- PricingService is pure logic, so each Theory row is one line: inputs and the expected total.
- The gold-customer rows prove the rule order: 15% wins even when the bulk discount would also apply.
- The controller tests mock IProductRepository with Moq; no database, no HTTP.
- result.Value holds the DTO when the action returned the object; result.Result holds NotFoundResult or StatusCodeResult otherwise.
- Verify checks the repository was called exactly once.
Real-world use case#
ShopSmart’s pricing and discount rules have over a hundred Theory cases. When marketing asked for a “gold customers get free shipping above ₹999” rule, the developer added rows first, watched them fail, then implemented it.
Common mistakes#
A test without real assertions passes for wrong results. Assert values and result types.
[Required] is applied by the framework during binding; calling the action directly skips it. Test validation with integration tests.
Mock its dependencies, never the thing you are testing.
Best practices#
- Theories for rule tables
- Assert on ActionResult<T>.Value and .Result
- Mock repositories and external services only
- Fast tests that run on every build
Remember this#
ActionResult<T>: .Value = object returned, .Result = IActionResult returned.
[Theory] + [InlineData] for business rules.
Moq: Setup → ReturnsAsync → Verify.
Practice exercise#
Your task: Add a Theory row proving that a gold customer buying 1 item at ₹999.99 pays ₹849.99 (rounded to 2 decimals).
Need a hint?
Show solution
// Tests/PricingRoundingTests.cs
using Xunit;
public class PricingService
{
public decimal PriceFor(decimal listPrice, int quantity, string? tier) =>
Math.Round(listPrice * quantity * (tier == "gold" ? 0.85m : 1m), 2);
}
public class PricingRoundingTests
{
[Theory]
[InlineData(999.99, 1, "gold", 849.99)]
public void PriceFor_rounds_to_two_decimals(double listPrice, int quantity, string tier, double expected) =>
Assert.Equal((decimal)expected, new PricingService().PriceFor((decimal)listPrice, quantity, tier));
}
Mini quiz#
An action returns NotFound(). Where is that in ActionResult<ProductDto>?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.