xUnit.net Guide: Complete Tutorial for C# and .NET Testing
xUnit.net is the most modern .NET testing framework, favored by Microsoft's own teams and the open-source community. It's the default choice for new .NET projects. This guide covers installation, writing Facts and Theories, using assertions, mocking dependencies, async testing, and running tests in CI pipelines.
Key Takeaways
xUnit uses [Fact] and [Theory] instead of [TestMethod]. Facts are single-scenario tests. Theories are data-driven tests with [InlineData] or [MemberData]. This is more expressive than MSTest or NUnit's attribute model.
Constructor/Dispose replaces [SetUp]/[TearDown]. xUnit creates a new class instance per test, so use the constructor for setup and IDisposable for teardown. This enforces test isolation by design.
ITestOutputHelper replaces Console.WriteLine. Inject ITestOutputHelper into your test class constructor for captured, per-test output — Console output is captured but harder to correlate to specific tests.
Assert.Throws<T> is synchronous; use await Assert.ThrowsAsync<T> for async. Mixing these is a common mistake that causes tests to pass incorrectly.
xUnit is the default in .NET templates. dotnet new xunit scaffolds a ready-to-run test project. For new projects, there's rarely a reason to choose otherwise.
What Is xUnit.net?
xUnit.net is an open-source unit testing framework for .NET, created by the original authors of NUnit v2. It's the testing framework used internally by the .NET team at Microsoft and is the default in many dotnet new templates.
Compared to NUnit and MSTest, xUnit is more opinionated. It:
- Discourages shared state between tests (new instance per test)
- Uses constructor injection instead of
[SetUp] - Eliminates
[TestFixture]attributes entirely - Promotes explicit, readable test code
Installing xUnit.net
New Project
dotnet new xunit -n MyProject.Tests
cd MyProject.Tests
dotnet add reference ../MyProject/MyProject.csprojThis creates a project with xUnit, xunit.runner.visualstudio, and Microsoft.NET.Test.Sdk already referenced.
Existing Project
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.SdkYour .csproj should look like:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0" />
</ItemGroup>
</Project>Writing Your First Test
using Xunit;
public class CalculatorTests
{
[Fact]
public void Add_TwoPositiveNumbers_ReturnsSum()
{
// Arrange
var calculator = new Calculator();
// Act
var result = calculator.Add(2, 3);
// Assert
Assert.Equal(5, result);
}
}Run it:
dotnet testNaming Convention
xUnit test names follow the MethodName_Scenario_ExpectedResult pattern:
// Method: Divide
// Scenario: DivideByZero
// Expected: ThrowsDivideByZeroException
public void Divide_ByZero_ThrowsDivideByZeroException()This naming makes test output self-documenting — when a test fails, you know exactly what broke.
Facts vs Theories
[Fact] — Single Scenario
A [Fact] runs once with no parameters:
[Fact]
public void IsEmpty_EmptyList_ReturnsTrue()
{
var list = new List<int>();
Assert.True(list.Count == 0);
}[Theory] — Data-Driven Tests
A [Theory] runs multiple times with different inputs. Use [InlineData] for simple values:
[Theory]
[InlineData(2, 3, 5)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
[InlineData(int.MaxValue, 1, int.MinValue)] // overflow
public void Add_VariousInputs_ReturnsCorrectSum(int a, int b, int expected)
{
var calculator = new Calculator();
Assert.Equal(expected, calculator.Add(a, b));
}For complex data, use [MemberData]:
public static IEnumerable<object[]> AddTestData =>
new List<object[]>
{
new object[] { new Order { Quantity = 5, Price = 10.0m }, 50.0m },
new object[] { new Order { Quantity = 0, Price = 10.0m }, 0.0m },
new object[] { new Order { Quantity = 3, Price = 0.0m }, 0.0m },
};
[Theory]
[MemberData(nameof(AddTestData))]
public void CalculateTotal_Order_ReturnsExpectedTotal(Order order, decimal expected)
{
var service = new OrderService();
Assert.Equal(expected, service.CalculateTotal(order));
}Or use [ClassData] for a dedicated data class:
public class PrimeNumberData : TheoryData<int>
{
public PrimeNumberData()
{
Add(2);
Add(3);
Add(5);
Add(7);
Add(11);
}
}
[Theory]
[ClassData(typeof(PrimeNumberData))]
public void IsPrime_PrimeNumbers_ReturnsTrue(int number)
{
Assert.True(MathHelper.IsPrime(number));
}Assertions
xUnit's Assert class covers the most common scenarios:
Equality
Assert.Equal(expected, actual); // value equality
Assert.NotEqual(unexpected, actual);
Assert.Equal(3.14, result, precision: 2); // floating point with precision
Assert.StrictEqual(expected, actual); // reference + value equalityBoolean and Null
Assert.True(condition);
Assert.False(condition);
Assert.Null(value);
Assert.NotNull(value);Collections
Assert.Empty(collection);
Assert.NotEmpty(collection);
Assert.Single(collection); // exactly one element
Assert.Contains(item, collection);
Assert.DoesNotContain(item, collection);
Assert.All(collection, item => Assert.True(item > 0)); // every element passesStrings
Assert.Contains("substring", actualString);
Assert.StartsWith("prefix", actualString);
Assert.EndsWith("suffix", actualString);
Assert.Matches(@"^\d{4}-\d{2}-\d{2}$", dateString); // regexExceptions
// Synchronous
var ex = Assert.Throws<ArgumentNullException>(() => service.Process(null));
Assert.Equal("input", ex.ParamName);
// Async
var ex = await Assert.ThrowsAsync<HttpRequestException>(
async () => await client.GetAsync("/invalid-endpoint")
);Type Checking
Assert.IsType<Dog>(animal); // exact type
Assert.IsAssignableFrom<Animal>(dog); // type or subtypeSetup and Teardown
xUnit creates a new instance of your test class for every [Fact] and [Theory]. Use the constructor for setup:
public class OrderServiceTests : IDisposable
{
private readonly OrderService _service;
private readonly Mock<IEmailSender> _emailSender;
public OrderServiceTests()
{
// Setup runs before each test
_emailSender = new Mock<IEmailSender>();
_service = new OrderService(_emailSender.Object);
}
public void Dispose()
{
// Teardown runs after each test
_service?.Dispose();
}
[Fact]
public void PlaceOrder_ValidOrder_SendsConfirmationEmail()
{
var order = new Order { Id = 1, Total = 99.99m };
_service.PlaceOrder(order);
_emailSender.Verify(x => x.Send(It.IsAny<string>(), It.IsAny<string>()), Times.Once);
}
}Shared Context with IClassFixture
When setup is expensive (database connections, HTTP clients), use IClassFixture<T> to share it across tests in a class:
public class DatabaseFixture : IDisposable
{
public AppDbContext DbContext { get; }
public DatabaseFixture()
{
// Expensive one-time setup
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb")
.Options;
DbContext = new AppDbContext(options);
DbContext.Database.EnsureCreated();
}
public void Dispose() => DbContext.Dispose();
}
public class ProductRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly AppDbContext _db;
public ProductRepositoryTests(DatabaseFixture fixture)
{
_db = fixture.DbContext;
}
[Fact]
public void GetById_ExistingProduct_ReturnsProduct()
{
// Uses shared database context
var repo = new ProductRepository(_db);
var product = repo.GetById(1);
Assert.NotNull(product);
}
}Async Testing
xUnit supports async Task test methods natively:
[Fact]
public async Task FetchUser_ValidId_ReturnsUser()
{
var client = new UserApiClient("https://api.example.com");
var user = await client.FetchUserAsync(42);
Assert.NotNull(user);
Assert.Equal(42, user.Id);
}
[Theory]
[InlineData(1)]
[InlineData(2)]
[InlineData(100)]
public async Task GetProduct_ValidIds_ReturnsNonNull(int productId)
{
var service = new ProductService(_dbContext);
var product = await service.GetProductAsync(productId);
Assert.NotNull(product);
}Never use .Result or .Wait() in xUnit tests — they can cause deadlocks in synchronization-context environments.
Mocking with Moq
xUnit is commonly paired with Moq for dependency mocking:
using Moq;
using Xunit;
public class NotificationServiceTests
{
[Fact]
public void SendAlert_ActiveUser_CallsEmailProvider()
{
// Arrange
var mockEmail = new Mock<IEmailProvider>();
var service = new NotificationService(mockEmail.Object);
var user = new User { Id = 1, IsActive = true, Email = "test@example.com" };
// Act
service.SendAlert(user, "System alert");
// Assert
mockEmail.Verify(
x => x.Send("test@example.com", It.Is<string>(s => s.Contains("System alert"))),
Times.Once
);
}
[Fact]
public void SendAlert_InactiveUser_DoesNotCallEmailProvider()
{
var mockEmail = new Mock<IEmailProvider>();
var service = new NotificationService(mockEmail.Object);
var user = new User { Id = 2, IsActive = false };
service.SendAlert(user, "Alert");
mockEmail.Verify(x => x.Send(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
}Organizing Tests
Collections for Parallel Control
xUnit runs test classes in parallel by default. Use [Collection] to group classes that share state:
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection("Database")]
public class OrderRepositoryTests
{
// shares DatabaseFixture with other [Collection("Database")] classes
// runs sequentially within the collection
}
[Collection("Database")]
public class ProductRepositoryTests
{
// same collection = sequential, not parallel
}Skipping Tests
[Fact(Skip = "Pending external API credentials")]
public void FetchFromThirdParty_ReturnsData()
{
// skipped
}Custom Display Names
[Fact(DisplayName = "Calculator: adding two negative numbers returns a negative result")]
public void Add_NegativeNumbers_ReturnsNegative()
{
Assert.Equal(-5, new Calculator().Add(-2, -3));
}Capturing Output
Use ITestOutputHelper instead of Console.WriteLine:
public class DiagnosticTests
{
private readonly ITestOutputHelper _output;
public DiagnosticTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void Process_LargeDataset_CompletesWithinTimeout()
{
var stopwatch = Stopwatch.StartNew();
var processor = new DataProcessor();
processor.ProcessAll(GenerateLargeDataset());
stopwatch.Stop();
_output.WriteLine($"Processing took {stopwatch.ElapsedMilliseconds}ms");
Assert.True(stopwatch.ElapsedMilliseconds < 5000);
}
}Output appears in test explorer and dotnet test output only for failed tests (or when using --logger options).
Running Tests
# Run all tests
dotnet <span class="hljs-built_in">test
<span class="hljs-comment"># Run with verbose output
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"console;verbosity=detailed"
<span class="hljs-comment"># Run a specific test class
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"FullyQualifiedName~OrderServiceTests"
<span class="hljs-comment"># Run tests matching a display name pattern
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"DisplayName~Calculator"
<span class="hljs-comment"># Run with coverage (requires coverlet)
dotnet <span class="hljs-built_in">test --collect:<span class="hljs-string">"XPlat Code Coverage"CI/CD Integration
GitHub Actions
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- run: dotnet restore
- run: dotnet build --no-restore
- run: dotnet test --no-build --logger "trx;LogFileName=results.trx"
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: "**/*.trx"Azure Pipelines
steps:
- task: DotNetCoreCLI@2
displayName: 'Run tests'
inputs:
command: test
projects: '**/*.Tests.csproj'
arguments: '--configuration Release --collect:"XPlat Code Coverage"'
- task: PublishCodeCoverageResults@1
inputs:
codeCoverageTool: Cobertura
summaryFileLocation: '**/coverage.cobertura.xml'Common Pitfalls
Don't share mutable state between tests. xUnit creates a new instance per test, but static fields are shared. Static state causes tests to interfere with each other.
Don't mix async and sync in the same test. If part of your code is async, make the test async end-to-end.
Use Assert.Equal(expected, actual) — order matters. If you reverse them, failure messages show "expected X but was Y" backwards, which is confusing.
[Theory] with a single [InlineData] should be a [Fact]. Don't use Theories unless you have multiple data sets.
xUnit vs NUnit vs MSTest
| Feature | xUnit | NUnit | MSTest |
|---|---|---|---|
| Setup attribute | Constructor | [SetUp] | [TestInitialize] |
| Data-driven | [Theory] / [InlineData] | [TestCase] | [DataRow] |
| Per-test isolation | New instance | Shared instance | Shared instance |
| .NET team choice | Yes | No | Legacy |
| Parallel by default | Yes | No | No |
xUnit's constructor-based isolation and first-class async support make it the modern default for .NET projects. For legacy codebases, NUnit and MSTest remain valid and fully supported.
End-to-End with HelpMeTest
While xUnit handles unit and integration tests inside your codebase, end-to-end browser testing requires a separate layer. HelpMeTest runs Robot Framework + Playwright tests against your live application, catching UI regressions, broken flows, and cross-browser issues that unit tests miss.
You can run HelpMeTest alongside your xUnit suite in CI — xUnit for fast internal feedback, HelpMeTest for the full user-facing layer. The Pro plan ($100/month flat) includes unlimited tests and parallel execution, so you're not paying per run.