On this page

Testing in .NET with xUnit and Moq

14 min read TextCh. 5 — Production

Why write tests?

Automated tests let you:

  1. Detect regressions — changes do not break existing functionality
  2. Design better — testable code is naturally more modular
  3. Document behavior — tests are executable specifications
  4. Refactor with confidence — if tests pass, the behavior is correct

In .NET, the most popular testing framework is xUnit, modern and well-integrated with the ecosystem.

Installing testing dependencies

# Create a test project
dotnet new xunit -n MyApi.Tests

# Reference the main project
dotnet add reference ../MyApi/MyApi.csproj

# Essential packages
dotnet add package Moq                               # mocking
dotnet add package FluentAssertions                  # readable assertions
dotnet add package Microsoft.AspNetCore.Mvc.Testing  # integration tests
dotnet add package Microsoft.EntityFrameworkCore.InMemory  # in-memory DB

xUnit: [Fact] and [Theory]

// [Fact] — a single test, no parameters
[Fact]
public void Add_TwoNumbers_ReturnsSum()
{
    // Arrange
    var calculator = new Calculator();

    // Act
    int result = calculator.Add(3, 4);

    // Assert
    Assert.Equal(7, result);
}

// [Theory] with [InlineData] — same test, multiple data sets
[Theory]
[InlineData(3,  4,  7)]
[InlineData(0,  0,  0)]
[InlineData(-1, 1,  0)]
[InlineData(int.MaxValue, 1, int.MinValue)] // overflow
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
    var calc = new Calculator();
    int result = calc.Add(a, b);
    Assert.Equal(expected, result);
}

// [Theory] with complex data using [MemberData]
public static IEnumerable<object[]> PriceData =>
    new List<object[]>
    {
        new object[] { 100m,  0.10m, 90m  }, // 10% discount
        new object[] { 200m,  0.20m, 160m }, // 20% discount
        new object[] { 50m,   0m,    50m  }, // no discount
    };

[Theory]
[MemberData(nameof(PriceData))]
public void ApplyDiscount_ReturnsCorrectReducedPrice(
    decimal price, decimal discount, decimal expected)
{
    var result = PriceService.ApplyDiscount(price, discount);
    Assert.Equal(expected, result);
}

Mocking with Moq

Moq allows creating fake objects that simulate the behavior of dependencies:

// Setup — what the mock returns when called
var repo = new Mock<IProductRepository>();

// Return a specific value
repo.Setup(r => r.GetByIdAsync(1))
    .ReturnsAsync(new Product { Id = 1, Name = "Laptop" });

// Return null for any ID that is not 1
repo.Setup(r => r.GetByIdAsync(It.IsAny<int>()))
    .ReturnsAsync((Product?)null);

// Simulate exceptions
repo.Setup(r => r.DeleteAsync(-1))
    .ThrowsAsync(new ArgumentException("Invalid ID"));

// Callback — execute additional code
repo.Setup(r => r.CreateAsync(It.IsAny<Product>()))
    .Callback<Product>(p => p.Id = 99)
    .ReturnsAsync(new Product { Id = 99 });

// Verify — check that a call was made as expected
repo.Verify(r => r.GetByIdAsync(1), Times.Once);
repo.Verify(r => r.DeleteAsync(It.IsAny<int>()), Times.Never);
repo.VerifyAll(); // verifies all Setups with verification enabled

Arrange-Act-Assert (AAA) pattern

public class OrderServiceTests
{
    [Fact]
    public async Task ProcessOrder_UpdatesStockAndSendsEmail()
    {
        // ── ARRANGE — prepare data and mocks ──────────────────
        var repoMock  = new Mock<IProductRepository>();
        var emailMock = new Mock<IEmailService>();

        var product = new Product { Id = 1, Name = "Laptop", Stock = 5 };
        repoMock.Setup(r => r.GetByIdAsync(1)).ReturnsAsync(product);
        repoMock.Setup(r => r.UpdateAsync(product)).ReturnsAsync(product);

        var service = new OrderService(repoMock.Object, emailMock.Object);
        var order   = new Order { ProductId = 1, Quantity = 2, CustomerEmail = "[email protected]" };

        // ── ACT — execute the action under test ───────────────
        var result = await service.ProcessAsync(order);

        // ── ASSERT — verify the result ────────────────────────
        result.Should().BeTrue();
        product.Stock.Should().Be(3); // 5 - 2
        emailMock.Verify(e => e.SendConfirmationAsync("[email protected]"), Times.Once);
    }

    [Fact]
    public async Task ProcessOrder_ThrowsException_WhenInsufficientStock()
    {
        // Arrange
        var repoMock = new Mock<IProductRepository>();
        repoMock.Setup(r => r.GetByIdAsync(1))
                .ReturnsAsync(new Product { Id = 1, Stock = 1 });

        var service = new OrderService(repoMock.Object, Mock.Of<IEmailService>());
        var order   = new Order { ProductId = 1, Quantity = 5 };

        // Act & Assert
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => service.ProcessAsync(order));
    }
}

Fixtures and shared constructors

// Shared fixture — created once for all tests in the class
public class DatabaseFixture : IDisposable
{
    public StoreDbContext Db { get; }

    public DatabaseFixture()
    {
        var options = new DbContextOptionsBuilder<StoreDbContext>()
            .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
            .Options;
        Db = new StoreDbContext(options);
        Db.Database.EnsureCreated();

        // Test data
        Db.Categories.Add(new Category { Id = 1, Name = "Electronics" });
        Db.SaveChanges();
    }

    public void Dispose() => Db.Dispose();
}

// Using the fixture
public class ProductTests : IClassFixture<DatabaseFixture>
{
    private readonly StoreDbContext _db;

    public ProductTests(DatabaseFixture fixture)
        => _db = fixture.Db;

    [Fact]
    public async Task Add_SavesInDB()
    {
        _db.Products.Add(new Product
        {
            Name = "Monitor", Price = 399m, Stock = 10, CategoryId = 1
        });
        await _db.SaveChangesAsync();

        var count = await _db.Products.CountAsync();
        count.Should().Be(1);
    }
}

Integration tests with WebApplicationFactory

// Fixture for the test server
public class ApiFixture : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureServices(services =>
        {
            // Replace real DB with InMemory
            var descriptor = services.SingleOrDefault(
                d => d.ServiceType == typeof(DbContextOptions<StoreDbContext>));
            if (descriptor != null)
                services.Remove(descriptor);

            services.AddDbContext<StoreDbContext>(opts =>
                opts.UseInMemoryDatabase("IntegrationTestDb"));
        });

        builder.UseEnvironment("Testing");
    }
}

public class ProductsIntegrationTests : IClassFixture<ApiFixture>
{
    private readonly HttpClient _http;

    public ProductsIntegrationTests(ApiFixture fixture)
        => _http = fixture.CreateClient();

    [Fact]
    public async Task GET_Returns200_WhenProductsExist()
    {
        var response = await _http.GetAsync("/api/v1/products");

        response.StatusCode.Should().Be(HttpStatusCode.OK);
        var body = await response.Content.ReadFromJsonAsync<List<ProductDto>>();
        body.Should().NotBeNull();
    }

    [Fact]
    public async Task POST_InvalidProduct_Returns400()
    {
        var invalid = new { Name = "", Price = -10m }; // fails validation
        var response = await _http.PostAsJsonAsync("/api/v1/products", invalid);

        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }
}

Running tests

# Run all tests
dotnet test

# With detailed output
dotnet test --verbosity normal

# Filter tests by name
dotnet test --filter "Add"

# With code coverage
dotnet test --collect:"XPlat Code Coverage"
dotnet tool install -g dotnet-reportgenerator-globaltool
reportgenerator -reports:"**/coverage.cobertura.xml" -targetdir:coverage-report

# Hot reload (re-runs on file changes)
dotnet watch test

Practice

  1. Unit tests: Write 3 [Fact] tests and 1 [Theory] for a DiscountCalculator class with methods ApplyDiscount(decimal price, decimal pct) and IsValid(decimal price).
  2. Repository mock: Use Moq to mock an IProductRepository and test a ProductService that throws NotFoundException when the product does not exist.
  3. Integration test: Create a test with WebApplicationFactory that tests POST /api/products with valid data and verifies the 201 Created and Location header.

In the next lesson you will put everything you have learned into practice by building a complete product catalog API with EF Core, JWT, middleware, and tests.

Name tests with the Method_Scenario_ExpectedResult pattern
A good test name describes exactly what it tests. Use the pattern: GetById_WhenIdDoesNotExist_ReturnsNull or Create_WithEmptyName_ThrowsArgumentException. This makes failure reports self-explanatory without needing to read the test code.
FluentAssertions for readable assertions
FluentAssertions (NuGet package) offers much more readable syntax than classic Assert: result.Should().Be(5) instead of Assert.Equal(5, result). It also gives more descriptive error messages on failure. Use it in all your projects.
Isolated tests with Moq and InMemory databases
Unit tests should NOT touch the real database. Use Moq to mock repositories and services. Integration tests CAN use a database, but use InMemory or SQLite in memory mode to avoid contaminating development/production data.
using Xunit;
using Moq;
using FluentAssertions;

// ── Tests with [Fact] and [Theory] ───────────────────
public class ProductServiceTests
{
    private readonly Mock<IProductRepository> _repoMock;
    private readonly ProductService _sut; // System Under Test

    public ProductServiceTests()
    {
        _repoMock = new Mock<IProductRepository>();
        _sut      = new ProductService(_repoMock.Object);
    }

    [Fact]
    public async Task GetById_ReturnsProduct_WhenExists()
    {
        // Arrange
        var expected = new Product { Id = 1, Name = "Laptop", Price = 999m };
        _repoMock.Setup(r => r.GetByIdAsync(1))
                 .ReturnsAsync(expected);

        // Act
        var result = await _sut.GetByIdAsync(1);

        // Assert
        result.Should().NotBeNull();
        result!.Id.Should().Be(1);
        result.Name.Should().Be("Laptop");
        _repoMock.Verify(r => r.GetByIdAsync(1), Times.Once);
    }

    [Fact]
    public async Task GetById_ReturnsNull_WhenNotFound()
    {
        _repoMock.Setup(r => r.GetByIdAsync(99))
                 .ReturnsAsync((Product?)null);

        var result = await _sut.GetByIdAsync(99);

        result.Should().BeNull();
    }

    // [Theory] — same test with different data
    [Theory]
    [InlineData("",    false)]
    [InlineData("  ",  false)]
    [InlineData("A",   false)]  // too short
    [InlineData("Laptop Pro", true)]
    [InlineData("X",   false)]
    public void IsNameValid_ReturnsCorrectResult(string name, bool expected)
    {
        var valid = ProductService.IsNameValid(name);
        valid.Should().Be(expected);
    }
}