Skip to content
Educora
Advanced21 min12 / 16

Testing with xUnit

Write unit tests with xUnit: `[Fact]`, `[Theory]` and `[InlineData]`, assertions, the FluentAssertions style and replacing dependencies with Moq.

Check yourself
In this lesson you will learn
  • Write a test class with [Fact] and Assert methods
  • Run one test with many data rows using [Theory] and [InlineData]
  • Replace an interface with Moq and verify calls

If you met JUnit in the Java course, much here will feel familiar: in C# too, tests are small methods that call code and compare the result with the expected value. The most popular test frameworks in .NET are xUnit, NUnit and MSTest; the dotnet new xunit template and ASP.NET Core's own source code use xUnit. First, let's look at the idea of a test without any library:

C#
(int Score, string Expected)[] cases = [(95, "A"), (80, "B"), (72, "C"), (49, "F")];
int passed = 0;

foreach (var (score, expected) in cases)
{
    string actual = Letter(score);
    bool ok = actual == expected;
    if (ok) passed++;
    Console.WriteLine($"{(ok ? "PASS" : "FAIL")} Letter({score}) = {actual}, expected {expected}");
}
Console.WriteLine($"{passed} of {cases.Length} passed");

// a bug hides here: > 80 instead of >= 80
static string Letter(int score) => score switch
{
    >= 90 => "A",
    > 80 => "B",
    >= 70 => "C",
    >= 50 => "D",
    _ => "F"
};
Expected output
PASS Letter(95) = A, expected A
FAIL Letter(80) = C, expected B
PASS Letter(72) = C, expected C
PASS Letter(49) = F, expected F
3 of 4 passed
The essence of a test: one check, many data rows — the bug at the boundary shows up at once

The test project and [Fact]

In the previous lesson we created a test project with dotnet new xunit -n Gradebook.Tests. The .NET 10 template adds the xunit, xunit.runner.visualstudio and Microsoft.NET.Test.Sdk packages and makes the Xunit namespace a global using. A test class is an ordinary public class with no attribute. A test method without parameters is marked with [Fact] — “a fact that is always true”. Names often follow the pattern Method_Scenario_Expected, and the test itself has the AAA structure (Arrange, Act, Assert).

C#
using Gradebook.Core;

namespace Gradebook.Tests;

public class GradeCalculatorTests
{
    [Fact]
    public void Average_OfThreeScores_ReturnsMean()
    {
        int[] scores = [85, 90, 95];                          // Arrange
        double result = GradeCalculator.Average(scores);      // Act
        Assert.Equal(90.0, result, precision: 3);             // Assert
    }

    [Fact]
    public void Average_OfEmptyList_Throws()
    {
        var ex = Assert.Throws<ArgumentException>(() => GradeCalculator.Average([]));
        Assert.Equal("No scores", ex.Message);
    }

    [Theory]
    [InlineData(95, "A")]
    [InlineData(80, "B")]
    [InlineData(72, "C")]
    [InlineData(49, "F")]
    public void Letter_ReturnsExpectedGrade(int score, string expected)
    {
        Assert.Equal(expected, GradeCalculator.Letter(score));
    }
}
Gradebook.Tests/GradeCalculatorTests.cs — the GradeCalculator class comes from the previous lesson

Assert.Equal(expected, actual) is the main check; for floating-point numbers precision sets the number of decimal places. Assert.Throws<T> checks the exception type and returns the exception. Other useful methods: Assert.True, Assert.Null, Assert.Contains, Assert.Empty, Assert.InRange. xUnit creates a new instance of the class for every test: setup code goes into the constructor and cleanup into IDisposable.Dispose() — there are no separate [SetUp] attributes.

JUnit 5xUnitMeaning
@Test[Fact]a single test
@ParameterizedTest + @CsvSource[Theory] + [InlineData]a data-driven test
@BeforeEach / @AfterEachconstructor / Dispose()before / after every test
@BeforeAllIClassFixture<T>shared setup for the whole class
assertThrowsAssert.Throws<T>checking for an exception

Tests are run with dotnet test (in Visual Studio and Rider, with the Test Explorer window). Suppose that, as in the example above, >= 80 in the Letter method has accidentally become > 80:

Terminal
dotnet test
Expected output
Failed Gradebook.Tests.GradeCalculatorTests.Letter_ReturnsExpectedGrade(score: 80, expected: "B") [2 ms]
  Error Message:
   Assert.Equal() Failure: Strings differ
Expected: "B"
Actual:   "C"

Failed!  - Failed:     1, Passed:     5, Skipped:     0, Total:     6, Duration: 41 ms - Gradebook.Tests.dll (net10.0)
Sample output (shortened): the [InlineData(80, "B")] case caught the bug

[Theory] and test data

[Theory] runs one test with different data: every [InlineData] attribute is a separate test case and appears in the report with its own parameters — above there were six test cases in total. If the data does not fit into an attribute (objects or lists, for example), [MemberData(nameof(Cases))] takes it from a static property. When you test asynchronous code, the method must return async Task:

Wrong: `async void`
[Fact]
public async void LoadsScores()          // async void: the runner cannot await it
{
    var scores = await repository.LoadAsync("Aysel");
    Assert.NotEmpty(scores);
}
Right: `async Task`
[Fact]
public async Task LoadsScores()          // async Task: xUnit awaits the result
{
    var scores = await repository.LoadAsync("Aysel");
    Assert.NotEmpty(scores);
}
The test runner cannot wait for an async void method, so its error may get lost or show up somewhere else

Replacing dependencies with Moq

ReportService gets scores from the IScoreRepository interface; in the real program a database stands behind it. A unit test does not need the database: the Moq library (dotnet add package Moq) creates a fake object of the interface — a mock. The dependency comes into the class through the constructor; here it is written with a C# 12 primary constructor.

C#
public interface IScoreRepository
{
    IReadOnlyList<int> GetScores(string student);   // e.g. reads a database
}

public class ReportService(IScoreRepository repository)
{
    public string Summary(string student)
    {
        double average = GradeCalculator.Average(repository.GetScores(student));
        return $"{student}: {GradeCalculator.Letter((int)average)}";
    }
}
The service knows the interface, not a concrete database
C#
using Moq;

public class ReportServiceTests
{
    [Fact]
    public void Summary_UsesScoresFromRepository()
    {
        var repo = new Mock<IScoreRepository>();
        repo.Setup(r => r.GetScores("Aysel")).Returns(new List<int> { 90, 80 });

        var service = new ReportService(repo.Object);
        string summary = service.Summary("Aysel");

        Assert.Equal("Aysel: B", summary);
        repo.Verify(r => r.GetScores("Aysel"), Times.Once);
    }
}
A test with Moq: the fake repository returns 90 and 80, the average is 85 — a “B”

new Mock<IScoreRepository>() creates the fake object, Setup(...).Returns(...) teaches it the answer, and repo.Object is the ready instance passed to the service. Verify(..., Times.Once) checks that the method was called exactly once. Moq can replace only interfaces and virtual methods — one more reason to declare dependencies as interfaces. A popular alternative library is NSubstitute.

The idea of FluentAssertions is to turn checks into chains that read like sentences: summary.Should().Be(...). The failure messages are more detailed too. Note: starting with version 8 (2025), FluentAssertions requires a paid licence for commercial projects; a free alternative is, for example, the Shouldly library.

Classic `Assert`
Assert.Equal("Aysel: B", summary);
Assert.Equal(3, scores.Count);
Assert.Contains(95, scores);
FluentAssertions style
summary.Should().Be("Aysel: B");
scores.Should().HaveCount(3).And.Contain(95);

What should you test? The test pyramid rule says: most of all fast unit tests (one class, fake dependencies), fewer integration tests (with a real database or web server), and the fewest UI tests that check the whole system. How much of the code is covered by tests is measured by the coverlet.collector package from the template: dotnet test --collect:"XPlat Code Coverage" creates a report file. But 100% coverage is not the goal — boundary cases and error-prone places matter more.

Key points

  • xUnit: [Fact] is a test without parameters, [Theory] + [InlineData] is a data-driven test.
  • Assert.Equal(expected, actual), Assert.Throws<T>, Assert.Contains are the main checks.
  • Every test runs on a new object: setup is in the constructor, cleanup in Dispose().
  • Asynchronous tests return async Task, never async void.
  • Moq: Setup(...).Returns(...) teaches the answer, .Object gives the fake instance and Verify checks the call.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which attribute marks a test whose parameters come from [InlineData]?