Skip to content

Expert · Testing, tooling and Unity · topic 36 of 43 · 35 min

Unit testing with xUnit

Explanation

A unit test is a small piece of code that calls your code with known inputs and checks the result automatically. xUnit is a popular free test framework for .NET: [Fact] marks one test, [Theory] with [InlineData] runs the same test with several inputs, and Assert.Equal(expected, actual) checks the answer. dotnet test builds and runs every test in seconds.

Why automated tests?

You change the GST calculation for one product type and accidentally break it for all others. Without tests you find out from an angry customer. With tests, you run one command and see within seconds that 3 of 120 checks fail, and exactly which ones. Tests let teams change code without fear, and most .NET jobs expect you to write them.

Think of it like this: Unit tests are like the quality check at a pressure-cooker factory. Every cooker is tested for the whistle, the lid lock and leaks before it leaves. The checks are the same every time, they are fast, and a faulty cooker is caught at the factory, not in a customer's kitchen.

Setting up a test project

bash
dotnet new sln -n Billing
dotnet new classlib -o Billing.Core
dotnet new xunit -o Billing.Tests
dotnet sln add Billing.Core Billing.Tests
dotnet add Billing.Tests reference Billing.Core
dotnet test
Tests live in their own project that references the code under test. dotnet test builds everything and runs all tests.

xUnit v3 is the newest major version of xUnit; check xunit.net for its current templates. [Fact], [Theory], [InlineData] and the Assert methods shown here work the same way in v2 and v3. MSTest and NUnit are other popular .NET test frameworks with very similar ideas.

The code under test

csharp
namespace Billing.Core;

public static class GstCalculator
{
    public static decimal AddGst(decimal amount, decimal ratePercent)
    {
        if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount), "Amount cannot be negative");
        return Math.Round(amount + amount * ratePercent / 100, 2, MidpointRounding.AwayFromZero);
    }
}
A small, pure method (same input, same output, no side effects) is the easiest kind to test.

Your first tests: Arrange, Act, Assert

csharp
using Billing.Core;

namespace Billing.Tests;

public class GstCalculatorTests
{
    [Fact]
    public void AddGst_18Percent_AddsTax()
    {
        // Arrange
        decimal amount = 1000m;

        // Act
        decimal total = GstCalculator.AddGst(amount, 18);

        // Assert
        Assert.Equal(1180m, total);
    }

    [Fact]
    public void AddGst_NegativeAmount_Throws()
    {
        Assert.Throws<ArgumentOutOfRangeException>(() => GstCalculator.AddGst(-1m, 18));
    }
}
Arrange sets up the inputs, Act calls the code once, Assert checks the outcome. Name tests Method_Scenario_Expected so a failure explains itself.
Careful: Assert.Equal takes the EXPECTED value first and the ACTUAL value second. Swapping them still passes or fails correctly, but the failure message says "Expected: 1179.99, Actual: 1180" the wrong way round and confuses everyone.

Theories: one test, many inputs

csharp
[Theory]
[InlineData(100, 5, 105)]
[InlineData(100, 12, 112)]
[InlineData(0, 18, 0)]
[InlineData(99.99, 18, 117.99)]
public void AddGst_VariousRates(double amount, double rate, double expected)
{
    Assert.Equal((decimal)expected, GstCalculator.AddGst((decimal)amount, (decimal)rate));
}
Each InlineData row is a separate test case. 99.99 + 17.9982 = 117.9882, which rounds to 117.99. C# attributes cannot hold decimal values, so the data is passed as double and converted to decimal inside the test.

Useful assertions

AssertionChecks
Assert.Equal(expected, actual)Values are equal
Assert.True(cond) / Assert.False(cond)A condition
Assert.Null(x) / Assert.NotNull(x)null or not
Assert.Contains(item, collection)A collection contains an item
Assert.Empty(collection)No items
Assert.Throws<T>(() => ...)The code throws exactly T
await Assert.ThrowsAsync<T>(() => ...)An async method throws T

Testing async code

csharp
[Fact]
public async Task LoadAsync_ReturnsSavedItems()
{
    var store = new InMemoryStore();
    await store.SaveAsync("pen");

    var items = await store.LoadAsync();

    Assert.Contains("pen", items);
}
Test methods can be async Task. Never async void.

What makes a good unit test

  • Fast: milliseconds. No real network, database or sleeping.
  • Independent: each test sets up its own data. xUnit creates a new instance of the test class for every test, so fields do not leak between tests.
  • Repeatable: same result every run. Avoid DateTime.Now and random numbers without a fixed seed.
  • One behaviour per test: when it fails, you know what broke.
  • Tests behaviour, not private details: test what the public method promises.

Common mistakes and fixes

  • A test with no Assert: it always passes and proves nothing.
  • Testing with real time: a test that checks "today is a weekday" fails every Saturday. Inject the time (next topic).
  • Big tests that check ten things: split them.
  • Ignoring failing tests: a red test means something is wrong. Fix the code or the test, do not skip it silently.

Need help?

Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.

Try:

Watch the lesson

Unit testing with xUnit · 1/7

Unit testing with xUnit

In this lesson you will write automated tests that check your code in seconds.

Prefer a video by a teacher? Find free videos on “xUnit unit testing C# tutorial Fact Theory InlineData dotnet test” on YouTube ↗

In real life: Guarding a loan EMI calculator

A bank's loan calculator shows customers their monthly EMI. A wrong formula could misquote thousands of customers. The team keeps a theory test with known EMI values checked against the bank's spreadsheet, and the build fails if any change breaks them.

csharp
public static class Emi
{
    // EMI = P * r * (1 + r)^n / ((1 + r)^n - 1), r = monthly rate
    public static decimal Monthly(decimal principal, double annualRatePercent, int months)
    {
        double r = annualRatePercent / 12 / 100;
        double factor = Math.Pow(1 + r, months);
        return Math.Round((decimal)((double)principal * r * factor / (factor - 1)), 0);
    }
}

public class EmiTests
{
    [Theory]
    [InlineData(100000, 12, 12, 8885)]
    [InlineData(500000, 9, 60, 10379)]
    public void Monthly_KnownValues(int principal, double rate, int months, int expected)
        => Assert.Equal((decimal)expected, Emi.Monthly(principal, rate, months));
}
Rs 1,00,000 at 12% for 12 months gives an EMI of about Rs 8,885; Rs 5,00,000 at 9% for 60 months about Rs 10,379. The expected values come from the standard EMI formula.

Practice (0/4 done)

Task 1 · Question

Which attribute marks a test that runs once with no parameters?

Task 2 · Question

What is the correct argument order for Assert.Equal?

Task 3 · Question

A [Theory] has three [InlineData] rows. How many test cases run?

Task 4 · Practice

Create a solution with a class library containing Grade.For(int marks) (90+ "A", 75+ "B", 40+ "C", else "F"; throw ArgumentOutOfRangeException outside 0 to 100) and an xUnit project. Write a [Theory] with at least 8 InlineData rows covering every boundary (0, 39, 40, 74, 75, 89, 90, 100) and two [Fact] tests for -1 and 101 throwing.

Quick quiz

Question 1

1.What does dotnet test do?

Question 2

2.What does Arrange-Act-Assert describe?

Question 3

3.Why should unit tests avoid DateTime.Now?

Question 4

4.How do you test that a method throws ArgumentException?

Question 5

5.What is the return type of an async xUnit test?

Read more (free)

Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.

What does dotnet test do?

Answer: Builds the projects and runs all the tests

It builds and runs tests, reporting passes and failures.

What does Arrange-Act-Assert describe?

Answer: The structure of a test: set up, call, check

It keeps tests readable.

Why should unit tests avoid DateTime.Now?

Answer: The result changes over time, so tests become unrepeatable

Tests must give the same result every run.

How do you test that a method throws ArgumentException?

Answer: Assert.Throws<ArgumentException>(() => Method())

Assert.Throws checks the exact exception type.

What is the return type of an async xUnit test?

Answer: Task

async Task tests let xUnit await them and see failures.

In Arrange, Act, Assert, what happens in the Act step?Related

Answer: You call the code being tested

Arrange prepares, Act calls the function once, Assert checks the result.

From Unit testing: checking one piece at a time

Can unit tests call private functions of their module?Related

Answer: Yes, because the tests module is a child module

Child modules can see private items of their parent.

From Unit tests

Which test kind should you have the MOST of?Related

Answer: Unit

Unit tests are fast and pinpoint failures; the pyramid has them at the base.

From Testing your code

What does Arrange-Act-Assert mean?Related

Answer: Set up the data, call the code, check the result

It is the simple shape of almost every test.

From Testing basics: unit, integration and end-to-end

Pass the quiz (60% or more) or mark this topic as done when you are ready.

Your progress is saved on this device only.

Next steps

  1. 3 more topics in “Testing, tooling and Unity” before its exam: Test doubles: fakes, stubs and mocks, NuGet packages, solutions and project structure, Game development with Unity and C#.
  2. Module 9 exam: Testing, tooling and Unity · 12 questions · 20 min, without help. 4 topics of this module still to finish first.
  3. Then the next module, Interviews, portfolio and careers. 43 topics left in the course before the final exam.

← Previous: Entity Framework Core: databases with C#