C# · lesson 21 of 26
Testing in C#
Testing C# with xUnit by example: Fact and Theory, readable failures with FluentAssertions, Moq mocks and Verify, fixtures, async tests, and NUnit.
By SkillByExample · Updated
Setting Up xUnit
A test project is a separate project that references the code under test and runs with dotnet test. The usual layout:
dotnet new classlib -n Shop.Core
dotnet new xunit -n Shop.Tests
dotnet add Shop.Tests reference Shop.Core
dotnet add Shop.Tests package Moq --version 4.20.72
dotnet add Shop.Tests package FluentAssertions --version 7.0.0
To keep each example to two files, the examples below put the class under test and its tests in the same Shop.Tests project; with the layout above, the production class lives in Shop.Core instead. FluentAssertions is pinned to 7.x, the last Apache-2.0 release — check the license terms of 8.x before adopting it in commercial code.
Facts and Theories
[Fact] is a test with no parameters. [Theory] runs the same test once per [InlineData] row. Each test follows Arrange, Act, Assert.
public class Calculator
{
public int Add(int a, int b) => a + b;
public double Divide(int a, int b) => b == 0 ? throw new DivideByZeroException() : (double)a / b;
public bool IsEvenProduct(int a, int b) => a * b % 2 == 0;
}
public class CalculatorTests
{
private readonly Calculator _sut = new();
[Fact]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
int result = _sut.Add(3, 4);
Assert.Equal(7, result);
}
[Fact]
public void Divide_ByZero_Throws() =>
Assert.Throws<DivideByZeroException>(() => _sut.Divide(10, 0));
[Theory]
[InlineData(2, 4, true)]
[InlineData(3, 4, false)]
[InlineData(3, 5, false)]
public void IsEvenProduct_ReturnsExpected(int a, int b, bool expected) =>
Assert.Equal(expected, _sut.IsEvenProduct(a, b));
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:01.16] CalculatorTests.IsEvenProduct_ReturnsExpected(a: 3, b: 4, expected: False) [FAIL]
Failed CalculatorTests.IsEvenProduct_ReturnsExpected(a: 3, b: 4, expected: False) [6 ms]
Error Message:
Assert.Equal() Failure
Expected: False
Actual: True
Stack Trace:
at CalculatorTests.IsEvenProduct_ReturnsExpected(Int32 a, Int32 b, Boolean expected) in CalculatorTests.cs:line 21
at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span`1 copyOfArgs, BindingFlags invokeAttr)
Failed! - Failed: 1, Passed: 4, Skipped: 0, Total: 5, Duration: 8 ms - Shop.Tests.dll (net8.0)
Five tests ran — each [InlineData] row counts as its own test — and one failed. The failure is in the test data, not the code: 3 × 4 = 12 is even, so (3, 4, false) is wrong. The report names the exact row that failed, (a: 3, b: 4, expected: False), and the file and line. _sut (“system under test”) is a field initialised per test, because xUnit creates a new CalculatorTests instance for every test.
MemberData for Computed Cases
When test cases need computation or objects that can’t be attribute arguments, supply them from a static member.
public class Calculator
{
public double Divide(int a, int b) => b == 0 ? throw new DivideByZeroException() : (double)a / b;
}
public class DivideTests
{
public static IEnumerable<object[]> Cases()
{
yield return new object[] { 10, 2, 5.0 };
yield return new object[] { 7, 2, 3.5 };
yield return new object[] { 1, 3, 0.33333 };
}
[Theory]
[MemberData(nameof(Cases))]
public void Divide_ValidInputs_ReturnsQuotient(int a, int b, double expected) =>
Assert.Equal(expected, new Calculator().Divide(a, b), precision: 5);
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 6 ms - Shop.Tests.dll (net8.0)
All three rows pass, including 1/3: precision: 5 rounds both values to five decimal places before comparing, which is how you compare floating-point results without being tripped up by the last bits. (dotnet test prints only failures and the summary by default; add --logger "console;verbosity=normal" to list every test.)
Readable Failures with FluentAssertions
A failing assertion should tell you what went wrong without opening the debugger. Compare the same failure in both styles.
public class Order
{
private readonly List<(string Product, int Qty, decimal Price)> _lines = new();
public void AddLine(string product, int qty, decimal price) => _lines.Add((product, qty, price));
public decimal Total => _lines.Sum(l => l.Price);
public IReadOnlyList<string> Products => _lines.Select(l => l.Product).ToList();
}
using FluentAssertions;
public class OrderTests
{
private static Order TwoLineOrder()
{
var order = new Order();
order.AddLine("Widget", 2, 9.99m);
order.AddLine("Gadget", 1, 49.99m);
return order;
}
[Fact]
public void Total_PlainAssert() => Assert.Equal(69.97m, TwoLineOrder().Total);
[Fact]
public void Total_Fluent() => TwoLineOrder().Total.Should().Be(69.97m);
[Fact]
public void Products_Fluent() =>
TwoLineOrder().Products.Should().HaveCount(2).And.ContainInOrder("Widget", "Gadget");
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.85] OrderTests.Total_PlainAssert [FAIL]
[xUnit.net 00:00:00.99] OrderTests.Total_Fluent [FAIL]
Failed OrderTests.Total_PlainAssert [19 ms]
Error Message:
Assert.Equal() Failure
Expected: 69.97
Actual: 59.98
Stack Trace:
at OrderTests.Total_PlainAssert() in OrderTests.cs:line 14
at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Failed OrderTests.Total_Fluent [146 ms]
Error Message:
Expected TwoLineOrder().Total to be 69.97M, but found 59.98M (difference of -9.99).
Stack Trace:
at FluentAssertions.Execution.XUnit2TestFramework.Throw(String message)
at FluentAssertions.Execution.TestFrameworkProvider.Throw(String message)
at FluentAssertions.Execution.DefaultAssertionStrategy.HandleFailure(String message)
at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
at FluentAssertions.Execution.AssertionScope.FailWith(Func`1 failReasonFunc)
at FluentAssertions.Execution.AssertionScope.FailWith(String message, Object[] args)
at FluentAssertions.Numeric.NumericAssertions`2.Be(T expected, String because, Object[] becauseArgs)
at OrderTests.Total_Fluent() in OrderTests.cs:line 17
at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr)
Failed! - Failed: 2, Passed: 1, Skipped: 0, Total: 3, Duration: 133 ms - Shop.Tests.dll (net8.0)
Both total tests caught the same bug — Total ignores the quantity, so the Widget line counts once instead of twice (59.98 instead of 69.97). The plain message is just two numbers; the FluentAssertions message names the expression it checked. The third test passes, and shows how collection assertions chain with .And. The fix is _lines.Sum(l => l.Qty * l.Price).
Mocking Dependencies with Moq
A unit test should exercise one class. Moq generates fake implementations of its interfaces so you can control what dependencies return and verify how they were called.
public record Order(int Id, string CustomerEmail, decimal Total);
public interface IOrderRepository { Task<bool> SaveAsync(Order order); }
public interface IEmailService { Task SendAsync(string to, string subject); }
public class OrderProcessor(IOrderRepository repo, IEmailService email)
{
public async Task<bool> ProcessAsync(Order order)
{
if (!await repo.SaveAsync(order)) return false;
await email.SendAsync(order.CustomerEmail, $"Order {order.Id} confirmed");
return true;
}
}
using FluentAssertions;
using Moq;
public class OrderProcessorTests
{
private readonly Mock<IOrderRepository> _repo = new();
private readonly Mock<IEmailService> _email = new();
private readonly OrderProcessor _sut;
public OrderProcessorTests() => _sut = new OrderProcessor(_repo.Object, _email.Object);
[Fact]
public async Task ValidOrder_SavesAndSendsEmail()
{
var order = new Order(1, "alice@example.com", 99m);
_repo.Setup(r => r.SaveAsync(order)).ReturnsAsync(true);
(await _sut.ProcessAsync(order)).Should().BeTrue();
_email.Verify(e => e.SendAsync("alice@example.com", "Order 1 confirmed"), Times.Once);
}
[Fact]
public async Task SaveFails_DoesNotSendEmail()
{
_repo.Setup(r => r.SaveAsync(It.IsAny<Order>())).ReturnsAsync(false);
(await _sut.ProcessAsync(new Order(2, "bob@example.com", 50m))).Should().BeFalse();
_email.Verify(e => e.SendAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task RepositoryThrows_ExceptionPropagates()
{
_repo.Setup(r => r.SaveAsync(It.IsAny<Order>())).ThrowsAsync(new TimeoutException("db timeout"));
await _sut.Invoking(s => s.ProcessAsync(new Order(3, "c@example.com", 10m)))
.Should().ThrowAsync<TimeoutException>().WithMessage("*timeout*");
}
}
$ dotnet test --logger "console;verbosity=normal"
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.4.5+1caef2f33e (64-bit .NET 8.0.31)
[xUnit.net 00:00:00.75] Discovering: Shop.Tests
[xUnit.net 00:00:00.81] Discovered: Shop.Tests
[xUnit.net 00:00:00.82] Starting: Shop.Tests
[xUnit.net 00:00:01.10] Finished: Shop.Tests
Passed OrderProcessorTests.ValidOrder_SavesAndSendsEmail [136 ms]
Passed OrderProcessorTests.SaveFails_DoesNotSendEmail [14 ms]
Passed OrderProcessorTests.RepositoryThrows_ExceptionPropagates [33 ms]
Test Run Successful.
Total tests: 3
Passed: 3
Total time: 2.0347 Seconds
No database and no mail server were involved: Setup decides what the fake repository returns, and Verify checks the interaction — the email was sent exactly once with the right arguments on success, and never on failure. An unconfigured mock method returns a default (false, null, or a completed task), which is why the email mock needed no setup. Verify outcomes where you can and interactions only where the interaction is the behaviour, as with sending an email; over-verified tests break on every refactor.
Setup, Teardown and Shared Fixtures
The constructor is per-test setup and Dispose is per-test teardown. A class fixture is created once and shared by every test in the class — the place for expensive setup such as a database.
public class DatabaseFixture : IDisposable
{
public static int Created;
public List<string> Rows { get; } = new() { "seed" };
public DatabaseFixture() => Created++;
public void Dispose() { }
}
using Xunit.Abstractions;
public class LifecycleTests : IClassFixture<DatabaseFixture>, IDisposable
{
private static int _instances;
private readonly DatabaseFixture _db;
private readonly ITestOutputHelper _out;
public LifecycleTests(DatabaseFixture db, ITestOutputHelper output)
{
_db = db;
_out = output;
_instances++;
}
[Fact]
public void First() => _out.WriteLine($"instance {_instances}, fixtures created {DatabaseFixture.Created}");
[Fact]
public void Second() => _out.WriteLine($"instance {_instances}, fixtures created {DatabaseFixture.Created}");
public void Dispose() => _out.WriteLine(" per-test teardown");
}
$ dotnet test --logger "console;verbosity=detailed"
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
bin/Debug/net8.0/Shop.Tests.dll
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.4.5+1caef2f33e (64-bit .NET 8.0.31)
[xUnit.net 00:00:00.56] Discovering: Shop.Tests
[xUnit.net 00:00:00.61] Discovered: Shop.Tests
[xUnit.net 00:00:00.62] Starting: Shop.Tests
[xUnit.net 00:00:00.70] Finished: Shop.Tests
Passed LifecycleTests.Second [3 ms]
Standard Output Messages:
instance 1, fixtures created 1
per-test teardown
Passed LifecycleTests.First [< 1 ms]
Standard Output Messages:
instance 2, fixtures created 1
per-test teardown
Test Run Successful.
Total tests: 2
Passed: 2
Total time: 1.5660 Seconds
Two test-class instances, one fixture — and notice Second ran before First: xUnit does not promise source order, which is another reason tests must not depend on each other. xUnit v2 does not capture Console output from tests, which is why per-test output goes through ITestOutputHelper; that output shows up under each test in detailed verbosity. Because the fixture is shared, tests that modify it can affect each other — reset it, or use a fresh database per test, when tests write data.
Testing Async Code and Cancellation
Test methods can be async Task; xUnit awaits them. Never make a test async void — the runner cannot observe its failures.
public interface IDataSource { Task<string> GetAsync(int id, CancellationToken ct); }
public class DataService(IDataSource source)
{
public async Task<string> FetchUpperAsync(int id, CancellationToken ct = default) =>
(await source.GetAsync(id, ct)).ToUpperInvariant();
}
public class SlowDataSource : IDataSource
{
public async Task<string> GetAsync(int id, CancellationToken ct)
{
await Task.Delay(5000, ct);
return "slow";
}
}
using System.Diagnostics;
using FluentAssertions;
using Moq;
public class DataServiceTests
{
[Fact]
public async Task Fetch_UppercasesResult()
{
var source = new Mock<IDataSource>();
source.Setup(s => s.GetAsync(42, It.IsAny<CancellationToken>())).ReturnsAsync("hello");
(await new DataService(source.Object).FetchUpperAsync(42)).Should().Be("HELLO");
}
[Fact]
public async Task Fetch_WhenCancelled_StopsQuickly()
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50));
var sw = Stopwatch.StartNew();
await new DataService(new SlowDataSource()).Invoking(s => s.FetchUpperAsync(1, cts.Token))
.Should().ThrowAsync<OperationCanceledException>();
sw.ElapsedMilliseconds.Should().BeLessThan(1000);
}
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 100 ms - Shop.Tests.dll (net8.0)
The cancellation test proves more than “it throws”: the elapsed-time check shows the 5-second call was actually abandoned when the token fired, rather than running to completion and throwing afterwards. ThrowAsync<OperationCanceledException> also accepts the derived TaskCanceledException that Task.Delay throws.
The Same Tests in NUnit
NUnit (dotnet new nunit) uses explicit attributes: [TestFixture], [Test], [TestCase], and [SetUp]/[TearDown] in place of the constructor and Dispose.
public class Calculator
{
public int Add(int a, int b) => a + b;
}
[TestFixture]
public class CalculatorNUnitTests
{
private Calculator _sut = null!;
[SetUp]
public void SetUp() => _sut = new Calculator();
[Test]
public void Add_ReturnsCorrectSum() => Assert.That(_sut.Add(2, 3), Is.EqualTo(5));
[TestCase(1, 2, 3)]
[TestCase(10, 20, 30)]
[TestCase(-1, 1, 1)]
public void Add_MultipleInputs(int a, int b, int expected) =>
Assert.That(_sut.Add(a, b), Is.EqualTo(expected));
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.NUnitTests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Failed Add_MultipleInputs(-1,1,1) [61 ms]
Error Message:
Expected: 1
But was: 0
Stack Trace:
at CalculatorNUnitTests.Add_MultipleInputs(Int32 a, Int32 b, Int32 expected) in CalculatorNUnitTests.cs:line 16
at InvokeStub_CalculatorNUnitTests.Add_MultipleInputs(Object, Span`1)
Failed! - Failed: 1, Passed: 3, Skipped: 0, Total: 4, Duration: 94 ms - Shop.NUnitTests.dll (net8.0)
The deliberately wrong (-1, 1, 1) case shows NUnit’s constraint-style message: Expected: 1 But was: 0. Unlike xUnit, NUnit reuses one fixture instance for all tests in the class, which is why it needs [SetUp] to reset _sut — forget that, and state leaks between tests.
Practice
- Fix the Order bug test-first. Correct
Order.Totalto multiply by quantity and rerun the FluentAssertions example so all three tests pass.
Solution
public class Order
{
private readonly List<(string Product, int Qty, decimal Price)> _lines = new();
public void AddLine(string product, int qty, decimal price) => _lines.Add((product, qty, price));
public decimal Total => _lines.Sum(l => l.Qty * l.Price);
public IReadOnlyList<string> Products => _lines.Select(l => l.Product).ToList();
}
using FluentAssertions;
public class OrderTests
{
private static Order TwoLineOrder()
{
var order = new Order();
order.AddLine("Widget", 2, 9.99m);
order.AddLine("Gadget", 1, 49.99m);
return order;
}
[Fact]
public void Total_PlainAssert() => Assert.Equal(69.97m, TwoLineOrder().Total);
[Fact]
public void Total_Fluent() => TwoLineOrder().Total.Should().Be(69.97m);
[Fact]
public void Products_Fluent() =>
TwoLineOrder().Products.Should().HaveCount(2).And.ContainInOrder("Widget", "Gadget");
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 15 ms - Shop.Tests.dll (net8.0)
- Verify with argument matching. Add a test that processes two orders and verifies
SendAsyncwas called exactly twice with any address ending in@example.com.
Solution
public record Order(int Id, string CustomerEmail, decimal Total);
public interface IOrderRepository { Task<bool> SaveAsync(Order order); }
public interface IEmailService { Task SendAsync(string to, string subject); }
public class OrderProcessor(IOrderRepository repo, IEmailService email)
{
public async Task<bool> ProcessAsync(Order order)
{
if (!await repo.SaveAsync(order)) return false;
await email.SendAsync(order.CustomerEmail, $"Order {order.Id} confirmed");
return true;
}
}
using Moq;
public class TwoOrderTests
{
[Fact]
public async Task TwoOrders_TwoEmails()
{
var repo = new Mock<IOrderRepository>();
repo.Setup(r => r.SaveAsync(It.IsAny<Order>())).ReturnsAsync(true);
var email = new Mock<IEmailService>();
var sut = new OrderProcessor(repo.Object, email.Object);
await sut.ProcessAsync(new Order(1, "a@example.com", 1m));
await sut.ProcessAsync(new Order(2, "b@example.com", 2m));
email.Verify(e => e.SendAsync(It.Is<string>(s => s.EndsWith("@example.com")), It.IsAny<string>()),
Times.Exactly(2));
}
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1, Duration: < 1 ms - Shop.Tests.dll (net8.0)
It.Is<T>(predicate) matches arguments by a condition instead of an exact value.
- Theory from a class. Replace the
MemberDatain the divide example with[ClassData(typeof(DivideCases))], whereDivideCasesimplementsIEnumerable<object[]>.
Solution
using System.Collections;
public class Calculator
{
public double Divide(int a, int b) => (double)a / b;
}
public class DivideCases : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
yield return new object[] { 10, 2, 5.0 };
yield return new object[] { 7, 2, 3.5 };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class DivideClassDataTests
{
[Theory]
[ClassData(typeof(DivideCases))]
public void Divide_ReturnsQuotient(int a, int b, double expected) =>
Assert.Equal(expected, new Calculator().Divide(a, b), precision: 5);
}
$ dotnet test
Test run for bin/Debug/net8.0/Shop.Tests.dll (.NETCoreApp,Version=v8.0)
Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 6 ms - Shop.Tests.dll (net8.0)