C# Unit Testing

Write automated tests for C# code with xUnit: create a test project, write Fact and Theory tests, assert results and exceptions, follow Arrange-Act-Assert, test async code, and replace dependencies with fakes.

Lesson 24 of 43Advanced18 min read

Every time you change code, there's a risk of breaking something that used to work. Checking everything by hand after each change is slow and unreliable. Unit tests are small methods that call your code with known inputs and check the results automatically. A good test suite runs in seconds, catches mistakes the moment you make them, and lets you improve code with confidence.

.NET has three popular testing frameworks: xUnit, NUnit and MSTest. They all work the same way: you mark methods as tests, call your code, and use assertions to check the outcome. This lesson uses xUnit, the framework used by the .NET team itself and the default in many new projects; the ideas transfer directly to the others.

You'll set up a test project, write single tests and data-driven tests, check for exceptions, test async methods, and use fakes to test code that depends on databases, clocks or web services. You'll also see why code designed around interfaces, as in the interfaces lesson, is so much easier to test.

Setting up a test project

Terminal
dotnet new xunit -o Shop.Tests
dotnet add Shop.Tests reference Shop
dotnet test
  • Tests live in a separate project that references the code being tested.
  • dotnet test builds everything and runs every test, printing a summary of passes and failures.
  • Visual Studio, VS Code and Rider also show a test explorer where you can run and debug single tests.

Facts, theories and assertions

C#
public class PriceCalculatorTests
{
    [Fact]
    public void Total_AddsPrices()
    {
        var calc = new PriceCalculator();               // Arrange
        decimal total = calc.Total([2.50m, 7.50m]);     // Act
        Assert.Equal(10m, total);                       // Assert
    }

    [Theory]
    [InlineData(100, 10, 90)]
    [InlineData(80, 25, 60)]
    public void ApplyDiscount_ReducesPrice(int price, int percent, int expected)
    {
        Assert.Equal(expected, PriceCalculator.ApplyDiscount(price, percent));
    }
}
  • [Fact] marks a test with no parameters.
  • [Theory] with [InlineData] runs the same test once for each row of data.
  • Assert.Equal(expected, actual) puts the expected value first, which makes failure messages read correctly.
  • Other common assertions: Assert.True, Assert.False, Assert.Null, Assert.NotNull, Assert.Contains, Assert.Empty.

Exceptions, async and fakes

C#
[Fact]
public void Withdraw_MoreThanBalance_Throws()
{
    var account = new Account(50m);
    Assert.Throws<InvalidOperationException>(() => account.Withdraw(80m));
}

[Fact]
public async Task LoadAsync_ReturnsProducts()
{
    var service = new ProductService(new FakeProductApi());
    var products = await service.LoadAsync();
    Assert.Equal(2, products.Count);
}
  • Assert.Throws<T> passes only if the code throws that exception type, and returns the exception so you can check its message.
  • Async tests return Task and use await; use Assert.ThrowsAsync<T> for async exceptions.
  • A fake is a simple class implementing the same interface as a real dependency, returning fixed data. Mocking libraries such as NSubstitute and Moq create these objects for you and can verify which methods were called.

A fresh test class for every test

xUnit creates a new instance of your test class for every single test it runs. That means the constructor works as the setup step, and fields can't leak changes from one test into another:

C#
public class BasketTests
{
    private readonly Basket basket;

    public BasketTests()
    {
        basket = new Basket();   // runs before each test
    }

    [Fact]
    public void Add_OneItem_HasOneItem()
    {
        basket.Add("Mug");
        Assert.Single(basket.Items);
    }

    [Fact]
    public void NewBasket_IsEmpty()
    {
        Assert.Empty(basket.Items);   // passes: this test has its own basket
    }
}

If a test needs clean-up afterwards, such as deleting a temporary file, implement IDisposable on the test class and put the clean-up in Dispose, which runs after each test.

Static fields are the exception: they're shared by every instance, so state stored in them survives between tests and can make results depend on the order tests run in. Keep test data in instance fields.

While you're working on one feature, you don't have to run the whole suite. dotnet test --filter "FullyQualifiedName~BasketTests" runs only the tests whose full name contains that text.

C# Advanced Syntax

xUnit syntax

using Xunit;

public class CartTests
{
    [Fact]
    public void NewCart_IsEmpty() => Assert.Empty(new Cart().Items);

    [Theory]
    [InlineData("", false)]
    [InlineData("ada@example.com", true)]
    public void IsEmail_Works(string input, bool expected) =>
        Assert.Equal(expected, Validator.IsEmail(input));

    [Fact]
    public async Task Save_Throws_WhenOffline() =>
        await Assert.ThrowsAsync<HttpRequestException>(() => new Sync(new OfflineApi()).SaveAsync());
}

C# Advanced Summary

  • Unit tests call code with known inputs and assert the results automatically.
  • [Fact] marks a single test; [Theory] with [InlineData] runs it for several inputs.
  • Assert.Equal(expected, actual), Assert.True and Assert.Throws<T> are core assertions.
  • Async tests return Task and await the code; fakes replace real dependencies.
  • dotnet test runs every test; Arrange-Act-Assert keeps tests readable.

C# Advanced Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Testing a discount calculator

C#
// Shop/Discounts.cs
public static class Discounts
{
    public static decimal Apply(decimal price, string? code)
    {
        if (price < 0) throw new ArgumentOutOfRangeException(nameof(price));
        decimal rate = code?.ToUpperInvariant() switch
        {
            "SAVE10" => 0.10m,
            "HALF" => 0.50m,
            _ => 0m
        };
        return Math.Round(price * (1 - rate), 2);
    }
}

// Shop.Tests/DiscountsTests.cs
public class DiscountsTests
{
    [Theory]
    [InlineData(100, "SAVE10", 90)]
    [InlineData(100, "save10", 90)]
    [InlineData(39.99, "HALF", 20.00)]
    [InlineData(50, null, 50)]
    [InlineData(50, "BOGUS", 50)]
    public void Apply_ReturnsDiscountedPrice(double price, string? code, double expected)
    {
        Assert.Equal((decimal)expected, Discounts.Apply((decimal)price, code));
    }

    [Fact]
    public void Apply_NegativePrice_Throws()
    {
        var ex = Assert.Throws<ArgumentOutOfRangeException>(() => Discounts.Apply(-1, null));
        Assert.Equal("price", ex.ParamName);
    }
}
Output
Passed!  - Failed: 0, Passed: 6, Skipped: 0, Total: 6
Line-by-line explanation
  1. Line 7ToUpperInvariant makes codes case-insensitive, which the second row checks.
  2. Lines 21 to 25Each InlineData row becomes its own test case.
  3. Line 23Half of 39.99 is 19.995, which rounds to the even neighbour 20.00 with banker's rounding.
  4. Lines 26 to 28Attribute values can't be decimals, so the test takes doubles and converts them.
  5. Line 34Assert.Throws returns the exception so its details can be checked too.
  6. Line 35ParamName shows which argument was rejected. Five theory rows plus one fact make six tests.
Example 2

A fake clock for time-based code

C#
public interface IClock { DateTime Now { get; } }

public class OpeningHours(IClock clock)
{
    public bool IsOpen()
    {
        var now = clock.Now;
        bool weekday = now.DayOfWeek is not (DayOfWeek.Saturday or DayOfWeek.Sunday);
        return weekday && now.Hour >= 9 && now.Hour < 17;
    }
}

public class FakeClock(DateTime now) : IClock
{
    public DateTime Now => now;
}

public class OpeningHoursTests
{
    [Theory]
    [InlineData(2026, 3, 4, 10, true)]    // Wednesday 10:00
    [InlineData(2026, 3, 4, 17, false)]   // Wednesday 17:00
    [InlineData(2026, 3, 7, 11, false)]   // Saturday
    public void IsOpen_DependsOnDayAndHour(int y, int m, int d, int hour, bool expected)
    {
        var hours = new OpeningHours(new FakeClock(new DateTime(y, m, d, hour, 0, 0)));
        Assert.Equal(expected, hours.IsOpen());
    }
}
Output
Passed!  - Failed: 0, Passed: 3, Skipped: 0, Total: 3
Line-by-line explanation
  1. Line 1The class depends on an interface, not on DateTime.Now directly.
  2. Line 8A not pattern excludes the weekend.
  3. Lines 13 to 16The fake clock always returns the time the test chose.
  4. Lines 21 to 23Tests cover a normal case, the closing-time boundary and a weekend.
  5. Line 26Without the interface, these tests would only pass at certain times of day.
Example 3

Testing async code with a fake API

C#
public interface IRatesApi { Task<decimal> GetRateAsync(string currency); }

public class Converter(IRatesApi api)
{
    public async Task<decimal> ToEurosAsync(decimal pounds)
    {
        if (pounds < 0) throw new ArgumentException("Amount can't be negative");
        decimal rate = await api.GetRateAsync("EUR");
        return Math.Round(pounds * rate, 2);
    }
}

public class FakeRates : IRatesApi
{
    public int Calls { get; private set; }
    public Task<decimal> GetRateAsync(string currency)
    {
        Calls++;
        return Task.FromResult(1.17m);
    }
}

public class ConverterTests
{
    [Fact]
    public async Task ToEuros_UsesTheRate()
    {
        var fake = new FakeRates();
        var result = await new Converter(fake).ToEurosAsync(100m);
        Assert.Equal(117.00m, result);
        Assert.Equal(1, fake.Calls);
    }

    [Fact]
    public async Task ToEuros_Negative_Throws()
    {
        await Assert.ThrowsAsync<ArgumentException>(() => new Converter(new FakeRates()).ToEurosAsync(-5m));
    }
}
Output
Passed!  - Failed: 0, Passed: 2, Skipped: 0, Total: 2
Line-by-line explanation
  1. Line 15The fake counts calls, so the test can check how it was used.
  2. Line 19Task.FromResult returns an already completed task, so the fake is instant.
  3. Line 26Async tests return Task and await the code under test.
  4. Line 30100 pounds at 1.17 is 117 euros.
  5. Line 37ThrowsAsync awaits the task and checks the exception type.

Common C# Advanced Mistakes

Expected and actual the wrong way round

Don't
Assert.Equal(result, 42);       // failure message says "Expected: <result>" which is confusing
Do
Assert.Equal(42, result);

Tests that depend on the real clock or network

Don't
Assert.True(new OpeningHours().IsOpen());   // passes only during opening hours
Do
var hours = new OpeningHours(new FakeClock(new DateTime(2026, 3, 4, 10, 0, 0)));
Assert.True(hours.IsOpen());

async void tests

Don't
[Fact]
public async void Loads() { await service.LoadAsync(); }   // failures can be missed
Do
[Fact]
public async Task Loads() { await service.LoadAsync(); }

C# Advanced in Real Projects

Testing in .NET teams

Professional teams run dotnet test on every pull request in continuous integration with GitHub Actions or Azure DevOps, and block merges when tests fail. Business rules, calculations and validation get thorough unit tests, while integration tests use WebApplicationFactory to start a real ASP.NET Core app in memory and call its endpoints. Libraries such as FluentAssertions make assertions read like sentences, and coverage tools show which code isn't tested yet.

C# Advanced FAQ

What is the difference between xUnit, NUnit and MSTest?

All three are mature .NET test frameworks that run with dotnet test and in every major editor. They mainly differ in attribute names: xUnit uses [Fact] and [Theory], NUnit uses [Test] and [TestCase], and MSTest uses [TestMethod] and [DataRow]. xUnit is a very common choice for new projects, but any of them is fine; pick the one your team already uses.

What is the difference between [Fact] and [Theory] in xUnit?

A [Fact] is a single test with no parameters that should always pass. A [Theory] is a test with parameters that runs once for each data row you supply, usually with [InlineData]. Use a theory when the same check applies to several input and output pairs.

How do I skip a test in xUnit?

Set the Skip property with a reason: [Fact(Skip = "Waiting for the payment sandbox")]. The test is reported as skipped instead of passing or failing. Don't leave skipped tests around for long, because they quietly stop protecting your code.

How do I test that a method throws an exception in C#?

In xUnit, use Assert.Throws<InvalidOperationException>(() => account.Withdraw(80m));, which fails if no exception or a different type is thrown. It returns the exception, so you can also check its Message. For async methods, use await Assert.ThrowsAsync<T>(...).

What is the difference between a mock and a fake?

A fake is a working but simplified implementation, such as an in-memory repository, that you write yourself. A mock is usually created by a library like Moq or NSubstitute and can also record calls, so a test can verify that a method was called with particular arguments. Both replace real dependencies so tests run fast and give the same result every time.

C# Advanced Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Fill in the blankseasy

Complete the attribute that marks a simple test in xUnit.

]
public void Adds() => Assert.Equal(4, 2 + 2);
Multiple choiceeasy

Which command runs every test in a solution?

Multiple choicemedium

What does [Theory] with three [InlineData] rows produce?

Predict the outputmedium

Will this test pass?

[Fact]
public void Divide_ByZero_Throws() =>
    Assert.Throws<DivideByZeroException>(() => { int z = 0; _ = 10 / z; });
Multiple choicehard

Why make classes depend on interfaces like IClock rather than DateTime.Now?

C# Advanced Quiz

5 questions to check what you learned.

  1. In Assert.Equal, which argument comes first?

    • The actual value
    • The expected value
    • The message
    • It doesn't matter
  2. What are the three parts of the Arrange-Act-Assert pattern?

    • Add, Assign, Approve
    • Build, run, deploy
    • Set up inputs, call the code under test, check the result
    • Create, update, delete
  3. What should an async test method return?

    • void
    • bool
    • Task<bool>
    • Task
  4. What is a fake in testing?

    • A test that always passes
    • A broken build
    • A simple implementation of a dependency that returns controlled data
    • A copy of the database
  5. Which assertion checks that awaited code throws an exception?

    • Assert.Throws
    • Assert.Fail
    • Assert.Raises
    • Assert.ThrowsAsync