Skip to content
Happy Programming Guide
Start learning
C#

C# Testing

Writing unit tests in C# with xUnit: arrange-act-assert, parameterised tests, mocking dependencies, and what is actually worth testing.

A laptop computer on a table

A unit test is a small program that runs part of your code and checks the answer. In C# that means xUnit, NUnit or MSTest — xUnit is the common default for new projects. This guide covers writing tests, the arrange-act-assert shape, testing code that depends on a database or a clock, and how to decide what deserves a test at all.

Setting up#

Terminal
dotnet new xunit -n MyApp.Tests
cd MyApp.Tests
dotnet add reference ../MyApp/MyApp.csproj
dotnet test

The convention is one test project per production project, named after it with .Tests appended.

A first test#

CSHARP
public class Calculator
{
    public decimal ApplyDiscount(decimal price, int percent)
    {
        if (percent < 0 || percent > 100)
            throw new ArgumentOutOfRangeException(nameof(percent));

        return Math.Round(price * (100 - percent) / 100m, 2);
    }
}
CSHARP
using Xunit;

public class CalculatorTests
{
    [Fact]
    public void ApplyDiscount_ReducesPriceByPercentage()
    {
        // Arrange
        var calculator = new Calculator();

        // Act
        decimal result = calculator.ApplyDiscount(100m, 20);

        // Assert
        Assert.Equal(80m, result);
    }

    [Fact]
    public void ApplyDiscount_RejectsNegativePercentage()
    {
        var calculator = new Calculator();

        Assert.Throws<ArgumentOutOfRangeException>(
            () => calculator.ApplyDiscount(100m, -5));
    }
}

Every test has the same three parts: set up the situation, do the thing, check the result. Keeping them visually separate makes tests scannable, which matters when one fails a year from now.

The naming convention MethodName_ExpectedBehaviour means a failing test tells you what broke without opening the file.

Parameterised tests#

CSHARP
[Theory]
[InlineData(100, 0, 100)]
[InlineData(100, 50, 50)]
[InlineData(100, 100, 0)]
[InlineData(19.99, 10, 17.99)]
public void ApplyDiscount_CalculatesCorrectly(decimal price, int percent, decimal expected)
{
    var calculator = new Calculator();
    Assert.Equal(expected, calculator.ApplyDiscount(price, percent));
}

Four cases, one method, and each appears separately in the results so you can see exactly which combination failed.

For values that cannot be compile-time constants, use a member data source:

CSHARP
public static IEnumerable<object[]> DiscountCases => new List<object[]>
{
    new object[] { 100m, 20, 80m },
    new object[] { 250m, 15, 212.50m },
};

[Theory]
[MemberData(nameof(DiscountCases))]
public void ApplyDiscount_HandlesCases(decimal price, int percent, decimal expected)
{
    Assert.Equal(expected, new Calculator().ApplyDiscount(price, percent));
}

The assertions you will use#

CSHARP
Assert.Equal(expected, actual);
Assert.NotEqual(a, b);
Assert.True(condition);
Assert.Null(value);
Assert.NotNull(value);
Assert.Empty(collection);
Assert.Contains("text", message);
Assert.Single(collection);
Assert.Throws<InvalidOperationException>(() => DoThing());
await Assert.ThrowsAsync<HttpRequestException>(() => FetchAsync());

// Floating point needs a tolerance
Assert.Equal(0.3, 0.1 + 0.2, precision: 10);

Making code testable#

This class cannot be tested reliably:

CSHARP
public class OrderService
{
    public bool IsExpired(Order order)
    {
        return order.CreatedAt.AddDays(30) < DateTime.Now;   // reads the clock
    }
}

The test result depends on when it runs. Inject the dependency instead:

CSHARP
public interface IClock
{
    DateTime Now { get; }
}

public class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

public class OrderService
{
    private readonly IClock _clock;

    public OrderService(IClock clock) => _clock = clock;

    public bool IsExpired(Order order) => order.CreatedAt.AddDays(30) < _clock.Now;
}
CSHARP
public class FixedClock : IClock
{
    public FixedClock(DateTime now) => Now = now;
    public DateTime Now { get; }
}

[Fact]
public void IsExpired_ReturnsTrueAfterThirtyDays()
{
    var order = new Order { CreatedAt = new DateTime(2026, 1, 1) };
    var service = new OrderService(new FixedClock(new DateTime(2026, 2, 15)));

    Assert.True(service.IsExpired(order));
}

The same pattern applies to databases, HTTP clients, file systems and random numbers — anything the test cannot control. Code that is hard to test is usually code with hidden dependencies, so the difficulty is telling you something about the design.

Mocking#

Terminal
dotnet add package Moq
CSHARP
using Moq;

[Fact]
public void PlaceOrder_SavesAndNotifies()
{
    var repository = new Mock<IOrderRepository>();
    var notifier = new Mock<INotifier>();

    repository.Setup(r => r.Save(It.IsAny<Order>())).Returns(42);

    var service = new OrderService(repository.Object, notifier.Object);
    service.PlaceOrder(new Order());

    repository.Verify(r => r.Save(It.IsAny<Order>()), Times.Once);
    notifier.Verify(n => n.Send(It.IsAny<string>()), Times.Once);
}

Use mocks for things you cannot call in a test — sending real emails, charging real cards. Do not mock your own simple classes; a real instance is clearer and tests more.

Setup and cleanup#

CSHARP
public class DatabaseTests : IDisposable
{
    private readonly TestDatabase _db;

    public DatabaseTests()      // runs before each test
    {
        _db = new TestDatabase();
    }

    public void Dispose()       // runs after each test
    {
        _db.Dispose();
    }
}

xUnit creates a new instance of the class for every test, so the constructor is your setup and there is no shared state to leak between tests. That is a deliberate design difference from NUnit and MSTest.

What to test#

Worth testing: business rules, calculations, validation, edge cases, boundaries, anything you have already had to fix once, and any bug you fix — write the failing test first, then make it pass.

Not worth testing: property getters and setters, framework behaviour, third-party libraries, and code that only wires other code together.

Running them#

Terminal
dotnet test
dotnet test --filter "FullyQualifiedName~Calculator"
dotnet test --logger "console;verbosity=detailed"
dotnet test --collect:"XPlat Code Coverage"

Questions people ask#

xUnit, NUnit or MSTest?

All three are capable. xUnit is the common choice for new .NET projects and has the cleanest isolation model. Use whichever your team already uses — the differences are small.

What is the difference between a unit and an integration test?

A unit test exercises one piece in isolation and runs in milliseconds. An integration test exercises several pieces together, often including a real database. You want many of the first and a few of the second.

Should I write tests before the code?

Test-driven development works well for algorithms and business rules where you can state the expected result up front. It is more awkward for exploratory work. Writing the test first for every bug fix is valuable regardless of your general approach.

How do I test private methods?

Usually you do not — test them through the public method that calls them. If a private method is complex enough to need its own tests, that is a hint it wants to be its own class.

Where to go next#

C# deployment, once the tests passRead next

Keep reading

C#

C# Deployment

How to publish a .NET application: framework-dependent versus self-contained builds, single-file output, trimming, Docker and configuration for production.

4 min read

C#

C# Basics

The core of C# in one page: types, variables, conditions, loops, methods, collections and classes, with the compiler errors beginners meet.

5 min read

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *