Mert Özen Further With Every Line
Backend .NET Core Web API — #29

Unit Testing an ASP.NET Core Web API: Service and Controller Tests with xUnit and Moq

Mert Özen Nov 4, 2026 11 min 2 views
Unit Testing an ASP.NET Core Web API: Service and Controller Tests with xUnit and Moq

Unit testing an ASP.NET Core Web API with xUnit and Moq: the right package versions for net6.0, AAA and naming, Setup and Verify, the Result/Value difference in ActionResult<T> tests, Theory, and a comparison with NSubstitute.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

In the previous post we used health checks to take the pulse of the running app. But a pulse doesn't tell you the code works correctly; it only tells you it runs. A rounding bug in the price calculation won't bother a health check one bit. The cheapest way to catch that bug before you deploy is a unit test.

A guess first. We have been writing this action for years:

[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken cancellationToken)
{
    var product = await _productService.GetByIdAsync(id, cancellationToken);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

We know that when the product is found, the HTTP side returns 200 OK. If you write Assert.IsType<OkObjectResult>(result.Result) while testing it, does the test pass? The answer is in the controller tests section.

What Does a Unit Test Test?

A unit test runs a small piece of your code, a method or a class, isolated from the rest of the world, and checks the result. No database, no HTTP, no file system. That is why it finishes in milliseconds and gives the same result every time. You don't take a car onto the highway to test its brakes; you hook the brake system up to a test bench, press the pedal and measure the pressure. A unit test is that bench.

Knowing what you don't test matters too. Don't try to verify ASP.NET Core's routing, model binding or EF Core's SQL generation with unit tests; those are the framework's job, and seeing them work together is the subject of the next post, integration tests. The territory of unit tests is your decisions: business rules, calculations, "throw this exception in this case" logic, which result the controller returns in which situation.

The code we will test is a slimmed-down version of the structure we built in the layered architecture post: a repository interface, a ProductService that uses it, and a controller that uses the service.

using ProductCatalog.Api.Models;

namespace ProductCatalog.Api.Services;

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<bool> ExistsByNameAsync(string name, CancellationToken cancellationToken);
    Task AddAsync(Product product, CancellationToken cancellationToken);
}

public interface IProductService
{
    Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken);
    Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken);
}

public class DuplicateProductException : Exception
{
    public DuplicateProductException(string name)
        : base($"A product named '{name}' already exists.")
    {
    }
}

public class ProductService : IProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository;
    }

    public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken cancellationToken)
    {
        var product = await _repository.GetByIdAsync(id, cancellationToken);
        return product is null ? null : new ProductDto(product.Id, product.Name, product.Price);
    }

    public async Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken cancellationToken)
    {
        var name = request.Name.Trim();

        if (await _repository.ExistsByNameAsync(name, cancellationToken))
        {
            throw new DuplicateProductException(name);
        }

        var product = new Product { Name = name, Price = request.Price };
        await _repository.AddAsync(product, cancellationToken);

        return new ProductDto(product.Id, product.Name, product.Price);
    }
}

ProductDto and CreateProductRequest are records: record ProductDto(int Id, string Name, decimal Price) and record CreateProductRequest(string Name, decimal Price). The controller consists of the GetById above and a Create action that returns CreatedAtAction.

Setting Up the Test Project

Tests live in a separate project. Let the folder layout be src/ and tests/:

dotnet new sln -n ProductCatalog
dotnet new xunit -o tests/ProductCatalog.Api.Tests
dotnet sln add src/ProductCatalog.Api tests/ProductCatalog.Api.Tests
dotnet add tests/ProductCatalog.Api.Tests reference src/ProductCatalog.Api

Turn the csproj generated by the template into this:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <!-- Versions we verified on net6.0 and net10.0 -->
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
    <PackageReference Include="xunit" Version="2.9.3" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.0.2" />
    <PackageReference Include="Moq" Version="4.21.0" />
    <PackageReference Include="NSubstitute" Version="6.2.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\ProductCatalog.Api\ProductCatalog.Api.csproj" />
  </ItemGroup>

</Project>

The versions are not accidental. Looking at the package contents on NuGet, Microsoft.NET.Test.Sdk drops everything below net8.0 with 17.14, and xunit.runner.visualstudio does the same with 3.1; the 17.13.0 and 3.0.2 in the csproj are the last versions that still support net6.0. Moq 4.21.0 and xUnit 2.9.3 target net6.0 directly, while NSubstitute 6.2.0 works through netstandard2.0. We also built and ran the same csproj with net10.0, and everything works there too.

Let me show why we fuss over this so much. When you run dotnet new xunit with the .NET 10 SDK, the template puts in Microsoft.NET.Test.Sdk 17.14.1 and xunit.runner.visualstudio 3.1.4. Switch the TargetFramework to net6.0, run dotnet test, and the build stops like this:

error : Microsoft.NET.Test.Sdk doesn't support net6.0 and has not been tested with it. Consider upgrading your TargetFramework to net8.0 or later. You may also set <SuppressTfmSupportBuildErrors>true</SuppressTfmSupportBuildErrors> in the project file to ignore this warning and attempt to run in this unsupported configuration at your own risk.

A First Test: AAA and Naming

using Moq;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
using Xunit;

namespace ProductCatalog.Api.Tests;

public class ProductServiceTests
{
    private readonly Mock<IProductRepository> _repository = new();
    private readonly ProductService _sut;

    public ProductServiceTests()
    {
        _sut = new ProductService(_repository.Object);
    }

    [Fact]
    public async Task GetByIdAsync_ProductExists_ReturnsDto()
    {
        // Arrange: setup
        _repository
            .Setup(r => r.GetByIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m });

        // Act: the single call under test
        var result = await _sut.GetByIdAsync(1, CancellationToken.None);

        // Assert: the expected outcome
        Assert.NotNull(result);
        Assert.Equal("Mechanical Keyboard", result.Name);
        Assert.Equal(1450m, result.Price);
    }

    [Fact]
    public async Task GetByIdAsync_ProductMissing_ReturnsNull()
    {
        var result = await _sut.GetByIdAsync(42, CancellationToken.None);

        Assert.Null(result);
    }
}

Every test has three parts: Arrange sets up the environment, Act triggers the behavior under test with a single call, Assert checks the result. This layout lets someone reading the test find the answers to "what did we set up, what did we call, what do we expect" at a glance. If you see more than one line in the Act part, you are probably testing two things at once.

For naming we use the Method_Scenario_ExpectedResult pattern, that is method, situation and expected result in that order. Someone who reads the name GetByIdAsync_ProductMissing_ReturnsNull knows what broke when the test turns red, without opening the code. _sut stands for "system under test"; it is a common abbreviation for setting the object under test apart from everything else.

xUnit creates a new instance of the test class for every test. The setup in the constructor runs fresh for each test, and mocks are not shared between tests. There is no need for a separate [SetUp] method.

Faking Dependencies with Moq

ProductService wants an IProductRepository. If we give it the real one, the test depends on the database. Instead we produce a fake object with Moq: new Mock<IProductRepository>() creates a class that implements the interface at runtime, and .Object is that object itself.

With Setup you say "if this method is called with these arguments, return this". It.IsAny<CancellationToken>() matches whatever token comes in. Look at the second test: we did no Setup at all and the test passed. Moq's default mode is "loose": when a method without a setup is called, it doesn't throw, it returns the default value of the return type. For Task<Product?>, that is a completed Task whose result is null. Handy, but it can also make a test pass for the wrong reason; we will come back to that.

Sometimes what matters is not the return value but a side effect. If a product with the same name exists, the service must not save anything:

[Fact]
public async Task CreateAsync_NameAlreadyExists_ThrowsAndDoesNotSave()
{
    _repository
        .Setup(r => r.ExistsByNameAsync("Wireless Mouse", It.IsAny<CancellationToken>()))
        .ReturnsAsync(true);
    var request = new CreateProductRequest("  Wireless Mouse ", 650m);

    await Assert.ThrowsAsync<DuplicateProductException>(
        () => _sut.CreateAsync(request, CancellationToken.None));

    _repository.Verify(
        r => r.AddAsync(It.IsAny<Product>(), It.IsAny<CancellationToken>()),
        Times.Never);
}

[Fact]
public async Task CreateAsync_ValidRequest_SavesTrimmedName()
{
    var request = new CreateProductRequest("  USB-C Hub  ", 899m);

    var result = await _sut.CreateAsync(request, CancellationToken.None);

    Assert.Equal("USB-C Hub", result.Name);
    _repository.Verify(
        r => r.AddAsync(
            It.Is<Product>(p => p.Name == "USB-C Hub" && p.Price == 899m),
            It.IsAny<CancellationToken>()),
        Times.Once);
}

In the first test the request comes in as " Wireless Mouse ", but the setup was made for "Wireless Mouse". If the test passes, the service trims the name before checking it; so the test documents a business rule too. Assert.ThrowsAsync expects the exception, and Verify with Times.Never confirms that AddAsync was never called. In the second test, It.Is looks inside the saved object: the trimmed name and the right price.

Controller Tests and ActionResult<T>

When testing the controller, we mock the service. A controller is an ordinary class; you can create it with new and call the action directly.

using Microsoft.AspNetCore.Mvc;
using Moq;
using ProductCatalog.Api.Controllers;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
using Xunit;

namespace ProductCatalog.Api.Tests;

public class ProductsControllerTests
{
    private readonly Mock<IProductService> _service = new();
    private readonly ProductsController _controller;

    public ProductsControllerTests()
    {
        _controller = new ProductsController(_service.Object);
    }

    [Fact]
    public async Task GetById_ProductExists_ReturnsProductAsValue()
    {
        var dto = new ProductDto(1, "Mechanical Keyboard", 1450m);
        _service
            .Setup(s => s.GetByIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(dto);

        var result = await _controller.GetById(1, CancellationToken.None);

        // return product; fills Value, not Result
        Assert.Null(result.Result);
        Assert.Equal(dto, result.Value);
    }

    [Fact]
    public async Task GetById_ProductMissing_ReturnsNotFound()
    {
        var result = await _controller.GetById(42, CancellationToken.None);

        Assert.IsType<NotFoundResult>(result.Result);
    }

    [Fact]
    public async Task Create_ValidRequest_ReturnsCreatedAtGetById()
    {
        var request = new CreateProductRequest("USB-C Hub", 899m);
        _service
            .Setup(s => s.CreateAsync(request, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new ProductDto(3, "USB-C Hub", 899m));

        var result = await _controller.Create(request, CancellationToken.None);

        var created = Assert.IsType<CreatedAtActionResult>(result.Result);
        Assert.Equal(nameof(ProductsController.GetById), created.ActionName);
        Assert.Equal(3, created.RouteValues!["id"]);
        Assert.Equal(new ProductDto(3, "USB-C Hub", 899m), created.Value);
    }
}

The answer to the guess is hidden in the first test. When we replaced its two Assert lines with Assert.IsType<OkObjectResult>(result.Result), the test turned red like this:

  Failed ProductCatalog.Api.Tests.ProductsControllerTests.GetById_ProductExists_ReturnsProductAsValue [26 ms]
  Error Message:
   Assert.IsType() Failure: Value is null
Expected: typeof(Microsoft.AspNetCore.Mvc.OkObjectResult)
Actual:   null

ActionResult<T> has two slots: Result and Value. When you say return NotFound();, the implicit conversion fills Result. When you say return product;, Value gets filled and Result stays null. The 200 OK is produced from Value by MVC's result executor after the action finishes; your unit test never reaches that stage. If the test wants an OkObjectResult, the action has to say return Ok(product);. Both are correct, but the test has to know which one you wrote.

The third test checks CreatedAtAction, which we used a lot in earlier posts. We look not at the status code but at the decision itself: which action it points to, whether the route value is right, whether the body is right. Producing the real URL for the Location header is routing's job; we will see it in the integration test.

The Same Test with Different Data: Theory

Pure functions are the most comfortable customers of unit testing. A small helper that calculates a discount:

namespace ProductCatalog.Api.Services;

public static class PriceCalculator
{
    public static decimal ApplyDiscount(decimal price, int discountPercent)
    {
        if (discountPercent < 0 || discountPercent > 90)
        {
            throw new ArgumentOutOfRangeException(nameof(discountPercent));
        }

        return Math.Round(price * (100 - discountPercent) / 100, 2);
    }
}
using ProductCatalog.Api.Services;
using Xunit;

namespace ProductCatalog.Api.Tests;

public class PriceCalculatorTests
{
    [Theory]
    [InlineData(1450, 10, 1305)]
    [InlineData(650, 0, 650)]
    [InlineData(99.99, 15, 84.99)]
    public void ApplyDiscount_ValidPercent_ReturnsRoundedPrice(decimal price, int percent, decimal expected)
    {
        var result = PriceCalculator.ApplyDiscount(price, percent);

        Assert.Equal(expected, result);
    }

    [Theory]
    [InlineData(-1)]
    [InlineData(91)]
    public void ApplyDiscount_PercentOutOfRange_Throws(int percent)
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => PriceCalculator.ApplyDiscount(100m, percent));
    }
}

[Fact] tests a single case, [Theory] tests a rule; every [InlineData] runs as a separate test and shows up separately in the report. Always add the boundary values (0, 90, 91, -1); that is where bugs hide most often.

A detail: if you try writing [InlineData(1450m, 10, 1305m)], the compiler stops you:

PriceCalculatorTests.cs(9,17): error CS0182: An attribute argument must be a constant expression, typeof expression or array creation expression of an attribute parameter type

C# doesn't accept decimal as an attribute argument. You write the numbers as int or double, and xUnit converts them to decimal, the parameter type. If precision is critical for you, supply the values as decimal from a property with [MemberData].

Let's run all the tests:

dotnet test
Passed!  - Failed:     0, Passed:    13, Skipped:     0, Total:    13, Duration: 40 ms - ProductCatalog.Api.Tests.dll (net6.0)

Thirteen tests, 40 milliseconds. The same tests passed on net10.0 as well.

The Same Test with NSubstitute

Moq is not the only option. NSubstitute offers a syntax with less ceremony; the same test looks like this:

using NSubstitute;
using ProductCatalog.Api.Models;
using ProductCatalog.Api.Services;
using Xunit;

namespace ProductCatalog.Api.Tests;

public class ProductServiceNSubstituteTests
{
    [Fact]
    public async Task CreateAsync_NameAlreadyExists_ThrowsAndDoesNotSave()
    {
        var repository = Substitute.For<IProductRepository>();
        repository.ExistsByNameAsync("Wireless Mouse", Arg.Any<CancellationToken>()).Returns(true);
        var sut = new ProductService(repository);

        await Assert.ThrowsAsync<DuplicateProductException>(
            () => sut.CreateAsync(new CreateProductRequest("Wireless Mouse", 650m), CancellationToken.None));

        await repository.DidNotReceive().AddAsync(Arg.Any<Product>(), Arg.Any<CancellationToken>());
    }
}

No .Object, no setup inside a lambda; you call the fake directly and add .Returns. Which one you pick doesn't matter much; what matters is settling on one per project. A historical note on the Moq side: in August 2023, version 4.20.0 shipped with a component called SponsorLink that read the developer's git email information during the build, and it was pulled back after the backlash. On NuGet, 4.20.0 and 4.20.1 are unlisted today and carry a security warning; stay away from them when you pin a version.

Common Pitfalls

Verifying everything. If you verify every call the service makes in a test, you are testing the implementation, not the behavior; the moment you refactor the code, the tests break even though the behavior stays the same. Use Verify when the side effect itself matters, like "must not save" in our example.

The lie of the loose mock. If you made the setup with the wrong argument, say GetByIdAsync(2, ...) instead of GetByIdAsync(1, ...), Moq returns null for the real call and your "returns null when the product is missing" test stays green for the wrong reason. Where you are suspicious, use new Mock<IProductRepository>(MockBehavior.Strict); every call without a setup throws.

Mocking DbContext. Imitating the LINQ behavior of a DbSet is both laborious and misleading; a mocked DbSet will happily run a query that can't be translated to SQL. Exercise the EF code behind the repository against a real database, in an integration test.

Writing async void tests. xUnit 2 still runs them, but the analyzer warns during the build: xUnit1048: Support for 'async void' unit tests is being removed from xUnit.net v3. Always return async Task. In the same way, don't use .Result or .Wait() inside a test; the deadlock and exception wrapping troubles we discussed in the async/await post apply to tests too.

Finally, Moq can only fake interfaces and virtual members. If you try to set up a non-virtual method of a concrete class, you get an exception at runtime. That is exactly the testability gain of taking dependencies through interfaces that we discussed in the DI post.

A Small Experiment

★☆☆ Change return product; in the GetById action to return Ok(product);. See which test breaks, and rewrite it for the new behavior with Assert.IsType<OkObjectResult>.

★★☆ Create the mock in ProductServiceTests with MockBehavior.Strict. Look at which tests break, add the missing Setup for each of them, and find out which test was passing for the wrong reason before.

★★★ Add the rule "the discounted price can't drop below 1 TRY" to PriceCalculator. First write the [Theory] that describes the rule and watch it fail, then write the code and turn it green. Use a data source that supplies decimal values through [MemberData].

Our unit tests verify the pieces one by one, but none of them sees that the controller is really bound to /api/products/1, that the JSON is serialized correctly or that validation returns a 400. In the next post we will spin up the whole app in memory with WebApplicationFactory and write integration tests at the HTTP level.