.cursor/skills/dotnet-tunit-test/SKILL.md
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 skillsauth add AGIBuild/Fulora dotnet-tunit-testInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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 |
tools
Captures learnings, errors, and corrections to enable continuous improvement. Use when: (1) A command or operation fails unexpectedly, (2) User corrects Claude ('No, that's wrong...', 'Actually...'), (3) User requests a capability that doesn't exist, (4) An external API or tool fails, (5) Claude realizes its knowledge is outdated or incorrect, (6) A better approach is discovered for a recurring task. Also review learnings before major tasks.
testing
Security headers configuration and best practices for ASP.NET Core Razor Pages applications. Covers CSP, HSTS, X-Frame-Options, and comprehensive security middleware setup. Use when configuring security headers in ASP.NET Core applications, implementing Content Security Policy (CSP), or setting up HSTS and other security-related HTTP headers.
development
Reviews designs and business goals for security vulnerabilities, data protection (in transit/at rest), authorization, and compliance alignment. Use when the user asks for a security review, threat modeling, attack surface analysis, data leakage prevention, or compliance/security assessment.
development
Best practices for building production-grade ASP.NET Core Razor Pages applications. Focuses on structure, lifecycle, binding, validation, security, and maintainability in web apps using Razor Pages as the primary UI framework. Use when building Razor Pages applications, designing PageModels and handlers, implementing model binding and validation, or securing Razor Pages with authentication and authorization.