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
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 testbuilds 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
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
[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
Taskand useawait; useAssert.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:
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.
Testing a discount calculator
// 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);
}
}Passed! - Failed: 0, Passed: 6, Skipped: 0, Total: 6Line-by-line explanation
- Line 7ToUpperInvariant makes codes case-insensitive, which the second row checks.
- Lines 21 to 25Each InlineData row becomes its own test case.
- Line 23Half of 39.99 is 19.995, which rounds to the even neighbour 20.00 with banker's rounding.
- Lines 26 to 28Attribute values can't be decimals, so the test takes doubles and converts them.
- Line 34Assert.Throws returns the exception so its details can be checked too.
- Line 35ParamName shows which argument was rejected. Five theory rows plus one fact make six tests.
A fake clock for time-based code
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());
}
}Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3Line-by-line explanation
- Line 1The class depends on an interface, not on DateTime.Now directly.
- Line 8A not pattern excludes the weekend.
- Lines 13 to 16The fake clock always returns the time the test chose.
- Lines 21 to 23Tests cover a normal case, the closing-time boundary and a weekend.
- Line 26Without the interface, these tests would only pass at certain times of day.
Testing async code with a fake API
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));
}
}Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2Line-by-line explanation
- Line 15The fake counts calls, so the test can check how it was used.
- Line 19Task.FromResult returns an already completed task, so the fake is instant.
- Line 26Async tests return Task and await the code under test.
- Line 30100 pounds at 1.17 is 117 euros.
- Line 37ThrowsAsync awaits the task and checks the exception type.
Common C# Advanced Mistakes
Expected and actual the wrong way round
Assert.Equal(result, 42); // failure message says "Expected: <result>" which is confusingAssert.Equal(42, result);Tests that depend on the real clock or network
Assert.True(new OpeningHours().IsOpen()); // passes only during opening hoursvar hours = new OpeningHours(new FakeClock(new DateTime(2026, 3, 4, 10, 0, 0)));
Assert.True(hours.IsOpen());async void tests
[Fact]
public async void Loads() { await service.LoadAsync(); } // failures can be missed[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.
Complete the attribute that marks a simple test in xUnit.
]
public void Adds() => Assert.Equal(4, 2 + 2);
Which command runs every test in a solution?
What does [Theory] with three [InlineData] rows produce?
Will this test pass?
[Fact]
public void Divide_ByZero_Throws() =>
Assert.Throws<DivideByZeroException>(() => { int z = 0; _ = 10 / z; });
Why make classes depend on interfaces like IClock rather than DateTime.Now?
C# Advanced Quiz
5 questions to check what you learned.
In Assert.Equal, which argument comes first?
- The actual value
- The expected value
- The message
- It doesn't matter
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
What should an async test method return?
- void
- bool
- Task<bool>
- Task
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
Which assertion checks that awaited code throws an exception?
- Assert.Throws
- Assert.Fail
- Assert.Raises
- Assert.ThrowsAsync