ASP.NET Core MVC · Production

Unit Testing

Tests let you change ShopSmart without fear. Unit tests check one piece — a price calculator, a controller action — in milliseconds, without a browser or database. With xUnit and Moq you will write your first useful ones.

Advanced 7 min Lesson 37 of 42Examples target .NET 9.
0/42 in ASP.NET Core MVC
On this page · 7 min · Advanced

In simple words#

Unit tests check each machine part on a test bench before assembling the whole machine.

xUnit
Test framework.
Mock
Fake dependency, e.g. a fake service.
Controller tests
Assert the returned ActionResult.

Learning objectives#

  • Create an xUnit test project and run dotnet test
  • Write Facts and data-driven Theories
  • Test controller actions and their IActionResult
  • Replace dependencies with Moq mocks

Explanation#

Create a test project next to the app: dotnet new xunit -n ShopSmart.Tests, then dotnet add reference ../ShopSmart. Run everything with dotnet test (or Test Explorer in Visual Studio).

A [Fact] is one test; a [Theory] with [InlineData] runs the same test with several inputs — perfect for pricing rules. Structure each test as Arrange, Act, Assert.

Controllers are plain classes: create one with its dependencies, call the action, and assert on the result type (ViewResult, NotFoundResult, RedirectToActionResult) and the model.

Moq creates fake implementations of interfaces: mock.Setup(r => r.FindAsync(7)).ReturnsAsync(product). Unconfigured methods return defaults (null), and Verify checks that a call happened.

Unit tests cover logic in isolation. Use integration tests (WebApplicationFactory, a real or containerised database) to check the pieces work together.

Syntax#

C# Illustrative
[Theory]
[InlineData(500, null, 549)]
public void Total_adds_shipping_below_999(int subtotal, string? coupon, int expected) =>
    Assert.Equal(expected, new PriceCalculator().Total(subtotal, coupon));

Code example: Tests for a service and a controller#

C#ShopSmart.Tests/PriceCalculatorTests.cs Illustrative
// Services/PriceCalculator.cs
public class PriceCalculator
{
    public decimal Total(decimal subtotal, string? coupon)
    {
        if (subtotal < 0) throw new ArgumentOutOfRangeException(nameof(subtotal));
        var discount = coupon?.ToUpperInvariant() switch
        {
            "SAVE10" => 0.10m,
            "SAVE20" when subtotal >= 2000 => 0.20m,
            _ => 0m,
        };
        var shipping = subtotal >= 999 ? 0m : 49m;
        return Math.Round(subtotal * (1 - discount), 2) + shipping;
    }
}


// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;


public interface IProductRepository
{
    Task<Product?> FindAsync(int id);
}


public class ProductsController(IProductRepository products) : Controller
{
    public async Task<IActionResult> Details(int id) =>
        await products.FindAsync(id) is { } product ? View(product) : NotFound();
}


// Tests/PriceCalculatorTests.cs
using Xunit;


public class PriceCalculatorTests
{
    private readonly PriceCalculator _calculator = new();


    [Theory]
    [InlineData(500, null, 549)]          // small orders pay shipping
    [InlineData(1000, null, 1000)]        // free shipping from 999
    [InlineData(1000, "save10", 900)]     // coupon codes are case-insensitive
    [InlineData(1500, "SAVE20", 1500)]    // SAVE20 needs at least 2000
    [InlineData(2500, "SAVE20", 2000)]
    public void Total_applies_coupons_and_shipping(int subtotal, string? coupon, int expected) =>
        Assert.Equal(expected, _calculator.Total(subtotal, coupon));


    [Fact]
    public void Total_rejects_negative_subtotals() =>
        Assert.Throws<ArgumentOutOfRangeException>(() => _calculator.Total(-1, null));
}


// Tests/ProductsControllerTests.cs
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;


public class ProductsControllerTests
{
    [Fact]
    public async Task Details_returns_the_view_with_the_product()
    {
        // Arrange
        var repository = new Mock<IProductRepository>();
        repository.Setup(r => r.FindAsync(7)).ReturnsAsync(new Product { Id = 7, Name = "Wireless Mouse" });
        var controller = new ProductsController(repository.Object);


        // Act
        var result = await controller.Details(7);


        // Assert
        var view = Assert.IsType<ViewResult>(result);
        Assert.Equal("Wireless Mouse", Assert.IsType<Product>(view.Model).Name);
        repository.Verify(r => r.FindAsync(7), Times.Once);
    }


    [Fact]
    public async Task Details_returns_404_for_unknown_products()
    {
        var repository = new Mock<IProductRepository>();       // FindAsync returns null unless set up
        var result = await new ProductsController(repository.Object).Details(99);
        Assert.IsType<NotFoundResult>(result);
    }
}
Expected output
dotnet test runs 8 tests (5 Theory cases + 3 Facts) and reports: Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8. Changing the free-shipping limit to 1000 makes the “1000, null, 1000” case still pass but would catch a typo like 9999 immediately.

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#

  1. PriceCalculator has pure logic, so tests just call it — no setup at all.
  2. The Theory runs five cases, including the tricky ones (case-insensitive codes, SAVE20 below its minimum).
  3. The Fact checks that invalid input throws.
  4. The controller test mocks IProductRepository, calls the action, and asserts the result type and model.
  5. Verify confirms the repository was called exactly once; the 404 test relies on Moq’s default null.

Real-world use case#

ShopSmart’s pricing, coupon and stock rules have Theory tests with dozens of cases. Every pull request runs dotnet test in the CI pipeline, and a failing test blocks the merge.

Common mistakes#

Testing EF Core instead of your logic

Unit tests full of DbContext setup test the framework. Put logic in services and test them with fakes; leave database behaviour to integration tests.

One giant test

A test that checks ten things fails without telling you which. Keep tests small with descriptive names.

Mocking everything

Over-mocked tests break on every refactor. Mock boundaries (repositories, email, payment), not every class.

Best practices#

  • Arrange–Act–Assert with clear names
  • Theories for rules with many cases
  • Mock only external boundaries
  • Run tests in CI on every push

Remember this#

[Fact] = one test. [Theory] + [InlineData] = many cases.

Controller test: call the action, Assert.IsType<…Result>.

Moq: Setup(…).ReturnsAsync(…), Verify(…).

Practice exercise#

Your task: Write a test that checks Details does not call the repository more than once and returns a ViewResult whose ViewName is null (the default view).

Need a hint?
Assert.Null(view.ViewName) and repository.Verify(r => r.FindAsync(It.IsAny<int>()), Times.Once).
Show solution
C# Illustrative
// Tests/ProductsControllerViewTests.cs
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;


public interface IProductRepository { Task<Product?> FindAsync(int id); }


public class ProductsController(IProductRepository products) : Controller
{
    public async Task<IActionResult> Details(int id) =>
        await products.FindAsync(id) is { } product ? View(product) : NotFound();
}


public class ProductsControllerViewTests
{
    [Fact]
    public async Task Details_uses_the_default_view_and_one_lookup()
    {
        var repository = new Mock<IProductRepository>();
        repository.Setup(r => r.FindAsync(3)).ReturnsAsync(new Product { Id = 3, Name = "USB-C Hub" });


        var view = Assert.IsType<ViewResult>(await new ProductsController(repository.Object).Details(3));


        Assert.Null(view.ViewName);
        repository.Verify(r => r.FindAsync(It.IsAny<int>()), Times.Once);
    }
}

Mini quiz#

Unit Testing quizMultiple choice Question 1 of 3

Which attribute runs one test method with several sets of inputs?

Score so far: 0/0

Want more? Practise all ASP.NET Core MVC MCQs · ASP.NET Core MVC interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.