
Tunit
- 16 installs
- 466 repo stars
- Updated July 25, 2026
- managedcode/dotnet-skills
Helps with ai & agent building tasks.
About
tunit is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- tunit
- AI & Agent Building
- AI-coding skill
Tunit by the numbers
- 16 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #11,040 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 3, 2026 (Skillselion catalog sync)
npx skills add https://github.com/managedcode/dotnet-skills --skill tunitAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| repo stars | ★ 466 |
| Last updated | July 25, 2026 |
| Repository | managedcode/dotnet-skills ↗ |
What it does
Helps with ai & agent building tasks.
Files
TUnit
Trigger On
- the repo uses TUnit
- you need to add, run, debug, or repair TUnit tests
- the repo uses Microsoft.Testing.Platform-based test execution
- the repo uses
ClassDataSource<...>(Shared = SharedType.PerTestSession),ParallelLimiter,TUnit.Playwright, or--treenode-filter
Value
- produce a concrete project delta: code, docs, config, tests, CI, or review artifact
- reduce ambiguity through explicit planning, verification, and final validation skills
- leave reusable project context so future tasks are faster and safer
Do Not Use For
- xUnit projects
- MSTest projects
- generic test strategy with no TUnit-specific mechanics
Inputs
- the nearest
AGENTS.md - the test project file and package references
- the repo's current TUnit execution command
Quick Start
1. Read the nearest AGENTS.md and confirm scope and constraints. 2. Run this skill's Workflow through the Ralph Loop until outcomes are acceptable. 3. Return the Required Result Format with concrete artifacts and verification evidence.
Workflow
1. Confirm the project really uses TUnit and not a different MTP-based framework. 2. Read the repo's real test command from AGENTS.md. If the repo has no explicit command yet, start with dotnet test PROJECT_OR_SOLUTION. 3. Keep the TUnit execution model intact:
- tests are source-generated at build time
- tests run in parallel by default
- built-in analyzers should remain enabled
4. Choose the fixture level deliberately:
- plain TUnit tests for isolated logic
- shared AppHost/Aspire fixtures for HTTP, SignalR, SSE, or UI flows
WebApplicationFactorylayered over shared Aspire infra when tests need Host DI services,IGrainFactory, or other runtime internals
5. Reuse expensive fixtures with ClassDataSource<Fixture>(Shared = SharedType.PerTestSession) instead of booting distributed infrastructure per test. 6. Fix isolation bugs instead of globally serializing the suite unless the repo already documented a justified exception. 7. Run the narrowest useful scope first with dotnet test ... -- --treenode-filter "...". Keep TUnit arguments after --. 8. Capture useful failure evidence: host log dumps, focused console output, coverage files, and Playwright screenshots/HTML for UI tests. 9. Use [Test], [Arguments], hooks, and dependencies only when they make the scenario clearer, not because the framework allows it.
Bootstrap When Missing
If TUnit is requested but not configured yet:
1. Detect current state:
rg -n "TUnit|Microsoft\\.Testing\\.Platform" -g '*.csproj' -g 'Directory.Build.*' .
2. Add the minimal package set to the test project:
dotnet add TEST_PROJECT.csproj package TUnit- add
Microsoft.NET.Test.Sdkonly when the repo's chosen TUnit project shape requires it; do not blindly duplicate runner packages
3. Keep the runner model explicit in AGENTS.md and CI:
- record that the repo uses Microsoft.Testing.Platform-compatible execution for this test project
- record the exact
dotnet test TEST_PROJECT.csprojcommand the repo will use
4. Add one small executable test using [Test]. 5. Run dotnet test TEST_PROJECT.csproj and return status: configured or status: improved. 6. If the repo intentionally standardizes on xUnit or MSTest, return status: not_applicable unless migration is explicitly requested.
Deliver
- TUnit tests that respect source generation and parallel execution
- commands that work in local and CI runs
- framework-specific verification guidance for the repo
- a fixture strategy that matches the actual test scope: logic-only, AppHost/API, Host DI/grains, or Playwright UI
Validate
- the command matches the repo's TUnit runner style
- focused runs use
--treenode-filterrather than VSTest-style--filter - shared distributed fixtures use
SharedType.PerTestSessionor an equivalent reuse pattern - shared state is isolated or explicitly controlled
- built-in TUnit analyzers remain active
- coverage tooling matches Microsoft.Testing.Platform if coverage is enabled
- UI failures capture artifacts and server-side failures expose enough logs to avoid blind reruns
Test Harness
flowchart LR
A["TUnit task"] --> B{"What does the test need?"}
B -->|"Single component only"| C["Plain TUnit test"]
B -->|"HTTP / SignalR / resource graph"| D["Shared Aspire/AppHost fixture"]
B -->|"Host DI / grains / runtime services"| E["Shared Aspire/AppHost fixture + WebApplicationFactory"]
B -->|"Browser automation"| F["Shared Aspire/AppHost fixture + Playwright"]
C & D & E & F --> G["Run focused with --treenode-filter"]
G --> H["Capture logs, artifacts, and coverage"]Ralph Loop
Use the Ralph Loop for every task, including docs, architecture, testing, and tooling work.
1. Plan first (mandatory):
- analyze current state
- define target outcome, constraints, and risks
- write a detailed execution plan
- list final validation skills to run at the end, with order and reason
2. Execute one planned step and produce a concrete delta. 3. Review the result and capture findings with actionable next fixes. 4. Apply fixes in small batches and rerun the relevant checks or review steps. 5. Update the plan after each iteration. 6. Repeat until outcomes are acceptable or only explicit exceptions remain. 7. If a dependency is missing, bootstrap it or return status: not_applicable with explicit reason and fallback path.
Required Result Format
status:complete|clean|improved|configured|not_applicable|blockedplan: concise plan and current iteration stepactions_taken: concrete changes madevalidation_skills: final skills run, or skipped with reasonsverification: commands, checks, or review evidence summaryremaining: top unresolved items ornone
For setup-only requests with no execution, return status: configured and exact next commands.
Load References
- references/patterns.md
- references/migration.md
- references/tunit.md
- references/integration-testing.md
Running Tests
TUnit uses Microsoft.Testing.Platform. Use --treenode-filter for filtering (not --filter), and keep runner switches after --.
# Run all tests
dotnet test MySolution.sln
# Run one test project
dotnet test tests/MyProject.Tests/MyProject.Tests.csproj
# Filter by class
dotnet test tests/MyProject.Tests/MyProject.Tests.csproj -- --treenode-filter "/*/*/CalculatorTests/*"
# Filter by category
dotnet test tests/MyProject.Tests/MyProject.Tests.csproj -- --treenode-filter "/*/*/*/*[Category=Integration]"
# Coverage on Microsoft.Testing.Platform
dotnet test MySolution.sln -- --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura
# Raw runner help when the repo needs direct TUnit app switches
dotnet run --project tests/MyProject.Tests/MyProject.Tests.csproj -- --helpFilter syntax: /<Assembly>/<Namespace>/<Class>/<Test> with * wildcards. See references/patterns.md for full examples.
Example Requests
- "Run this TUnit project correctly."
- "Fix our TUnit CI command."
- "Add a regression test in TUnit without breaking parallelism."
{
"version": "1.1.0",
"category": "Testing",
"packages": [
"TUnit"
]
}
TUnit Integration Testing Patterns
Use this reference when the repo uses TUnit for integration, API, SignalR, Orleans, or Playwright-driven UI suites rather than only pure unit tests.
The patterns below are grounded in working suites from AIBase and WA.Storied.Agents: shared per-session fixtures, Aspire-backed distributed application boot, optional WebApplicationFactory layering for DI/grain access, and concrete artifact capture on failures.
Pick The Right Fixture Level
| Need | Recommended Pattern |
|---|---|
| Plain logic verification | normal TUnit test class with no shared infra |
| Real HTTP/API/resource graph | ClassDataSource<AspireTestFixture>(Shared = SharedType.PerTestSession) |
| Direct DI services, managers, or grains | ClassDataSource<AIBaseTestApplication>(Shared = SharedType.PerTestSession) or equivalent WebApplicationFactory wrapper |
| Browser automation | shared Aspire/AppHost fixture plus Playwright helpers or TUnit.Playwright |
Shared AppHost Fixture
For real distributed tests, keep one AppHost boot per test session:
[ClassDataSource<AspireTestFixture>(Shared = SharedType.PerTestSession)]
public sealed class HealthTests(AspireTestFixture fixture)
{
[Test]
public async Task Health_endpoint_returns_ok()
{
using var client = fixture.CreateApiClient();
var response = await client.GetAsync("/health");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
}This is the right shape for:
- API health and contract tests
- SignalR or SSE flows
- Playwright-backed UI tests
- AppHost resource graph and startup validation
Mix TUnit With WebApplicationFactory
When the test needs runtime services or Orleans grains from the Host container, keep the TUnit fixture but expose a WebApplicationFactory wrapper:
[ClassDataSource<TestApplication>(Shared = SharedType.PerTestSession)]
public sealed class OrderRuntimeTests(TestApplication app)
{
[Test]
public async Task Order_runtime_can_resolve_grain_from_host_scope()
{
await using var scope = app.CreateScope();
var grainFactory = scope.ServiceProvider.GetRequiredService<IGrainFactory>();
var grain = grainFactory.GetGrain<IOrderGrain>(Guid.NewGuid());
await grain.SubmitAsync(new SubmitOrder("PO-42"));
var state = await grain.GetStateAsync();
await Assert.That(state.Number).IsEqualTo("PO-42");
}
}This pattern is especially useful for:
- Orleans grain integration tests
- service-manager and repository tests
- host-level dependency graph validation
- co-hosted SignalR/API/runtime tests
TUnit Hooks For Deterministic Context
Use hooks for per-test context that must be reset cleanly:
public abstract class IntegrationTestBase(TestApplication app)
{
protected TestApplication Application { get; } = app;
private IDisposable? _scope;
[Before(Test)]
public void SetContext()
{
_scope = TestRequestContextScope.Push("test-user");
}
[After(Test)]
public void ClearContext()
{
_scope?.Dispose();
_scope = null;
}
}Keep hooks small, explicit, and local to the test concern.
Focused Commands
For TUnit on Microsoft.Testing.Platform, keep the repo's command shape and pass framework switches after --:
# Full project
dotnet test --project Tests/MyProject.Tests/MyProject.Tests.csproj
# One class
dotnet test --project Tests/MyProject.Tests/MyProject.Tests.csproj -- --treenode-filter "/*/*/ChatControllerTests/*"
# One category
dotnet test --project Tests/MyProject.Tests/MyProject.Tests.csproj -- --treenode-filter "/*/*/*/*[Category=Integration]"
# Coverage
dotnet test --project Tests/MyProject.Tests/MyProject.Tests.csproj -- --coverage --coverage-output coverage.cobertura.xml --coverage-output-format coberturaDo not use VSTest-style --filter for TUnit suites. Do not put TUnit switches before --.
Playwright In TUnit Suites
Shared fixture boot:
public async Task InitializePlaywrightAsync()
{
var exitCode = Microsoft.Playwright.Program.Main(["install", "chromium"]);
if (exitCode != 0)
{
throw new InvalidOperationException($"Playwright install failed: {exitCode}");
}
Playwright ??= await Microsoft.Playwright.Playwright.CreateAsync();
Browser ??= await Playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
}Per-test usage:
await fixture.InitializePlaywrightAsync();
await using var context = await fixture.CreateBrowserContextAsync();
var page = await context.NewPageAsync();Reuse the browser process, but never reuse a mutable page or browser context across tests.
Capture Results And Failure Evidence
Good TUnit integration suites capture more than just the assertion failure:
- host-side error log dump on HTTP 500 or startup failures
- coverage output such as
coverage.cobertura.xml - screenshots and HTML for Playwright failures
- narrowed console output from the fixture rather than unfiltered infrastructure noise
Example failure-artifact pattern:
private static async Task CaptureArtifactsAsync(IPage page)
{
Directory.CreateDirectory("artifacts");
var timestamp = DateTime.UtcNow.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture);
await page.ScreenshotAsync(new PageScreenshotOptions
{
Path = Path.Combine("artifacts", $"failure-{timestamp}.png"),
FullPage = true
});
await File.WriteAllTextAsync(
Path.Combine("artifacts", $"failure-{timestamp}.html"),
await page.ContentAsync());
}Example server-log dump pattern:
var logStart = DateTimeOffset.UtcNow;
try
{
var response = await app.CreateApiClient().GetAsync("/health");
response.EnsureSuccessStatusCode();
}
catch
{
Console.WriteLine(app.GetErrorLogDump(logStart));
throw;
}Practical Rules
- Prefer
ClassDataSource<...>(Shared = SharedType.PerTestSession)for expensive distributed fixtures. - Keep fixture code responsible for startup, teardown, and shared helpers only; assertions belong in the test classes.
- Resolve real connection strings and endpoints from the Aspire fixture instead of copying appsettings into the test project.
- Use coverage and filter switches that match Microsoft.Testing.Platform, not VSTest conventions.
- Capture enough diagnostics on the first failing run so the next step is a fix, not a blind rerun.
Migrating to TUnit
From xUnit
Package Changes
Remove:
<PackageReference Include="xunit" Version="*" />
<PackageReference Include="xunit.runner.visualstudio" Version="*" />Add:
<PackageReference Include="TUnit" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />Attribute Mappings
| xUnit | TUnit |
|---|---|
[Fact] | [Test] |
[Theory] | [Test] with data attributes |
[InlineData(...)] | [Arguments(...)] |
[MemberData(...)] | [MethodDataSource(...)] |
[ClassData(...)] | [ClassDataSource<T>] |
[Trait("Category", "...")] | [Category("...")] |
[Collection("...")] | [NotInParallel("...")] |
Constructor Injection
xUnit:
public class MyTests
{
private readonly ITestOutputHelper _output;
public MyTests(ITestOutputHelper output)
{
_output = output;
}
[Fact]
public void TestMethod()
{
_output.WriteLine("Test output");
Assert.True(true);
}
}TUnit (with primary constructor):
public class MyTests(ITestOutputHelper output)
{
[Test]
public async Task TestMethod()
{
output.WriteLine("Test output");
await Assert.That(true).IsTrue();
}
}Basic Test Conversion
xUnit:
public class CalculatorTests
{
[Fact]
public void Add_ReturnsSum()
{
var calc = new Calculator();
var result = calc.Add(2, 3);
Assert.Equal(5, result);
}
[Theory]
[InlineData(1, 1, 2)]
[InlineData(2, 3, 5)]
[InlineData(-1, 1, 0)]
public void Add_WithParameters_ReturnsExpected(int a, int b, int expected)
{
var calc = new Calculator();
Assert.Equal(expected, calc.Add(a, b));
}
}TUnit:
public class CalculatorTests
{
[Test]
public async Task Add_ReturnsSum()
{
var calc = new Calculator();
var result = calc.Add(2, 3);
await Assert.That(result).IsEqualTo(5);
}
[Test]
[Arguments(1, 1, 2)]
[Arguments(2, 3, 5)]
[Arguments(-1, 1, 0)]
public async Task Add_WithParameters_ReturnsExpected(int a, int b, int expected)
{
var calc = new Calculator();
await Assert.That(calc.Add(a, b)).IsEqualTo(expected);
}
}MemberData to MethodDataSource
xUnit:
public class DataTests
{
public static IEnumerable<object[]> TestData =>
new List<object[]>
{
new object[] { "hello", 5 },
new object[] { "world", 5 }
};
[Theory]
[MemberData(nameof(TestData))]
public void Test_WithMemberData(string input, int expectedLength)
{
Assert.Equal(expectedLength, input.Length);
}
}TUnit:
public class DataTests
{
public static IEnumerable<(string, int)> TestData()
{
yield return ("hello", 5);
yield return ("world", 5);
}
[Test]
[MethodDataSource(nameof(TestData))]
public async Task Test_WithMethodData(string input, int expectedLength)
{
await Assert.That(input.Length).IsEqualTo(expectedLength);
}
}Lifecycle Hooks
xUnit:
public class LifecycleTests : IDisposable, IAsyncLifetime
{
public async Task InitializeAsync()
{
// Before each test
}
public async Task DisposeAsync()
{
// After each test
}
public void Dispose()
{
// Cleanup
}
}TUnit:
public class LifecycleTests : IAsyncDisposable
{
[Before(Test)]
public async Task BeforeEachTest()
{
// Before each test
}
[After(Test)]
public async Task AfterEachTest()
{
// After each test
}
public async ValueTask DisposeAsync()
{
// Cleanup
}
}Collection Fixtures (Shared Context)
xUnit:
[CollectionDefinition("Database")]
public class DatabaseCollection : ICollectionFixture<DatabaseFixture> { }
[Collection("Database")]
public class DatabaseTests
{
private readonly DatabaseFixture _fixture;
public DatabaseTests(DatabaseFixture fixture)
{
_fixture = fixture;
}
}TUnit:
public class DatabaseTests
{
private static DatabaseFixture _fixture = null!;
[Before(Class)]
public static async Task SetupFixture()
{
_fixture = new DatabaseFixture();
await _fixture.InitializeAsync();
}
[After(Class)]
public static async Task TeardownFixture()
{
await _fixture.DisposeAsync();
}
[Test]
[NotInParallel("Database")]
public async Task DatabaseTest()
{
// Use _fixture
}
}Assertion Mappings
| xUnit | TUnit |
|---|---|
Assert.Equal(expected, actual) | await Assert.That(actual).IsEqualTo(expected) |
Assert.NotEqual(unexpected, actual) | await Assert.That(actual).IsNotEqualTo(unexpected) |
Assert.True(condition) | await Assert.That(condition).IsTrue() |
Assert.False(condition) | await Assert.That(condition).IsFalse() |
Assert.Null(obj) | await Assert.That(obj).IsNull() |
Assert.NotNull(obj) | await Assert.That(obj).IsNotNull() |
Assert.Empty(collection) | await Assert.That(collection).IsEmpty() |
Assert.NotEmpty(collection) | await Assert.That(collection).IsNotEmpty() |
Assert.Contains(item, collection) | await Assert.That(collection).Contains(item) |
Assert.Throws<T>(action) | await Assert.That(action).ThrowsException().OfType<T>() |
await Assert.ThrowsAsync<T>(func) | await Assert.That(func).ThrowsException().OfType<T>() |
---
From NUnit
Package Changes
Remove:
<PackageReference Include="NUnit" Version="*" />
<PackageReference Include="NUnit3TestAdapter" Version="*" />Add:
<PackageReference Include="TUnit" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />Attribute Mappings
| NUnit | TUnit |
|---|---|
[Test] | [Test] |
[TestCase(...)] | [Test] with [Arguments(...)] |
[TestCaseSource(...)] | [MethodDataSource(...)] |
[Category("...")] | [Category("...")] |
[SetUp] | [Before(Test)] |
[TearDown] | [After(Test)] |
[OneTimeSetUp] | [Before(Class)] |
[OneTimeTearDown] | [After(Class)] |
[Ignore("...")] | [Skip("...")] |
[Timeout(...)] | [Timeout(...)] |
[Retry(...)] | [Retry(...)] |
[Order(...)] | [DependsOn(...)] |
[NonParallelizable] | [NotInParallel] |
Basic Test Conversion
NUnit:
[TestFixture]
public class CalculatorTests
{
private Calculator _calculator;
[SetUp]
public void Setup()
{
_calculator = new Calculator();
}
[Test]
public void Add_ReturnsSum()
{
var result = _calculator.Add(2, 3);
Assert.That(result, Is.EqualTo(5));
}
[TestCase(1, 1, 2)]
[TestCase(2, 3, 5)]
[TestCase(-1, 1, 0)]
public void Add_WithParameters_ReturnsExpected(int a, int b, int expected)
{
Assert.That(_calculator.Add(a, b), Is.EqualTo(expected));
}
}TUnit:
public class CalculatorTests
{
private Calculator _calculator = null!;
[Before(Test)]
public async Task Setup()
{
_calculator = new Calculator();
await Task.CompletedTask;
}
[Test]
public async Task Add_ReturnsSum()
{
var result = _calculator.Add(2, 3);
await Assert.That(result).IsEqualTo(5);
}
[Test]
[Arguments(1, 1, 2)]
[Arguments(2, 3, 5)]
[Arguments(-1, 1, 0)]
public async Task Add_WithParameters_ReturnsExpected(int a, int b, int expected)
{
await Assert.That(_calculator.Add(a, b)).IsEqualTo(expected);
}
}TestCaseSource to MethodDataSource
NUnit:
public class DataTests
{
private static IEnumerable<TestCaseData> TestCases()
{
yield return new TestCaseData("hello", 5).SetName("HelloCase");
yield return new TestCaseData("world", 5).SetName("WorldCase");
}
[TestCaseSource(nameof(TestCases))]
public void Test_WithTestCaseSource(string input, int expectedLength)
{
Assert.That(input.Length, Is.EqualTo(expectedLength));
}
}TUnit:
public class DataTests
{
public static IEnumerable<(string, int)> TestCases()
{
yield return ("hello", 5);
yield return ("world", 5);
}
[Test]
[MethodDataSource(nameof(TestCases))]
public async Task Test_WithMethodData(string input, int expectedLength)
{
await Assert.That(input.Length).IsEqualTo(expectedLength);
}
}OneTimeSetUp/OneTimeTearDown
NUnit:
[TestFixture]
public class DatabaseTests
{
private static Database _db;
[OneTimeSetUp]
public void OneTimeSetup()
{
_db = new Database();
_db.Connect();
}
[OneTimeTearDown]
public void OneTimeTeardown()
{
_db.Disconnect();
}
[Test]
public void QueryTest()
{
var result = _db.Query("SELECT 1");
Assert.That(result, Is.Not.Null);
}
}TUnit:
public class DatabaseTests
{
private static Database _db = null!;
[Before(Class)]
public static async Task OneTimeSetup()
{
_db = new Database();
await _db.ConnectAsync();
}
[After(Class)]
public static async Task OneTimeTeardown()
{
await _db.DisconnectAsync();
}
[Test]
public async Task QueryTest()
{
var result = await _db.QueryAsync("SELECT 1");
await Assert.That(result).IsNotNull();
}
}Constraint-Based Assertions
| NUnit | TUnit |
|---|---|
Assert.That(x, Is.EqualTo(y)) | await Assert.That(x).IsEqualTo(y) |
Assert.That(x, Is.Not.EqualTo(y)) | await Assert.That(x).IsNotEqualTo(y) |
Assert.That(x, Is.Null) | await Assert.That(x).IsNull() |
Assert.That(x, Is.Not.Null) | await Assert.That(x).IsNotNull() |
Assert.That(x, Is.True) | await Assert.That(x).IsTrue() |
Assert.That(x, Is.False) | await Assert.That(x).IsFalse() |
Assert.That(x, Is.GreaterThan(y)) | await Assert.That(x).IsGreaterThan(y) |
Assert.That(x, Is.LessThan(y)) | await Assert.That(x).IsLessThan(y) |
Assert.That(list, Has.Count.EqualTo(n)) | await Assert.That(list).HasCount(n) |
Assert.That(list, Contains.Item(x)) | await Assert.That(list).Contains(x) |
Assert.That(list, Is.Empty) | await Assert.That(list).IsEmpty() |
Assert.That(s, Does.StartWith("x")) | await Assert.That(s).StartsWith("x") |
Assert.That(s, Does.Contain("x")) | await Assert.That(s).Contains("x") |
Assert.That(() => x, Throws.TypeOf<T>()) | await Assert.That(() => x).ThrowsException().OfType<T>() |
Assert.Multiple(() => { ... }) | await Assert.Multiple(() => { ... }) |
Parallelism Control
NUnit:
[TestFixture]
[NonParallelizable]
public class SequentialTests
{
[Test]
public void Test1() { }
[Test]
public void Test2() { }
}
[TestFixture]
[Parallelizable(ParallelScope.None)]
public class AnotherSequentialTests { }TUnit:
[NotInParallel]
public class SequentialTests
{
[Test]
public async Task Test1() { }
[Test]
public async Task Test2() { }
}
// Or at the test level:
public class MixedParallelTests
{
[Test]
[NotInParallel]
public async Task SequentialTest() { }
[Test]
public async Task ParallelTest() { }
}---
Common Migration Steps
1. Update packages in the project file 2. Replace attributes using the mapping tables above 3. Convert assertions to async TUnit assertions 4. Update lifecycle hooks from interfaces to attributes 5. Add async/await to test methods (TUnit assertions are async) 6. Review parallelism - TUnit is parallel by default 7. Run tests and fix any remaining compilation errors
Automated Find-Replace Patterns
// xUnit
[Fact] -> [Test]
[Theory] -> [Test]
[InlineData( -> [Arguments(
Assert.Equal( -> await Assert.That(
Assert.True( -> await Assert.That(
Assert.NotNull( -> await Assert.That(
// NUnit
[TestCase( -> [Arguments(
[SetUp] -> [Before(Test)]
[TearDown] -> [After(Test)]
[OneTimeSetUp] -> [Before(Class)]
[OneTimeTearDown] -> [After(Class)]
Assert.That(x, Is.EqualTo(y)) -> await Assert.That(x).IsEqualTo(y)Post-Migration Checklist
- [ ] All tests compile
- [ ] All tests pass with
dotnet test - [ ] Parallel execution works correctly
- [ ] Shared state is properly isolated
- [ ] CI pipeline updated if needed
- [ ] TUnit analyzers enabled and warnings addressed
TUnit Patterns
Source Generation
TUnit uses source generators to discover and wire up tests at compile time rather than runtime reflection. This means:
- Tests are compiled as static method invocations
- Build errors surface test discovery issues early
- No runtime reflection overhead
- IDE support for test discovery depends on generator output
Basic Test Structure
public class CalculatorTests(ITestOutputHelper output)
{
[Test]
public async Task Add_ReturnsCorrectSum()
{
var calculator = new Calculator();
var result = calculator.Add(2, 3);
await Assert.That(result).IsEqualTo(5);
}
}Parameterized Tests with Arguments
public class MathTests
{
[Test]
[Arguments(1, 2, 3)]
[Arguments(0, 0, 0)]
[Arguments(-1, 1, 0)]
public async Task Add_WithVariousInputs_ReturnsExpectedSum(int a, int b, int expected)
{
var result = Calculator.Add(a, b);
await Assert.That(result).IsEqualTo(expected);
}
}Matrix Tests
Combine multiple argument sources to create test matrices:
public class MatrixTests
{
[Test]
[MatrixDataSource]
public async Task ProcessData_HandlesAllCombinations(
[Matrix("json", "xml", "csv")] string format,
[Matrix(true, false)] bool compress)
{
var processor = new DataProcessor();
var result = await processor.ProcessAsync(format, compress);
await Assert.That(result.Success).IsTrue();
}
}Method Data Source
public class DataDrivenTests
{
[Test]
[MethodDataSource(nameof(GetTestCases))]
public async Task Validate_WithMethodData(string input, bool expected)
{
var result = Validator.IsValid(input);
await Assert.That(result).IsEqualTo(expected);
}
public static IEnumerable<(string, bool)> GetTestCases()
{
yield return ("valid@email.com", true);
yield return ("invalid", false);
yield return ("", false);
}
}Class Data Source
public sealed class SharedFixture : IAsyncInitializer, IAsyncDisposable
{
public Task InitializeAsync() => Task.CompletedTask;
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
public class ClassDataTests
{
[Test]
[ClassDataSource<SharedFixture>(Shared = SharedType.PerTestSession)]
public async Task Process_WithSharedFixture(SharedFixture fixture)
{
await Assert.That(fixture).IsNotNull();
}
}Use Shared = SharedType.PerTestSession for expensive integration fixtures such as AppHost boot, WebApplicationFactory, or browser setup. For full distributed-app patterns, load integration-testing.md.
Parallel Testing
TUnit runs tests in parallel by default. Design tests for isolation.
Default Parallel Execution
All tests run in parallel unless explicitly constrained:
public class ParallelTests
{
[Test]
public async Task Test1() => await Task.Delay(100);
[Test]
public async Task Test2() => await Task.Delay(100);
// Both tests run simultaneously
}Controlling Parallelism
Disable parallelism for a specific test:
public class SharedResourceTests
{
[Test]
[NotInParallel]
public async Task Test_ThatModifiesGlobalState()
{
// Runs alone, not in parallel with other [NotInParallel] tests
}
}Group tests that must not run together:
public class DatabaseTests
{
[Test]
[NotInParallel("Database")]
public async Task CreateUser_InsertsRecord()
{
// Other tests with [NotInParallel("Database")] wait
}
[Test]
[NotInParallel("Database")]
public async Task DeleteUser_RemovesRecord()
{
// Runs sequentially with other "Database" group tests
}
}Parallel Limits
Limit concurrent test execution:
[assembly: ParallelLimiter<MaxParallelTests>]
public class MaxParallelTests : IParallelLimit
{
public int Limit => Environment.ProcessorCount;
}Class-Level Parallelism Control
[NotInParallel]
public class SequentialTestClass
{
[Test]
public async Task Test1() { }
[Test]
public async Task Test2() { }
// All tests in this class run sequentially
}Assertions
TUnit provides fluent, async-first assertions.
Basic Assertions
[Test]
public async Task BasicAssertions()
{
var value = 42;
var text = "Hello";
var list = new[] { 1, 2, 3 };
await Assert.That(value).IsEqualTo(42);
await Assert.That(value).IsGreaterThan(40);
await Assert.That(value).IsLessThanOrEqualTo(42);
await Assert.That(text).IsNotNull();
await Assert.That(text).IsNotEmpty();
await Assert.That(text).Contains("ell");
await Assert.That(text).StartsWith("He");
await Assert.That(list).HasCount(3);
await Assert.That(list).Contains(2);
}Exception Assertions
[Test]
public async Task ThrowsException()
{
var action = () => throw new InvalidOperationException("test");
await Assert.That(action).ThrowsException()
.OfType<InvalidOperationException>()
.WithMessage("test");
}
[Test]
public async Task ThrowsAsync()
{
var asyncAction = async () =>
{
await Task.Delay(1);
throw new ArgumentNullException("param");
};
await Assert.That(asyncAction).ThrowsException()
.OfType<ArgumentNullException>();
}Collection Assertions
[Test]
public async Task CollectionAssertions()
{
var items = new[] { 1, 2, 3, 4, 5 };
await Assert.That(items).HasCount(5);
await Assert.That(items).Contains(3);
await Assert.That(items).DoesNotContain(6);
await Assert.That(items).AllSatisfy(x => x > 0);
await Assert.That(items).IsEquivalentTo([5, 4, 3, 2, 1]);
await Assert.That(items).IsInAscendingOrder();
}Object Assertions
[Test]
public async Task ObjectAssertions()
{
var user = new User("John", 30);
await Assert.That(user).IsNotNull();
await Assert.That(user.Name).IsEqualTo("John");
await Assert.That(user.Age).IsGreaterThanOrEqualTo(18);
await Assert.That(user).IsOfType<User>();
}Multiple Assertions
Group related assertions to report all failures:
[Test]
public async Task MultipleAssertions()
{
var result = new Result(true, "OK", 200);
await Assert.Multiple(() =>
{
Assert.That(result.Success).IsTrue();
Assert.That(result.Message).IsEqualTo("OK");
Assert.That(result.Code).IsEqualTo(200);
});
}Test Lifecycle Hooks
Setup and Teardown
public class LifecycleTests : IAsyncDisposable
{
private HttpClient _client = null!;
[Before(Test)]
public async Task BeforeEachTest()
{
_client = new HttpClient();
await Task.CompletedTask;
}
[After(Test)]
public async Task AfterEachTest()
{
_client.Dispose();
await Task.CompletedTask;
}
[Test]
public async Task TestWithHttpClient()
{
var response = await _client.GetAsync("https://example.com");
await Assert.That(response.IsSuccessStatusCode).IsTrue();
}
public async ValueTask DisposeAsync()
{
_client.Dispose();
await Task.CompletedTask;
}
}Class-Level Hooks
public class ClassLevelHooks
{
private static TestServer _server = null!;
[Before(Class)]
public static async Task BeforeAllTests()
{
_server = new TestServer();
await _server.StartAsync();
}
[After(Class)]
public static async Task AfterAllTests()
{
await _server.StopAsync();
}
[Test]
public async Task Test1() { }
[Test]
public async Task Test2() { }
}Assembly-Level Hooks
public class GlobalSetup
{
[Before(Assembly)]
public static async Task GlobalBeforeAll()
{
await Database.MigrateAsync();
}
[After(Assembly)]
public static async Task GlobalAfterAll()
{
await Database.CleanupAsync();
}
}Dependency Injection
Constructor Injection with Primary Constructors
public class ServiceTests(
ITestOutputHelper output,
CancellationToken cancellationToken)
{
[Test]
public async Task Service_DoesWork()
{
output.WriteLine("Starting test");
var service = new MyService();
await service.DoWorkAsync(cancellationToken);
await Assert.That(service.IsComplete).IsTrue();
}
}Using Test Context
public class ContextTests(TestContext context)
{
[Test]
public async Task AccessTestMetadata()
{
var testName = context.TestDetails.TestName;
var className = context.TestDetails.TestClass.Name;
await Assert.That(testName).IsNotEmpty();
}
}Test Dependencies
Explicit Test Ordering
public class OrderedTests
{
private static int _counter;
[Test]
[DependsOn(nameof(First))]
public async Task Second()
{
await Assert.That(_counter).IsEqualTo(1);
_counter++;
}
[Test]
public async Task First()
{
_counter = 1;
await Task.CompletedTask;
}
[Test]
[DependsOn(nameof(Second))]
public async Task Third()
{
await Assert.That(_counter).IsEqualTo(2);
}
}Timeouts
Test-Level Timeout
public class TimeoutTests
{
[Test]
[Timeout(5000)] // 5 seconds
public async Task MustCompleteQuickly()
{
await Task.Delay(100);
await Assert.That(true).IsTrue();
}
}Class-Level Timeout
[Timeout(10000)]
public class TimeBoundTests
{
[Test]
public async Task Test1() { }
[Test]
public async Task Test2() { }
}Categories and Filtering
Test Categories
public class CategorizedTests
{
[Test]
[Category("Unit")]
public async Task UnitTest() { }
[Test]
[Category("Integration")]
public async Task IntegrationTest() { }
[Test]
[Category("Unit")]
[Category("Fast")]
public async Task FastUnitTest() { }
}Skipping Tests
public class SkipTests
{
[Test]
[Skip("Pending implementation")]
public async Task NotYetImplemented() { }
[Test]
[SkipWhen(nameof(ShouldSkip))]
public async Task ConditionallySkipped() { }
public static bool ShouldSkip() =>
!Environment.GetEnvironmentVariable("RUN_SLOW_TESTS")?.Equals("true", StringComparison.OrdinalIgnoreCase) ?? true;
}Custom Attributes
Retry Failed Tests
public class RetryTests
{
[Test]
[Retry(3)]
public async Task FlakyTest()
{
// Will retry up to 3 times on failure
var result = await ExternalService.CallAsync();
await Assert.That(result.Success).IsTrue();
}
}Repeat Tests
public class RepeatTests
{
[Test]
[Repeat(5)]
public async Task RunMultipleTimes()
{
// Runs 5 times to check for intermittent issues
await Assert.That(true).IsTrue();
}
}Running Tests
TUnit uses Microsoft.Testing.Platform. Prefer dotnet run over dotnet test for full CLI flag access.
Basic Execution
# Run via test host (recommended)
dotnet run --project Tests.csproj
# Run via dotnet test
dotnet test Tests.csprojFiltering with --treenode-filter
TUnit uses --treenode-filter, not --filter. Syntax: /<Assembly>/<Namespace>/<Class>/<Test>
# All tests in a class
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/CalculatorTests/*"
# Specific test method
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/CalculatorTests/Add_ReturnsSum"
# Filter by namespace
dotnet run --project Tests.csproj -- --treenode-filter "/*/MyApp.Tests.Unit/*/*"
# Filter by category
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/*/*[Category=Unit]"
# Exclude category
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/*/*[Category!=Slow]"
# Multiple filters (OR)
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/ClassA/*|/*/*/ClassB/*"
# Combine filters (AND)
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/*/*[Category=Unit][Priority=High]"
# Custom property filter
dotnet run --project Tests.csproj -- --treenode-filter "/*/*/*/*[Owner=TeamA]"Other CLI Options
# List available tests
dotnet run --project Tests.csproj -- --list-tests
# Run with specific timeout
dotnet run --project Tests.csproj -- --timeout 60000
# Output detailed results
dotnet run --project Tests.csproj -- --results-directory ./resultsTUnit in MCAF
Open/Free Status
- open source
- free to use
Install
Template-based start:
dotnet new install TUnit.Templates
dotnet new TUnit -n MyTestProjectVerify First
Before adding packages or templates, check whether the repo already uses TUnit:
rg -n "PackageReference Include=\"TUnit\"|\\[Test\\]|\\[Arguments\\]|ParallelLimiter|DependsOn" -g '*.csproj' -g '*.cs' .Use this reference when the repository already chose TUnit and you need the right commands, expectations, and CI integration points.
For shared AppHost fixtures, WebApplicationFactory, Playwright UI harnesses, and log/artifact capture, load integration-testing.md.
Detect TUnit
Look for the package and its common attributes:
rg -n "PackageReference Include=\"TUnit\"|\\[Test\\]|\\[Arguments\\]|ParallelLimiter|DependsOn" -g '*.csproj' -g '*.cs' .TUnit is built on Microsoft.Testing.Platform, uses source generation for test discovery, and runs tests in parallel by default.
Common Commands
Start with the repo's test command from AGENTS.md. If the repo has not documented one yet, these are the safe defaults:
dotnet test MySolution.sln
dotnet test tests/MyProject.Tests/MyProject.Tests.csproj
dotnet test tests/MyProject.Tests/MyProject.Tests.csproj -- --treenode-filter "/*/*/MyTestClass/*"
dotnet test MySolution.sln -- --coverage --coverage-output coverage.cobertura.xml --coverage-output-format cobertura
dotnet run --project tests/MyProject.Tests/MyProject.Tests.csproj -- --helpNew-project quick start from the official template:
cd MyTestProject
dotnet runCI Notes
- Assume concurrency unless the repo has explicitly limited it.
- Do not share mutable static state, temp paths, or fixed ports across tests.
- Build once, then re-run focused projects with
--no-buildwhere the repo supports it. - For coverage on Microsoft.Testing.Platform, prefer the repo's documented MTP coverage switches such as
--coverage --coverage-output .... - Publish human-readable reports separately with ReportGenerator if the pipeline needs HTML or Markdown summaries.
- For integration/UI suites, capture first-failure evidence: host log dumps, screenshots, and HTML artifacts.
Good Defaults
- use
[Arguments]for stable parameterized cases - use
[DependsOn]sparingly and only for real orchestration constraints - use
[ParallelLimiter]locally when a boundary genuinely cannot run at full concurrency - keep hooks small and explicit so parallel failures stay diagnosable