- Write a test class with
[Fact]andAssertmethods - 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:
(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"
};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 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).
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 lessonAssert.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 5 | xUnit | Meaning |
|---|---|---|
@Test | [Fact] | a single test |
@ParameterizedTest + @CsvSource | [Theory] + [InlineData] | a data-driven test |
@BeforeEach / @AfterEach | constructor / Dispose() | before / after every test |
@BeforeAll | IClassFixture<T> | shared setup for the whole class |
assertThrows | Assert.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:
dotnet testFailed 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)
[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:
[Fact]
public async void LoadsScores() // async void: the runner cannot await it
{
var scores = await repository.LoadAsync("Aysel");
Assert.NotEmpty(scores);
}[Fact]
public async Task LoadsScores() // async Task: xUnit awaits the result
{
var scores = await repository.LoadAsync("Aysel");
Assert.NotEmpty(scores);
}async void method, so its error may get lost or show up somewhere elseReplacing 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.
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)}";
}
}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);
}
}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.
Assert.Equal("Aysel: B", summary);
Assert.Equal(3, scores.Count);
Assert.Contains(95, scores);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.Containsare the main checks.- Every test runs on a new object: setup is in the constructor, cleanup in
Dispose(). - Asynchronous tests return
async Task, neverasync void. - Moq:
Setup(...).Returns(...)teaches the answer,.Objectgives the fake instance andVerifychecks the call.
Check yourself
10 questions. Every correct answer earns XP.
[InlineData]?