From dotnet-skills
Guidelines for writing TUnit tests in .NET, including setup, assertions, async testing, and best practices. Use when writing unit tests with TUnit framework, setting up TUnit in a .NET project, or migrating from other test frameworks to TUnit.
npx claudepluginhub wshaddix/dotnet-skillsThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Captures architectural decisions in Claude Code sessions as structured ADRs. Auto-detects choices between alternatives and maintains a docs/adr log for codebase rationale.
Use this skill when:
[Arguments], [MethodDataSource], or [ClassDataSource][Before]/[After])[NotInParallel], [DependsOn], or parallel groupsTUnit.AspNetCoreTUnit is a modern, source-generated testing framework for .NET built on the Microsoft Testing Platform. Key characteristics:
[TestClass] attribute needed - Only [Test] on methodsdotnet new install TUnit.Templates
dotnet new TUnit -n "MyApp.Tests"
dotnet new console --name MyApp.Tests
cd MyApp.Tests
dotnet add package TUnit --prerelease
Remove any auto-generated Program.cs -- TUnit handles the entry point.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TUnit" Version="*" />
</ItemGroup>
</Project>
| Package | Why |
|---|---|
Microsoft.NET.Test.Sdk | Breaks TUnit test discovery -- TUnit uses Microsoft.Testing.Platform, not VSTest |
coverlet.collector / coverlet.msbuild | Incompatible with TUnit -- use the built-in --coverage flag instead |
TUnit automatically provides global usings for TUnit.Core, TUnit.Assertions, and TUnit.Assertions.Extensions. You do not need explicit using statements in test files.
namespace MyApp.Tests;
public class CalculatorTests
{
[Test]
public async Task Add_TwoNumbers_ReturnsSum()
{
var result = 2 + 3;
await Assert.That(result).IsEqualTo(5);
}
}
[Test]
public void SyncTest() // Valid -- synchronous, no assertions
{
var result = Calculate(2, 3);
}
[Test]
public async Task AsyncTest() // Recommended -- required if using assertions
{
await Assert.That(42).IsEqualTo(42);
}
// async void is NOT allowed -- compiler error
Rule: If you use Assert.That(...), the test method must be async Task because assertions are awaitable.
All TUnit assertions follow the pattern await Assert.That(actual).SomeCondition().
// Equality
await Assert.That(result).IsEqualTo(5);
await Assert.That(result).IsNotEqualTo(0);
// Comparison
await Assert.That(score).IsGreaterThan(70);
await Assert.That(age).IsLessThanOrEqualTo(100);
await Assert.That(temp).IsBetween(20, 30);
// Boolean
await Assert.That(isValid).IsTrue();
await Assert.That(isDeleted).IsFalse();
// Null
await Assert.That(result).IsNotNull();
await Assert.That(optional).IsNull();
// Type
await Assert.That(obj).IsTypeOf<MyClass>();
await Assert.That(message).Contains("Hello");
await Assert.That(filename).StartsWith("test_");
await Assert.That(email).Matches(@"^[\w\.-]+@[\w\.-]+\.\w+$");
await Assert.That(input).IsNotEmpty();
await Assert.That(numbers).Contains(42);
await Assert.That(items).Count().IsEqualTo(5);
await Assert.That(list).IsNotEmpty();
await Assert.That(values).All(x => x > 0);
await Assert.That(numbers).IsEquivalentTo(new[] { 5, 4, 3, 2, 1 }); // order-independent
await Assert.That(numbers).IsInOrder();
// Basic exception testing
await Assert.That(() => int.Parse("not a number"))
.Throws<FormatException>();
// Async exception testing
await Assert.That(async () => await FailingOperationAsync())
.Throws<HttpRequestException>();
// Exact type (no subclasses)
await Assert.That(() => throw new ArgumentNullException())
.ThrowsExactly<ArgumentNullException>();
// Exception message
await Assert.That(() => throw new InvalidOperationException("Operation failed"))
.Throws<InvalidOperationException>()
.WithMessage("Operation failed");
await Assert.That(() => throw new ArgumentException("The parameter 'userId' is invalid"))
.Throws<ArgumentException>()
.WithMessageContaining("userId");
// ArgumentException parameter name
await Assert.That(() => ValidateUser(null!))
.Throws<ArgumentNullException>()
.WithParameterName("user");
// Inner exceptions
await Assert.That(() => ThrowWithInner())
.Throws<InvalidOperationException>()
.WithInnerException()
.Throws<FormatException>();
// No exception thrown
await Assert.That(() => int.Parse("42"))
.ThrowsNothing();
await Assert.That(username)
.IsNotNull()
.And.IsNotEmpty()
.And.Length().IsGreaterThan(3)
.And.Length().IsLessThan(20);
await Assert.That(statusCode)
.IsEqualTo(200)
.Or.IsEqualTo(201)
.Or.IsEqualTo(204);
using (Assert.Multiple())
{
await Assert.That(user.FirstName).IsEqualTo("John");
await Assert.That(user.LastName).IsEqualTo("Doe");
await Assert.That(user.Age).IsGreaterThan(18);
}
// All failures reported together, not just the first one
await Assert.That(3.14159).IsEqualTo(Math.PI).Within(0.001);
// WRONG -- assertion never executes, test always passes
Assert.That(result).IsEqualTo(5);
// CORRECT
await Assert.That(result).IsEqualTo(5);
TUnit includes a built-in analyzer that warns about unawaited assertions.
[Test]
[Arguments(1, 1, 2)]
[Arguments(1, 2, 3)]
[Arguments(2, 2, 4)]
public async Task Add_ReturnsExpectedResult(int a, int b, int expected)
{
await Assert.That(a + b).IsEqualTo(expected);
}
Supports metadata: DisplayName, Categories, Skip:
[Test]
[Arguments("Chrome", "120")]
[Arguments("Safari", "17", Skip = "Safari not available in CI")]
public async Task BrowserTest(string browser, string version) { }
public static class TestData
{
public static IEnumerable<Func<(int A, int B, int Expected)>> AdditionCases()
{
yield return () => (1, 2, 3);
yield return () => (2, 2, 4);
yield return () => (5, 5, 10);
}
}
public class MathTests
{
[Test]
[MethodDataSource(typeof(TestData), nameof(TestData.AdditionCases))]
public async Task Add_WithData(int a, int b, int expected)
{
await Assert.That(a + b).IsEqualTo(expected);
}
}
For reference types, return Func<T> (not T) to ensure each test gets a fresh instance.
public class TestWebServer : IAsyncInitializer, IAsyncDisposable
{
public WebApplicationFactory<Program>? Factory { get; private set; }
public async Task InitializeAsync()
{
Factory = new WebApplicationFactory<Program>();
await Task.CompletedTask;
}
public async ValueTask DisposeAsync()
{
if (Factory != null) await Factory.DisposeAsync();
}
}
[ClassDataSource<TestWebServer>(Shared = SharedType.PerTestSession)]
public class ApiTests(TestWebServer server)
{
[Test]
public async Task HealthCheck_ReturnsOk()
{
var client = server.Factory!.CreateClient();
var response = await client.GetAsync("/health");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
}
SharedType options:
None (default) -- new instance per testPerClass -- shared within the test classPerAssembly -- shared within the assemblyPerTestSession -- single instance for entire test runKeyed -- shared among tests with the same KeyTUnit creates a new instance of the test class for each test method. Instance fields are never shared between tests.
public class MyTests
{
private int _value;
[Test, NotInParallel]
public void Test1() { _value = 99; }
[Test, NotInParallel]
public async Task Test2()
{
// _value is 0 here -- different instance!
await Assert.That(_value).IsEqualTo(0);
}
}
Use static fields if you intentionally need shared state.
public class DatabaseTests
{
private TestDatabase? _database;
[Before(Test)] // Instance method, runs before each test
public async Task SetupDatabase()
{
_database = await TestDatabase.CreateAsync();
}
[Before(Class)] // Must be static, runs once before all tests in class
public static async Task ClassSetup()
{
await GlobalResource.InitializeAsync();
}
[Before(Assembly)] // Must be static, runs once before all tests in assembly
public static async Task AssemblySetup() { }
}
public class DatabaseTests
{
[After(Test)] // Instance method, runs after each test
public async Task Cleanup()
{
if (_database != null) await _database.DisposeAsync();
}
[After(Class)] // Must be static, runs once after all tests in class
public static async Task ClassCleanup() { }
}
Every [After] method runs even if a previous one fails. Exceptions are aggregated.
| Level | Scope | Static? |
|---|---|---|
[Before(Test)] / [After(Test)] | Each test | Instance |
[Before(Class)] / [After(Class)] | Once per class | Static |
[Before(Assembly)] / [After(Assembly)] | Once per assembly | Static |
[Before(TestSession)] / [After(TestSession)] | Once per test run | Static |
Place in a GlobalHooks.cs at the project root:
public static class GlobalHooks
{
[BeforeEvery(Test)]
public static void BeforeEachTest(TestContext context)
{
Console.WriteLine($"Starting: {context.Metadata.TestName}");
}
[AfterEvery(Test)]
public static async Task AfterEachTest(TestContext context)
{
if (context.Execution.Result?.State == TestState.Failed)
{
await CaptureScreenshotAsync();
}
}
}
Hooks can accept context and cancellation token:
[Before(Test)]
public async Task Setup(TestContext context, CancellationToken ct)
{
Console.WriteLine($"Setting up: {context.Metadata.TestName}");
await SomeOperation(ct);
}
TUnit runs all tests concurrently by default. Write independent, stateless tests.
[Test, NotInParallel]
public async Task ModifiesSharedResource() { }
// These two won't run in parallel with each other (shared key)
[Test, NotInParallel("DatabaseTest")]
public async Task DbTest1() { }
[Test, NotInParallel("DatabaseTest")]
public async Task DbTest2() { }
// This can still run in parallel with the above
[Test, NotInParallel("FileTest")]
public async Task FileTest1() { }
[Test]
public async Task Step1_CreateUser() { }
[Test]
[DependsOn(nameof(Step1_CreateUser))]
public async Task Step2_UpdateUser() { }
[Test]
[DependsOn(nameof(Step2_UpdateUser))]
public async Task Step3_DeleteUser() { }
// Other unrelated tests still run in parallel
[assembly: NotInParallel]
dotnet run -c Release --maximum-parallel-tests 8
public class MicrosoftDiDataSourceAttribute
: DependencyInjectionDataSourceAttribute<IServiceScope>
{
private static readonly IServiceProvider ServiceProvider = CreateProvider();
public override IServiceScope CreateScope(DataGeneratorMetadata metadata)
=> ServiceProvider.CreateScope();
public override object? Create(IServiceScope scope, Type type)
=> scope.ServiceProvider.GetService(type);
private static IServiceProvider CreateProvider()
=> new ServiceCollection()
.AddSingleton<IMyService, MyService>()
.AddTransient<IRepository, Repository>()
.BuildServiceProvider();
}
[MicrosoftDiDataSource]
public class ServiceTests(IMyService service, IRepository repo)
{
[Test]
public async Task ServiceWorks()
{
var result = await service.DoWorkAsync();
await Assert.That(result).IsNotNull();
}
}
Install the TUnit.AspNetCore package:
dotnet add package TUnit.AspNetCore
using TUnit.AspNetCore;
public class AppFactory : TestWebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "ConnectionStrings:Default", "..." }
});
});
}
}
public abstract class IntegrationTestBase : WebApplicationTest<AppFactory, Program> { }
public class TodoApiTests : IntegrationTestBase
{
[Test]
public async Task GetTodos_ReturnsOk()
{
var client = Factory.CreateClient();
var response = await client.GetAsync("/todos");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
// Override per-test services
protected override void ConfigureTestServices(IServiceCollection services)
{
services.ReplaceService<IEmailService>(new FakeEmailService());
}
// Override per-test configuration
protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Feature:Enabled", "true" }
});
}
}
Each test gets a unique ID for resource isolation:
public abstract class DatabaseTestBase : IntegrationTestBase
{
protected string TableName { get; private set; } = null!;
protected override async Task SetupAsync()
{
TableName = GetIsolatedName("todos"); // "Test_42_todos"
await CreateTableAsync(TableName);
}
protected override void ConfigureTestConfiguration(IConfigurationBuilder config)
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Database:TableName", TableName }
});
}
[After(HookType.Test)]
public async Task Cleanup() => await DropTableAsync(TableName);
}
# Preferred -- easier flag passing
dotnet run -c Release
# With coverage and TRX report
dotnet run -c Release --coverage --report-trx
# Using dotnet test (flags go after --)
dotnet test -c Release -- --coverage --report-trx
| IDE | Setting Required |
|---|---|
| Visual Studio | Enable "Use testing platform server mode" in Tools > Manage Preview Features |
| Rider | Enable "Testing Platform support" in Settings > Build, Execution, Deployment > Unit Testing > Testing Platform |
| VS Code | Install C# Dev Kit, enable "Dotnet > Test Window > Use Testing Platform Protocol" |
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: 9.0.x
- run: dotnet restore
- run: dotnet build --no-restore -c Release
- run: dotnet run --project tests/MyApp.Tests -c Release --no-build -- --report-trx --coverage
- uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: |
**/TestResults/*.trx
**/TestResults/**/coverage.cobertura.xml
Use descriptive names: Method_Scenario_ExpectedBehavior
[Test]
public async Task CalculateTotal_WithDiscount_ReturnsReducedPrice() { }
[Test]
public async Task CreateUser_WithDuplicateEmail_ThrowsConflictException() { }
Mirror production code structure:
MyApp/
Services/
OrderService.cs
MyApp.Tests/
Services/
OrderServiceTests.cs
// WRONG -- silently passes
Assert.That(result).IsEqualTo(5);
// CORRECT
await Assert.That(result).IsEqualTo(5);
// WRONG -- each test gets a new class instance
private int _counter;
[Test, NotInParallel]
public void Increment() { _counter++; }
[Test, NotInParallel]
public async Task Check()
{
await Assert.That(_counter).IsEqualTo(1); // Fails -- _counter is 0
}
This package is for VSTest-based frameworks. TUnit uses Microsoft.Testing.Platform. Including it will break test discovery.
Tests run in parallel by default. Never assume order unless you use [DependsOn] or [NotInParallel(Order = N)].
// BAD -- mock everything
var mockLogger = new Mock<ILogger>();
var mockValidator = new Mock<IValidator>();
var mockCalculator = new Mock<IPriceCalculator>();
// BETTER -- only mock external dependencies
var logger = NullLogger.Instance;
var validator = new OrderValidator(); // Real, fast
var mockRepository = new Mock<IOrderRepository>(); // Mock database
| Practice | Why |
|---|---|
Always await assertions | Unawaited assertions silently pass |
Use async Task for test methods | Required by TUnit's assertion model |
| One logical behavior per test | Keeps tests focused and failure messages clear |
Use Assert.Multiple for related checks | See all failures at once |
Prefer [DependsOn] over [NotInParallel(Order)] | Maintains parallelism for unrelated tests |
Use [ClassDataSource] for expensive resources | Share across tests with SharedType.PerTestSession |
| Test behavior, not implementation | Avoid brittle mock-verification tests |
Use GetIsolatedName() in integration tests | Ensures parallel test isolation |
Place [BeforeEvery]/[AfterEvery] in GlobalHooks.cs | Easy to find global hooks |
Do not install Microsoft.NET.Test.Sdk | Breaks TUnit test discovery |