Skip to main content
C# intermediate Lesson 20 of 25

Testing in C#

Unit testing with xUnit, NUnit, FluentAssertions, Moq, and test organization in C#.

Setting Up xUnit

Automated tests are one of the highest-leverage investments in a codebase. They catch regressions before they reach production, document intended behavior, and give you confidence to refactor. xUnit is the de facto standard test framework for modern .NET — it is used by the .NET team itself and encourages good practices like isolated, stateless tests. Set up a test project alongside your main project and reference it.

dotnet new classlib -n MyApp.Core
dotnet new xunit -n MyApp.Tests
dotnet add MyApp.Tests/MyApp.Tests.csproj reference MyApp.Core/MyApp.Core.csproj
dotnet add MyApp.Tests package Moq               # mocking library
dotnet add MyApp.Tests package FluentAssertions  # readable assertions
dotnet test                                       # run all tests

Basic xUnit Tests

A good unit test has three parts: Arrange (set up the inputs and state), Act (call the code under test), and Assert (verify the result). xUnit uses [Fact] for tests with no parameters and [Theory] with [InlineData] for data-driven tests that run the same logic against multiple inputs. xUnit creates a fresh instance of the test class for each test, preventing shared state from leaking between tests.

using Xunit;

public class CalculatorTests
{
    private readonly Calculator _sut;  // sut = System Under Test — a common convention

    public CalculatorTests()
    {
        _sut = new Calculator();  // fresh instance per test — no shared state
    }

    [Fact]
    public void Add_TwoPositiveNumbers_ReturnsSum()
    {
        // Arrange — set up inputs
        int a = 3, b = 4;

        // Act — call the code under test
        int result = _sut.Add(a, b);

        // Assert — verify the outcome
        Assert.Equal(7, result);
    }

    [Fact]
    public void Divide_ByZero_ThrowsDivideByZeroException()
    {
        // Assert.Throws verifies that the action throws the expected exception type
        Assert.Throws<DivideByZeroException>(() => _sut.Divide(10, 0));
    }

    // [Theory] + [InlineData] runs the same test with multiple input sets
    [Theory]
    [InlineData(2, 4, true)]   // 2*4=8 is even
    [InlineData(3, 4, false)]  // 3*4=12... wait, that IS even — useful to catch logic bugs
    [InlineData(0, 6, true)]
    public void IsEvenProduct_VariousInputs_ReturnsExpected(int a, int b, bool expected)
    {
        bool result = _sut.IsEvenProduct(a, b);
        Assert.Equal(expected, result);
    }

    // [MemberData] sources test cases from a method — useful for complex objects
    [Theory]
    [MemberData(nameof(DivisionTestCases))]
    public void Divide_ValidInputs_ReturnsQuotient(int a, int b, double expected)
    {
        double result = _sut.Divide(a, b);
        Assert.Equal(expected, result, precision: 5);
    }

    public static IEnumerable<object[]> DivisionTestCases()
    {
        yield return new object[] { 10, 2, 5.0 };
        yield return new object[] { 7, 2, 3.5 };
        yield return new object[] { 9, 3, 3.0 };
    }
}

FluentAssertions

The built-in Assert methods work, but their failure messages are often unhelpful — “Expected True but was False” tells you nothing. FluentAssertions replaces them with a natural-language API that produces detailed failure messages like “Expected collection to have count 3, but found 2.” It also provides rich assertion methods for collections, exceptions, dates, and async operations that would otherwise require multiple lines with plain xUnit.

using FluentAssertions;

public class OrderServiceTests
{
    [Fact]
    public void GetTotal_WithMultipleLines_ReturnsSumOfLineTotals()
    {
        var order = new Order();
        order.AddLine("Widget", qty: 2, unitPrice: 9.99m);
        order.AddLine("Gadget", qty: 1, unitPrice: 49.99m);

        decimal total = order.Total;

        // Reads like English and gives a clear message on failure
        total.Should().Be(69.97m);
    }

    [Fact]
    public void GetActiveCustomers_ReturnsOnlyActiveOnes()
    {
        var customers = new List<Customer>
        {
            new("Alice", isActive: true),
            new("Bob",   isActive: false),
            new("Carol", isActive: true),
        };
        var service = new CustomerService(customers);

        var result = service.GetActiveCustomers().ToList();

        // FluentAssertions has rich collection assertion methods
        result.Should().HaveCount(2);
        result.Should().AllSatisfy(c => c.IsActive.Should().BeTrue());
        result.Should().ContainSingle(c => c.Name == "Alice");
        result.Should().NotContain(c => c.Name == "Bob");
    }

    [Fact]
    public void ParseDate_ValidString_ReturnsParsedDate()
    {
        var result = DateParser.Parse("2024-06-15");

        // Chain assertions for multiple properties in one readable expression
        result.Should().NotBeNull();
        result.Should().HaveYear(2024).And.HaveMonth(6).And.HaveDay(15);
    }

    [Fact]
    public async Task FetchAsync_WhenCalled_ReturnsNonEmptyList()
    {
        var service = new DataService();
        var result = await service.FetchAsync();
        result.Should().NotBeNullOrEmpty();
    }
}

Mocking with Moq

A unit test should test one class in isolation. But most classes depend on other classes — a service depends on a repository, a controller depends on a service. Moq lets you create fake implementations of interfaces at runtime, configure their return values, and verify they were called correctly. This keeps tests fast (no database, no network) and focused on a single unit of behavior.

using Moq;
using FluentAssertions;
using Xunit;

public class OrderProcessorTests
{
    private readonly Mock<IOrderRepository> _repoMock;
    private readonly Mock<IEmailService>    _emailMock;
    private readonly OrderProcessor        _sut;

    public OrderProcessorTests()
    {
        _repoMock  = new Mock<IOrderRepository>();
        _emailMock = new Mock<IEmailService>();
        // Inject the mock objects (not the mocks themselves) into the class under test
        _sut = new OrderProcessor(_repoMock.Object, _emailMock.Object);
    }

    [Fact]
    public async Task ProcessAsync_ValidOrder_SavesAndSendsEmail()
    {
        // Arrange — configure mocks to return specific values
        var order = new Order { Id = 1, CustomerEmail = "[email protected]", Total = 99m };
        _repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
                 .ReturnsAsync(true);
        _emailMock.Setup(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()))
                  .Returns(Task.CompletedTask);

        // Act
        var result = await _sut.ProcessAsync(order);

        // Assert — verify return value AND that the right calls were made
        result.Should().BeTrue();
        _repoMock.Verify(r => r.SaveAsync(order), Times.Once);
        _emailMock.Verify(e => e.SendAsync("[email protected]", It.IsAny<string>()), Times.Once);
    }

    [Fact]
    public async Task ProcessAsync_WhenSaveFails_DoesNotSendEmail()
    {
        var order = new Order { Id = 2, CustomerEmail = "[email protected]", Total = 50m };
        // Simulate a save failure
        _repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
                 .ReturnsAsync(false);

        var result = await _sut.ProcessAsync(order);

        result.Should().BeFalse();
        // Verify the email service was never called — guard against accidental side effects
        _emailMock.Verify(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
    }

    [Fact]
    public async Task ProcessAsync_RepositoryThrows_PropagatesException()
    {
        var order = new Order { Id = 3, Total = 10m };
        // Simulate an infrastructure failure
        _repoMock.Setup(r => r.SaveAsync(It.IsAny<Order>()))
                 .ThrowsAsync(new DbException("Connection failed"));

        // FluentAssertions can assert on async exceptions too
        await _sut.Invoking(s => s.ProcessAsync(order))
                  .Should().ThrowAsync<DbException>()
                  .WithMessage("*Connection failed*");
    }
}

Test Organization

As a test suite grows, organization becomes important. Nested classes group related tests and make the test explorer easy to navigate. IClassFixture<T> solves the performance problem of expensive setup (like spinning up an in-memory database) by sharing it across all tests in a class, while still running each test in isolation — the fixture is created once and injected into each test class instance.

// Nested classes group tests by the operation being tested
public class UserServiceTests
{
    public class GetByIdTests
    {
        [Fact]
        public async Task WhenUserExists_ReturnsUser() { /* ... */ }

        [Fact]
        public async Task WhenUserNotFound_ReturnsNull() { /* ... */ }
    }

    public class CreateUserTests
    {
        [Fact]
        public async Task WithValidData_CreatesAndReturnsUser() { /* ... */ }

        [Fact]
        public async Task WithDuplicateEmail_ThrowsValidationException() { /* ... */ }
    }
}

// IClassFixture — shared, expensive setup created once per test class
public class DatabaseFixture : IDisposable
{
    public readonly AppDbContext Db;

    public DatabaseFixture()
    {
        // Create a unique in-memory database — isolated per fixture instance
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;
        Db = new AppDbContext(options);
        Db.Database.EnsureCreated();
    }

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

public class CustomerRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly AppDbContext _db;

    // xUnit injects the fixture via the constructor
    public CustomerRepositoryTests(DatabaseFixture fixture)
        => _db = fixture.Db;

    [Fact]
    public async Task AddAsync_PersistsCustomer()
    {
        var repo = new CustomerRepository(_db);
        await repo.AddAsync(new Customer { Name = "Alice" });
        var all = await repo.GetAllAsync();
        all.Should().ContainSingle(c => c.Name == "Alice");
    }
}

Testing Async Code

Async code is just as testable as synchronous code — test methods simply use async Task instead of void, and you await the code under test. Testing cancellation is straightforward with CancellationTokenSource: cancel the token before passing it to the operation and assert that OperationCanceledException is thrown.

[Fact]
public async Task FetchDataAsync_WithValidId_ReturnsResult()
{
    // Arrange — set up a mock that returns a known value asynchronously
    var mock = new Mock<IDataSource>();
    mock.Setup(s => s.GetAsync(42)).ReturnsAsync("hello");

    var service = new DataService(mock.Object);

    // Act — await the async method under test
    string result = await service.FetchDataAsync(42);

    // Assert
    result.Should().Be("hello");
}

[Fact]
public async Task FetchDataAsync_WhenCancelled_ThrowsOperationCancelled()
{
    // Cancel the token before passing it — simulates an already-cancelled context
    using var cts = new CancellationTokenSource();
    cts.Cancel();

    var service = new DataService(new SlowDataSource());

    await service.Invoking(s => s.FetchDataAsync(1, cts.Token))
                 .Should().ThrowAsync<OperationCanceledException>();
}

NUnit Comparison

If you prefer NUnit syntax, the concepts map directly. NUnit uses [TestFixture] and [Test] instead of xUnit’s convention-based discovery, [SetUp]/[TearDown] instead of constructors, and [TestCase] for parameterized tests. The choice between them is mostly a matter of team preference; both are well-supported and feature-complete.

using NUnit.Framework;

[TestFixture]
public class CalculatorNUnitTests
{
    private Calculator _sut;

    [SetUp]
    public void SetUp() => _sut = new Calculator();  // runs before each test

    [Test]
    public void Add_ReturnsCorrectSum()
    {
        Assert.That(_sut.Add(2, 3), Is.EqualTo(5));
    }

    // [TestCase] is NUnit's equivalent of xUnit's [InlineData]
    [TestCase(1, 2, 3)]
    [TestCase(10, 20, 30)]
    public void Add_MultipleInputs(int a, int b, int expected)
    {
        Assert.That(_sut.Add(a, b), Is.EqualTo(expected));
    }

    [TearDown]
    public void TearDown() { /* runs after each test — clean up here */ }
}

Frequently Asked Questions

Which test framework should I use — xUnit, NUnit, or MSTest?
xUnit is the most modern and is used by the .NET team itself. It encourages good practices (no shared state between tests, constructor-based setup). NUnit is very mature with a rich assertion library. MSTest is Microsoft's built-in option. For new projects, xUnit is the most common choice.
What is the difference between a unit test and an integration test?
A unit test tests a single class or method in isolation, with all dependencies replaced by fakes or mocks. An integration test tests multiple components working together — often involving a real database, file system, or HTTP client. Unit tests are fast; integration tests catch wiring issues.
What does Moq do?
Moq creates mock objects that implement interfaces or virtual methods. You configure what they return and verify they were called correctly. This lets you test a class in isolation without needing real implementations of its dependencies.