On this page
Testing in .NET with xUnit and Moq
Why write tests?
Automated tests let you:
- Detect regressions — changes do not break existing functionality
- Design better — testable code is naturally more modular
- Document behavior — tests are executable specifications
- 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 DBxUnit: [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 enabledArrange-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 testPractice
- Unit tests: Write 3 [Fact] tests and 1 [Theory] for a
DiscountCalculatorclass with methodsApplyDiscount(decimal price, decimal pct)andIsValid(decimal price). - Repository mock: Use Moq to mock an
IProductRepositoryand test aProductServicethat throwsNotFoundExceptionwhen the product does not exist. - Integration test: Create a test with
WebApplicationFactorythat testsPOST /api/productswith valid data and verifies the 201 Created andLocationheader.
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);
}
}
Sign in to track your progress