Testing with xUnit

A complete xUnit test class, Assert.Throws, and data-driven tests with Theory and InlineData.

Why xUnit

xUnit is the most widely used testing framework in the modern .NET ecosystem — the default in dotnet new templates and the de facto standard for new projects (alongside alternatives like NUnit and MSTest, which follow broadly similar ideas). Create a test project and reference the library under test:

Bash
dotnet new xunit -n BankAccount.Tests
cd BankAccount.Tests
dotnet add reference ../BankAccount/BankAccount.csproj
Bash
dotnet test

The code under test

C#
// BankAccount.cs
public class InsufficientFundsException : Exception
{
    public decimal Balance { get; }
    public decimal Amount { get; }

    public InsufficientFundsException(decimal balance, decimal amount)
        : base($"Cannot withdraw {amount:C}: balance is only {balance:C}")
    {
        Balance = balance;
        Amount = amount;
    }
}

public class BankAccount
{
    public decimal Balance { get; private set; }

    public BankAccount(decimal initialBalance = 0m)
    {
        if (initialBalance < 0)
            throw new ArgumentException("Initial balance cannot be negative", nameof(initialBalance));
        Balance = initialBalance;
    }

    public void Deposit(decimal amount)
    {
        if (amount <= 0) throw new ArgumentException("Deposit amount must be positive");
        Balance += amount;
    }

    public void Withdraw(decimal amount)
    {
        if (amount > Balance) throw new InsufficientFundsException(Balance, amount);
        Balance -= amount;
    }
}

A first test class

xUnit test methods are marked [Fact] for a test with no parameters — a single, fixed scenario. Assertions come from the static Assert class:

C#
// BankAccountTests.cs
using Xunit;

public class BankAccountTests
{
    [Fact]
    public void NewAccount_HasZeroBalanceByDefault()
    {
        var account = new BankAccount();

        Assert.Equal(0m, account.Balance);
    }

    [Fact]
    public void Deposit_IncreasesBalance()
    {
        var account = new BankAccount(100m);

        account.Deposit(50m);

        Assert.Equal(150m, account.Balance);
    }

    [Fact]
    public void Withdraw_DecreasesBalance()
    {
        var account = new BankAccount(100m);

        account.Withdraw(40m);

        Assert.Equal(60m, account.Balance);
    }

    [Fact]
    public void Withdraw_MoreThanBalance_ThrowsInsufficientFundsException()
    {
        var account = new BankAccount(100m);

        var exception = Assert.Throws<InsufficientFundsException>(() => account.Withdraw(250m));

        Assert.Equal(100m, exception.Balance);
        Assert.Equal(250m, exception.Amount);
    }
}
Bash
dotnet test
Plaintext
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4

Assert.Throws<TException>(() => ...) both verifies the expected exception type was thrown and returns the caught exception itself, so the test can go on to assert against its properties — exactly like Balance/Amount above — rather than only checking that some exception of the right type occurred.

[Theory] and [InlineData] — one test, many inputs

A [Fact] tests exactly one fixed scenario. A [Theory] runs the same test method once per row of supplied data, avoiding several nearly-identical [Fact] methods that only differ in their input values:

C#
public class BankAccountTheoryTests
{
    [Theory]
    [InlineData(100, 50, 150)]
    [InlineData(0, 25, 25)]
    [InlineData(1000, 0.01, 1000.01)]
    public void Deposit_AddsAmountToBalance(decimal initial, decimal depositAmount, decimal expected)
    {
        var account = new BankAccount(initial);

        account.Deposit(depositAmount);

        Assert.Equal(expected, account.Balance);
    }

    [Theory]
    [InlineData(0)]
    [InlineData(-1)]
    [InlineData(-100.50)]
    public void Deposit_NonPositiveAmount_ThrowsArgumentException(decimal amount)
    {
        var account = new BankAccount(100m);

        Assert.Throws<ArgumentException>(() => account.Deposit(amount));
    }
}
Plaintext
Passed!  - Failed:     0, Passed:     6, Skipped:     0, Total:     6

Each [InlineData(...)] attribute supplies one row of arguments, matched positionally to the test method's parameters — xUnit reports every row as its own individually named, individually pass/failing test in the output, so one bad case among several is immediately identifiable rather than hidden inside a single test with a manual loop.

[InlineData] values must be compile-time constants. For data that can't be expressed that way — a list of objects, or values computed at runtime — [MemberData] sources rows from a static property or method instead:

C#
public class BankAccountMemberDataTests
{
    public static IEnumerable<object[]> WithdrawalScenarios =>
        new List<object[]>
        {
            new object[] { 100m, 40m, 60m },
            new object[] { 500m, 500m, 0m },
            new object[] { 250.75m, 0.75m, 250m },
        };

    [Theory]
    [MemberData(nameof(WithdrawalScenarios))]
    public void Withdraw_ReducesBalanceCorrectly(decimal initial, decimal withdrawal, decimal expected)
    {
        var account = new BankAccount(initial);

        account.Withdraw(withdrawal);

        Assert.Equal(expected, account.Balance);
    }
}

Common Assert methods

C#
Assert.Equal(expected, actual);          // value equality
Assert.NotEqual(expected, actual);
Assert.True(condition);
Assert.False(condition);
Assert.Null(value);
Assert.NotNull(value);
Assert.Contains(item, collection);
Assert.Empty(collection);
Assert.IsType<BankAccount>(obj);
Assert.Throws<ArgumentException>(() => someAction());

Assert.Equal on a custom record or a value type compares by value, not reference — the same value-based equality records get for free, covered on the OOP page in this track — which is exactly what a test asserting "the balance is now 150" needs.

Setup shared across tests: the constructor and IDisposable

Unlike some frameworks that need a separate [SetUp]-style attribute, xUnit re-instantiates the test class itself for every single test method, so the constructor is the natural place for shared setup — each test automatically gets its own fresh instance, with no state leaking between tests:

C#
public class BankAccountFixtureTests : IDisposable
{
    private readonly BankAccount _account;

    public BankAccountFixtureTests()
    {
        // runs before EVERY test method in this class — a brand-new BankAccount each time
        _account = new BankAccount(500m);
    }

    [Fact]
    public void Deposit_Works()
    {
        _account.Deposit(100m);
        Assert.Equal(600m, _account.Balance);
    }

    [Fact]
    public void Withdraw_Works()
    {
        _account.Withdraw(100m);
        Assert.Equal(400m, _account.Balance);
    }

    public void Dispose()
    {
        // runs after EVERY test method — cleanup, if the test class holds any disposable resources
    }
}

Both tests above see _account.Balance starting at exactly 500, because the constructor reruns before each one — there's no risk of Deposit_Works running first and leaving a mutated balance that Withdraw_Works would otherwise see.

Comparing [Fact] and [Theory]

[Fact] [Theory]
Scenarios tested Exactly one, fixed One per data row supplied
Data source Hardcoded in the method body [InlineData] (compile-time constants) or [MemberData]/[ClassData] (computed)
Test output One result One result per row, individually named
Use when The scenario genuinely doesn't vary The same logic needs checking against several different inputs

Common mistakes

  • Writing several nearly-identical [Fact] methods that only differ in their input values, instead of one [Theory] with [InlineData] rows — more code to maintain, and a missing case is easy to overlook compared to an obviously incomplete list of [InlineData] rows.
  • Sharing mutable state across test methods via a static field instead of relying on xUnit's per-test-method constructor instantiation — it defeats the isolation xUnit gives you for free and makes test outcomes depend on execution order.
  • Forgetting that [InlineData] values must be compile-time constants — trying to pass an object literal or a runtime-computed value there is a compile error; use [MemberData] instead.
  • Asserting only that an exception was thrown (a bare try/catch with a manual flag) instead of using Assert.Throws<TException>, which both verifies the specific expected type and gives back the exception for further assertions against its properties.

Interview questions

Q: What's the difference between [Fact] and [Theory] in xUnit, and when would you choose one over the other? [Fact] represents a single, fixed test scenario with no parameters. [Theory] runs the same test method once for each row of data supplied via [InlineData] (or [MemberData]/[ClassData] for non-constant data), reporting each row as its own individually named result. Use [Theory] whenever you'd otherwise write several [Fact] methods that only differ in their input values — it keeps the test logic in one place and makes the set of covered cases explicit and easy to extend.

Q: How does xUnit provide test isolation without a dedicated [SetUp] attribute? xUnit constructs a brand-new instance of the test class for every single test method in it, so any setup performed in the constructor — creating a fresh BankAccount, for instance — runs again before each test, and no state persists between tests by default. If the test class implements IDisposable, its Dispose() method is called after each test as the corresponding teardown step, mirroring the constructor's per-test setup with matching per-test cleanup.