Unit Testing with xUnit
How do you know your code works? Running the program and checking the output by eye works once, but not after every change, and not for the hundred cases a bank or a railway system must get right. Unit tests are small methods that call your code with known inputs and check the results automatically. Run them after every change and you will know within seconds if you broke something.
xUnit is the most widely used test framework in modern .NET; the .NET team itself uses it. In this lesson you will set up a class library with a test project, write tests with [Fact] and [Theory], check results and exceptions, and run them with dotnet test.
Setting up a solution
Tests live in a separate test project that references the code being tested. The code itself goes in a class library (a project that builds a .dll with no Main), so that both an app and the tests can use it. A solution file groups the projects.
mkdir BankApp
cd BankApp
dotnet new sln
dotnet new classlib -o Bank.Core
dotnet new install xunit.v3.templates
dotnet new xunit3 -o Bank.Tests
dotnet add Bank.Tests/Bank.Tests.csproj reference Bank.Core/Bank.Core.csproj
dotnet sln add Bank.Core/Bank.Core.csproj Bank.Tests/Bank.Tests.csproj- In .NET 10,
dotnet new slncreates the newer XML-basedBankApp.slnxfile (older SDKs created.sln). dotnet new install xunit.v3.templatesinstalls the templates for xUnit v3, the current major version, once per computer.dotnet new xunit3then creates the test project.dotnet add ... reference ...lets the tests use the classes inBank.Core.
Open Bank.Tests.csproj and check that <TargetFramework> says net10.0, the same as the library; change it if the template chose an older version. The template may also create or update a global.json file that tells dotnet test which test runner to use. Leave it in place.
The code to test
Replace Bank.Core/Class1.cs with Account.cs:
namespace Bank.Core;
public class Account(string owner, decimal openingBalance = 0m)
{
public string Owner { get; } = owner;
public decimal Balance { get; private set; } = openingBalance;
public void Deposit(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
Balance += amount;
}
public void Withdraw(decimal amount)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);
if (amount > Balance)
{
throw new InvalidOperationException("Insufficient funds");
}
Balance -= amount;
}
}
public static class Interest
{
public static decimal Simple(decimal principal, decimal ratePercent, int years) =>
Math.Round(principal * ratePercent * years / 100, 2);
}Facts: one test, one scenario
A fact is a test that is always true for fixed input. Mark a public method with [Fact]. Inside, follow the Arrange, Act, Assert pattern: set up the objects, call the code, check the result.
using Bank.Core;
namespace Bank.Tests;
public class AccountTests
{
[Fact]
public void Deposit_AddsAmountToBalance()
{
// Arrange
var account = new Account("Priya", 1000m);
// Act
account.Deposit(500m);
// Assert
Assert.Equal(1500m, account.Balance);
}
[Fact]
public void Withdraw_MoreThanBalance_ThrowsAndKeepsBalance()
{
var account = new Account("Rohan", 300m);
var ex = Assert.Throws<InvalidOperationException>(() => account.Withdraw(500m));
Assert.Equal("Insufficient funds", ex.Message);
Assert.Equal(300m, account.Balance);
}
}Assert.Equal(expected, actual)passes if the two values are equal. Put the expected value first; the failure message depends on it.Assert.Throws<T>(action)passes only if the lambda throws exactly typeT, and returns the exception so you can check its message.- Other useful checks:
Assert.True,Assert.False,Assert.Null,Assert.NotNull,Assert.Contains,Assert.Empty.
Test names describe the scenario and the expected result, such as Withdraw_MoreThanBalance_ThrowsAndKeepsBalance. When a test fails, its name alone should tell you what broke.
Theories: one test, many inputs
When the same check should hold for many inputs, write a theory: a test method with parameters, marked [Theory], and one [InlineData] attribute per set of values.
using Bank.Core;
namespace Bank.Tests;
public class InterestTests
{
[Theory]
[InlineData(10000.0, 7.5, 2, 1500.0)]
[InlineData(5000.0, 6.0, 3, 900.0)]
[InlineData(1234.56, 7.1, 1, 87.65)]
public void Simple_ReturnsInterestRoundedToPaise(double principal, double rate, int years, double expected)
{
decimal result = Interest.Simple((decimal)principal, (decimal)rate, years);
Assert.Equal((decimal)expected, result);
}
[Theory]
[InlineData(0)]
[InlineData(-50)]
public void Deposit_ZeroOrNegative_Throws(int amount)
{
var account = new Account("Kabir");
Assert.Throws<ArgumentOutOfRangeException>(() => account.Deposit(amount));
}
}xUnit runs the theory once per [InlineData] row, and each row counts as a separate test. The third row checks rounding: 1234.56 x 7.1 / 100 is 87.65376, which rounds to 87.65. Attribute arguments must be simple constants, and decimal is not allowed in attributes, so the money values are passed as double and converted. For complex inputs, xUnit also offers [MemberData] and TheoryData<...>.
Running the tests
From the solution folder, run:
dotnet testdotnet test builds the solution, finds every test and runs them. Here that is seven tests: two facts in AccountTests and five theory rows in InterestTests. It ends with a summary of how many tests passed, failed and were skipped; the exact layout depends on the test runner version. Editors show the same results in a Test Explorer panel where you can run one test at a time.
To see a failure, change Balance += amount; to Balance -= amount; and run again. The deposit test fails with a message that shows both values, similar to this:
Assert.Equal() Failure: Values differ
Expected: 1500
Actual: 500That message, plus the test name and the line number, points straight at the bug. Undo the change and everything passes again.
Common mistakes
- Swapping expected and actual.
Assert.Equal(account.Balance, 1500m)still passes or fails correctly, but the failure message becomes confusing. - Testing many things in one test. When it fails you cannot tell which part broke. Split it.
- Tests that depend on each other or on order. xUnit creates a new instance of the test class for every test; never rely on state left by another test.
- Using
decimalin[InlineData]. It does not compile; passdoubleorstringand convert, or useTheoryData. - Only testing the happy path. Test invalid input, limits (0, negative, maximum) and exceptions too.
Key points
- Unit tests check small pieces of code automatically; keep code in a class library and tests in a separate test project.
- Create an xUnit v3 project with
dotnet new xunit3and reference the library withdotnet add ... reference. [Fact]marks a single test;[Theory]with[InlineData]runs one test for many inputs.Assert.Equal(expected, actual)andAssert.Throws<T>are the most used checks.- Follow Arrange, Act, Assert, and give tests descriptive names.
dotnet testbuilds and runs all tests and reports passed and failed counts.
Exercise
Add a method Transfer(Account to, decimal amount) to Account that withdraws from this account and deposits into to. Write three tests: a successful transfer of Rs 400 from Meera (Rs 1,000) to Farhan (Rs 200) that checks both balances, a transfer larger than the balance that throws InvalidOperationException and leaves both balances unchanged, and a theory that zero and negative amounts throw ArgumentOutOfRangeException.
Show solution
In Account:
public void Transfer(Account to, decimal amount)
{
Withdraw(amount); // validates and throws before any money moves
to.Deposit(amount);
}In the test project:
using Bank.Core;
namespace Bank.Tests;
public class TransferTests
{
[Fact]
public void Transfer_MovesMoneyBetweenAccounts()
{
var meera = new Account("Meera", 1000m);
var farhan = new Account("Farhan", 200m);
meera.Transfer(farhan, 400m);
Assert.Equal(600m, meera.Balance);
Assert.Equal(600m, farhan.Balance);
}
[Fact]
public void Transfer_MoreThanBalance_ThrowsAndChangesNothing()
{
var meera = new Account("Meera", 1000m);
var farhan = new Account("Farhan", 200m);
Assert.Throws<InvalidOperationException>(() => meera.Transfer(farhan, 5000m));
Assert.Equal(1000m, meera.Balance);
Assert.Equal(200m, farhan.Balance);
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Transfer_NonPositiveAmount_Throws(int amount)
{
var meera = new Account("Meera", 1000m);
var farhan = new Account("Farhan", 200m);
Assert.Throws<ArgumentOutOfRangeException>(() => meera.Transfer(farhan, amount));
}
}Calling Withdraw first means every check runs before any balance changes, which is exactly what the second test proves. Run dotnet test and all the new tests should pass.
Quick Quiz
1. Which attribute marks a test that runs once with fixed input?
[Fact] is a single test; [Theory] is a test that runs for each data row.
2. In Assert.Equal(1500m, account.Balance), which value is the expected one?
xUnit takes the expected value first, which makes failure messages read correctly.
3. What does Assert.Throws<InvalidOperationException>(() => account.Withdraw(500m)) check?
Assert.Throws<T> passes only if the code throws that exact exception type.
4. Why are the money values in [InlineData] written as double in the lesson?
C# attributes accept only simple constant types, and decimal is not one of them.
Learn C# with a mentor
Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
