From 436890192dcb2ecff16ff6714a2fc803ad1abf26 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 00:27:30 +0000 Subject: [PATCH 01/14] chore(deps): update tunit to 0.90.45 (#3704) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 228c6330ae..ec3912a600 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -83,9 +83,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 22910e7010..8790141d38 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj index a4b32c0577..6b8756e2b3 100644 --- a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj @@ -9,7 +9,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index f46cee483f..72d33c134e 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index edce74a3dc..d17a766c1c 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 49f0a91673..2c815b6c45 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj index f04b3a2186..3937c662d9 100644 --- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj @@ -8,7 +8,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj index e31feb2883..b4b96ac046 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index fd782b106b..a8b1aaeab7 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From dbcb477633f48e64c96e50104b97ec0cd5b2b540 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 00:34:59 +0000 Subject: [PATCH 02/14] Update README.md (#3705) Co-authored-by: thomhurst <30480171_thomhurst@users.noreply.github.com> --- README.md | 80 +++++++++++++++++++++++++++---------------------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 38f1a58d43..b051df79d4 100644 --- a/README.md +++ b/README.md @@ -378,7 +378,7 @@ dotnet add package TUnit --prerelease ``` BenchmarkDotNet v0.15.5, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +AMD EPYC 7763 2.69GHz, 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 Job-GVKUBM : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 @@ -388,10 +388,10 @@ Runtime=.NET 10.0 ``` | Method | Version | Mean | Error | StdDev | Median | |------------- |-------- |--------:|---------:|---------:|--------:| -| Build_TUnit | 0.90.42 | 1.845 s | 0.0334 s | 0.0312 s | 1.843 s | -| Build_NUnit | 4.4.0 | 1.623 s | 0.0184 s | 0.0172 s | 1.622 s | -| Build_MSTest | 4.0.1 | 1.712 s | 0.0262 s | 0.0245 s | 1.710 s | -| Build_xUnit3 | 3.2.0 | 1.622 s | 0.0185 s | 0.0173 s | 1.616 s | +| Build_TUnit | 0.90.45 | 1.771 s | 0.0283 s | 0.0264 s | 1.766 s | +| Build_NUnit | 4.4.0 | 1.549 s | 0.0157 s | 0.0147 s | 1.550 s | +| Build_MSTest | 4.0.1 | 1.637 s | 0.0145 s | 0.0135 s | 1.639 s | +| Build_xUnit3 | 3.2.0 | 1.554 s | 0.0166 s | 0.0156 s | 1.558 s | ### Scenario: Tests running asynchronous operations and async/await patterns @@ -407,13 +407,13 @@ AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores Runtime=.NET 10.0 ``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |---------:|---------:|--------:|---------:| -| TUnit | 0.90.42 | 547.4 ms | 4.61 ms | 4.09 ms | 547.6 ms | -| NUnit | 4.4.0 | 705.8 ms | 6.32 ms | 5.60 ms | 706.3 ms | -| MSTest | 4.0.1 | 684.9 ms | 8.34 ms | 7.80 ms | 685.1 ms | -| xUnit3 | 3.2.0 | 758.0 ms | 10.48 ms | 9.29 ms | 754.3 ms | -| TUnit_AOT | 0.90.42 | 123.7 ms | 0.48 ms | 0.43 ms | 123.7 ms | +| Method | Version | Mean | Error | StdDev | Median | +|---------- |-------- |---------:|---------:|---------:|---------:| +| TUnit | 0.90.45 | 549.9 ms | 3.53 ms | 3.31 ms | 549.9 ms | +| NUnit | 4.4.0 | 669.8 ms | 11.15 ms | 10.43 ms | 667.4 ms | +| MSTest | 4.0.1 | 636.2 ms | 5.08 ms | 4.50 ms | 637.0 ms | +| xUnit3 | 3.2.0 | 719.8 ms | 8.22 ms | 7.69 ms | 717.8 ms | +| TUnit_AOT | 0.90.45 | 124.3 ms | 0.19 ms | 0.17 ms | 124.3 ms | ### Scenario: Parameterized tests with multiple test cases using data attributes @@ -431,11 +431,11 @@ Runtime=.NET 10.0 ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.90.42 | 470.89 ms | 6.153 ms | 5.755 ms | 468.77 ms | -| NUnit | 4.4.0 | 601.51 ms | 11.821 ms | 16.180 ms | 600.22 ms | -| MSTest | 4.0.1 | 614.37 ms | 10.872 ms | 10.169 ms | 615.60 ms | -| xUnit3 | 3.2.0 | 612.32 ms | 11.616 ms | 10.297 ms | 610.50 ms | -| TUnit_AOT | 0.90.42 | 23.49 ms | 0.122 ms | 0.102 ms | 23.49 ms | +| TUnit | 0.90.45 | 477.02 ms | 4.816 ms | 4.270 ms | 477.96 ms | +| NUnit | 4.4.0 | 603.38 ms | 11.818 ms | 15.777 ms | 601.62 ms | +| MSTest | 4.0.1 | 613.28 ms | 12.125 ms | 11.908 ms | 611.29 ms | +| xUnit3 | 3.2.0 | 612.43 ms | 12.074 ms | 12.399 ms | 610.22 ms | +| TUnit_AOT | 0.90.45 | 25.26 ms | 0.204 ms | 0.191 ms | 25.24 ms | ### Scenario: Tests executing massively parallel workloads with CPU-bound, I/O-bound, and mixed operations @@ -443,21 +443,21 @@ Runtime=.NET 10.0 ``` BenchmarkDotNet v0.15.5, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +Intel Xeon Platinum 8370C CPU 2.80GHz (Max: 2.22GHz), 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 - [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 - Job-GVKUBM : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v4 + Job-GVKUBM : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v4 Runtime=.NET 10.0 ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |-----------:|---------:|---------:|-----------:| -| TUnit | 0.90.42 | 703.8 ms | 6.26 ms | 5.85 ms | 704.9 ms | -| NUnit | 4.4.0 | 1,235.1 ms | 10.77 ms | 9.55 ms | 1,233.1 ms | -| MSTest | 4.0.1 | 3,017.4 ms | 10.70 ms | 10.01 ms | 3,017.0 ms | -| xUnit3 | 3.2.0 | 3,106.6 ms | 6.65 ms | 6.22 ms | 3,105.1 ms | -| TUnit_AOT | 0.90.42 | 231.6 ms | 0.69 ms | 0.61 ms | 231.6 ms | +| TUnit | 0.90.45 | 560.4 ms | 10.93 ms | 9.69 ms | 558.1 ms | +| NUnit | 4.4.0 | 1,195.4 ms | 9.98 ms | 8.84 ms | 1,195.2 ms | +| MSTest | 4.0.1 | 3,010.5 ms | 22.59 ms | 21.13 ms | 3,011.2 ms | +| xUnit3 | 3.2.0 | 3,075.0 ms | 17.70 ms | 15.69 ms | 3,068.8 ms | +| TUnit_AOT | 0.90.45 | 130.6 ms | 1.02 ms | 0.91 ms | 130.3 ms | ### Scenario: Tests with complex parameter combinations creating 25-125 test variations @@ -465,21 +465,21 @@ Runtime=.NET 10.0 ``` BenchmarkDotNet v0.15.5, Linux Ubuntu 24.04.3 LTS (Noble Numbat) -AMD EPYC 7763 2.45GHz, 1 CPU, 4 logical and 2 physical cores +Intel Xeon Platinum 8370C CPU 2.80GHz (Max: 2.79GHz), 1 CPU, 4 logical and 2 physical cores .NET SDK 10.0.100-rc.2.25502.107 - [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 - Job-GVKUBM : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v3 + [Host] : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v4 + Job-GVKUBM : .NET 10.0.0 (10.0.0-rc.2.25502.107, 10.0.25.50307), X64 RyuJIT x86-64-v4 Runtime=.NET 10.0 ``` -| Method | Version | Mean | Error | StdDev | Median | -|---------- |-------- |-----------:|--------:|--------:|-----------:| -| TUnit | 0.90.42 | 591.9 ms | 3.50 ms | 2.92 ms | 591.9 ms | -| NUnit | 4.4.0 | 1,570.1 ms | 5.01 ms | 4.18 ms | 1,569.5 ms | -| MSTest | 4.0.1 | 1,525.9 ms | 6.93 ms | 6.48 ms | 1,527.3 ms | -| xUnit3 | 3.2.0 | 1,618.5 ms | 7.43 ms | 6.95 ms | 1,617.7 ms | -| TUnit_AOT | 0.90.42 | 129.6 ms | 0.60 ms | 0.56 ms | 129.6 ms | +| Method | Version | Mean | Error | StdDev | Median | +|---------- |-------- |------------:|----------:|----------:|------------:| +| TUnit | 0.90.45 | 513.03 ms | 6.315 ms | 5.907 ms | 511.12 ms | +| NUnit | 4.4.0 | 1,550.74 ms | 9.008 ms | 7.985 ms | 1,548.81 ms | +| MSTest | 4.0.1 | 1,527.53 ms | 13.291 ms | 11.782 ms | 1,528.44 ms | +| xUnit3 | 3.2.0 | 1,596.19 ms | 7.741 ms | 6.862 ms | 1,594.28 ms | +| TUnit_AOT | 0.90.45 | 77.55 ms | 0.266 ms | 0.249 ms | 77.58 ms | ### Scenario: Large-scale parameterized tests with 100+ test cases testing framework scalability @@ -497,11 +497,11 @@ Runtime=.NET 10.0 ``` | Method | Version | Mean | Error | StdDev | Median | |---------- |-------- |----------:|----------:|----------:|----------:| -| TUnit | 0.90.42 | 504.02 ms | 4.378 ms | 4.095 ms | 503.49 ms | -| NUnit | 4.4.0 | 586.77 ms | 11.474 ms | 10.733 ms | 584.69 ms | -| MSTest | 4.0.1 | 579.34 ms | 11.212 ms | 17.456 ms | 575.64 ms | -| xUnit3 | 3.2.0 | 588.82 ms | 11.643 ms | 10.891 ms | 587.34 ms | -| TUnit_AOT | 0.90.42 | 42.83 ms | 1.011 ms | 2.817 ms | 42.78 ms | +| TUnit | 0.90.45 | 490.15 ms | 3.566 ms | 3.161 ms | 490.62 ms | +| NUnit | 4.4.0 | 574.57 ms | 11.285 ms | 15.820 ms | 573.89 ms | +| MSTest | 4.0.1 | 495.45 ms | 9.313 ms | 15.039 ms | 490.06 ms | +| xUnit3 | 3.2.0 | 570.65 ms | 7.268 ms | 6.443 ms | 570.53 ms | +| TUnit_AOT | 0.90.45 | 42.56 ms | 1.071 ms | 3.091 ms | 42.52 ms | From af4a7f20554540d531638f52774a4f8dd0d40d84 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 10:29:07 +0000 Subject: [PATCH 03/14] chore(deps): update dependency benchmarkdotnet.annotations to 0.15.6 (#3707) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ec3912a600..e18dfd4e71 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,7 +8,7 @@ - + From ff5e910bc462a1aeb6e057a54445997340b93a10 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 11:01:40 +0000 Subject: [PATCH 04/14] chore(deps): update dependency benchmarkdotnet to 0.15.6 (#3706) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index e18dfd4e71..575e88d4ee 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,7 +7,7 @@ - + From b8e081d3f86d7ce5da4155236817d97389e9da63 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:17:40 +0000 Subject: [PATCH 05/14] Enhance documentation for TUnit migration and usage --- docs/docs/extensions/extensions.md | 119 ++++++- docs/docs/faq.md | 186 +++++++++- docs/docs/getting-started/installation.md | 34 ++ docs/docs/migration/mstest.md | 195 +++++++++- docs/docs/migration/nunit.md | 195 +++++++++- docs/docs/migration/xunit.md | 193 +++++++++- docs/docs/troubleshooting.md | 416 ++++++++++++++++++++++ 7 files changed, 1310 insertions(+), 28 deletions(-) diff --git a/docs/docs/extensions/extensions.md b/docs/docs/extensions/extensions.md index ff24d19437..fd78fc7d22 100644 --- a/docs/docs/extensions/extensions.md +++ b/docs/docs/extensions/extensions.md @@ -1,35 +1,124 @@ # Extensions As TUnit is built on top of Microsoft.Testing.Platform, it can tap into generic testing extension packages. -Here will list a few of them. -## Code Coverage +## Built-In Extensions + +The following extensions are **automatically included** when you install the **TUnit** meta package. You don't need to install them separately! + +### Code Coverage + Code coverage is provided via the `Microsoft.Testing.Extensions.CodeCoverage` NuGet package. -Install: -``` -dotnet add package Microsoft.Testing.Extensions.CodeCoverage -``` -Then you can run your tests with the `--coverage` flag. -``` +**✅ Included automatically with the TUnit package** - No manual installation needed! + +#### Usage + +Run your tests with the `--coverage` flag: +```bash +# Basic coverage dotnet run --configuration Release --coverage + +# Specify output location +dotnet run --configuration Release --coverage --coverage-output ./coverage/ + +# Specify output format (cobertura is default) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# Multiple formats +dotnet run --configuration Release --coverage \ + --coverage-output-format cobertura \ + --coverage-output-format xml ``` -[More information can be found here](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-platform-extensions-code-coverage) +#### Important: Coverlet Incompatibility ⚠️ + +**If you're migrating from xUnit, NUnit, or MSTest:** -## TRX Test Reports -Trx reports are provided via the `Microsoft.Testing.Extensions.TrxReport` NuGet package. +- **Remove Coverlet** (`coverlet.collector` or `coverlet.msbuild`) from your project +- TUnit uses Microsoft.Testing.Platform (not VSTest), which is incompatible with Coverlet +- Microsoft.Testing.Extensions.CodeCoverage is the modern replacement and provides the same functionality -Install: +**Migration Example:** +```xml + + + + + + ``` -dotnet add package Microsoft.Testing.Extensions.TrxReport + +See the migration guides for detailed instructions: +- [xUnit Migration Guide - Code Coverage](../migration/xunit.md#code-coverage) +- [NUnit Migration Guide - Code Coverage](../migration/nunit.md#code-coverage) +- [MSTest Migration Guide - Code Coverage](../migration/mstest.md#code-coverage) + +#### Advanced Configuration + +You can customize coverage with a `.runsettings` file: + +**coverage.runsettings:** +```xml + + + + + + + + + + .*\.dll$ + + + .*tests\.dll$ + + + + + + + + ``` -Then you can run your tests with the `--report-trx` flag. + +**Use it:** +```bash +dotnet run --configuration Release --coverage --coverage-settings coverage.runsettings ``` + +**📚 More Resources:** +- [Microsoft's Code Coverage Documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-platform-extensions-code-coverage) +- [Unit Testing Code Coverage Guide](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) + +--- + +### TRX Test Reports + +TRX reports are provided via the `Microsoft.Testing.Extensions.TrxReport` NuGet package. + +**✅ Included automatically with the TUnit package** - No manual installation needed! + +#### Usage + +Run your tests with the `--report-trx` flag: +```bash +# Generate TRX report dotnet run --configuration Release --report-trx + +# Specify output location +dotnet run --configuration Release --report-trx --report-trx-filename ./reports/testresults.trx ``` -[More information can be found here](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-platform-extensions-test-reports) +**📚 More Resources:** +- [Microsoft's TRX Report Documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-platform-extensions-test-reports) + +--- + +## Optional Extensions + +These extensions are **not** included with the TUnit package and must be installed manually if needed. ## Crash Dump Crash dump is an extension to help diagnose unexplained crashes, provided via the `Microsoft.Testing.Extensions.CrashDump` NuGet package. diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 96b8d63318..d554f55c06 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -14,6 +14,190 @@ Ensure you're not installing the old `Microsoft.NET.Test.Sdk` NuGet package ### I can't see any tests in my IDE? -Make sure you've enabled Testing Platform support in your IDE's settings. +Make sure you've enabled Testing Platform support in your IDE's settings. Currently, TUnit is best supported in Visual Studio 2022 (v17.9+) and JetBrains Rider (2024.1+). +### Why do I have to await all assertions? Can I use synchronous assertions? + +All TUnit assertions must be awaited. There's no synchronous alternative. + +**Why this design?** + +TUnit's assertion library is built around async from the ground up. This means: +- All assertions work consistently, whether they're simple value checks or complex async operations +- Custom assertions can perform async work (like database queries or HTTP calls) +- No sync-over-async patterns that cause deadlocks +- Assertions can be chained without blocking + +**What this means when migrating:** + +You need to convert your tests to `async Task` and add `await` before assertions. + +Before (xUnit/NUnit/MSTest): +```csharp +[Test] +public void MyTest() +{ + var result = Calculate(2, 3); + Assert.Equal(5, result); +} +``` + +After (TUnit): +```csharp +[Test] +public async Task MyTest() +{ + var result = Calculate(2, 3); + await Assert.That(result).IsEqualTo(5); +} +``` + +**Automated migration** + +TUnit includes code fixers that handle most of this conversion for you: + +```bash +# For xUnit +dotnet format analyzers --severity info --diagnostics TUXU0001 + +# For NUnit +dotnet format analyzers --severity info --diagnostics TUNU0001 + +# For MSTest +dotnet format analyzers --severity info --diagnostics TUMS0001 +``` + +The code fixer converts test methods to async, adds await to assertions, and updates attribute names. It handles most common cases automatically, though you may need to adjust complex scenarios manually. + +See the migration guides for step-by-step instructions: +- [xUnit migration](migration/xunit.md#using-tunits-code-fixers) +- [NUnit migration](migration/nunit.md#using-tunits-code-fixers) +- [MSTest migration](migration/mstest.md#using-tunits-code-fixers) + +**What you gain** + +Async assertions enable patterns that aren't possible with synchronous assertions: + +```csharp +[Test] +public async Task AsyncAssertion_Example() +{ + // Await async operations in assertions + await Assert.That(async () => await GetUserAsync(123)) + .Throws(); + + // Chain assertions naturally + var user = await GetUserAsync(456); + await Assert.That(user.Email) + .IsNotNull() + .And.Contains("@example.com"); +} +``` + +**Watch out for missing awaits** + +The most common mistake is forgetting `await`. The compiler warns you, but the test will pass without actually running the assertion: + +```csharp +// Wrong - test passes without checking anything +Assert.That(result).IsEqualTo(5); // Returns a Task that's ignored + +// Correct +await Assert.That(result).IsEqualTo(5); +``` + +### Does TUnit work with Coverlet for code coverage? + +**No.** Coverlet (`coverlet.collector` or `coverlet.msbuild`) is **not compatible** with TUnit. + +**Why?** TUnit uses the modern `Microsoft.Testing.Platform` instead of the legacy VSTest platform. Coverlet only works with VSTest. + +**Solution:** Use `Microsoft.Testing.Extensions.CodeCoverage` instead, which is: +- ✅ **Automatically included** with the TUnit meta package +- ✅ Provides the same functionality as Coverlet +- ✅ Outputs Cobertura and XML formats +- ✅ Works with all major CI/CD systems + +See the [Code Coverage documentation](extensions/extensions.md#code-coverage) for usage instructions. + +### How do I get code coverage in TUnit? + +Code coverage is **built-in** and automatically included with the TUnit package! + +**Basic usage:** +```bash +dotnet run --configuration Release --coverage +``` + +**With output location:** +```bash +dotnet run --configuration Release --coverage --coverage-output ./coverage/ +``` + +**Specify format (cobertura, xml, etc.):** +```bash +dotnet run --configuration Release --coverage --coverage-output-format cobertura +``` + +See the [Code Coverage documentation](extensions/extensions.md#code-coverage) for advanced configuration. + +### My code coverage stopped working after migrating to TUnit. What do I do? + +This typically happens if you still have Coverlet packages installed. + +**Fix:** +1. **Remove Coverlet** from your `.csproj`: + ```xml + + + + ``` + +2. **Ensure you're using the TUnit meta package** (not just TUnit.Core): + ```xml + + ``` + +3. **Update your commands** to use the new coverage flags: + ```bash + # Old (VSTest + Coverlet) + dotnet test --collect:"XPlat Code Coverage" + + # New (TUnit + Microsoft Coverage) + dotnet run --configuration Release --coverage + ``` + +4. **Update CI/CD pipelines** to use the new commands + +See the migration guides for detailed instructions: +- [xUnit Migration - Code Coverage](migration/xunit.md#code-coverage) +- [NUnit Migration - Code Coverage](migration/nunit.md#code-coverage) +- [MSTest Migration - Code Coverage](migration/mstest.md#code-coverage) + +### What code coverage tool should I use with TUnit? + +Use **Microsoft.Testing.Extensions.CodeCoverage**, which is: +- ✅ **Already included** with the TUnit package (no manual installation) +- ✅ Built and maintained by Microsoft +- ✅ Works seamlessly with Microsoft.Testing.Platform +- ✅ Outputs industry-standard formats (Cobertura, XML) +- ✅ Compatible with all major CI/CD systems and coverage viewers + +**Do not use:** +- ❌ Coverlet (incompatible with Microsoft.Testing.Platform) + +### Why don't my coverage files get generated? + +**Common causes:** + +1. **Using TUnit.Engine only** (without the TUnit meta package) + - The TUnit meta package includes the coverage extension automatically + - If using TUnit.Engine directly, you must manually install `Microsoft.Testing.Extensions.CodeCoverage` + +2. **Using .NET 7 or earlier** + - Microsoft.Testing.Platform requires .NET 8+ + - Upgrade to .NET 8 or later + +See the [Code Coverage Troubleshooting](troubleshooting.md#code-coverage-issues) for more solutions. + diff --git a/docs/docs/getting-started/installation.md b/docs/docs/getting-started/installation.md index 69033e02d0..38d5ca5b1d 100644 --- a/docs/docs/getting-started/installation.md +++ b/docs/docs/getting-started/installation.md @@ -50,6 +50,40 @@ public class MyTests // No [TestClass] needed! } ``` +### What's Included in the TUnit Package + +When you install the **TUnit** meta package, you automatically get several useful extensions without any additional installation: + +#### ✅ Built-In Extensions + +**Microsoft.Testing.Extensions.CodeCoverage** +- 📊 Code coverage support via `--coverage` flag +- 📈 Outputs Cobertura and XML formats +- 🔄 Replacement for Coverlet (which is **not compatible** with TUnit) + +**Microsoft.Testing.Extensions.TrxReport** +- 📝 TRX test report generation via `--report-trx` flag +- 🤝 Compatible with Azure DevOps and other CI/CD systems + +This means you can run tests with coverage and reports right away: + +```bash +# Run tests with code coverage +dotnet run --configuration Release --coverage + +# Run tests with TRX report +dotnet run --configuration Release --report-trx + +# Both coverage and report +dotnet run --configuration Release --coverage --report-trx +``` + +**Important:** Do **not** install `coverlet.collector` or `coverlet.msbuild`. These packages are incompatible with TUnit because they require the VSTest platform, while TUnit uses the modern Microsoft.Testing.Platform. + +For more details, see: +- [Code Coverage Documentation](../extensions/extensions.md#code-coverage) +- [Extensions Overview](../extensions/extensions.md) + That's it. We're ready to write our first test. Your `.csproj` should be as simple as something like: diff --git a/docs/docs/migration/mstest.md b/docs/docs/migration/mstest.md index 80a5f54af6..d5286b1bd5 100644 --- a/docs/docs/migration/mstest.md +++ b/docs/docs/migration/mstest.md @@ -1,10 +1,19 @@ # Migrating from MSTest -## Using TUnit's Code Fixers +## Automated Migration with Code Fixers -TUnit has code fixers to help automate the migration from MSTest to TUnit. +TUnit includes code fixers that automate most of the migration work. -These code fixers will handle most common scenarios, but you'll likely still need to do some manual adjustments. If you encounter issues or have suggestions for improvements, please raise an issue. +**What gets converted:** +- Tests to `async Task` with awaited assertions +- Removes `[TestClass]`, converts `[TestMethod]` → `[Test]` +- MSTest assertions to TUnit's fluent syntax +- `[DataRow]` → `[Arguments]`, `[DynamicData]` → `[MethodDataSource]` +- `[TestInitialize]`/`[TestCleanup]` → `[Before(Test)]`/`[After(Test)]` + +The code fixer handles most common patterns automatically (roughly 80-90% of typical test suites). You'll need to manually adjust complex cases like TestContext usage or intricate async patterns. + +If you find something that should be automated but isn't, please [open an issue](https://github.com/thomhurst/TUnit/issues). ### Steps @@ -909,4 +918,182 @@ public class ContextTests 8. **No DeploymentItem**: Configure file copying in your .csproj instead of using `[DeploymentItem]`. -9. **Property-Based Metadata**: Use `[Property("key", "value")]` for all metadata (Owner, Priority, Category, custom properties). \ No newline at end of file +9. **Property-Based Metadata**: Use `[Property("key", "value")]` for all metadata (Owner, Priority, Category, custom properties). + +## Code Coverage + +### Important: Coverlet is Not Compatible with TUnit + +If you're using **Coverlet** (`coverlet.collector` or `coverlet.msbuild`) for code coverage in your MSTest projects, you'll need to migrate to **Microsoft.Testing.Extensions.CodeCoverage**. + +**Why?** TUnit uses the modern `Microsoft.Testing.Platform` instead of VSTest, and Coverlet only works with the legacy VSTest platform. + +### Good News: Coverage is Built In! 🎉 + +When you install the **TUnit** meta package, it automatically includes `Microsoft.Testing.Extensions.CodeCoverage` for you. You don't need to install it separately! + +### Migration Steps + +#### 1. Remove Coverlet Packages + +Remove any Coverlet packages from your project file: + +**Remove these lines from your `.csproj`:** +```xml + + + +``` + +#### 2. Verify TUnit Meta Package + +Ensure you're using the **TUnit** meta package (not just TUnit.Core): + +**Your `.csproj` should have:** +```xml + +``` + +This automatically brings in: +- `Microsoft.Testing.Extensions.CodeCoverage` (coverage support) +- `Microsoft.Testing.Extensions.TrxReport` (test result reports) + +#### 3. Update Your Coverage Commands + +Replace your old Coverlet commands with the new Microsoft coverage syntax: + +**Old (Coverlet with MSTest):** +```bash +# With coverlet.collector +dotnet test --collect:"XPlat Code Coverage" + +# With coverlet.msbuild +dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura +``` + +**New (TUnit with Microsoft Coverage):** +```bash +# Run tests with coverage +dotnet run --configuration Release --coverage + +# Specify output location +dotnet run --configuration Release --coverage --coverage-output ./coverage/ + +# Specify coverage format (default is cobertura) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# Multiple formats +dotnet run --configuration Release --coverage --coverage-output-format cobertura --coverage-output-format xml +``` + +#### 4. Update CI/CD Pipelines + +If you have CI/CD pipelines that reference Coverlet, update them to use the new commands: + +**GitHub Actions Example:** +```yaml +# Old (MSTest with Coverlet) +- name: Run tests with coverage + run: dotnet test --collect:"XPlat Code Coverage" + +# New (TUnit with Microsoft Coverage) +- name: Run tests with coverage + run: dotnet run --project ./tests/MyProject.Tests --configuration Release --coverage +``` + +**Azure Pipelines Example:** +```yaml +# Old (MSTest with Coverlet) +- task: DotNetCoreCLI@2 + inputs: + command: 'test' + arguments: '--collect:"XPlat Code Coverage"' + +# New (TUnit with Microsoft Coverage) +- task: DotNetCoreCLI@2 + inputs: + command: 'run' + arguments: '--configuration Release --coverage --coverage-output $(Agent.TempDirectory)/coverage/' +``` + +### Coverage Output Formats + +The Microsoft coverage tool supports multiple output formats: + +```bash +# Cobertura (default, widely supported) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# XML (Visual Studio format) +dotnet run --configuration Release --coverage --coverage-output-format xml + +# Cobertura + XML +dotnet run --configuration Release --coverage \ + --coverage-output-format cobertura \ + --coverage-output-format xml +``` + +### Viewing Coverage Results + +Coverage files are generated in your test output directory: + +``` +TestResults/ + ├── coverage.cobertura.xml + └── / + └── coverage.xml +``` + +You can view these with: +- **Visual Studio** - Built-in coverage viewer (MSTest users will find this familiar) +- **VS Code** - Extensions like "Coverage Gutters" +- **ReportGenerator** - Generate HTML reports: `reportgenerator -reports:coverage.cobertura.xml -targetdir:coveragereport` +- **CI Tools** - Most CI systems can parse Cobertura format natively (same as before) + +### Advanced Coverage Configuration + +You can customize coverage behavior with a `.runsettings` file (same format as MSTest): + +**coverage.runsettings:** +```xml + + + + + + + + + + .*\.dll$ + + + .*tests\.dll$ + + + + + + + + +``` + +**Use it:** +```bash +dotnet run --configuration Release --coverage --coverage-settings coverage.runsettings +``` + +### Troubleshooting + +**Coverage files not generated?** +- Ensure you're using the TUnit meta package, not just TUnit.Engine +- Verify you're on .NET 8+ (required for Microsoft.Testing.Platform) + +**Missing coverage for some assemblies?** +- Use a `.runsettings` file to explicitly include/exclude modules +- See [Microsoft's documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) + +**Need help?** +- See [TUnit Code Coverage Documentation](../extensions/extensions.md#code-coverage) +- Check [Microsoft's Code Coverage Guide](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) \ No newline at end of file diff --git a/docs/docs/migration/nunit.md b/docs/docs/migration/nunit.md index 2ef731a898..71b06d8d90 100644 --- a/docs/docs/migration/nunit.md +++ b/docs/docs/migration/nunit.md @@ -1,10 +1,19 @@ # Migrating from NUnit -## Using TUnit's Code Fixers +## Automated Migration with Code Fixers -TUnit has code fixers to help automate the migration from NUnit to TUnit. +TUnit includes code fixers that automate most of the migration work. -These code fixers will handle most common scenarios, but you'll likely still need to do some manual adjustments. If you encounter issues or have suggestions for improvements, please raise an issue. +**What gets converted:** +- Tests to `async Task` with awaited assertions +- Removes `[TestFixture]`, converts `[TestCase]` → `[Arguments]` +- Both classic and constraint-based NUnit assertions to TUnit's fluent syntax +- `[TestCaseSource]` → `[MethodDataSource]` +- `[SetUp]`/`[TearDown]` → `[Before(Test)]`/`[After(Test)]` + +The code fixer handles most common patterns automatically (roughly 80-90% of typical test suites). You'll need to manually adjust complex cases like custom fixtures or intricate async patterns. + +If you find something that should be automated but isn't, please [open an issue](https://github.com/thomhurst/TUnit/issues). ### Steps @@ -647,4 +656,182 @@ public static class AssemblyHooks 6. **TestContext Injection**: Instead of a static `TestContext`, TUnit injects it as a parameter where needed. -7. **Isolated Test Instances**: Each test runs in its own class instance (NUnit's default behavior can be different). \ No newline at end of file +7. **Isolated Test Instances**: Each test runs in its own class instance (NUnit's default behavior can be different). + +## Code Coverage + +### Important: Coverlet is Not Compatible with TUnit + +If you're using **Coverlet** (`coverlet.collector` or `coverlet.msbuild`) for code coverage in your NUnit projects, you'll need to migrate to **Microsoft.Testing.Extensions.CodeCoverage**. + +**Why?** TUnit uses the modern `Microsoft.Testing.Platform` instead of VSTest, and Coverlet only works with the legacy VSTest platform. + +### Good News: Coverage is Built In! 🎉 + +When you install the **TUnit** meta package, it automatically includes `Microsoft.Testing.Extensions.CodeCoverage` for you. You don't need to install it separately! + +### Migration Steps + +#### 1. Remove Coverlet Packages + +Remove any Coverlet packages from your project file: + +**Remove these lines from your `.csproj`:** +```xml + + + +``` + +#### 2. Verify TUnit Meta Package + +Ensure you're using the **TUnit** meta package (not just TUnit.Core): + +**Your `.csproj` should have:** +```xml + +``` + +This automatically brings in: +- `Microsoft.Testing.Extensions.CodeCoverage` (coverage support) +- `Microsoft.Testing.Extensions.TrxReport` (test result reports) + +#### 3. Update Your Coverage Commands + +Replace your old Coverlet commands with the new Microsoft coverage syntax: + +**Old (Coverlet with NUnit):** +```bash +# With coverlet.collector +dotnet test --collect:"XPlat Code Coverage" + +# With coverlet.msbuild +dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura +``` + +**New (TUnit with Microsoft Coverage):** +```bash +# Run tests with coverage +dotnet run --configuration Release --coverage + +# Specify output location +dotnet run --configuration Release --coverage --coverage-output ./coverage/ + +# Specify coverage format (default is cobertura) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# Multiple formats +dotnet run --configuration Release --coverage --coverage-output-format cobertura --coverage-output-format xml +``` + +#### 4. Update CI/CD Pipelines + +If you have CI/CD pipelines that reference Coverlet, update them to use the new commands: + +**GitHub Actions Example:** +```yaml +# Old (NUnit with Coverlet) +- name: Run tests with coverage + run: dotnet test --collect:"XPlat Code Coverage" + +# New (TUnit with Microsoft Coverage) +- name: Run tests with coverage + run: dotnet run --project ./tests/MyProject.Tests --configuration Release --coverage +``` + +**Azure Pipelines Example:** +```yaml +# Old (NUnit with Coverlet) +- task: DotNetCoreCLI@2 + inputs: + command: 'test' + arguments: '--collect:"XPlat Code Coverage"' + +# New (TUnit with Microsoft Coverage) +- task: DotNetCoreCLI@2 + inputs: + command: 'run' + arguments: '--configuration Release --coverage --coverage-output $(Agent.TempDirectory)/coverage/' +``` + +### Coverage Output Formats + +The Microsoft coverage tool supports multiple output formats: + +```bash +# Cobertura (default, widely supported) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# XML (Visual Studio format) +dotnet run --configuration Release --coverage --coverage-output-format xml + +# Cobertura + XML +dotnet run --configuration Release --coverage \ + --coverage-output-format cobertura \ + --coverage-output-format xml +``` + +### Viewing Coverage Results + +Coverage files are generated in your test output directory: + +``` +TestResults/ + ├── coverage.cobertura.xml + └── / + └── coverage.xml +``` + +You can view these with: +- **Visual Studio** - Built-in coverage viewer +- **VS Code** - Extensions like "Coverage Gutters" +- **ReportGenerator** - Generate HTML reports: `reportgenerator -reports:coverage.cobertura.xml -targetdir:coveragereport` +- **CI Tools** - Most CI systems can parse Cobertura format natively + +### Advanced Coverage Configuration + +You can customize coverage behavior with a `.runsettings` file: + +**coverage.runsettings:** +```xml + + + + + + + + + + .*\.dll$ + + + .*tests\.dll$ + + + + + + + + +``` + +**Use it:** +```bash +dotnet run --configuration Release --coverage --coverage-settings coverage.runsettings +``` + +### Troubleshooting + +**Coverage files not generated?** +- Ensure you're using the TUnit meta package, not just TUnit.Engine +- Verify you're on .NET 8+ (required for Microsoft.Testing.Platform) + +**Missing coverage for some assemblies?** +- Use a `.runsettings` file to explicitly include/exclude modules +- See [Microsoft's documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) + +**Need help?** +- See [TUnit Code Coverage Documentation](../extensions/extensions.md#code-coverage) +- Check [Microsoft's Code Coverage Guide](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) \ No newline at end of file diff --git a/docs/docs/migration/xunit.md b/docs/docs/migration/xunit.md index 58a006b7b5..f3ae7459e6 100644 --- a/docs/docs/migration/xunit.md +++ b/docs/docs/migration/xunit.md @@ -1,12 +1,19 @@ # Migrating from xUnit.net -## Using TUnit's Code Fixers +## Automated Migration with Code Fixers -TUnit has some code fixers to help automate some of the migration for you. +TUnit includes code fixers that automate most of the migration work. -Now bear in mind, these won't be perfect, and you'll likely still have to do some bits manually, but it should make life a bit easier. +**What gets converted:** +- Tests to `async Task` with awaited assertions +- `[Fact]` → `[Test]`, `[Theory]` → `[Test]` +- xUnit assertions to TUnit's fluent syntax +- `[InlineData]` → `[Arguments]`, `[MemberData]` → `[MethodDataSource]` +- Constructors and IDisposable to TUnit hooks -If you think something could be improved, or something seemed to break, raise an issue so we can make this better and work for more people. +The code fixer handles most common patterns automatically (roughly 80-90% of typical test suites). You'll need to manually adjust complex cases like custom fixtures or intricate async patterns. + +If you find something that should be automated but isn't, please [open an issue](https://github.com/thomhurst/TUnit/issues). ### Steps @@ -965,6 +972,184 @@ public class UserServiceTests(DatabaseFixture dbFixture) - Data sources return strongly-typed values (not object[]) - Fluent assertion syntax +## Code Coverage + +### Important: Coverlet is Not Compatible with TUnit + +If you're using **Coverlet** (`coverlet.collector` or `coverlet.msbuild`) for code coverage in your xUnit projects, you'll need to migrate to **Microsoft.Testing.Extensions.CodeCoverage**. + +**Why?** TUnit uses the modern `Microsoft.Testing.Platform` instead of VSTest, and Coverlet only works with the legacy VSTest platform. + +### Good News: Coverage is Built In! 🎉 + +When you install the **TUnit** meta package, it automatically includes `Microsoft.Testing.Extensions.CodeCoverage` for you. You don't need to install it separately! + +### Migration Steps + +#### 1. Remove Coverlet Packages + +Remove any Coverlet packages from your project file: + +**Remove these lines from your `.csproj`:** +```xml + + + +``` + +#### 2. Verify TUnit Meta Package + +Ensure you're using the **TUnit** meta package (not just TUnit.Core): + +**Your `.csproj` should have:** +```xml + +``` + +This automatically brings in: +- `Microsoft.Testing.Extensions.CodeCoverage` (coverage support) +- `Microsoft.Testing.Extensions.TrxReport` (test result reports) + +#### 3. Update Your Coverage Commands + +Replace your old Coverlet commands with the new Microsoft coverage syntax: + +**Old (Coverlet with xUnit):** +```bash +# With coverlet.collector +dotnet test --collect:"XPlat Code Coverage" + +# With coverlet.msbuild +dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=cobertura +``` + +**New (TUnit with Microsoft Coverage):** +```bash +# Run tests with coverage +dotnet run --configuration Release --coverage + +# Specify output location +dotnet run --configuration Release --coverage --coverage-output ./coverage/ + +# Specify coverage format (default is cobertura) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# Multiple formats +dotnet run --configuration Release --coverage --coverage-output-format cobertura --coverage-output-format xml +``` + +#### 4. Update CI/CD Pipelines + +If you have CI/CD pipelines that reference Coverlet, update them to use the new commands: + +**GitHub Actions Example:** +```yaml +# Old (xUnit with Coverlet) +- name: Run tests with coverage + run: dotnet test --collect:"XPlat Code Coverage" + +# New (TUnit with Microsoft Coverage) +- name: Run tests with coverage + run: dotnet run --project ./tests/MyProject.Tests --configuration Release --coverage +``` + +**Azure Pipelines Example:** +```yaml +# Old (xUnit with Coverlet) +- task: DotNetCoreCLI@2 + inputs: + command: 'test' + arguments: '--collect:"XPlat Code Coverage"' + +# New (TUnit with Microsoft Coverage) +- task: DotNetCoreCLI@2 + inputs: + command: 'run' + arguments: '--configuration Release --coverage --coverage-output $(Agent.TempDirectory)/coverage/' +``` + +### Coverage Output Formats + +The Microsoft coverage tool supports multiple output formats: + +```bash +# Cobertura (default, widely supported) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# XML (Visual Studio format) +dotnet run --configuration Release --coverage --coverage-output-format xml + +# Cobertura + XML +dotnet run --configuration Release --coverage \ + --coverage-output-format cobertura \ + --coverage-output-format xml +``` + +### Viewing Coverage Results + +Coverage files are generated in your test output directory: + +``` +TestResults/ + ├── coverage.cobertura.xml + └── / + └── coverage.xml +``` + +You can view these with: +- **Visual Studio** - Built-in coverage viewer +- **VS Code** - Extensions like "Coverage Gutters" +- **ReportGenerator** - Generate HTML reports: `reportgenerator -reports:coverage.cobertura.xml -targetdir:coveragereport` +- **CI Tools** - Most CI systems can parse Cobertura format natively + +### Advanced Coverage Configuration + +You can customize coverage behavior with a `.runsettings` file: + +**coverage.runsettings:** +```xml + + + + + + + + + + .*\.dll$ + + + .*tests\.dll$ + + + + + + + + +``` + +**Use it:** +```bash +dotnet run --configuration Release --coverage --coverage-settings coverage.runsettings +``` + +### Troubleshooting + +**Coverage files not generated?** +- Ensure you're using the TUnit meta package, not just TUnit.Engine +- Verify you're on .NET 8+ (required for Microsoft.Testing.Platform) + +**Missing coverage for some assemblies?** +- Use a `.runsettings` file to explicitly include/exclude modules +- See [Microsoft's documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) + +**Need help?** +- See [TUnit Code Coverage Documentation](../extensions/extensions.md#code-coverage) +- Check [Microsoft's Code Coverage Guide](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) + ## Quick Reference | xUnit | TUnit | diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md index 439066435d..8fc2320e11 100644 --- a/docs/docs/troubleshooting.md +++ b/docs/docs/troubleshooting.md @@ -223,6 +223,185 @@ await Assert.That(0.1 + 0.2).IsEqualTo(0.3); await Assert.That(0.1 + 0.2).IsEqualTo(0.3).Within(0.0001); ``` +### Assertion Not Awaited (Test Passes Without Checking) + +**Symptoms:** +- Test passes but assertion never executes +- Compiler warning: "This async method lacks 'await' operators" +- Test passes when it should fail + +**Root Cause:** + +Forgetting to `await` an assertion means it returns a `Task` that's never executed. The test completes immediately without checking anything. + +**Example:** + +```csharp +[Test] +public async Task BadTest() +{ + var result = Calculate(2, 2); + + // Wrong - missing await + Assert.That(result).IsEqualTo(5); // Returns Task, never awaited + + // Test passes because assertion never runs +} +``` + +**Solution:** + +Always await assertions: + +```csharp +[Test] +public async Task GoodTest() +{ + var result = Calculate(2, 2); + await Assert.That(result).IsEqualTo(4); +} +``` + +**Prevention:** + +The compiler warns you about this (CS4014: "Because this call is not awaited..."). To catch these at build time, enable treating warnings as errors: + +```xml + + true + +``` + +See also: [FAQ: Why do I have to await all assertions?](faq.md#why-do-i-have-to-await-all-assertions-can-i-use-synchronous-assertions) + +### Array and Collection Comparison Issues + +**Symptoms:** +- "IsEqualTo doesn't work for arrays" +- Arrays with same values fail equality check +- Error messages about reference equality vs value equality + +**Root Cause:** + +Arrays use reference equality by default. You need to use collection-specific assertion methods. + +#### Comparing Arrays + +```csharp +var expected = new[] { 1, 2, 3 }; +var actual = new[] { 1, 2, 3 }; + +// Wrong - compares references, not values +await Assert.That(actual).IsEqualTo(expected); // Fails + +// Correct - use IsEquivalentTo for collections +await Assert.That(actual).IsEquivalentTo(expected); // Passes +``` + +Note that `IsEquivalentTo` ignores order. If order matters, assert on elements individually: + +```csharp +await Assert.That(actual).HasCount().EqualTo(expected.Length); +for (int i = 0; i < expected.Length; i++) +{ + await Assert.That(actual[i]).IsEqualTo(expected[i]); +} +``` + +#### Arrays of Complex Types + +```csharp +var expected = new[] +{ + new User { Id = 1, Name = "Alice" }, + new User { Id = 2, Name = "Bob" } +}; + +// May not work without custom equality implementation +await Assert.That(actual).IsEquivalentTo(expected); + +// More reliable - assert on properties +await Assert.That(actual).HasCount().EqualTo(2); +await Assert.That(actual[0].Name).IsEqualTo("Alice"); +await Assert.That(actual[1].Name).IsEqualTo("Bob"); + +// Or compare projected values +await Assert.That(actual.Select(u => u.Name)) + .IsEquivalentTo(new[] { "Alice", "Bob" }); +``` + +#### Arrays of Tuples (Known Limitation) + +```csharp +var expected = new[] { (1, "a"), (2, "b") }; +var actual = new[] { (1, "a"), (2, "b") }; + +// Current limitation - may not work as expected +// await Assert.That(actual).IsEquivalentTo(expected); + +// Workaround - assert individual elements +await Assert.That(actual).HasCount().EqualTo(2); +await Assert.That(actual[0]).IsEqualTo((1, "a")); +await Assert.That(actual[1]).IsEqualTo((2, "b")); +``` + +#### Lists and Other Collections + +```csharp +var list = new List { 1, 2, 3 }; + +// Works for IEnumerable types +await Assert.That(list).IsEquivalentTo(new[] { 1, 2, 3 }); + +// Check specific properties +await Assert.That(list).HasCount().EqualTo(3); +await Assert.That(list).Contains(2); +await Assert.That(list).DoesNotContain(5); +``` + +**General Approach:** +- Use `IsEquivalentTo` for unordered collection comparison +- Iterate and assert elements for ordered comparison +- Assert on key properties for complex types +- Consider implementing `IEquatable` on your types for cleaner assertions + +### Assertion on Wrong Type + +**Symptoms:** +- Compiler error: "Cannot convert from 'X' to 'Y'" +- Assertion method not available for type +- IntelliSense doesn't show expected assertions + +#### String vs Object Assertions + +```csharp +object value = "hello"; + +// Doesn't compile - object doesn't have string-specific assertions +// await Assert.That(value).StartsWith("h"); + +// Cast to the correct type +await Assert.That((string)value).StartsWith("h"); + +// Or check the type first +await Assert.That(value).IsTypeOf(); +await Assert.That((string)value).StartsWith("h"); +``` + +#### Nullable Values + +```csharp +int? nullableInt = 5; + +// Option 1: Check for null, then access value +await Assert.That(nullableInt).IsNotNull(); +await Assert.That(nullableInt!.Value).IsEqualTo(5); + +// Option 2: Use HasValue pattern +await Assert.That(nullableInt.HasValue).IsTrue(); +await Assert.That(nullableInt.GetValueOrDefault()).IsEqualTo(5); +``` + ## Dependency Injection Issues ### Services Not Available @@ -489,6 +668,243 @@ var expected = "Line1\r\nLine2"; var expected = $"Line1{Environment.NewLine}Line2"; ``` +## Code Coverage Issues + +### Coverage Files Not Generated + +**Symptoms:** +- No coverage files in TestResults folder +- `--coverage` flag has no effect +- Coverage reports empty or missing + +**Common Causes and Solutions:** + +#### 1. Using TUnit.Engine Without Extensions +```xml + + + + + +``` + +**Fix:** Use the TUnit meta package, or manually add the coverage extension if using TUnit.Engine directly: +```xml + + +``` + +#### 2. Using .NET 7 or Earlier +```bash +# Check your .NET version +dotnet --version +``` + +**Requirements:** +- ✅ .NET 8 or later (required for Microsoft.Testing.Platform) +- ❌ .NET 7 or earlier (not compatible) + +**Fix:** Upgrade to .NET 8 or later in your project file: +```xml +net8.0 +``` + +#### 3. Configuration Not Set to Release +```bash +# It's generally better to run coverage in Release configuration +dotnet test --configuration Release --coverage +``` + +### Coverlet Still Installed + +**Symptoms:** +- Coverage stopped working after migrating to TUnit +- Conflicts between coverage tools +- "Could not load file or assembly" errors related to coverage + +**Root Cause:** Coverlet is **not compatible** with TUnit because: +- Coverlet requires VSTest platform +- TUnit uses Microsoft.Testing.Platform +- These platforms are mutually exclusive + +**Solution:** + +1. **Remove Coverlet packages** from your `.csproj`: +```xml + + + +``` + +2. **Ensure TUnit meta package is installed**: +```xml + +``` + +3. **Update coverage commands**: +```bash +# Old (VSTest + Coverlet) +dotnet test --collect:"XPlat Code Coverage" + +# New (TUnit + Microsoft Coverage) +dotnet run --configuration Release --coverage +``` + +See the [Code Coverage FAQ](faq.md#does-tunit-work-with-coverlet-for-code-coverage) for more details. + +### Missing Coverage for Some Assemblies + +**Symptoms:** +- Coverage reports show 0% for some projects +- Some assemblies excluded from coverage +- Unexpected gaps in coverage + +**Solutions:** + +#### 1. Create a `.runsettings` File +```xml + + + + + + + + + + + .*\.dll$ + .*MyProject\.dll$ + + + .*tests?\.dll$ + .*TestHelpers\.dll$ + + + + + + + + +``` + +#### 2. Use the Settings File +```bash +dotnet run --configuration Release --coverage --coverage-settings coverage.runsettings +``` + +### Coverage Format Not Recognized by CI/CD + +**Symptoms:** +- CI/CD doesn't display coverage results +- Coverage upload fails +- "Unsupported format" errors + +**Solutions:** + +#### 1. Check Output Format +```bash +# Default is Cobertura (widely supported) +dotnet run --configuration Release --coverage --coverage-output-format cobertura + +# For Visual Studio +dotnet run --configuration Release --coverage --coverage-output-format xml + +# Multiple formats +dotnet run --configuration Release --coverage \ + --coverage-output-format cobertura \ + --coverage-output-format xml +``` + +#### 2. Verify Output Location +```bash +# Coverage files generated in TestResults by default +ls TestResults/ + +# Expected files: +# - coverage.cobertura.xml +# - /coverage.xml +``` + +#### 3. Common CI/CD Configurations + +**GitHub Actions:** +```yaml +- name: Run tests with coverage + run: dotnet run --project tests/MyProject.Tests --configuration Release --coverage + +- name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./tests/MyProject.Tests/TestResults/coverage.cobertura.xml +``` + +**Azure Pipelines:** +```yaml +- task: DotNetCoreCLI@2 + inputs: + command: 'run' + projects: 'tests/**/*.csproj' + arguments: '--configuration Release --coverage --coverage-output $(Agent.TempDirectory)/coverage/' + +- task: PublishCodeCoverageResults@2 + inputs: + summaryFileLocation: '$(Agent.TempDirectory)/coverage/**/coverage.cobertura.xml' +``` + +### Coverage Percentage Seems Wrong + +**Symptoms:** +- Coverage percentage doesn't match expectations +- Test code included in coverage +- Dependencies inflating coverage numbers + +**Solutions:** + +#### 1. Exclude Test Projects +```xml + + + + .*tests?\.dll$ + .*\.Tests\.dll$ + + +``` + +#### 2. Exclude Generated Code +```xml + + + .*\.g\.cs$ + .*\.Designer\.cs$ + + +``` + +#### 3. Include Only Production Code +```xml + + + .*MyCompany\.MyProduct\..*\.dll$ + + + .*tests?\.dll$ + + +``` + +### Need More Help with Coverage? + +See also: +- [Code Coverage FAQ](faq.md#does-tunit-work-with-coverlet-for-code-coverage) +- [Code Coverage Documentation](extensions/extensions.md#code-coverage) +- [xUnit Migration - Code Coverage](migration/xunit.md#code-coverage) +- [NUnit Migration - Code Coverage](migration/nunit.md#code-coverage) +- [MSTest Migration - Code Coverage](migration/mstest.md#code-coverage) +- [Microsoft's Coverage Documentation](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) + ## Debugging Tips ### Enable Diagnostic Logging From b54484244be7d76406618294b783e417d8eeb54a Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:53:30 +0000 Subject: [PATCH 06/14] Add Testing Cookbook and Philosophy Documentation --- docs/docs/examples/tunit-ci-pipeline.md | 653 ++++++++++++++- docs/docs/guides/best-practices.md | 706 ++++++++++++++++ docs/docs/guides/cookbook.md | 792 ++++++++++++++++++ docs/docs/guides/philosophy.md | 182 ++++ .../docs/test-lifecycle/property-injection.md | 2 +- docs/sidebars.ts | 9 + 6 files changed, 2322 insertions(+), 22 deletions(-) create mode 100644 docs/docs/guides/best-practices.md create mode 100644 docs/docs/guides/cookbook.md create mode 100644 docs/docs/guides/philosophy.md diff --git a/docs/docs/examples/tunit-ci-pipeline.md b/docs/docs/examples/tunit-ci-pipeline.md index 1726b9f700..378f04f4fc 100644 --- a/docs/docs/examples/tunit-ci-pipeline.md +++ b/docs/docs/examples/tunit-ci-pipeline.md @@ -1,29 +1,640 @@ -# TUnit in CI pipelines +# TUnit in CI/CD Pipelines -When using TUnit for end-to-end (E2E) tests or TUnit's Playwright library for UI testing, you will likely run these tests in a CI/CD pipeline—either on a schedule or as part of a release. In such cases, it is important to publish the test results for visibility and reporting. +When using TUnit in a CI/CD pipeline, you'll want to run tests, collect results, and publish reports for visibility. This guide provides complete, production-ready pipeline configurations for popular CI/CD platforms. -The best practice is to use the .NET SDK CLI (dotnet test) directly to maintain full control over execution, ensure reproducibility across environments, and allow for local debugging. +The best practice is to use the .NET SDK CLI (`dotnet test` or `dotnet run`) directly to maintain full control over execution, ensure reproducibility across environments, and allow for local debugging. -Below is an example of how to execute and publish TUnit test results to Azure DevOps Test Runs. +> **Note**: The `--` separator is required to pass arguments to the test runner when using `dotnet test` when using SDKs older than .NET 10. -> Note: The -- separator is required to pass arguments to the test runner. +## GitHub Actions + +### Complete Workflow with Matrix Strategy + +This workflow tests multiple .NET versions across different operating systems, collects code coverage, and publishes results: + +```yaml +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main ] + +jobs: + test: + name: Test on ${{ matrix.os }} - .NET ${{ matrix.dotnet-version }} + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + dotnet-version: ['8.0.x', '9.0.x'] + fail-fast: false # Continue running other jobs if one fails + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup .NET ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Run tests with coverage + run: dotnet test --configuration Release --no-build --coverage --report-trx --results-directory ./TestResults + + - name: Upload test results + if: always() # Run even if tests fail + uses: actions/upload-artifact@v4 + with: + name: test-results-${{ matrix.os }}-${{ matrix.dotnet-version }} + path: ./TestResults/*.trx + + - name: Upload coverage + if: always() + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.os }}-${{ matrix.dotnet-version }} + path: ./TestResults/*.coverage + + publish-results: + name: Publish Test Results + needs: test + runs-on: ubuntu-latest + if: always() + + steps: + - name: Download all test results + uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: ./TestResults + + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: ./TestResults/**/*.trx +``` + +### Workflow with AOT Testing + +Test your code with Native AOT compilation: + +```yaml +name: AOT Tests + +on: [push, pull_request] + +jobs: + test-aot: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Restore + run: dotnet restore + + - name: Publish with AOT + run: dotnet publish -c Release -p:PublishAot=true --use-current-runtime + + - name: Run AOT tests + run: ./bin/Release/net9.0/linux-x64/publish/YourTestProject +``` + +### PR Comment with Test Results + +Post test results as a comment on pull requests: ```yaml +name: PR Tests + +on: + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + permissions: + pull-requests: write # Required to comment on PRs + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' + + - name: Run tests + run: dotnet test --configuration Release --report-trx --results-directory ./TestResults + + - name: Comment PR with results + if: always() + uses: EnricoMi/publish-unit-test-result-action@v2 + with: + files: ./TestResults/*.trx + comment_mode: always +``` + +## Azure DevOps + +### Complete Pipeline with Stages + +```yaml +trigger: + branches: + include: + - main + - develop + +pr: + branches: + include: + - main + +pool: + vmImage: 'ubuntu-latest' + +variables: + buildConfiguration: 'Release' + dotnetSdkVersion: '9.0.x' + +stages: +- stage: Build + displayName: 'Build Stage' + jobs: + - job: Build + displayName: 'Build Job' + steps: + - task: UseDotNet@2 + displayName: 'Install .NET SDK' + inputs: + packageType: 'sdk' + version: '$(dotnetSdkVersion)' + + - script: dotnet restore + displayName: 'Restore dependencies' + + - script: dotnet build --configuration $(buildConfiguration) --no-restore + displayName: 'Build solution' + + - script: dotnet publish --configuration $(buildConfiguration) --no-build --output $(Build.ArtifactStagingDirectory) + displayName: 'Publish artifacts' + + - publish: $(Build.ArtifactStagingDirectory) + artifact: drop + displayName: 'Publish build artifacts' + +- stage: Test + displayName: 'Test Stage' + dependsOn: Build + jobs: + - job: Test + displayName: 'Run Tests' + steps: + - task: UseDotNet@2 + displayName: 'Install .NET SDK' + inputs: + packageType: 'sdk' + version: '$(dotnetSdkVersion)' + + - script: dotnet restore + displayName: 'Restore dependencies' + + - script: dotnet build --configuration $(buildConfiguration) --no-restore + displayName: 'Build solution' + + - script: | + dotnet test --configuration $(buildConfiguration) --no-build \ + --coverage --coverage-output-format cobertura \ + --report-trx --results-directory $(Agent.TempDirectory) + displayName: 'Run tests with coverage' + continueOnError: true + + - task: PublishTestResults@2 + displayName: 'Publish test results' + condition: always() + inputs: + testResultsFormat: 'VSTest' + testResultsFiles: '**/*.trx' + searchFolder: '$(Agent.TempDirectory)' + failTaskOnFailedTests: true + testRunTitle: 'TUnit Test Results' + + - task: PublishCodeCoverageResults@2 + displayName: 'Publish code coverage' + condition: always() + inputs: + codeCoverageTool: 'Cobertura' + summaryFileLocation: '$(Agent.TempDirectory)/**/coverage.cobertura.xml' +``` + +### Multi-Platform Testing Matrix + +```yaml +strategy: + matrix: + linux_net8: + vmImage: 'ubuntu-latest' + dotnetVersion: '8.0.x' + linux_net9: + vmImage: 'ubuntu-latest' + dotnetVersion: '9.0.x' + windows_net8: + vmImage: 'windows-latest' + dotnetVersion: '8.0.x' + windows_net9: + vmImage: 'windows-latest' + dotnetVersion: '9.0.x' + macos_net9: + vmImage: 'macos-latest' + dotnetVersion: '9.0.x' + +pool: + vmImage: $(vmImage) + steps: - - script: dotnet test --configuration Release -- --report-trx --results-directory $(Agent.TempDirectory) - displayName: 'Run tests and output .trx file' - continueOnError: true - - - task: PublishTestResults@2 - displayName: 'Publish Test Results from *.trx files' - inputs: - testResultsFormat: VSTest - testResultsFiles: '*.trx' - searchFolder: '$(Agent.TempDirectory)' - failTaskOnFailedTests: true - failTaskOnMissingResultsFile: true -``` -> Best Practice: -> For efficiency and clarity in failures, separate restore, build, and test into distinct steps. -> A common approach is to perform restore and build in a "build pipeline", then execute tests using --no-build in a separate "test pipeline" to avoid redundant compilation and improve performance. +- task: UseDotNet@2 + inputs: + version: '$(dotnetVersion)' + +- script: dotnet test --configuration Release --report-trx + displayName: 'Run tests on $(vmImage) with .NET $(dotnetVersion)' +``` + +## GitLab CI + +### Complete Pipeline with Stages + +```yaml +image: mcr.microsoft.com/dotnet/sdk:9.0 + +variables: + BUILD_CONFIGURATION: Release + COVERAGE_THRESHOLD: 80 + +stages: + - build + - test + - report + +before_script: + - dotnet --version + +build: + stage: build + script: + - dotnet restore + - dotnet build --configuration $BUILD_CONFIGURATION --no-restore + artifacts: + paths: + - "*/bin/$BUILD_CONFIGURATION/" + - "*/obj/$BUILD_CONFIGURATION/" + expire_in: 1 hour + +test:unit: + stage: test + dependencies: + - build + script: + - dotnet test --configuration $BUILD_CONFIGURATION --no-build + --coverage --coverage-output-format cobertura + --report-trx --results-directory ./TestResults + coverage: '/Total\s+\|\s+(\d+\.?\d*)%/' + artifacts: + when: always + paths: + - TestResults/ + reports: + junit: TestResults/*.trx + coverage_report: + coverage_format: cobertura + path: TestResults/coverage.cobertura.xml + +test:integration: + stage: test + dependencies: + - build + script: + - dotnet test --configuration $BUILD_CONFIGURATION --no-build + --filter "Category=Integration" + --report-trx --results-directory ./TestResults + artifacts: + when: always + paths: + - TestResults/ + reports: + junit: TestResults/*.trx + +coverage-report: + stage: report + dependencies: + - test:unit + script: + - dotnet tool install -g dotnet-reportgenerator-globaltool + - reportgenerator + "-reports:TestResults/coverage.cobertura.xml" + "-targetdir:coverage" + "-reporttypes:Html;Badges" + - echo "Coverage report generated" + coverage: '/Total\s+\|\s+(\d+\.?\d*)%/' + artifacts: + paths: + - coverage/ +``` + +### Matrix Testing Multiple .NET Versions + +```yaml +.test-template: + stage: test + script: + - dotnet test --configuration Release --report-trx + +test:net8: + extends: .test-template + image: mcr.microsoft.com/dotnet/sdk:8.0 + +test:net9: + extends: .test-template + image: mcr.microsoft.com/dotnet/sdk:9.0 +``` + +## CircleCI + +### Complete Configuration + +```yaml +version: 2.1 + +orbs: + dotnet: circleci/dotnet@2.0 + +executors: + dotnet-executor: + docker: + - image: mcr.microsoft.com/dotnet/sdk:9.0 + +jobs: + build: + executor: dotnet-executor + steps: + - checkout + + - run: + name: Restore dependencies + command: dotnet restore + + - run: + name: Build + command: dotnet build --configuration Release --no-restore + + - persist_to_workspace: + root: . + paths: + - "*/bin/Release/" + - "*/obj/Release/" + + test: + executor: dotnet-executor + steps: + - checkout + + - attach_workspace: + at: . + + - run: + name: Run tests + command: | + dotnet test --configuration Release --no-build \ + --coverage --coverage-output-format cobertura \ + --report-trx --results-directory ./TestResults + + - run: + name: Process test results + when: always + command: | + dotnet tool install -g trx2junit + trx2junit TestResults/*.trx + + - store_test_results: + path: ./TestResults + + - store_artifacts: + path: ./TestResults + destination: test-results + + test-matrix: + parameters: + dotnet-version: + type: string + docker: + - image: mcr.microsoft.com/dotnet/sdk:<< parameters.dotnet-version >> + steps: + - checkout + - run: dotnet test --configuration Release + +workflows: + version: 2 + build-and-test: + jobs: + - build + - test: + requires: + - build + - test-matrix: + name: test-net8 + dotnet-version: "8.0" + - test-matrix: + name: test-net9 + dotnet-version: "9.0" +``` + +## Docker + +### Dockerfile for Running Tests + +```dockerfile +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +WORKDIR /src + +# Copy solution and project files +COPY *.sln . +COPY src/**/*.csproj ./src/ +COPY tests/**/*.csproj ./tests/ + +# Restore dependencies +RUN dotnet restore + +# Copy all source code +COPY . . + +# Build +RUN dotnet build --configuration Release --no-restore + +# Run tests +FROM build AS test +WORKDIR /src +RUN dotnet test --configuration Release --no-build \ + --coverage --report-trx --results-directory /testresults + +# Export test results +FROM scratch AS export +COPY --from=test /testresults / +``` + +### Docker Compose for Integration Tests + +```yaml +version: '3.8' + +services: + tests: + build: + context: . + dockerfile: Dockerfile + target: test + environment: + - DOTNET_ENVIRONMENT=Test + - ConnectionStrings__Database=Host=postgres;Database=testdb;Username=test;Password=test + depends_on: + - postgres + - redis + volumes: + - ./TestResults:/testresults + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: testdb + POSTGRES_USER: test + POSTGRES_PASSWORD: test + ports: + - "5432:5432" + + redis: + image: redis:7 + ports: + - "6379:6379" +``` + +Run tests with: + +```bash +docker-compose up --build tests +``` + +## Best Practices + +### Separate Restore, Build, and Test + +For efficiency and clarity, separate these steps: + +```yaml +# GitHub Actions +- name: Restore + run: dotnet restore + +- name: Build + run: dotnet build --no-restore --configuration Release + +- name: Test + run: dotnet test --no-build --configuration Release +``` + +### Use Caching + +Cache NuGet packages to speed up builds: + +```yaml +# GitHub Actions +- name: Cache NuGet packages + uses: actions/cache@v3 + with: + path: ~/.nuget/packages + key: ${{ runner.os }}-nuget-${{ hashFiles('**/*.csproj') }} + restore-keys: | + ${{ runner.os }}-nuget- +``` + +### Parallel Test Execution + +Configure parallelism based on available resources: + +```bash +# Default: Use all available cores +dotnet test + +# Limit parallelism in resource-constrained environments +dotnet test -- --maximum-parallel-tests 4 +``` + +### Filter Tests by Category + +Run different test categories in separate jobs: + +```yaml +# Unit tests (fast) +- name: Unit Tests + run: dotnet test --filter "Category=Unit" + +# Integration tests (slower) +- name: Integration Tests + run: dotnet test --filter "Category=Integration" +``` + +### Fail Fast in PRs + +Use fail-fast mode for quick feedback in pull requests: + +```bash +dotnet test --fail-fast +``` + +## Troubleshooting + +### Tests Timing Out + +Increase the test timeout: + +```bash +dotnet test -- --timeout 5m # 5 minutes +``` + +### Coverage Files Not Generated + +Ensure you're using the TUnit meta package (not just TUnit.Engine) and running on .NET 8+: + +```xml + +``` + +### Out of Memory in CI + +Limit parallel test execution: + +```bash +dotnet test -- --maximum-parallel-tests 2 +``` + +Or increase the heap size: + +```bash +export DOTNET_GCHeapHardLimit=0x40000000 # 1GB +dotnet test +``` diff --git a/docs/docs/guides/best-practices.md b/docs/docs/guides/best-practices.md new file mode 100644 index 0000000000..f9e9fd0dec --- /dev/null +++ b/docs/docs/guides/best-practices.md @@ -0,0 +1,706 @@ +# Best Practices + +This guide covers best practices for writing clean, maintainable, and robust tests with TUnit. Following these patterns will help you create a test suite that's easy to understand and maintain over time. + +## Test Naming + +### Use Descriptive Names + +Good test names clearly describe what's being tested and what's expected. A common pattern is `Method_Scenario_ExpectedBehavior`: + +```csharp +// ✅ Good: Clearly describes what's being tested +[Test] +public async Task CalculateTotal_WithDiscount_ReturnsReducedPrice() +{ + var calculator = new PriceCalculator(); + var result = calculator.CalculateTotal(100, discount: 0.2); + await Assert.That(result).IsEqualTo(80); +} + +// ❌ Bad: Vague and unclear +[Test] +public async Task Test1() +{ + var calculator = new PriceCalculator(); + var result = calculator.CalculateTotal(100, 0.2); + await Assert.That(result).IsEqualTo(80); +} +``` + +### Alternative Naming Patterns + +You can also use sentence-like names that read naturally: + +```csharp +[Test] +public async Task When_discount_is_applied_total_is_reduced() +{ + // Test implementation +} + +[Test] +public async Task Should_return_reduced_price_when_discount_applied() +{ + // Test implementation +} +``` + +Pick a naming convention and stick to it throughout your project for consistency. + +## Test Organization + +### One Test Class Per Production Class + +Organize your tests to mirror your production code structure: + +``` +MyApp/ + Services/ + OrderService.cs + PaymentService.cs + +MyApp.Tests/ + Services/ + OrderServiceTests.cs + PaymentServiceTests.cs +``` + +This makes it easy to find tests and keeps your test suite organized as your codebase grows. + +### Group Related Tests + +Use nested classes or separate test classes to group related test scenarios: + +```csharp +public class OrderServiceTests +{ + public class CreateOrder + { + [Test] + public async Task Creates_order_with_valid_data() + { + // Test implementation + } + + [Test] + public async Task Throws_exception_when_user_not_found() + { + // Test implementation + } + } + + public class CancelOrder + { + [Test] + public async Task Cancels_order_successfully() + { + // Test implementation + } + + [Test] + public async Task Throws_when_order_already_shipped() + { + // Test implementation + } + } +} +``` + +### Keep Test Files Focused + +Each test file should focus on testing a single class or component. If your test file is getting large (>500 lines), consider splitting it into multiple files or using nested classes. + +## Assertion Best Practices + +### Prefer Specific Assertions + +Use the most specific assertion available for better failure messages: + +```csharp +// ✅ Good: Specific assertion with clear failure message +await Assert.That(result).IsEqualTo(5); +// Failure: Expected 5 but was 3 + +// ❌ Okay but less helpful: Generic boolean assertion +await Assert.That(result == 5).IsTrue(); +// Failure: Expected true but was false +``` + +### One Logical Assertion Per Test + +Each test should verify one specific behavior. Multiple assertions are fine if they're testing different aspects of the same behavior: + +```csharp +// ✅ Good: Multiple assertions testing one behavior (user creation) +[Test] +public async Task CreateUser_SetsAllProperties() +{ + var user = await userService.CreateUser("john@example.com", "John Doe"); + + await Assert.That(user.Email).IsEqualTo("john@example.com"); + await Assert.That(user.Name).IsEqualTo("John Doe"); + await Assert.That(user.CreatedAt).IsNotEqualTo(default(DateTime)); +} + +// ❌ Bad: Testing multiple unrelated behaviors +[Test] +public async Task UserService_Works() +{ + var user = await userService.CreateUser("john@example.com", "John"); + await Assert.That(user.Email).IsEqualTo("john@example.com"); + + await userService.DeleteUser(user.Id); + var deleted = await userService.GetUser(user.Id); + await Assert.That(deleted).IsNull(); +} +``` + +### Always Await Assertions + +TUnit assertions are async and must be awaited. Forgetting `await` means the assertion never runs: + +```csharp +// ❌ Wrong: Assertion returns Task that's never awaited +[Test] +public async Task MyTest() +{ + Assert.That(result).IsEqualTo(5); // Test passes without checking! +} + +// ✅ Correct: Assertion is awaited and executed +[Test] +public async Task MyTest() +{ + await Assert.That(result).IsEqualTo(5); +} +``` + +The compiler will warn you about unawaited tasks, but watch for this common mistake. + +## Test Lifecycle Management + +### Use Hooks for Setup and Cleanup + +TUnit provides several hooks for test lifecycle management. Use them to keep your test logic clean: + +```csharp +public class DatabaseTests +{ + private TestDatabase? _database; + + [Before(Test)] + public async Task SetupDatabase() + { + _database = await TestDatabase.CreateAsync(); + } + + [After(Test)] + public async Task CleanupDatabase() + { + if (_database != null) + await _database.DisposeAsync(); + } + + [Test] + public async Task Can_insert_record() + { + // Database is ready to use + await _database!.InsertAsync(new Record { Id = 1 }); + var result = await _database.GetAsync(1); + await Assert.That(result).IsNotNull(); + } +} +``` + +### Choose the Right Hook Level + +- `[Before(Test)]` / `[After(Test)]`: Runs before/after each test (most common) +- `[Before(Class)]` / `[After(Class)]`: Runs once per test class +- `[Before(Assembly)]` / `[After(Assembly)]`: Runs once per test assembly + +### Sharing Expensive Resources + +For expensive setup that needs to be shared across tests (like web servers, databases, or containers), use `[ClassDataSource<>]` with shared types and `IAsyncInitializer`/`IAsyncDisposable`: + +```csharp +// ✅ Best: Shared resource with ClassDataSource +public class TestWebServer : IAsyncInitializer, IAsyncDisposable +{ + public WebApplicationFactory? Factory { get; private set; } + + public async Task InitializeAsync() + { + Factory = new WebApplicationFactory(); + await Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + if (Factory != null) + await Factory.DisposeAsync(); + } +} + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class ApiTests(TestWebServer server) +{ + [Test] + public async Task Can_call_endpoint() + { + var client = server.Factory!.CreateClient(); + var response = await client.GetAsync("/api/health"); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + } + + [Test] + public async Task Can_get_users() + { + var client = server.Factory!.CreateClient(); + var response = await client.GetAsync("/api/users"); + await Assert.That(response.IsSuccessStatusCode).IsTrue(); + } +} +``` + +**Why this is better:** +- Keeps test files simpler (no static fields or Before/After hooks) +- Shared resources work across multiple test classes +- Can share across assemblies using `SharedType.PerTestSession` +- Cleaner lifecycle management with `IAsyncInitializer`/`IAsyncDisposable` +- Type-safe dependency injection into test constructors + +**Shared Type Options:** +- `SharedType.PerTestSession`: One instance for entire test run, shared across assemblies (best for expensive resources) +- `SharedType.PerClass`: One instance per test class (default) +- `SharedType.None`: New instance per test + +You can also use hooks, but they're less flexible: + +```csharp +// ❌ Less flexible: Using hooks for shared setup +public class ApiTests +{ + private static WebApplicationFactory? _factory; + + [Before(Class)] + public static async Task StartServer() + { + _factory = new WebApplicationFactory(); + } + + [After(Class)] + public static async Task StopServer() + { + _factory?.Dispose(); + } + + // Tests use static _factory field +} +``` + +### Avoid Complex Setup Logic + +Keep your setup code simple and focused. If setup is complex, extract it to helper methods: + +```csharp +// ✅ Good: Simple setup with extracted helpers +[Before(Test)] +public async Task Setup() +{ + _database = await CreateTestDatabase(); + _testUser = await CreateTestUser(); +} + +private async Task CreateTestDatabase() +{ + var db = await TestDatabase.CreateAsync(); + await db.SeedDefaultData(); + return db; +} + +// ❌ Bad: Complex setup logic in hook +[Before(Test)] +public async Task Setup() +{ + _database = await TestDatabase.CreateAsync(); + await _database.ExecuteAsync("CREATE TABLE Users (...)"); + await _database.ExecuteAsync("INSERT INTO Users VALUES (...)"); + await _database.ExecuteAsync("CREATE TABLE Orders (...)"); + // ... lots more setup code +} +``` + +## Parallelism Guidance + +### Tests Run in Parallel By Default + +TUnit runs tests in parallel for better performance. Write your tests to be independent: + +```csharp +// ✅ Good: Test is self-contained and independent +[Test] +public async Task Can_create_order() +{ + var orderId = Guid.NewGuid(); // Unique ID + var order = new Order { Id = orderId, Total = 100 }; + await orderService.CreateAsync(order); + + var result = await orderService.GetAsync(orderId); + await Assert.That(result).IsNotNull(); +} +``` + +### Use NotInParallel When Needed + +Some tests can't run in parallel (database tests, file system tests). Use `[NotInParallel]`: + +```csharp +// Tests that modify shared state +[Test, NotInParallel] +public async Task Updates_configuration_file() +{ + await ConfigurationManager.SetAsync("key", "value"); + var result = await ConfigurationManager.GetAsync("key"); + await Assert.That(result).IsEqualTo("value"); +} +``` + +### Control Execution Order + +When tests need to run in a specific order, use `[DependsOn]` instead of `NotInParallel` with `Order`: + +```csharp +// ✅ Good: Use DependsOn for ordering while maintaining parallelism +[Test] +public async Task Step1_CreateUser() +{ + // Runs first +} + +[Test] +[DependsOn(nameof(Step1_CreateUser))] +public async Task Step2_UpdateUser() +{ + // Runs after Step1_CreateUser completes + // Other unrelated tests can still run in parallel +} + +[Test] +[DependsOn(nameof(Step2_UpdateUser))] +public async Task Step3_DeleteUser() +{ + // Runs after Step2_UpdateUser completes +} +``` + +**Why `[DependsOn]` is better:** +- More intuitive: explicitly declares dependencies between tests +- More flexible: tests can depend on multiple other tests +- Maintains parallelism: unrelated tests still run in parallel +- Better for complex workflows: clear dependency chains + +You can also use `NotInParallel` with `Order`, but this forces sequential execution: + +```csharp +// ❌ Less flexible: Forces all tests to run sequentially +[Test, NotInParallel(Order = 1)] +public async Task Step1_CreateUser() +{ + // Runs first +} + +[Test, NotInParallel(Order = 2)] +public async Task Step2_UpdateUser() +{ + // Runs second, but blocks all other tests +} +``` + +**Important:** If tests need ordering, they might be too tightly coupled. Consider: +- Refactoring into a single test +- Using proper setup/teardown +- Making tests truly independent + +### Use Parallel Groups + +Group related tests that can't run in parallel with each other but can run in parallel with other groups: + +```csharp +public class FileSystemTests +{ + // These tests can't run in parallel with each other + // but can run in parallel with DatabaseTests + + [Test, NotInParallel("FileGroup")] + public async Task Test1_WritesFile() + { + // Test implementation + } + + [Test, NotInParallel("FileGroup")] + public async Task Test2_ReadsFile() + { + // Test implementation + } +} + +public class DatabaseTests +{ + [Test, NotInParallel("DbGroup")] + public async Task Test1_InsertsRecord() + { + // Runs in parallel with FileSystemTests + } +} +``` + +## Common Anti-Patterns to Avoid + +### Avoid Test Interdependence + +Each test should be completely independent and not rely on other tests: + +```csharp +// ❌ Bad: Tests depend on execution order +private static User? _user; + +[Test] +public async Task Test1_CreateUser() +{ + _user = await userService.CreateAsync("john@example.com"); +} + +[Test] +public async Task Test2_UpdateUser() +{ + // Assumes Test1 ran first! + _user!.Name = "Jane Doe"; + await userService.UpdateAsync(_user); +} + +// ✅ Good: Each test is independent +[Test] +public async Task Can_create_user() +{ + var user = await userService.CreateAsync("john@example.com"); + await Assert.That(user.Email).IsEqualTo("john@example.com"); +} + +[Test] +public async Task Can_update_user() +{ + var user = await userService.CreateAsync("jane@example.com"); + user.Name = "Jane Doe"; + await userService.UpdateAsync(user); + + var updated = await userService.GetAsync(user.Id); + await Assert.That(updated.Name).IsEqualTo("Jane Doe"); +} +``` + +### Avoid Shared Instance State + +**Important:** TUnit creates a new instance of your test class for each test method. Don't rely on instance fields to share state: + +```csharp +// ❌ Bad: Trying to share instance state between tests +public class MyTests +{ + private int _value; // Different instance per test! + + [Test, NotInParallel] + public void Test1() + { + _value = 99; + } + + [Test, NotInParallel] + public async Task Test2() + { + await Assert.That(_value).IsEqualTo(99); // Fails! _value is 0 + } +} + +// ✅ Good: Use static fields if you really need shared state +public class MyTests +{ + private static int _value; // Shared across all tests + + [Test, NotInParallel] + public void Test1() + { + _value = 99; + } + + [Test, NotInParallel] + public async Task Test2() + { + await Assert.That(_value).IsEqualTo(99); // Works! + } +} +``` + +But seriously: if tests need to share state, reconsider your design. It's usually better to make tests independent. + +### Avoid Complex Test Logic + +Tests should be simple and easy to understand. Avoid complex conditionals, loops, or calculations: + +```csharp +// ❌ Bad: Complex logic in test +[Test] +public async Task CalculatesTotals() +{ + var items = await GetItems(); + decimal expected = 0; + foreach (var item in items) + { + if (item.IsDiscounted) + expected += item.Price * 0.8m; + else + expected += item.Price; + } + + var result = calculator.CalculateTotal(items); + await Assert.That(result).IsEqualTo(expected); +} + +// ✅ Good: Simple, explicit test +[Test] +public async Task CalculateTotal_WithMixedItems() +{ + var items = new[] + { + new Item { Price = 100, IsDiscounted = false }, // 100 + new Item { Price = 50, IsDiscounted = true } // 40 + }; + + var result = calculator.CalculateTotal(items); + await Assert.That(result).IsEqualTo(140); +} +``` + +If your test has complex logic, you're essentially writing code to test code. Keep it simple! + +### Avoid Over-Mocking + +Don't mock everything. Use real implementations when they're fast and reliable: + +```csharp +// ❌ Bad: Mocking things that don't need mocking +[Test] +public async Task ProcessOrder() +{ + var mockLogger = new Mock(); + var mockValidator = new Mock(); + var mockCalculator = new Mock(); + var mockRepository = new Mock(); + + // So much setup... +} + +// ✅ Good: Only mock expensive or external dependencies +[Test] +public async Task ProcessOrder() +{ + var logger = new NullLogger(); // Real lightweight implementation + var validator = new OrderValidator(); // Real validator is fast + var calculator = new PriceCalculator(); // Simple calculations + var mockRepository = new Mock(); // Mock database + + // Much simpler! +} +``` + +Mock external dependencies (databases, APIs, file systems) but use real implementations for simple logic. + +### Avoid Testing Implementation Details + +Test behavior, not implementation. Your tests should verify what the code does, not how it does it: + +```csharp +// ❌ Bad: Testing internal implementation +[Test] +public async Task ProcessOrder_CallsRepositorySaveMethod() +{ + var mockRepository = new Mock(); + var service = new OrderService(mockRepository.Object); + + await service.ProcessOrder(order); + + // Verifying method calls instead of behavior + mockRepository.Verify(r => r.Save(It.IsAny()), Times.Once); +} + +// ✅ Good: Testing actual behavior +[Test] +public async Task ProcessOrder_SavesOrderToDatabase() +{ + var repository = new InMemoryOrderRepository(); + var service = new OrderService(repository); + + await service.ProcessOrder(order); + + // Verifying the result + var saved = await repository.GetAsync(order.Id); + await Assert.That(saved).IsNotNull(); + await Assert.That(saved.Status).IsEqualTo(OrderStatus.Processed); +} +``` + +Tests that verify implementation details are brittle and break when you refactor. + +## Performance Considerations + +TUnit is designed for performance at scale. Follow these guidelines to keep your test suite fast: + +### Optimize Test Discovery + +- Use AOT mode for faster test discovery and lower memory usage +- Keep data sources lightweight (see [Performance Best Practices](../advanced/performance-best-practices.md)) +- Limit matrix test combinations to avoid test explosion + +### Optimize Test Execution + +- Let tests run in parallel (it's fast!) +- Only use `[NotInParallel]` when absolutely necessary +- Configure parallelism based on your CPU: `[assembly: MaxParallelTests(Environment.ProcessorCount)]` +- Avoid expensive setup in `[Before(Test)]` hooks - use class or assembly-level hooks for shared resources + +### Avoid Slow Operations in Tests + +Tests should be fast. If a test takes more than a few seconds, look for optimization opportunities: + +```csharp +// ❌ Slow: Real HTTP calls +[Test] +public async Task GetUserData() +{ + var client = new HttpClient(); + var response = await client.GetAsync("https://api.example.com/users"); + // Slow and unreliable +} + +// ✅ Fast: Use in-memory test doubles +[Test] +public async Task GetUserData() +{ + var client = new TestHttpClient(); // In-memory fake + var response = await client.GetAsync("/users"); + // Fast and reliable +} +``` + +For detailed performance guidance, see [Performance Best Practices](../advanced/performance-best-practices.md). + +## Summary + +Following these best practices will help you: + +- Write tests that are easy to understand and maintain +- Create a fast, reliable test suite that scales +- Catch bugs without introducing brittle tests +- Make your codebase more maintainable over time + +Remember: good tests are simple, focused, independent, and fast. When in doubt, ask yourself: "Will someone else understand what this test is doing and why it might fail?" diff --git a/docs/docs/guides/cookbook.md b/docs/docs/guides/cookbook.md new file mode 100644 index 0000000000..cc321ff678 --- /dev/null +++ b/docs/docs/guides/cookbook.md @@ -0,0 +1,792 @@ +# Testing Cookbook + +This cookbook provides practical, copy-paste examples for common testing scenarios with TUnit. Each recipe is a complete, working example you can adapt for your own tests. + +## Dependency Injection Testing + +### Testing with Microsoft.Extensions.DependencyInjection + +```csharp +using Microsoft.Extensions.DependencyInjection; +using TUnit.Core; + +public class UserServiceTests +{ + private ServiceProvider? _serviceProvider; + private IUserService? _userService; + + [Before(Test)] + public async Task Setup() + { + // Create a service collection and register dependencies + var services = new ServiceCollection(); + + // Register services + services.AddSingleton(); + services.AddScoped(); + services.AddLogging(); + + _serviceProvider = services.BuildServiceProvider(); + _userService = _serviceProvider.GetRequiredService(); + } + + [After(Test)] + public async Task Cleanup() + { + _serviceProvider?.Dispose(); + } + + [Test] + public async Task CreateUser_RegistersNewUser() + { + var email = "test@example.com"; + var name = "Test User"; + + var user = await _userService!.CreateAsync(email, name); + + await Assert.That(user.Email).IsEqualTo(email); + await Assert.That(user.Name).IsEqualTo(name); + await Assert.That(user.Id).IsNotEqualTo(Guid.Empty); + } +} +``` + +### Testing with Scoped Services + +```csharp +public class OrderServiceTests +{ + private ServiceProvider? _serviceProvider; + + [Before(Test)] + public async Task Setup() + { + var services = new ServiceCollection(); + services.AddDbContext(options => + options.UseInMemoryDatabase("TestDb")); + services.AddScoped(); + + _serviceProvider = services.BuildServiceProvider(); + } + + [After(Test)] + public async Task Cleanup() + { + _serviceProvider?.Dispose(); + } + + [Test] + public async Task CreateOrder_UsesScop​edDbContext() + { + // Create a scope for this test + using var scope = _serviceProvider!.CreateScope(); + var orderService = scope.ServiceProvider.GetRequiredService(); + + var order = await orderService.CreateAsync(new CreateOrderRequest + { + CustomerId = 1, + Items = new[] { new OrderItem { ProductId = 1, Quantity = 2 } } + }); + + await Assert.That(order.Id).IsGreaterThan(0); + await Assert.That(order.Items).HasCount().EqualTo(1); + } +} +``` + +## API Testing with WebApplicationFactory + +### Testing a Minimal API Endpoint (Shared Server) + +For API tests, it's more efficient to share a single WebApplicationFactory across all tests: + +```csharp +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; +using TUnit.Core; + +// Shared web server for all API tests +public class TestWebServer : IAsyncInitializer, IAsyncDisposable +{ + public WebApplicationFactory? Factory { get; private set; } + + public async Task InitializeAsync() + { + Factory = new WebApplicationFactory(); + await Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + if (Factory != null) + await Factory.DisposeAsync(); + } +} + +[ClassDataSource(Shared = SharedType.PerTestSession)] +public class UserApiTests(TestWebServer server) +{ + [Test] + public async Task GetUsers_ReturnsSuccessStatus() + { + var client = server.Factory!.CreateClient(); + var response = await client.GetAsync("/api/users"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } + + [Test] + public async Task CreateUser_ReturnsCreatedUser() + { + var client = server.Factory!.CreateClient(); + var newUser = new CreateUserRequest + { + Email = "test@example.com", + Name = "Test User" + }; + + var response = await client.PostAsJsonAsync("/api/users", newUser); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var user = await response.Content.ReadFromJsonAsync(); + await Assert.That(user).IsNotNull(); + await Assert.That(user!.Email).IsEqualTo(newUser.Email); + } + + [Test] + public async Task GetUser_WithInvalidId_ReturnsNotFound() + { + var client = server.Factory!.CreateClient(); + var response = await client.GetAsync("/api/users/99999"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } +} +``` + +### Testing with Authentication (Per-Test Setup) + +When each test needs different configuration (like different auth setups), use per-test hooks: + +```csharp +public class AuthenticatedApiTests +{ + private WebApplicationFactory? _factory; + private HttpClient? _client; + + [Before(Test)] + public async Task Setup() + { + _factory = new WebApplicationFactory() + .WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + // Configure test authentication + services.AddAuthentication("Test") + .AddScheme( + "Test", options => { }); + }); + }); + + _client = _factory.CreateClient(); + + // Add auth header + _client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Test"); + } + + [After(Test)] + public async Task Cleanup() + { + _client?.Dispose(); + if (_factory != null) + await _factory.DisposeAsync(); + } + + [Test] + public async Task GetProfile_WithAuth_ReturnsUserProfile() + { + var response = await _client!.GetAsync("/api/profile"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var profile = await response.Content.ReadFromJsonAsync(); + await Assert.That(profile).IsNotNull(); + } + + [Test] + public async Task GetProfile_WithoutAuth_ReturnsUnauthorized() + { + _client!.DefaultRequestHeaders.Authorization = null; + + var response = await _client.GetAsync("/api/profile"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} +``` + +> **Tip**: Use per-test setup (hooks) when tests need different configurations. Use shared setup (`ClassDataSource` with `SharedType.PerTestSession`) when all tests can use the same configuration. + +## Mocking Patterns + +### Using Moq + +```csharp +using Moq; +using TUnit.Core; + +public class OrderServiceMoqTests +{ + [Test] + public async Task ProcessOrder_CallsPaymentService() + { + // Arrange + var mockPaymentService = new Mock(); + mockPaymentService + .Setup(p => p.ProcessPaymentAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new PaymentResult { Success = true, TransactionId = "TX123" }); + + var orderService = new OrderService(mockPaymentService.Object); + var order = new Order { Total = 100.00m, PaymentMethod = "Credit Card" }; + + // Act + var result = await orderService.ProcessAsync(order); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + mockPaymentService.Verify( + p => p.ProcessPaymentAsync(100.00m, "Credit Card"), + Times.Once); + } + + [Test] + public async Task ProcessOrder_HandlesPaymentFailure() + { + // Arrange + var mockPaymentService = new Mock(); + mockPaymentService + .Setup(p => p.ProcessPaymentAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new PaymentException("Insufficient funds")); + + var orderService = new OrderService(mockPaymentService.Object); + var order = new Order { Total = 1000.00m, PaymentMethod = "Credit Card" }; + + // Act & Assert + await Assert.That(async () => await orderService.ProcessAsync(order)) + .ThrowsExactly() + .WithMessage("Insufficient funds"); + } +} +``` + +### Using NSubstitute + +```csharp +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using TUnit.Core; + +public class OrderServiceNSubstituteTests +{ + [Test] + public async Task ProcessOrder_CallsPaymentService() + { + // Arrange + var paymentService = Substitute.For(); + paymentService + .ProcessPaymentAsync(Arg.Any(), Arg.Any()) + .Returns(new PaymentResult { Success = true, TransactionId = "TX123" }); + + var orderService = new OrderService(paymentService); + var order = new Order { Total = 100.00m, PaymentMethod = "Credit Card" }; + + // Act + var result = await orderService.ProcessAsync(order); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await paymentService.Received(1).ProcessPaymentAsync(100.00m, "Credit Card"); + } + + [Test] + public async Task ProcessOrder_HandlesPaymentFailure() + { + // Arrange + var paymentService = Substitute.For(); + paymentService + .ProcessPaymentAsync(Arg.Any(), Arg.Any()) + .Throws(new PaymentException("Insufficient funds")); + + var orderService = new OrderService(paymentService); + var order = new Order { Total = 1000.00m, PaymentMethod = "Credit Card" }; + + // Act & Assert + await Assert.That(async () => await orderService.ProcessAsync(order)) + .ThrowsExactly() + .WithMessage("Insufficient funds"); + } +} +``` + +### Partial Mocks and Spy Pattern + +```csharp +using Moq; + +public class NotificationServiceTests +{ + [Test] + public async Task SendNotification_LogsAttempt() + { + // Arrange - create a partial mock that calls real methods + var mockLogger = new Mock(); + var notificationService = new Mock(mockLogger.Object) + { + CallBase = true // Call real implementation + }; + + // Override only the SendEmail method + notificationService + .Setup(n => n.SendEmailAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + // Act + await notificationService.Object.NotifyUserAsync("user@example.com", "Hello"); + + // Assert - verify the real method called SendEmail + notificationService.Verify( + n => n.SendEmailAsync("user@example.com", It.IsAny()), + Times.Once); + } +} +``` + +## Data-Driven Test Patterns + +### Using MethodDataSource + +```csharp +using TUnit.Core; + +public class CalculatorDataDrivenTests +{ + [Test] + [MethodDataSource(nameof(GetCalculationTestCases))] + public async Task Add_ReturnsCorrectSum(int a, int b, int expected) + { + var calculator = new Calculator(); + var result = calculator.Add(a, b); + await Assert.That(result).IsEqualTo(expected); + } + + public static IEnumerable<(int, int, int)> GetCalculationTestCases() + { + yield return (1, 2, 3); + yield return (0, 0, 0); + yield return (-1, 1, 0); + yield return (100, 200, 300); + } +} +``` + +### Using MethodDataSource with Complex Objects + +```csharp +public class OrderValidationTests +{ + [Test] + [MethodDataSource(nameof(GetInvalidOrders))] + public async Task ValidateOrder_WithInvalidData_ReturnsErrors( + Order order, + string expectedError) + { + var validator = new OrderValidator(); + + var result = await validator.ValidateAsync(order); + + await Assert.That(result.IsValid).IsFalse(); + await Assert.That(result.Errors).Contains(e => e.Message.Contains(expectedError)); + } + + public static IEnumerable<(Order, string)> GetInvalidOrders() + { + yield return ( + new Order { Total = 0, Items = new List() }, + "Order must have at least one item" + ); + + yield return ( + new Order { Total = -10, Items = new List { new() { Quantity = 1 } } }, + "Total must be positive" + ); + + yield return ( + new Order { Total = 100, CustomerId = 0, Items = new List { new() } }, + "Customer ID is required" + ); + } +} +``` + +### Using DataSourceGenerator + +```csharp +using TUnit.Core; + +public class UserTestDataGenerator : DataSourceGeneratorAttribute +{ + public override IEnumerable GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) + { + yield return new User { Id = 1, Email = "user1@example.com", Role = "Admin" }; + yield return new User { Id = 2, Email = "user2@example.com", Role = "User" }; + yield return new User { Id = 3, Email = "user3@example.com", Role = "Guest" }; + } +} + +public class UserPermissionTests +{ + [Test] + [UserTestDataGenerator] + public async Task CheckPermissions_ReturnsCorrectAccess(User user) + { + var permissionService = new PermissionService(); + + var canDelete = await permissionService.CanDeleteAsync(user); + + if (user.Role == "Admin") + await Assert.That(canDelete).IsTrue(); + else + await Assert.That(canDelete).IsFalse(); + } +} +``` + +## Exception Testing + +### Testing for Specific Exceptions + +```csharp +using TUnit.Core; + +public class ExceptionTests +{ + [Test] + public async Task Divide_ByZero_ThrowsException() + { + var calculator = new Calculator(); + + await Assert.That(() => calculator.Divide(10, 0)) + .ThrowsExactly(); + } + + [Test] + public async Task CreateUser_WithInvalidEmail_ThrowsValidationException() + { + var userService = new UserService(); + + await Assert.That(async () => await userService.CreateAsync("invalid-email", "John")) + .ThrowsExactly() + .WithMessage("Invalid email format"); + } +} +``` + +### Testing Exception Messages and Properties + +```csharp +public class DetailedExceptionTests +{ + [Test] + public async Task ProcessPayment_InsufficientFunds_ThrowsWithDetails() + { + var paymentService = new PaymentService(); + var payment = new Payment { Amount = 1000, AccountBalance = 100 }; + + var exception = await Assert.That(async () => await paymentService.ProcessAsync(payment)) + .ThrowsExactly(); + + await Assert.That(exception.Message).Contains("Insufficient funds"); + await Assert.That(exception.RequiredAmount).IsEqualTo(1000); + await Assert.That(exception.AvailableAmount).IsEqualTo(100); + } +} +``` + +### Testing Async Exceptions + +```csharp +public class AsyncExceptionTests +{ + [Test] + public async Task FetchData_WithInvalidUrl_ThrowsHttpException() + { + var apiClient = new ApiClient(); + + // Test async method that throws + await Assert.That(async () => await apiClient.GetDataAsync("invalid-url")) + .ThrowsExactly(); + } + + [Test] + public async Task ProcessBatch_WithTimeout_ThrowsTaskCanceledException() + { + var processor = new BatchProcessor(); + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + + await Assert.That(async () => await processor.ProcessAsync(1000, cts.Token)) + .Throws(); + } +} +``` + +## Integration Test Patterns + +### Testing with In-Memory Database + +```csharp +using Microsoft.EntityFrameworkCore; +using TUnit.Core; + +public class OrderRepositoryIntegrationTests +{ + private DbContextOptions? _options; + private OrderDbContext? _context; + + [Before(Test)] + public async Task Setup() + { + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _context = new OrderDbContext(_options); + await _context.Database.EnsureCreatedAsync(); + } + + [After(Test)] + public async Task Cleanup() + { + if (_context != null) + { + await _context.Database.EnsureDeletedAsync(); + await _context.DisposeAsync(); + } + } + + [Test] + public async Task SaveOrder_PersistsToDatabase() + { + var repository = new OrderRepository(_context!); + var order = new Order + { + CustomerId = 1, + Total = 100.00m, + Status = OrderStatus.Pending + }; + + await repository.SaveAsync(order); + await _context!.SaveChangesAsync(); + + var saved = await _context.Orders.FirstOrDefaultAsync(o => o.Id == order.Id); + await Assert.That(saved).IsNotNull(); + await Assert.That(saved!.Total).IsEqualTo(100.00m); + } + + [Test] + public async Task GetOrder_WithRelatedData_LoadsNavigationProperties() + { + // Seed data + var order = new Order + { + CustomerId = 1, + Items = new List + { + new() { ProductId = 1, Quantity = 2, Price = 10.00m }, + new() { ProductId = 2, Quantity = 1, Price = 20.00m } + } + }; + _context!.Orders.Add(order); + await _context.SaveChangesAsync(); + + // Test + var repository = new OrderRepository(_context); + var loaded = await repository.GetWithItemsAsync(order.Id); + + await Assert.That(loaded).IsNotNull(); + await Assert.That(loaded!.Items).HasCount().EqualTo(2); + } +} +``` + +### Testing with Test Containers (Docker) + +```csharp +using Testcontainers.PostgreSql; +using TUnit.Core; + +public class PostgresIntegrationTests +{ + private PostgreSqlContainer? _container; + private string? _connectionString; + + [Before(Test)] + public async Task Setup() + { + _container = new PostgreSqlBuilder() + .WithImage("postgres:15") + .WithDatabase("testdb") + .WithUsername("test") + .WithPassword("test") + .Build(); + + await _container.StartAsync(); + _connectionString = _container.GetConnectionString(); + } + + [After(Test)] + public async Task Cleanup() + { + if (_container != null) + await _container.DisposeAsync(); + } + + [Test] + public async Task CanConnectToPostgres() + { + using var connection = new NpgsqlConnection(_connectionString); + await connection.OpenAsync(); + + await Assert.That(connection.State).IsEqualTo(ConnectionState.Open); + } +} +``` + +### Testing HTTP Client Integration + +```csharp +using System.Net; +using TUnit.Core; + +public class HttpClientIntegrationTests +{ + private HttpClient? _httpClient; + + [Before(Test)] + public async Task Setup() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true + }; + + _httpClient = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.example.com"), + Timeout = TimeSpan.FromSeconds(30) + }; + + _httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer test-token"); + } + + [After(Test)] + public void Cleanup() + { + _httpClient?.Dispose(); + } + + [Test] + public async Task GetUser_ReturnsValidResponse() + { + var response = await _httpClient!.GetAsync("/users/1"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var user = await response.Content.ReadFromJsonAsync(); + await Assert.That(user).IsNotNull(); + await Assert.That(user!.Id).IsEqualTo(1); + } + + [Test] + public async Task GetUser_HandlesRateLimiting() + { + // Make multiple requests to trigger rate limiting + var tasks = Enumerable.Range(1, 100) + .Select(i => _httpClient!.GetAsync($"/users/{i}")); + + var responses = await Task.WhenAll(tasks); + + var rateLimited = responses.Count(r => r.StatusCode == HttpStatusCode.TooManyRequests); + await Assert.That(rateLimited).IsGreaterThan(0); + } +} +``` + +### Testing File System Operations + +```csharp +using TUnit.Core; + +public class FileServiceTests +{ + private string? _testDirectory; + + [Before(Test)] + public async Task Setup() + { + _testDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_testDirectory); + } + + [After(Test)] + public async Task Cleanup() + { + if (_testDirectory != null && Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } + + [Test] + public async Task SaveFile_CreatesFileWithContent() + { + var fileService = new FileService(); + var fileName = "test.txt"; + var content = "Hello, World!"; + var filePath = Path.Combine(_testDirectory!, fileName); + + await fileService.SaveAsync(filePath, content); + + await Assert.That(File.Exists(filePath)).IsTrue(); + var savedContent = await File.ReadAllTextAsync(filePath); + await Assert.That(savedContent).IsEqualTo(content); + } + + [Test, NotInParallel("FileSystem")] + public async Task DeleteFile_RemovesFile() + { + var filePath = Path.Combine(_testDirectory!, "delete-me.txt"); + await File.WriteAllTextAsync(filePath, "content"); + + var fileService = new FileService(); + await fileService.DeleteAsync(filePath); + + await Assert.That(File.Exists(filePath)).IsFalse(); + } +} +``` + +## Summary + +These cookbook recipes cover the most common testing scenarios. You can adapt these patterns for your specific needs: + +- **Dependency Injection**: Use service collections for realistic testing with dependencies +- **API Testing**: Use `WebApplicationFactory` for end-to-end API tests +- **Mocking**: Choose Moq or NSubstitute based on your preference +- **Data-Driven Tests**: Use `MethodDataSource` or `DataSourceGenerator` for parameterized tests +- **Exception Testing**: Use TUnit's fluent exception assertions +- **Integration Tests**: Test with real databases, containers, or file systems + +For more examples, check out the [examples directory](../examples/) in the documentation. diff --git a/docs/docs/guides/philosophy.md b/docs/docs/guides/philosophy.md new file mode 100644 index 0000000000..c37a930279 --- /dev/null +++ b/docs/docs/guides/philosophy.md @@ -0,0 +1,182 @@ +# TUnit Philosophy & Design Decisions + +If you're wondering why TUnit does things differently from other testing frameworks, this page has the answers. Understanding the reasoning behind TUnit's design will help you use it effectively and decide if it's right for your project. + +## Core Principles + +### Performance First + +TUnit is built for speed at any scale. Whether you have 100 tests or 100,000, they should run as fast as possible. Tests run in parallel by default, using Roslyn source generators to discover tests at compile time instead of expensive runtime reflection. You can choose between source-generated mode (fastest) or reflection mode (more flexible) depending on your needs. + +Fast tests create faster feedback loops. When tests run quickly, developers actually run them more often. They catch bugs earlier and stay in flow instead of context-switching while waiting for test results. + +### Modern .NET First + +TUnit embraces modern .NET without compromises. Everything is async by default. Assertions, hooks, all of it. It uses C# 12+ features like collection expressions and file-scoped namespaces. Native AOT and trimming work out of the box. It's built on Microsoft.Testing.Platform instead of the legacy VSTest infrastructure. + +Modern .NET applications deserve a modern testing framework. TUnit doesn't carry the baggage of .NET Framework support or patterns from a decade ago. + +### Test Isolation + +Every test should be completely independent. TUnit creates a new instance of your test class for each test method, so instance fields can't leak between tests. Tests can run in any order, on any thread, without affecting each other. If you need shared state, you make it explicit with the `static` keyword. + +Isolated tests are reliable tests. You never get those mysterious failures where Test B only fails when Test A runs first. Everything is deterministic. + +### Developer Experience + +Writing tests should be pleasant, not painful. TUnit has minimal boilerplate—just put `[Test]` on your methods, no class attributes needed. Assertions read naturally: `await Assert.That(value).IsEqualTo(expected)`. Error messages are clear and actionable. You can access test metadata through `TestContext` whenever you need it. + +Developers spend a lot of time writing and debugging tests. Small improvements in ergonomics really do add up over time. + +## Key Design Decisions + +### Why Dual-Mode Execution? + +TUnit offers two ways to discover and run tests: source generation (the default) and reflection mode. + +Source-generated mode discovers tests at compile time using Roslyn source generators. It generates explicit test registration code, which makes it the fastest option. The downside is you need to recompile when tests change, but that's usually not a problem. + +Reflection mode discovers tests at runtime. It's slightly slower but more flexible for dynamic scenarios. No code generation means it's simpler in some ways, but you lose the performance benefits. + +Why support both? Different scenarios need different trade-offs. CI/CD pipelines benefit from maximum speed with source generation. AOT scenarios require it. But if you're doing something dynamic or just want the simplicity of runtime discovery, reflection mode is there. Users aren't locked into one approach. + +### Why All Assertions Must Be Awaited + +This is probably TUnit's most controversial decision: all assertions return `Task` and must be awaited. + +```csharp +// TUnit - must await +await Assert.That(result).IsEqualTo(expected); + +// Other frameworks - no await +Assert.Equal(expected, result); +``` + +The reasoning: consistency and extensibility. If all assertions work the same way, you never have to remember which ones need await and which don't. Custom assertions can do async work like database queries or HTTP calls. You can chain assertions naturally without blocking threads. And you avoid all the sync-over-async deadlock problems. + +Yes, it's more verbose. Yes, there's a learning curve. But it enables patterns that just aren't possible with sync assertions: + +```csharp +// Custom async assertion +await Assert.That(async () => await GetUserAsync(id)) + .ThrowsAsync(); + +// Chained assertions without blocking +await Assert.That(user.Email) + .IsNotNull() + .And.Contains("@example.com"); +``` + +The benefits outweigh the extra `await` keyword. Plus, the code fixers handle most of the migration work automatically anyway. + +### Why Microsoft.Testing.Platform? + +TUnit is built on Microsoft.Testing.Platform instead of the legacy VSTest infrastructure. + +The new platform was designed for .NET 5+ from scratch. It's faster, more extensible, and both `dotnet test` and `dotnet run` work well with it. More importantly, it's where Microsoft is investing going forward. + +The downside? Some older tools only work with VSTest. Coverlet is the most notable example. But Microsoft provides `Microsoft.Testing.Extensions.CodeCoverage` as the modern alternative, and it actually works better with the new platform anyway. + +### Why Parallel by Default? + +Most testing frameworks make you opt-in to parallelism. TUnit flips that around. + +Parallel tests are dramatically faster. Modern CPUs have many cores—TUnit uses them. And here's the thing: tests that are safe to run in parallel are usually well-isolated tests. Making parallelism the default pushes you toward better test design. + +When do you opt-out? Use `[NotInParallel]` for tests that modify shared files or databases, use global state, must run in a specific order, or access hardware like cameras or GPIO pins. + +```csharp +[Test, NotInParallel] +public async Task ModifiesConfigFile() +{ + // This test modifies a shared config file +} +``` + +### Why New Instance Per Test? + +TUnit creates a new instance of your test class for each test method. + +```csharp +public class MyTests +{ + private int _counter = 0; // Fresh for each test + + [Test] + public async Task Test1() + { + _counter++; + await Assert.That(_counter).IsEqualTo(1); // Always passes + } + + [Test] + public async Task Test2() + { + _counter++; + await Assert.That(_counter).IsEqualTo(1); // Always passes + } +} +``` + +This prevents instance fields from leaking between tests. No race conditions on instance data. No mysterious "Test B fails when Test A runs first" issues. If you need shared state, you use `static`, which makes it explicit and obvious in the code. + +Coming from NUnit (which shares instances by default)? Yes, this is a breaking change. But it's the right default for test isolation. + +### Why Source Generators? + +TUnit uses Roslyn source generators for test discovery. No runtime reflection means better performance and Native AOT compatibility. You get compile-time errors for test configuration issues instead of runtime surprises. IDEs understand the generated code, so IntelliSense and refactoring work better. + +Source generators do add complexity and can make debugging trickier. But for most users, the performance and AOT benefits are worth it. If you really need more flexibility, reflection mode is always available with `dotnet test -- --reflection`. + +## What Problems Does TUnit Solve? + +### Slow Test Suites + +Traditional frameworks run tests sequentially by default and use runtime reflection for discovery. TUnit runs tests in parallel and discovers them at compile time with source generators. The result? Test suites often run 5-10x faster. + +### Flaky Tests from State Leaks + +NUnit shares test class instances. xUnit allows state in constructors. It's easy to accidentally share state and get those frustrating "works alone, fails in the suite" bugs. TUnit creates a new instance per test with no way to opt out. If you need shared state, you use `static`, making it explicit. Parallel execution catches state problems early instead of hiding them. + +### Limited Async Support + +Older frameworks have a mix of sync and async APIs. You need `IAsyncLifetime` for async setup. Some parts do sync-over-async. TUnit is async everywhere—hooks, assertions, all of it. No deadlocks, clean code throughout. + +### Poor AOT Support + +Heavy runtime reflection doesn't work with Native AOT. TUnit uses source generation, supports AOT from day one, and has proper trimming annotations. Your tests work with Native AOT deployments. + +## Comparison with Other Frameworks + +### TUnit vs xUnit + +xUnit and TUnit have a lot in common—neither requires class attributes, both have modern extensible designs. The main differences: TUnit runs parallel by default with better async control, uses source generation for discovery, has a richer hook system instead of constructor/IDisposable patterns, and uses fluent assertions instead of static methods. + +### TUnit vs NUnit + +Both use `[Test]` attributes and have rich assertion libraries. The biggest difference is isolation: TUnit creates a new instance per test, NUnit shares by default. TUnit also defaults to parallel execution (NUnit defaults to sequential), has async assertions (NUnit is sync), and doesn't need the `[TestFixture]` attribute. + +### TUnit vs MSTest + +Both are Microsoft-backed with good IDE integration. TUnit drops the class attribute requirement (MSTest needs `[TestClass]`), runs on the modern testing platform, defaults to parallel execution, and has better async support throughout. + +For detailed comparisons, check out [Framework Differences](../comparison/framework-differences.md). + +## When to Choose TUnit + +TUnit is a good fit when performance matters—you have large test suites that need to run fast. It's designed for modern .NET (8+), works great with Native AOT, and shines when you want parallel test execution. If you're starting a new project without legacy constraints, TUnit is worth considering. + +When might you want alternatives? If you're on .NET Framework (TUnit requires .NET 8+), use NUnit or xUnit. If you have an existing huge test suite, migration costs might outweigh the benefits. If your team strongly prefers another framework's style, that's a legitimate reason to stick with what works for you. Or if you absolutely need a tool that only works with VSTest, you'll need to use something else. + +## The Bottom Line + +TUnit exists because modern .NET deserves a modern testing framework. One that prioritizes performance, isolation, and developer experience without carrying the baggage of legacy compromises. + +Every decision—async assertions, parallel-by-default, source generation—flows from wanting tests to be fast, isolated, modern, and pleasant to write. Tests should run in parallel, create new instances per test, support async naturally, and minimize boilerplate. + +If that resonates with you, TUnit is probably a good fit for your project. + +For migration details, check out: +- [xUnit Migration](../migration/xunit.md) +- [NUnit Migration](../migration/nunit.md) +- [MSTest Migration](../migration/mstest.md) diff --git a/docs/docs/test-lifecycle/property-injection.md b/docs/docs/test-lifecycle/property-injection.md index 2780cf3f08..d9e9692f96 100644 --- a/docs/docs/test-lifecycle/property-injection.md +++ b/docs/docs/test-lifecycle/property-injection.md @@ -324,7 +324,7 @@ When using nested property injection, the `Shared` parameter becomes crucial: ### Best Practices -1. **Use Appropriate Sharing**: Share expensive resources like test containers using `PerTestSession` or `Globally` +1. **Use Appropriate Sharing**: Share expensive resources like test containers using `PerTestSession` 2. **Implement IAsyncInitializer**: For complex setup that requires async operations 3. **Implement IAsyncDisposable**: Ensure proper cleanup of resources 4. **Order Independence**: Don't rely on initialization order between sibling properties diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 97bb7cf071..d4c09d32f0 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -25,6 +25,15 @@ const sidebars: SidebarsConfig = { 'troubleshooting', ], }, + { + type: 'category', + label: 'Guides', + items: [ + 'guides/philosophy', + 'guides/best-practices', + 'guides/cookbook', + ], + }, { type: 'category', label: 'Test Authoring', From e589ce2bfa9319a8b01aa8d94ac39467fdc6d886 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 13:55:10 +0000 Subject: [PATCH 07/14] Enhance philosophy documentation by adding guidance on using [DependsOn] for test dependencies --- docs/docs/guides/philosophy.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/docs/guides/philosophy.md b/docs/docs/guides/philosophy.md index c37a930279..d12b05407e 100644 --- a/docs/docs/guides/philosophy.md +++ b/docs/docs/guides/philosophy.md @@ -22,6 +22,8 @@ Every test should be completely independent. TUnit creates a new instance of you Isolated tests are reliable tests. You never get those mysterious failures where Test B only fails when Test A runs first. Everything is deterministic. +But what about when tests really do need to depend on each other? Use `[DependsOn]` to enforce ordering. It ensures the dependency always runs first, and if it fails, the dependent test gets skipped instead of running with bad state. This reduces flakiness compared to hoping tests run in the right order by accident. + ### Developer Experience Writing tests should be pleasant, not painful. TUnit has minimal boilerplate—just put `[Test]` on your methods, no class attributes needed. Assertions read naturally: `await Assert.That(value).IsEqualTo(expected)`. Error messages are clear and actionable. You can access test metadata through `TestContext` whenever you need it. From fd08a783f2efd4e292abeeebece63608ad2329e4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:10:26 +0000 Subject: [PATCH 08/14] Enhance migration documentation by adding quick reference tables for MSTest, NUnit, and xUnit --- docs/docs/guides/cookbook.md | 2 +- docs/docs/migration/mstest.md | 30 +++++++++++++++++++++++++++ docs/docs/migration/nunit.md | 24 +++++++++++++++++++++ docs/docs/migration/xunit.md | 39 +++++++++++++++++------------------ 4 files changed, 74 insertions(+), 21 deletions(-) diff --git a/docs/docs/guides/cookbook.md b/docs/docs/guides/cookbook.md index cc321ff678..95b0eaad8d 100644 --- a/docs/docs/guides/cookbook.md +++ b/docs/docs/guides/cookbook.md @@ -789,4 +789,4 @@ These cookbook recipes cover the most common testing scenarios. You can adapt th - **Exception Testing**: Use TUnit's fluent exception assertions - **Integration Tests**: Test with real databases, containers, or file systems -For more examples, check out the [examples directory](../examples/) in the documentation. +For more examples, check out the [examples section](../examples/intro) in the documentation. diff --git a/docs/docs/migration/mstest.md b/docs/docs/migration/mstest.md index d5286b1bd5..0e257746d4 100644 --- a/docs/docs/migration/mstest.md +++ b/docs/docs/migration/mstest.md @@ -1,5 +1,35 @@ # Migrating from MSTest +## Quick Reference + +| MSTest | TUnit | +|--------|-------| +| `[TestClass]` | *(remove - not needed)* | +| `[TestMethod]` | `[Test]` | +| `[DataRow(...)]` | `[Arguments(...)]` | +| `[DynamicData(nameof(...), ...)]` | `[MethodDataSource(nameof(...))]` | +| `[TestCategory("value")]` | `[Property("Category", "value")]` | +| `[Ignore]` | `[Skip]` | +| `[Priority(n)]` | `[Property("Priority", "n")]` | +| `[Owner("value")]` | `[Property("Owner", "value")]` | +| `[TestInitialize]` | `[Before(Test)]` | +| `[TestCleanup]` | `[After(Test)]` | +| `[ClassInitialize]` | `[Before(Class)]` *(remove TestContext parameter)* | +| `[ClassCleanup]` | `[After(Class)]` | +| `[AssemblyInitialize]` | `[Before(Assembly)]` *(remove TestContext parameter)* | +| `[AssemblyCleanup]` | `[After(Assembly)]` | +| `[Timeout(ms)]` | `[Timeout(ms)]` | +| `[DataTestMethod]` | `[Test]` | +| `public TestContext TestContext { get; set; }` | `TestContext` method parameter | +| `Assert.AreEqual(expected, actual)` | `await Assert.That(actual).IsEqualTo(expected)` | +| `Assert.IsTrue(condition)` | `await Assert.That(condition).IsTrue()` | +| `Assert.IsNull(value)` | `await Assert.That(value).IsNull()` | +| `Assert.ThrowsException(() => ...)` | `await Assert.ThrowsAsync(() => ...)` | +| `Assert.Inconclusive("reason")` | `Skip.Test("reason")` | +| `CollectionAssert.Contains(collection, item)` | `await Assert.That(collection).Contains(item)` | +| `StringAssert.Contains(text, substring)` | `await Assert.That(text).Contains(substring)` | +| `Assert.AreSame(expected, actual)` | `await Assert.That(actual).IsSameReference(expected)` | + ## Automated Migration with Code Fixers TUnit includes code fixers that automate most of the migration work. diff --git a/docs/docs/migration/nunit.md b/docs/docs/migration/nunit.md index 71b06d8d90..55eaad34ea 100644 --- a/docs/docs/migration/nunit.md +++ b/docs/docs/migration/nunit.md @@ -1,5 +1,29 @@ # Migrating from NUnit +## Quick Reference + +| NUnit | TUnit | +|-------|-------| +| `[TestFixture]` | *(remove - not needed)* | +| `[Test]` | `[Test]` | +| `[TestCase(...)]` | `[Arguments(...)]` | +| `[TestCaseSource(nameof(...))]` | `[MethodDataSource(nameof(...))]` | +| `[Category("value")]` | `[Property("Category", "value")]` | +| `[Ignore]` | `[Skip]` | +| `[Explicit]` | `[Explicit]` | +| `[SetUp]` | `[Before(Test)]` | +| `[TearDown]` | `[After(Test)]` | +| `[OneTimeSetUp]` | `[Before(Class)]` | +| `[OneTimeTearDown]` | `[After(Class)]` | +| `[SetUpFixture]` + `[OneTimeSetUp]` | `[Before(Assembly)]` on static method | +| `[Values(...)]` on parameter | `[Matrix(...)]` on method | +| `Assert.AreEqual(expected, actual)` | `await Assert.That(actual).IsEqualTo(expected)` | +| `Assert.That(actual, Is.EqualTo(expected))` | `await Assert.That(actual).IsEqualTo(expected)` | +| `Assert.Throws(() => ...)` | `await Assert.ThrowsAsync(() => ...)` | +| `TestContext.WriteLine(...)` | `TestContext` parameter with `context.OutputWriter.WriteLine(...)` | +| `CollectionAssert.AreEqual(expected, actual)` | `await Assert.That(actual).IsEquivalentTo(expected)` | +| `StringAssert.Contains(substring, text)` | `await Assert.That(text).Contains(substring)` | + ## Automated Migration with Code Fixers TUnit includes code fixers that automate most of the migration work. diff --git a/docs/docs/migration/xunit.md b/docs/docs/migration/xunit.md index f3ae7459e6..cbb8064ced 100644 --- a/docs/docs/migration/xunit.md +++ b/docs/docs/migration/xunit.md @@ -1,5 +1,24 @@ # Migrating from xUnit.net +## Quick Reference + +| xUnit | TUnit | +|-------|-------| +| `[Fact]` | `[Test]` | +| `[Theory]` | `[Test]` | +| `[InlineData(...)]` | `[Arguments(...)]` | +| `[MemberData(nameof(...))]` | `[MethodDataSource(nameof(...))]` | +| `[ClassData(typeof(...))]` | `[MethodDataSource(nameof(ClassName.Method))]` | +| `[Trait("key", "value")]` | `[Property("key", "value")]` | +| `IClassFixture` | `[ClassDataSource(Shared = SharedType.PerClass)]` | +| `[Collection("name")]` | `[ClassDataSource(Shared = SharedType.Keyed, Key = "name")]` | +| Constructor | Constructor or `[Before(Test)]` | +| `IDisposable` | `IDisposable` or `[After(Test)]` | +| `IAsyncLifetime` | `[Before(Test)]` / `[After(Test)]` | +| `ITestOutputHelper` | `TestContext` parameter | +| `Assert.Equal(expected, actual)` | `await Assert.That(actual).IsEqualTo(expected)` | +| `Assert.Throws(() => ...)` | `await Assert.ThrowsAsync(() => ...)` | + ## Automated Migration with Code Fixers TUnit includes code fixers that automate most of the migration work. @@ -1149,23 +1168,3 @@ dotnet run --configuration Release --coverage --coverage-settings coverage.runse **Need help?** - See [TUnit Code Coverage Documentation](../extensions/extensions.md#code-coverage) - Check [Microsoft's Code Coverage Guide](https://learn.microsoft.com/en-us/dotnet/core/testing/unit-testing-code-coverage) - -## Quick Reference - -| xUnit | TUnit | -|-------|-------| -| `[Fact]` | `[Test]` | -| `[Theory]` | `[Test]` | -| `[InlineData(...)]` | `[Arguments(...)]` | -| `[MemberData(nameof(...))]` | `[MethodDataSource(nameof(...))]` | -| `[ClassData(typeof(...))]` | `[MethodDataSource(nameof(ClassName.Method))]` | -| `[Trait("key", "value")]` | `[Property("key", "value")]` | -| `IClassFixture` | `[ClassDataSource(Shared = SharedType.PerClass)]` | -| `[Collection("name")]` | `[ClassDataSource(Shared = SharedType.Keyed, Key = "name")]` | -| Constructor | Constructor or `[Before(Test)]` | -| `IDisposable` | `IDisposable` or `[After(Test)]` | -| `IAsyncLifetime` | `[Before(Test)]` / `[After(Test)]` | -| `ITestOutputHelper` | `TestContext` parameter | -| `Assert.Equal(expected, actual)` | `await Assert.That(actual).IsEqualTo(expected)` | -| `Assert.Throws(() => ...)` | `await Assert.ThrowsAsync(() => ...)` | - From b402e251d40e4c77af44b50a97d119e6715d43e4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:11:08 +0000 Subject: [PATCH 09/14] Fix migration guide links to point to automated migration sections for xUnit, NUnit, and MSTest --- docs/docs/faq.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs/faq.md b/docs/docs/faq.md index d554f55c06..2b89bd00b0 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -71,9 +71,9 @@ dotnet format analyzers --severity info --diagnostics TUMS0001 The code fixer converts test methods to async, adds await to assertions, and updates attribute names. It handles most common cases automatically, though you may need to adjust complex scenarios manually. See the migration guides for step-by-step instructions: -- [xUnit migration](migration/xunit.md#using-tunits-code-fixers) -- [NUnit migration](migration/nunit.md#using-tunits-code-fixers) -- [MSTest migration](migration/mstest.md#using-tunits-code-fixers) +- [xUnit migration](migration/xunit.md#automated-migration-with-code-fixers) +- [NUnit migration](migration/nunit.md#automated-migration-with-code-fixers) +- [MSTest migration](migration/mstest.md#automated-migration-with-code-fixers) **What you gain** From 9f9b9f14804e850cbc6f573fd6e20209ca9481e5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:27:02 +0000 Subject: [PATCH 10/14] Clarify .NET version requirements for code coverage in migration and troubleshooting documentation --- .../extensibility/source-generator-assertions.md | 2 +- docs/docs/examples/tunit-ci-pipeline.md | 2 +- docs/docs/faq.md | 6 +++--- docs/docs/guides/best-practices.md | 4 ++-- docs/docs/guides/philosophy.md | 10 +++++----- docs/docs/migration/mstest.md | 2 +- docs/docs/migration/nunit.md | 2 +- docs/docs/migration/xunit.md | 2 +- docs/docs/test-authoring/class-data-source.md | 4 ++-- docs/docs/test-lifecycle/property-injection.md | 2 +- docs/docs/troubleshooting.md | 6 +++--- 11 files changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/docs/assertions/extensibility/source-generator-assertions.md b/docs/docs/assertions/extensibility/source-generator-assertions.md index 814aa0f4b9..6679a2b538 100644 --- a/docs/docs/assertions/extensibility/source-generator-assertions.md +++ b/docs/docs/assertions/extensibility/source-generator-assertions.md @@ -4,7 +4,7 @@ sidebar_position: 2 # Source Generator Assertions -TUnit provides source generators to make creating custom assertions incredibly easy. Instead of manually writing assertion classes and extension methods, you can simply decorate your methods with attributes and let the generator do the work. +TUnit provides source generators to simplify creating custom assertions. Instead of manually writing assertion classes and extension methods, you can decorate your methods with attributes and let the generator do the work. ## Overview diff --git a/docs/docs/examples/tunit-ci-pipeline.md b/docs/docs/examples/tunit-ci-pipeline.md index 378f04f4fc..07ea89d282 100644 --- a/docs/docs/examples/tunit-ci-pipeline.md +++ b/docs/docs/examples/tunit-ci-pipeline.md @@ -617,7 +617,7 @@ dotnet test -- --timeout 5m # 5 minutes ### Coverage Files Not Generated -Ensure you're using the TUnit meta package (not just TUnit.Engine) and running on .NET 8+: +Ensure you're using the TUnit meta package (not just TUnit.Engine): ```xml diff --git a/docs/docs/faq.md b/docs/docs/faq.md index 2b89bd00b0..e698a3d2f9 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -195,9 +195,9 @@ Use **Microsoft.Testing.Extensions.CodeCoverage**, which is: - The TUnit meta package includes the coverage extension automatically - If using TUnit.Engine directly, you must manually install `Microsoft.Testing.Extensions.CodeCoverage` -2. **Using .NET 7 or earlier** - - Microsoft.Testing.Platform requires .NET 8+ - - Upgrade to .NET 8 or later +2. **Older .NET SDK versions** + - Ensure you have a recent .NET SDK installed + - The Microsoft.Testing.Platform and extensions support .NET Standard 2.0+ See the [Code Coverage Troubleshooting](troubleshooting.md#code-coverage-issues) for more solutions. diff --git a/docs/docs/guides/best-practices.md b/docs/docs/guides/best-practices.md index f9e9fd0dec..6bc1304297 100644 --- a/docs/docs/guides/best-practices.md +++ b/docs/docs/guides/best-practices.md @@ -272,8 +272,8 @@ public class ApiTests(TestWebServer server) **Shared Type Options:** - `SharedType.PerTestSession`: One instance for entire test run, shared across assemblies (best for expensive resources) -- `SharedType.PerClass`: One instance per test class (default) -- `SharedType.None`: New instance per test +- `SharedType.PerClass`: One instance per test class +- `SharedType.None`: New instance per test (default) You can also use hooks, but they're less flexible: diff --git a/docs/docs/guides/philosophy.md b/docs/docs/guides/philosophy.md index d12b05407e..957cb94280 100644 --- a/docs/docs/guides/philosophy.md +++ b/docs/docs/guides/philosophy.md @@ -14,7 +14,7 @@ Fast tests create faster feedback loops. When tests run quickly, developers actu TUnit embraces modern .NET without compromises. Everything is async by default. Assertions, hooks, all of it. It uses C# 12+ features like collection expressions and file-scoped namespaces. Native AOT and trimming work out of the box. It's built on Microsoft.Testing.Platform instead of the legacy VSTest infrastructure. -Modern .NET applications deserve a modern testing framework. TUnit doesn't carry the baggage of .NET Framework support or patterns from a decade ago. +Modern .NET applications deserve a modern testing framework. While TUnit supports .NET Standard 2.0 (including .NET Framework), it's designed around modern patterns and idioms, not legacy approaches from a decade ago. ### Test Isolation @@ -83,7 +83,7 @@ The downside? Some older tools only work with VSTest. Coverlet is the most notab Most testing frameworks make you opt-in to parallelism. TUnit flips that around. -Parallel tests are dramatically faster. Modern CPUs have many cores—TUnit uses them. And here's the thing: tests that are safe to run in parallel are usually well-isolated tests. Making parallelism the default pushes you toward better test design. +Running tests in parallel can improve execution time. Modern CPUs have many cores—TUnit uses them by default. And here's the thing: tests that are safe to run in parallel are usually well-isolated tests. Making parallelism the default encourages better test design. When do you opt-out? Use `[NotInParallel]` for tests that modify shared files or databases, use global state, must run in a specific order, or access hardware like cameras or GPIO pins. @@ -134,7 +134,7 @@ Source generators do add complexity and can make debugging trickier. But for mos ### Slow Test Suites -Traditional frameworks run tests sequentially by default and use runtime reflection for discovery. TUnit runs tests in parallel and discovers them at compile time with source generators. The result? Test suites often run 5-10x faster. +Traditional frameworks run tests sequentially by default and use runtime reflection for discovery. TUnit runs tests in parallel by default and discovers them at compile time with source generators. This can significantly reduce test suite execution time, especially for large test suites. ### Flaky Tests from State Leaks @@ -166,9 +166,9 @@ For detailed comparisons, check out [Framework Differences](../comparison/framew ## When to Choose TUnit -TUnit is a good fit when performance matters—you have large test suites that need to run fast. It's designed for modern .NET (8+), works great with Native AOT, and shines when you want parallel test execution. If you're starting a new project without legacy constraints, TUnit is worth considering. +TUnit is a good fit when performance matters—you have large test suites that need to run fast. It supports .NET Standard 2.0, so it works with .NET Framework and all modern .NET versions. It works great with Native AOT, and shines when you want parallel test execution. If you're starting a new project without legacy constraints, TUnit is worth considering. -When might you want alternatives? If you're on .NET Framework (TUnit requires .NET 8+), use NUnit or xUnit. If you have an existing huge test suite, migration costs might outweigh the benefits. If your team strongly prefers another framework's style, that's a legitimate reason to stick with what works for you. Or if you absolutely need a tool that only works with VSTest, you'll need to use something else. +When might you want alternatives? If you have an existing huge test suite, migration costs might outweigh the benefits. If your team strongly prefers another framework's style, that's a legitimate reason to stick with what works for you. Or if you absolutely need a tool that only works with VSTest, you'll need to use something else. ## The Bottom Line diff --git a/docs/docs/migration/mstest.md b/docs/docs/migration/mstest.md index 0e257746d4..87cf616944 100644 --- a/docs/docs/migration/mstest.md +++ b/docs/docs/migration/mstest.md @@ -1118,7 +1118,7 @@ dotnet run --configuration Release --coverage --coverage-settings coverage.runse **Coverage files not generated?** - Ensure you're using the TUnit meta package, not just TUnit.Engine -- Verify you're on .NET 8+ (required for Microsoft.Testing.Platform) +- Verify you have a recent .NET SDK installed **Missing coverage for some assemblies?** - Use a `.runsettings` file to explicitly include/exclude modules diff --git a/docs/docs/migration/nunit.md b/docs/docs/migration/nunit.md index 55eaad34ea..9ef9543610 100644 --- a/docs/docs/migration/nunit.md +++ b/docs/docs/migration/nunit.md @@ -850,7 +850,7 @@ dotnet run --configuration Release --coverage --coverage-settings coverage.runse **Coverage files not generated?** - Ensure you're using the TUnit meta package, not just TUnit.Engine -- Verify you're on .NET 8+ (required for Microsoft.Testing.Platform) +- Verify you have a recent .NET SDK installed **Missing coverage for some assemblies?** - Use a `.runsettings` file to explicitly include/exclude modules diff --git a/docs/docs/migration/xunit.md b/docs/docs/migration/xunit.md index cbb8064ced..5351506234 100644 --- a/docs/docs/migration/xunit.md +++ b/docs/docs/migration/xunit.md @@ -1159,7 +1159,7 @@ dotnet run --configuration Release --coverage --coverage-settings coverage.runse **Coverage files not generated?** - Ensure you're using the TUnit meta package, not just TUnit.Engine -- Verify you're on .NET 8+ (required for Microsoft.Testing.Platform) +- Verify you have a recent .NET SDK installed **Missing coverage for some assemblies?** - Use a `.runsettings` file to explicitly include/exclude modules diff --git a/docs/docs/test-authoring/class-data-source.md b/docs/docs/test-authoring/class-data-source.md index bcaa445e66..8461d81fcb 100644 --- a/docs/docs/test-authoring/class-data-source.md +++ b/docs/docs/test-authoring/class-data-source.md @@ -11,8 +11,8 @@ Ideally don't manipulate the state of this object within your tests if your obje Options are: -### Shared = SharedType.None -The instance is not shared ever. A new one will be created for you. +### Shared = SharedType.None (Default) +The instance is not shared ever. A new one will be created for you. This is the default if `Shared` is not specified. ### Shared = SharedType.PerClass The instance is shared for every test in the same class as itself, that also has this setting. diff --git a/docs/docs/test-lifecycle/property-injection.md b/docs/docs/test-lifecycle/property-injection.md index d9e9692f96..58470f0fa9 100644 --- a/docs/docs/test-lifecycle/property-injection.md +++ b/docs/docs/test-lifecycle/property-injection.md @@ -320,7 +320,7 @@ When using nested property injection, the `Shared` parameter becomes crucial: - **`SharedType.PerAssembly`**: Single instance shared for every test in the same assembly as itself. - **`SharedType.PerClass`**: One instance per test class - **`SharedType.Keyed`**: Share instances based on a key value -- **`SharedType.None`**: New instance for each injection point +- **`SharedType.None`**: New instance for each injection point (default) ### Best Practices diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md index 8fc2320e11..e4aae55567 100644 --- a/docs/docs/troubleshooting.md +++ b/docs/docs/troubleshooting.md @@ -701,10 +701,10 @@ dotnet --version ``` **Requirements:** -- ✅ .NET 8 or later (required for Microsoft.Testing.Platform) -- ❌ .NET 7 or earlier (not compatible) +- Ensure you have a recent .NET SDK installed +- Microsoft.Testing.Platform supports .NET Standard 2.0+ -**Fix:** Upgrade to .NET 8 or later in your project file: +**Tip:** Use a recent .NET SDK version for the best experience: ```xml net8.0 ``` From 116ddace152fed6ba9e5a607e9cb620c096a8bab Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:43:53 +0000 Subject: [PATCH 11/14] Enhance documentation on test method signatures and hook methods, clarifying synchronous and asynchronous usage for TUnit assertions and lifecycle management. --- docs/docs/faq.md | 11 +++++--- .../writing-your-first-test.md | 27 +++++++++++++++++++ docs/docs/migration/nunit.md | 15 ++++++----- docs/docs/test-lifecycle/cleanup.md | 24 +++++++++++++++++ docs/docs/test-lifecycle/setup.md | 24 +++++++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) diff --git a/docs/docs/faq.md b/docs/docs/faq.md index e698a3d2f9..359d1948df 100644 --- a/docs/docs/faq.md +++ b/docs/docs/faq.md @@ -21,13 +21,16 @@ Currently, TUnit is best supported in Visual Studio 2022 (v17.9+) and JetBrains All TUnit assertions must be awaited. There's no synchronous alternative. +**Important:** Test methods themselves can be either synchronous (`void`) or asynchronous (`async Task`). However, if your test uses TUnit's assertion library (`Assert.That(...)`), the test method **must** be `async Task` because assertions return awaitable objects that must be awaited to execute. Tests without assertions can remain synchronous. See [Test Method Signatures](getting-started/writing-your-first-test.md#test-method-signatures) for examples. + **Why this design?** -TUnit's assertion library is built around async from the ground up. This means: +TUnit's assertion library uses the awaitable pattern (custom objects with `GetAwaiter()` methods). This means: +- Assertions don't execute until they're awaited - this is when the actual verification happens - All assertions work consistently, whether they're simple value checks or complex async operations - Custom assertions can perform async work (like database queries or HTTP calls) - No sync-over-async patterns that cause deadlocks -- Assertions can be chained without blocking +- Assertions can be chained fluently before execution **What this means when migrating:** @@ -101,10 +104,10 @@ The most common mistake is forgetting `await`. The compiler warns you, but the t ```csharp // Wrong - test passes without checking anything -Assert.That(result).IsEqualTo(5); // Returns a Task that's ignored +Assert.That(result).IsEqualTo(5); // Returns an awaitable object that's never executed // Correct -await Assert.That(result).IsEqualTo(5); +await Assert.That(result).IsEqualTo(5); // The await triggers the actual assertion execution ``` ### Does TUnit work with Coverlet for code coverage? diff --git a/docs/docs/getting-started/writing-your-first-test.md b/docs/docs/getting-started/writing-your-first-test.md index 7ed3375c59..ef503ae7d3 100644 --- a/docs/docs/getting-started/writing-your-first-test.md +++ b/docs/docs/getting-started/writing-your-first-test.md @@ -66,6 +66,33 @@ We haven't actually made it do anything yet, but we should be able to build our Tests will pass if they execute successfully without any exceptions. +## Test Method Signatures + +Test methods can be either synchronous or asynchronous: + +```csharp +[Test] +public void SynchronousTest() // ✅ Valid - synchronous test +{ + var result = Calculate(2, 3); + // Simple synchronous test without assertions +} + +[Test] +public async Task AsyncTestWithAssertions() // ✅ Recommended - asynchronous test +{ + var result = Calculate(2, 3); + await Assert.That(result).IsEqualTo(5); // Assertions must be awaited +} +``` + +**Important Notes:** +- If you use TUnit's assertion library (`Assert.That(...)`), your test **must** be `async Task` because assertions return awaitable objects that must be awaited to execute +- Synchronous `void` tests are allowed but cannot use assertions +- `async void` tests are **not allowed** and will cause a compiler error +- **Best Practice**: Use `async Task` for all tests to enable TUnit's assertion library +- **Technical Detail**: Assertions return custom assertion builder objects with a `GetAwaiter()` method, making them awaitable + Let's add some code to show you how a test might look once finished: ```csharp diff --git a/docs/docs/migration/nunit.md b/docs/docs/migration/nunit.md index 9ef9543610..792f6b1f77 100644 --- a/docs/docs/migration/nunit.md +++ b/docs/docs/migration/nunit.md @@ -8,7 +8,7 @@ | `[Test]` | `[Test]` | | `[TestCase(...)]` | `[Arguments(...)]` | | `[TestCaseSource(nameof(...))]` | `[MethodDataSource(nameof(...))]` | -| `[Category("value")]` | `[Property("Category", "value")]` | +| `[Category("value")]` | `[Category("value")]` *(same)* or `[Property("Category", "value")]` | | `[Ignore]` | `[Skip]` | | `[Explicit]` | `[Explicit]` | | `[SetUp]` | `[Before(Test)]` | @@ -16,7 +16,7 @@ | `[OneTimeSetUp]` | `[Before(Class)]` | | `[OneTimeTearDown]` | `[After(Class)]` | | `[SetUpFixture]` + `[OneTimeSetUp]` | `[Before(Assembly)]` on static method | -| `[Values(...)]` on parameter | `[Matrix(...)]` on method | +| `[Values(...)]` on parameter | `[Matrix(...)]` on each parameter | | `Assert.AreEqual(expected, actual)` | `await Assert.That(actual).IsEqualTo(expected)` | | `Assert.That(actual, Is.EqualTo(expected))` | `await Assert.That(actual).IsEqualTo(expected)` | | `Assert.Throws(() => ...)` | `await Assert.ThrowsAsync(() => ...)` | @@ -267,8 +267,9 @@ public class CombinationTests public class CombinationTests { [Test] - [Matrix([1, 2, 3], ["a", "b"])] - public async Task TestCombinations(int x, string y) + public async Task TestCombinations( + [Matrix(1, 2, 3)] int x, + [Matrix("a", "b")] string y) { await Assert.That(x).IsGreaterThan(0); await Assert.That(y).IsNotNull(); @@ -277,9 +278,9 @@ public class CombinationTests ``` **Key Changes:** -- `[Values(...)]` attributes on parameters → `[Matrix(...)]` attribute on method -- All combinations are automatically generated -- Cleaner syntax with collection expressions +- `[Values(...)]` attributes on parameters → `[Matrix(...)]` attributes on parameters +- All combinations are automatically generated (3 × 2 = 6 test cases) +- Each parameter gets its own `[Matrix]` attribute with the values to test ### Test Fixture with Parameters diff --git a/docs/docs/test-lifecycle/cleanup.md b/docs/docs/test-lifecycle/cleanup.md index 234424dbc2..9062d3b0c9 100644 --- a/docs/docs/test-lifecycle/cleanup.md +++ b/docs/docs/test-lifecycle/cleanup.md @@ -4,6 +4,30 @@ TUnit supports having your test class implement `IDisposable` or `IAsyncDisposab You can also declare a method with an `[After(...)]` or an `[AfterEvery(...)]` attribute. +## Hook Method Signatures + +Hook methods can be either synchronous or asynchronous: + +```csharp +[After(Test)] +public void SynchronousCleanup() // ✅ Valid - synchronous hook +{ + _resource?.Dispose(); +} + +[After(Test)] +public async Task AsyncCleanup() // ✅ Valid - asynchronous hook +{ + await new HttpClient().GetAsync("https://localhost/test-finished-notifier"); +} +``` + +**Important Notes:** +- Hooks can be `void` (synchronous) or `async Task` (asynchronous) +- Use async hooks when you need to perform async operations (HTTP calls, database queries, etc.) +- Use synchronous hooks for simple cleanup (disposing objects, resetting state, etc.) +- `async void` hooks are **not allowed** and will cause a compiler error + ## [After(HookType)] ### [After(Test)] diff --git a/docs/docs/test-lifecycle/setup.md b/docs/docs/test-lifecycle/setup.md index f45c7dfe9b..2e82b2d2b0 100644 --- a/docs/docs/test-lifecycle/setup.md +++ b/docs/docs/test-lifecycle/setup.md @@ -7,6 +7,30 @@ E.g. pinging a service to wake it up in preparation for the tests. For this, we can declare a method with a `[Before(...)]` or a `[BeforeEvery(...)]` attribute. +## Hook Method Signatures + +Hook methods can be either synchronous or asynchronous: + +```csharp +[Before(Test)] +public void SynchronousSetup() // ✅ Valid - synchronous hook +{ + _value = 99; +} + +[Before(Test)] +public async Task AsyncSetup() // ✅ Valid - asynchronous hook +{ + _response = await new HttpClient().GetAsync("https://localhost/ping"); +} +``` + +**Important Notes:** +- Hooks can be `void` (synchronous) or `async Task` (asynchronous) +- Use async hooks when you need to perform async operations (HTTP calls, database queries, etc.) +- Use synchronous hooks for simple setup (setting fields, initializing values, etc.) +- `async void` hooks are **not allowed** and will cause a compiler error + ## [Before(HookType)] ### [Before(Test)] From 232da1d9fd9a169b1d6c092fd0c0992c6d13d8e4 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:07:21 +0000 Subject: [PATCH 12/14] Refactor assertions to use 'Within' method for tolerance checks across multiple documentation files --- docs/docs/assertions/datetime.md | 14 ++--- .../assertions/equality-and-comparison.md | 10 ++-- docs/docs/assertions/getting-started.md | 2 +- docs/docs/assertions/numeric.md | 28 +++++----- docs/docs/execution/test-filters.md | 14 ++++- docs/docs/parallelism/not-in-parallel.md | 4 +- docs/docs/test-authoring/class-data-source.md | 9 +++- docs/docs/test-authoring/matrix-tests.md | 2 +- docs/docs/test-lifecycle/cleanup.md | 53 +++++++++++++++++++ docs/docs/test-lifecycle/setup.md | 53 +++++++++++++++++++ 10 files changed, 156 insertions(+), 33 deletions(-) diff --git a/docs/docs/assertions/datetime.md b/docs/docs/assertions/datetime.md index accb815d46..05c36d206f 100644 --- a/docs/docs/assertions/datetime.md +++ b/docs/docs/assertions/datetime.md @@ -21,7 +21,7 @@ public async Task DateTime_With_Tolerance() // await Assert.That(almostNow).IsEqualTo(now); // With tolerance - passes - await Assert.That(almostNow).IsEqualTo(now, tolerance: TimeSpan.FromSeconds(1)); + await Assert.That(almostNow).IsEqualTo(now).Within(TimeSpan.FromSeconds(1)); } ``` @@ -35,15 +35,15 @@ public async Task Various_Tolerance_Values() // Millisecond tolerance var time1 = baseTime.AddMilliseconds(100); - await Assert.That(time1).IsEqualTo(baseTime, tolerance: TimeSpan.FromMilliseconds(500)); + await Assert.That(time1).IsEqualTo(baseTime).Within(TimeSpan.FromMilliseconds(500)); // Second tolerance var time2 = baseTime.AddSeconds(5); - await Assert.That(time2).IsEqualTo(baseTime, tolerance: TimeSpan.FromSeconds(10)); + await Assert.That(time2).IsEqualTo(baseTime).Within(TimeSpan.FromSeconds(10)); // Minute tolerance var time3 = baseTime.AddMinutes(2); - await Assert.That(time3).IsEqualTo(baseTime, tolerance: TimeSpan.FromMinutes(5)); + await Assert.That(time3).IsEqualTo(baseTime).Within(TimeSpan.FromMinutes(5)); } ``` @@ -190,7 +190,7 @@ public async Task DateTimeOffset_With_Tolerance() var now = DateTimeOffset.Now; var almostNow = now.AddSeconds(1); - await Assert.That(almostNow).IsEqualTo(now, tolerance: TimeSpan.FromSeconds(5)); + await Assert.That(almostNow).IsEqualTo(now).Within(TimeSpan.FromSeconds(5)); } ``` @@ -272,7 +272,7 @@ public async Task TimeOnly_With_Tolerance() var time1 = new TimeOnly(10, 30, 0); var time2 = new TimeOnly(10, 30, 5); - await Assert.That(time2).IsEqualTo(time1, tolerance: TimeSpan.FromSeconds(10)); + await Assert.That(time2).IsEqualTo(time1).Within(TimeSpan.FromSeconds(10)); } ``` @@ -428,7 +428,7 @@ public async Task Record_Created_Recently() var now = DateTime.UtcNow; // Created within last minute - await Assert.That(createdAt).IsEqualTo(now, tolerance: TimeSpan.FromMinutes(1)); + await Assert.That(createdAt).IsEqualTo(now).Within(TimeSpan.FromMinutes(1)); await Assert.That(createdAt).IsInPastUtc(); } ``` diff --git a/docs/docs/assertions/equality-and-comparison.md b/docs/docs/assertions/equality-and-comparison.md index 78920a4624..f20912b79f 100644 --- a/docs/docs/assertions/equality-and-comparison.md +++ b/docs/docs/assertions/equality-and-comparison.md @@ -246,7 +246,7 @@ public async Task Double_With_Tolerance() // await Assert.That(actual).IsEqualTo(expected); // With tolerance - passes - await Assert.That(actual).IsEqualTo(expected, tolerance: 0.001); + await Assert.That(actual).IsEqualTo(expected).Within(0.001); } ``` @@ -259,7 +259,7 @@ public async Task Float_With_Tolerance() float actual = 3.14159f; float expected = 3.14f; - await Assert.That(actual).IsEqualTo(expected, tolerance: 0.01f); + await Assert.That(actual).IsEqualTo(expected).Within(0.01f); } ``` @@ -272,7 +272,7 @@ public async Task Decimal_With_Tolerance() decimal price = 19.995m; decimal expected = 20.00m; - await Assert.That(price).IsEqualTo(expected, tolerance: 0.01m); + await Assert.That(price).IsEqualTo(expected).Within(0.01m); } ``` @@ -286,7 +286,7 @@ public async Task Long_With_Tolerance() long expected = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Allow 100ms difference - await Assert.That(timestamp).IsEqualTo(expected, tolerance: 100L); + await Assert.That(timestamp).IsEqualTo(expected).Within(100L); } ``` @@ -442,7 +442,7 @@ public async Task Mathematical_Constants() { var calculatedPi = CalculatePiUsingLeibniz(10000); - await Assert.That(calculatedPi).IsEqualTo(Math.PI, tolerance: 0.0001); + await Assert.That(calculatedPi).IsEqualTo(Math.PI).Within(0.0001); } ``` diff --git a/docs/docs/assertions/getting-started.md b/docs/docs/assertions/getting-started.md index e4d5d1fa3b..ad1aa482ab 100644 --- a/docs/docs/assertions/getting-started.md +++ b/docs/docs/assertions/getting-started.md @@ -214,7 +214,7 @@ await Assert.That(temperature).IsGreaterThanOrEqualTo(32); For floating-point comparisons: ```csharp -await Assert.That(3.14159).IsEqualTo(Math.PI, tolerance: 0.001); +await Assert.That(3.14159).IsEqualTo(Math.PI).Within(0.001); ``` ### Testing Async Operations diff --git a/docs/docs/assertions/numeric.md b/docs/docs/assertions/numeric.md index 6b8cbc8f07..ce38e4eb26 100644 --- a/docs/docs/assertions/numeric.md +++ b/docs/docs/assertions/numeric.md @@ -119,7 +119,7 @@ public async Task Double_Tolerance() // await Assert.That(result).IsEqualTo(expected); // With tolerance - safe - await Assert.That(result).IsEqualTo(expected, tolerance: 0.001); + await Assert.That(result).IsEqualTo(expected).Within( 0.001); } ``` @@ -132,7 +132,7 @@ public async Task Float_Tolerance() float pi = 3.14159f; float approximation = 3.14f; - await Assert.That(pi).IsEqualTo(approximation, tolerance: 0.01f); + await Assert.That(pi).IsEqualTo(approximation).Within(0.01f); } ``` @@ -147,7 +147,7 @@ public async Task Decimal_Tolerance() decimal price = 19.995m; decimal rounded = 20.00m; - await Assert.That(price).IsEqualTo(rounded, tolerance: 0.01m); + await Assert.That(price).IsEqualTo(rounded).Within(0.01m); } ``` @@ -164,7 +164,7 @@ public async Task Long_Tolerance() long timestamp2 = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); // Allow 100ms difference - await Assert.That(timestamp1).IsEqualTo(timestamp2, tolerance: 100L); + await Assert.That(timestamp1).IsEqualTo(timestamp2).Within(100L); } ``` @@ -185,7 +185,7 @@ public async Task Calculate_Total_Price() await Assert.That(total).IsPositive(); await Assert.That(total).IsGreaterThan(subtotal); - await Assert.That(total).IsEqualTo(32.37m, tolerance: 0.01m); + await Assert.That(total).IsEqualTo(32.37m).Within(0.01m); } ``` @@ -198,7 +198,7 @@ public async Task Celsius_To_Fahrenheit() double celsius = 20.0; double fahrenheit = celsius * 9.0 / 5.0 + 32.0; - await Assert.That(fahrenheit).IsEqualTo(68.0, tolerance: 0.1); + await Assert.That(fahrenheit).IsEqualTo(68.0).Within(0.1); await Assert.That(fahrenheit).IsGreaterThan(celsius); } ``` @@ -215,7 +215,7 @@ public async Task Calculate_Percentage() await Assert.That(percentage).IsPositive(); await Assert.That(percentage).IsBetween(0, 100); - await Assert.That(percentage).IsEqualTo(87.5, tolerance: 0.1); + await Assert.That(percentage).IsEqualTo(87.5).Within(0.1); } ``` @@ -228,7 +228,7 @@ public async Task Calculate_Average() var numbers = new[] { 10, 20, 30, 40, 50 }; double average = numbers.Average(); - await Assert.That(average).IsEqualTo(30.0, tolerance: 0.01); + await Assert.That(average).IsEqualTo(30.0).Within(0.01); await Assert.That(average).IsGreaterThan(numbers.Min()); await Assert.That(average).IsLessThan(numbers.Max()); } @@ -327,7 +327,7 @@ public async Task Division() { double result = 10.0 / 4.0; - await Assert.That(result).IsEqualTo(2.5, tolerance: 0.001); + await Assert.That(result).IsEqualTo(2.5).Within(0.001); await Assert.That(result).IsPositive(); } ``` @@ -357,7 +357,7 @@ public async Task Math_Round() double value = 3.7; double rounded = Math.Round(value); - await Assert.That(rounded).IsEqualTo(4.0, tolerance: 0.001); + await Assert.That(rounded).IsEqualTo(4.0).Within(0.001); } ``` @@ -398,10 +398,10 @@ public async Task Math_Abs() public async Task Math_Power_Sqrt() { double squared = Math.Pow(5, 2); - await Assert.That(squared).IsEqualTo(25.0, tolerance: 0.001); + await Assert.That(squared).IsEqualTo(25.0).Within(0.001); double root = Math.Sqrt(25); - await Assert.That(root).IsEqualTo(5.0, tolerance: 0.001); + await Assert.That(root).IsEqualTo(5.0).Within(0.001); } ``` @@ -414,7 +414,7 @@ public async Task Math_Trigonometry() double angle = Math.PI / 4; // 45 degrees double sine = Math.Sin(angle); - await Assert.That(sine).IsEqualTo(Math.Sqrt(2) / 2, tolerance: 0.0001); + await Assert.That(sine).IsEqualTo(Math.Sqrt(2) / 2).Within(0.0001); await Assert.That(sine).IsPositive(); await Assert.That(sine).IsBetween(0, 1); } @@ -556,7 +556,7 @@ public async Task Growth_Rate() decimal growthRate = (currentValue - previousValue) / previousValue * 100; await Assert.That(growthRate).IsPositive(); - await Assert.That(growthRate).IsEqualTo(25m, tolerance: 0.1m); + await Assert.That(growthRate).IsEqualTo(25m).Within(0.1m); } ``` diff --git a/docs/docs/execution/test-filters.md b/docs/docs/execution/test-filters.md index e7840526e4..03feebddb9 100644 --- a/docs/docs/execution/test-filters.md +++ b/docs/docs/execution/test-filters.md @@ -13,9 +13,19 @@ You must use the `--treenode-filter` flag on the command line. The syntax for the filter value is (without the angled brackets) `////` -Will cards are also supported with `*` +Wildcards are also supported with `*` -As well as `and`, `or`, `starts with`, `ends with`, `equals` and other operators. For full information on the treenode filters, see [here](https://github.com/microsoft/testfx/blob/main/docs/mstest-runner-graphqueryfiltering/graph-query-filtering.md) +## Filter Operators + +TUnit supports several operators for building complex filters: + +- **Wildcard matching:** Use `*` for pattern matching (e.g., `LoginTests*` matches `LoginTests`, `LoginTestsSuite`, etc.) +- **Equality:** Use `=` for exact match (e.g., `[Category=Unit]`) +- **Negation:** Use `!=` for excluding values (e.g., `[Category!=Performance]`) +- **AND operator:** Use `&` to combine conditions (e.g., `[Category=Unit]&[Priority=High]`) +- **OR operator:** Use `|` to match either condition - requires parentheses (e.g., `(/*/*/Class1/*)|(/*/*/Class2/*)`) + +For full information on the treenode filters, see [Microsoft's documentation](https://github.com/microsoft/testfx/blob/main/docs/mstest-runner-graphqueryfiltering/graph-query-filtering.md) So an example could be: diff --git a/docs/docs/parallelism/not-in-parallel.md b/docs/docs/parallelism/not-in-parallel.md index c2bdd56a74..b5d00be5d4 100644 --- a/docs/docs/parallelism/not-in-parallel.md +++ b/docs/docs/parallelism/not-in-parallel.md @@ -30,10 +30,10 @@ public class MyTestClass } [Test] - [NotInParallel(DatabaseTest, RegistrationTest)] + [NotInParallel(new[] { DatabaseTest, RegistrationTest })] public async Task MyTest2() { - + } [Test] diff --git a/docs/docs/test-authoring/class-data-source.md b/docs/docs/test-authoring/class-data-source.md index 8461d81fcb..4b157bfb1d 100644 --- a/docs/docs/test-authoring/class-data-source.md +++ b/docs/docs/test-authoring/class-data-source.md @@ -63,6 +63,8 @@ public class MyTestClass If you are using an overload that supports injecting multiple classes at once (e.g. `ClassDataSource`) then you should specify multiple SharedTypes in an array and keys where applicable. +**Important:** The `Keys` array is **positional** - each index corresponds to the type at that position in the generic parameters. Only types with `SharedType.Keyed` need keys; other positions can be empty strings or omitted. + E.g. ```csharp @@ -70,7 +72,12 @@ E.g. [ClassDataSource ( Shared = [SharedType.PerTestSession, SharedType.Keyed, SharedType.PerClass, SharedType.Keyed, SharedType.None], - Keys = [ "Value2Key", "Value4Key" ] + Keys = ["", "Value2Key", "", "Value4Key", ""] + // Index 0: Value1 (PerTestSession) - empty string (no key needed) + // Index 1: Value2 (Keyed) - "Value2Key" + // Index 2: Value3 (PerClass) - empty string (no key needed) + // Index 3: Value4 (Keyed) - "Value4Key" + // Index 4: Value5 (None) - empty string (no key needed) )] public class MyType(Value1 value1, Value2 value2, Value3 value3, Value4 value4, Value5 value5) { diff --git a/docs/docs/test-authoring/matrix-tests.md b/docs/docs/test-authoring/matrix-tests.md index f9bb9d3d0a..1d7aa954af 100644 --- a/docs/docs/test-authoring/matrix-tests.md +++ b/docs/docs/test-authoring/matrix-tests.md @@ -96,7 +96,7 @@ public class MyTestClass [MatrixDataSource] public async Task MyTest( [MatrixRange(1, 10)] int value1, - [MatrixMethod(nameof(Numbers))] int value2 + [MatrixMethod(nameof(Numbers))] int value2 ) { var result = Add(value1, value2); diff --git a/docs/docs/test-lifecycle/cleanup.md b/docs/docs/test-lifecycle/cleanup.md index 9062d3b0c9..da9f4a988f 100644 --- a/docs/docs/test-lifecycle/cleanup.md +++ b/docs/docs/test-lifecycle/cleanup.md @@ -28,6 +28,59 @@ public async Task AsyncCleanup() // ✅ Valid - asynchronous hook - Use synchronous hooks for simple cleanup (disposing objects, resetting state, etc.) - `async void` hooks are **not allowed** and will cause a compiler error +### Hook Parameters + +Hooks can optionally accept parameters for accessing context information and cancellation tokens: + +```csharp +[After(Test)] +public async Task Cleanup(TestContext context, CancellationToken cancellationToken) +{ + // Access test results via context + if (context.Result?.Status == TestStatus.Failed) + { + await CaptureScreenshot(cancellationToken); + } +} + +[After(Class)] +public static async Task ClassCleanup(ClassHookContext context, CancellationToken cancellationToken) +{ + // Use cancellation token for timeout-aware cleanup operations + await DisposeResources(cancellationToken); +} + +[After(Test)] +public async Task CleanupWithToken(CancellationToken cancellationToken) +{ + // Can use CancellationToken without context + await FlushBuffers(cancellationToken); +} + +[After(Test)] +public async Task CleanupWithContext(TestContext context) +{ + // Can use context without CancellationToken + Console.WriteLine($"Test {context.TestDetails.TestName} completed"); +} +``` + +**Valid Parameter Combinations:** +- No parameters: `public void Hook() { }` +- Context only: `public void Hook(TestContext context) { }` +- CancellationToken only: `public async Task Hook(CancellationToken ct) { }` +- Both: `public async Task Hook(TestContext context, CancellationToken ct) { }` + +**Context Types by Hook Level:** + +| Hook Level | Context Type | Example | +|------------|-------------|---------| +| `[After(Test)]` | `TestContext` | Access test results, output writer | +| `[After(Class)]` | `ClassHookContext` | Access class information | +| `[After(Assembly)]` | `AssemblyHookContext` | Access assembly information | +| `[After(TestSession)]` | `TestSessionContext` | Access test session information | +| `[After(TestDiscovery)]` | `TestDiscoveryContext` | Access discovery results | + ## [After(HookType)] ### [After(Test)] diff --git a/docs/docs/test-lifecycle/setup.md b/docs/docs/test-lifecycle/setup.md index 2e82b2d2b0..8c74557de5 100644 --- a/docs/docs/test-lifecycle/setup.md +++ b/docs/docs/test-lifecycle/setup.md @@ -31,6 +31,59 @@ public async Task AsyncSetup() // ✅ Valid - asynchronous hook - Use synchronous hooks for simple setup (setting fields, initializing values, etc.) - `async void` hooks are **not allowed** and will cause a compiler error +### Hook Parameters + +Hooks can optionally accept parameters for accessing context information and cancellation tokens: + +```csharp +[Before(Test)] +public async Task Setup(TestContext context, CancellationToken cancellationToken) +{ + // Access test information via context + Console.WriteLine($"Setting up test: {context.TestDetails.TestName}"); + + // Use cancellation token for timeout-aware operations + await SomeLongRunningOperation(cancellationToken); +} + +[Before(Class)] +public static async Task ClassSetup(ClassHookContext context, CancellationToken cancellationToken) +{ + // Both context and cancellation token available for class-level hooks + await InitializeResources(cancellationToken); +} + +[Before(Test)] +public async Task SetupWithToken(CancellationToken cancellationToken) +{ + // Can use CancellationToken without context + await Task.Delay(100, cancellationToken); +} + +[Before(Test)] +public async Task SetupWithContext(TestContext context) +{ + // Can use context without CancellationToken + Console.WriteLine(context.TestDetails.TestName); +} +``` + +**Valid Parameter Combinations:** +- No parameters: `public void Hook() { }` +- Context only: `public void Hook(TestContext context) { }` +- CancellationToken only: `public async Task Hook(CancellationToken ct) { }` +- Both: `public async Task Hook(TestContext context, CancellationToken ct) { }` + +**Context Types by Hook Level:** + +| Hook Level | Context Type | Example | +|------------|-------------|---------| +| `[Before(Test)]` | `TestContext` | Access test details, output writer | +| `[Before(Class)]` | `ClassHookContext` | Access class information | +| `[Before(Assembly)]` | `AssemblyHookContext` | Access assembly information | +| `[Before(TestSession)]` | `TestSessionContext` | Access test session information | +| `[Before(TestDiscovery)]` | `BeforeTestDiscoveryContext` | Access discovery context | + ## [Before(HookType)] ### [Before(Test)] From b41d254e797dc8029de5d6194c09c070a075f4e7 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 18:04:36 +0000 Subject: [PATCH 13/14] Implement GetNestedClassName method and update test ID generation to use nested class names with '+' separator (#3708) --- .../Writers/FailedTestInitializationWriter.cs | 5 ++-- .../Extensions/TypeExtensions.cs | 23 +++++++++++++++++++ .../Services/TestIdentifierService.cs | 4 ++-- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/TUnit.Core.SourceGenerator/CodeGenerators/Writers/FailedTestInitializationWriter.cs b/TUnit.Core.SourceGenerator/CodeGenerators/Writers/FailedTestInitializationWriter.cs index cd18e57587..69471a7923 100644 --- a/TUnit.Core.SourceGenerator/CodeGenerators/Writers/FailedTestInitializationWriter.cs +++ b/TUnit.Core.SourceGenerator/CodeGenerators/Writers/FailedTestInitializationWriter.cs @@ -8,7 +8,8 @@ public static class FailedTestInitializationWriter public static void GenerateFailedTestCode(ICodeWriter sourceBuilder, DynamicTestSourceDataModel testSourceDataModel) { - var testId = $"{testSourceDataModel.Class.ContainingNamespace}.{testSourceDataModel.Class.Name}.{testSourceDataModel.Method.Name}_InitializationFailure"; + var className = testSourceDataModel.Class.GetNestedClassName(); + var testId = $"{testSourceDataModel.Class.ContainingNamespace}.{className}.{testSourceDataModel.Method.Name}_InitializationFailure"; sourceBuilder.Append("return"); sourceBuilder.Append("["); @@ -16,7 +17,7 @@ public static void GenerateFailedTestCode(ICodeWriter sourceBuilder, sourceBuilder.Append("{"); sourceBuilder.Append($"TestId = \"{testId}\","); sourceBuilder.Append($"MethodName = $\"{testSourceDataModel.Method.Name}\","); - sourceBuilder.Append($"Exception = new global::TUnit.Core.Exceptions.TestFailedInitializationException(\"{testSourceDataModel.Class.Name}.{testSourceDataModel.Method.Name} failed to initialize\", exception),"); + sourceBuilder.Append($"Exception = new global::TUnit.Core.Exceptions.TestFailedInitializationException(\"{className}.{testSourceDataModel.Method.Name} failed to initialize\", exception),"); sourceBuilder.Append($"TestFilePath = @\"{testSourceDataModel.FilePath}\","); sourceBuilder.Append($"TestLineNumber = {testSourceDataModel.LineNumber},"); sourceBuilder.Append("}"); diff --git a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs index d4b2266807..726185ffa0 100644 --- a/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs +++ b/TUnit.Core.SourceGenerator/Extensions/TypeExtensions.cs @@ -348,4 +348,27 @@ public static bool IsCollectionType(this ITypeSymbol typeSymbol, Compilation com innerType = null; return false; } + + /// + /// Gets the nested class name with '+' separator (matching .NET Type.FullName convention). + /// For example: OuterClass+InnerClass + /// + public static string GetNestedClassName(this INamedTypeSymbol typeSymbol) + { + var typeHierarchy = new List(); + var currentType = typeSymbol; + + // Walk up the containing type chain + while (currentType != null) + { + typeHierarchy.Add(currentType.Name); + currentType = currentType.ContainingType; + } + + // Reverse to get outer-to-inner order + typeHierarchy.Reverse(); + + // Join with '+' separator (matching .NET Type.FullName convention for nested types) + return string.Join("+", typeHierarchy); + } } diff --git a/TUnit.Engine/Services/TestIdentifierService.cs b/TUnit.Engine/Services/TestIdentifierService.cs index 5456917368..8a8504fad5 100644 --- a/TUnit.Engine/Services/TestIdentifierService.cs +++ b/TUnit.Engine/Services/TestIdentifierService.cs @@ -199,12 +199,12 @@ private static string GetTypeNameWithGenerics(Type type) // Reverse to get outer-to-inner order typeHierarchy.Reverse(); - // Append all types with dot separator + // Append all types with + separator (matching .NET Type.FullName convention for nested types) for (var i = 0; i < typeHierarchy.Count; i++) { if (i > 0) { - sb.Append('.'); + sb.Append('+'); } sb.Append(typeHierarchy[i]); } From 4bab94740a22aa956fe2593ac71b8e8792de2a5c Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 5 Nov 2025 20:33:08 +0000 Subject: [PATCH 14/14] +semver:major - Refactor TestContext and related interfaces for tidiness and cohesion (#3709) * Refactor TestContext and related interfaces to enhance event handling and service retrieval; add new output methods for better test output management. * Refactor TestContext and related interfaces for improved API consistency and type safety * Refactor TestContext interface to enhance event handling with nullable lazy-initialized properties; reorganize properties for improved clarity and consistency. * Refactor ITestMetadata and TestBuilderContext to change Id type from Guid to string; update TestContext and related files for consistency in ID handling. * Refactor ITestMetadata and TestBuilderContext to replace Id with DefinitionId for improved clarity; update related files for consistency in test identification. --- TUnit.Core/Contexts/TestRegisteredContext.cs | 2 +- .../Extensions/ServiceProviderExtensions.cs | 18 +++++ .../Extensions/TestContextExtensions.cs | 9 +-- TUnit.Core/Helpers/DataSourceHelpers.cs | 2 +- TUnit.Core/Interfaces/ITestDependencies.cs | 19 +++-- TUnit.Core/Interfaces/ITestEvents.cs | 34 +++++++- TUnit.Core/Interfaces/ITestMetadata.cs | 5 +- TUnit.Core/Interfaces/ITestOutput.cs | 16 ++++ TUnit.Core/Interfaces/ITestParallelization.cs | 7 -- TUnit.Core/Interfaces/ITestStateBag.cs | 54 +++++++++++-- TUnit.Core/TestBuilderContext.cs | 4 +- TUnit.Core/TestContext.Dependencies.cs | 8 +- TUnit.Core/TestContext.Events.cs | 26 +++++- TUnit.Core/TestContext.Metadata.cs | 2 +- TUnit.Core/TestContext.Output.cs | 5 +- TUnit.Core/TestContext.Parallelization.cs | 5 -- TUnit.Core/TestContext.StateBag.cs | 47 +++++++++++ TUnit.Core/TestContext.cs | 23 +++--- TUnit.Core/TestContextEvents.cs | 8 +- .../TestArgumentRegistrationService.cs | 8 +- .../Services/TestExecution/TestCoordinator.cs | 5 +- TUnit.Engine/TestInitializer.cs | 2 +- ...Has_No_API_Changes.DotNet10_0.verified.txt | 38 ++++++--- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 38 ++++++--- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 38 ++++++--- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 38 ++++++--- ...rContextsOnEnumerableDataGeneratorTests.cs | 8 +- .../testcontext-interface-organization.md | 79 +++++++++++++++---- 28 files changed, 408 insertions(+), 140 deletions(-) create mode 100644 TUnit.Core/Extensions/ServiceProviderExtensions.cs diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 53cfac1063..72eaad11c4 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -49,7 +49,7 @@ public void SetHookExecutor(IHookExecutor executor) /// public void SetParallelLimiter(IParallelLimit parallelLimit) { - TestContext.Parallelism.SetLimiter(parallelLimit); + TestContext.ParallelLimiter = parallelLimit; } /// diff --git a/TUnit.Core/Extensions/ServiceProviderExtensions.cs b/TUnit.Core/Extensions/ServiceProviderExtensions.cs new file mode 100644 index 0000000000..415fa11908 --- /dev/null +++ b/TUnit.Core/Extensions/ServiceProviderExtensions.cs @@ -0,0 +1,18 @@ +namespace TUnit.Core.Extensions; + +/// +/// Extension methods for . +/// +internal static class ServiceProviderExtensions +{ + /// + /// Gets a service of the specified type from the service provider. + /// + /// The type of service to retrieve + /// The service provider + /// The service instance, or null if not found + public static T? GetService(this IServiceProvider serviceProvider) where T : class + { + return serviceProvider.GetService(typeof(T)) as T; + } +} diff --git a/TUnit.Core/Extensions/TestContextExtensions.cs b/TUnit.Core/Extensions/TestContextExtensions.cs index 3c9f99f698..c51dd14951 100644 --- a/TUnit.Core/Extensions/TestContextExtensions.cs +++ b/TUnit.Core/Extensions/TestContextExtensions.cs @@ -6,11 +6,6 @@ namespace TUnit.Core.Extensions; public static class TestContextExtensions { - public static T? GetService(this TestContext context) where T : class - { - return context.GetService(); - } - public static string GetClassTypeName(this TestContext context) { var parameters = context.Metadata.TestDetails.MethodMetadata.Class.Parameters; @@ -51,7 +46,7 @@ public static string GetClassTypeName(this TestContext context) | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)] T>(this TestContext context, DynamicTest dynamicTest) where T : class { - await context.GetService()!.AddDynamicTest(context, dynamicTest);; + await context.Services.GetService()!.AddDynamicTest(context, dynamicTest);; } /// @@ -75,6 +70,6 @@ public static async Task CreateTestVariant( Enums.TestRelationship relationship = Enums.TestRelationship.Derived, string? displayName = null) { - await context.GetService()!.CreateTestVariant(context, arguments, properties, relationship, displayName); + await context.Services.GetService()!.CreateTestVariant(context, arguments, properties, relationship, displayName); } } diff --git a/TUnit.Core/Helpers/DataSourceHelpers.cs b/TUnit.Core/Helpers/DataSourceHelpers.cs index 7c8b439ecf..d77e6aff47 100644 --- a/TUnit.Core/Helpers/DataSourceHelpers.cs +++ b/TUnit.Core/Helpers/DataSourceHelpers.cs @@ -563,7 +563,7 @@ public static void RegisterTypeCreator(Func> dataSourceAttribute, TestContext.Current, TestContext.Current?.Metadata.TestDetails.ClassInstance, - TestContext.Current?.Events, + TestContext.Current?.InternalEvents, TestContext.Current?.StateBag.Items ?? new ConcurrentDictionary() ); diff --git a/TUnit.Core/Interfaces/ITestDependencies.cs b/TUnit.Core/Interfaces/ITestDependencies.cs index a2f0e78a1a..8f4ae67be6 100644 --- a/TUnit.Core/Interfaces/ITestDependencies.cs +++ b/TUnit.Core/Interfaces/ITestDependencies.cs @@ -25,17 +25,24 @@ public interface ITestDependencies TestRelationship Relationship { get; } /// - /// Gets tests matching the specified predicate. + /// Gets all registered tests that match the specified predicate. /// - IEnumerable GetTests(Func predicate); + /// The predicate to filter tests by. + /// A read-only list of matching test contexts. + IReadOnlyList GetTests(Func predicate); /// - /// Gets all tests with the specified name. + /// Gets all registered tests that match the specified test name. /// - List GetTests(string testName); + /// The name of the test method. + /// A read-only list of matching test contexts. + IReadOnlyList GetTests(string testName); /// - /// Gets all tests with the specified name and class type. + /// Gets all registered tests that match the specified test name and class type. /// - List GetTests(string testName, Type classType); + /// The name of the test method. + /// The type of the test class. + /// A read-only list of matching test contexts. + IReadOnlyList GetTests(string testName, Type classType); } diff --git a/TUnit.Core/Interfaces/ITestEvents.cs b/TUnit.Core/Interfaces/ITestEvents.cs index cfced760c6..2b9fd9d67a 100644 --- a/TUnit.Core/Interfaces/ITestEvents.cs +++ b/TUnit.Core/Interfaces/ITestEvents.cs @@ -7,7 +7,37 @@ namespace TUnit.Core.Interfaces; public interface ITestEvents { /// - /// Gets the event manager for this test, providing hooks for custom test lifecycle integration. + /// Gets the event that is raised when the test context is disposed. /// - TestContextEvents Events { get; } + AsyncEvent? OnDispose { get; } + + /// + /// Gets the event that is raised when the test has been registered with the test runner. + /// + AsyncEvent? OnTestRegistered { get; } + + /// + /// Gets the event that is raised before the test is initialized. + /// + AsyncEvent? OnInitialize { get; } + + /// + /// Gets the event that is raised before the test method is invoked. + /// + AsyncEvent? OnTestStart { get; } + + /// + /// Gets the event that is raised after the test method has completed. + /// + AsyncEvent? OnTestEnd { get; } + + /// + /// Gets the event that is raised if the test was skipped. + /// + AsyncEvent? OnTestSkipped { get; } + + /// + /// Gets the event that is raised before a test is retried. + /// + AsyncEvent<(TestContext TestContext, int RetryAttempt)>? OnTestRetry { get; } } diff --git a/TUnit.Core/Interfaces/ITestMetadata.cs b/TUnit.Core/Interfaces/ITestMetadata.cs index 3101f3cd51..5af5c04058 100644 --- a/TUnit.Core/Interfaces/ITestMetadata.cs +++ b/TUnit.Core/Interfaces/ITestMetadata.cs @@ -7,9 +7,10 @@ namespace TUnit.Core.Interfaces; public interface ITestMetadata { /// - /// Gets the unique identifier for this test instance. + /// Gets the unique identifier for the test definition (template/source) that generated this test. + /// This ID is shared across all instances of parameterized tests. /// - Guid Id { get; } + string DefinitionId { get; } /// /// Gets the detailed metadata about this test, including class type, method info, and arguments. diff --git a/TUnit.Core/Interfaces/ITestOutput.cs b/TUnit.Core/Interfaces/ITestOutput.cs index bfce5c5f33..3af148ff1f 100644 --- a/TUnit.Core/Interfaces/ITestOutput.cs +++ b/TUnit.Core/Interfaces/ITestOutput.cs @@ -58,4 +58,20 @@ public interface ITestOutput /// /// The accumulated error output string GetErrorOutput(); + + /// + /// Writes a line of text to standard output. + /// Convenience method for StandardOutput.WriteLine(message). + /// Thread-safe for concurrent calls. + /// + /// The message to write + void WriteLine(string message); + + /// + /// Writes a line of text to error output. + /// Convenience method for ErrorOutput.WriteLine(message). + /// Thread-safe for concurrent calls. + /// + /// The error message to write + void WriteError(string message); } diff --git a/TUnit.Core/Interfaces/ITestParallelization.cs b/TUnit.Core/Interfaces/ITestParallelization.cs index bdf9cb0acf..c323499a9f 100644 --- a/TUnit.Core/Interfaces/ITestParallelization.cs +++ b/TUnit.Core/Interfaces/ITestParallelization.cs @@ -23,7 +23,6 @@ public interface ITestParallelization /// /// Gets the parallel limiter that controls how many tests can run concurrently. - /// Returns null if no limiter is configured. /// IParallelLimit? Limiter { get; } @@ -33,10 +32,4 @@ public interface ITestParallelization /// /// The constraint to add void AddConstraint(IParallelConstraint constraint); - - /// - /// Sets the parallel limiter for this test. - /// - /// The parallel limit to apply - void SetLimiter(IParallelLimit parallelLimit); } diff --git a/TUnit.Core/Interfaces/ITestStateBag.cs b/TUnit.Core/Interfaces/ITestStateBag.cs index 3b4face009..1646c6e16a 100644 --- a/TUnit.Core/Interfaces/ITestStateBag.cs +++ b/TUnit.Core/Interfaces/ITestStateBag.cs @@ -1,18 +1,62 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; namespace TUnit.Core.Interfaces; /// -/// Provides access to the runtime state storage for test-scoped values. +/// Provides a type-safe, thread-safe bag for storing and retrieving custom state during a test's execution. /// Accessed via . -/// This is a thread-safe key-value store for sharing data between hooks, data sources, and test methods. /// public interface ITestStateBag { /// - /// Gets the thread-safe dictionary for storing arbitrary test-scoped data. - /// Use this to share state between hooks, data sources, and test methods within a single test execution. - /// Thread-safe for concurrent access. + /// Gets the underlying concurrent dictionary for direct access. /// ConcurrentDictionary Items { get; } + + /// + /// Gets or sets a value in the state bag. + /// + /// The key of the value to get or set. + /// The value associated with the specified key. + object? this[string key] { get; set; } + + /// + /// Gets the number of items in the state bag. + /// + int Count { get; } + + /// + /// Gets a value indicating whether the specified key exists in the state bag. + /// + /// The key to check. + /// true if the key exists; otherwise, false. + bool ContainsKey(string key); + + /// + /// Gets the value associated with the specified key, or adds it if it does not exist. + /// + /// The type of the value. + /// The key of the value to get or add. + /// The function used to generate a value for the key if it does not exist. + /// The value for the key. This will be either the existing value for the key if the key is already in the dictionary, or the new value if the key was not in the dictionary. + /// Thrown if a value already exists for the key but is not of type . + T GetOrAdd(string key, Func valueFactory); + + /// + /// Attempts to get the value associated with the specified key. + /// + /// The type of the value. + /// The key of the value to get. + /// When this method returns, contains the value associated with the specified key, if the key is found and the value is of the correct type; otherwise, the default value for the type of the value parameter. + /// true if the key was found and the value is of the correct type; otherwise, false. + bool TryGetValue(string key, [MaybeNullWhen(false)] out T value); + + /// + /// Attempts to remove a value with the specified key. + /// + /// The key of the element to remove. + /// When this method returns, contains the object removed from the bag, or null if the key does not exist. + /// true if the object was removed successfully; otherwise, false. + bool TryRemove(string key, [MaybeNullWhen(false)] out object? value); } diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs index 4aea369aa1..2efdc6ef4a 100644 --- a/TUnit.Core/TestBuilderContext.cs +++ b/TUnit.Core/TestBuilderContext.cs @@ -19,7 +19,7 @@ public static TestBuilderContext? Current internal set => BuilderContexts.Value = value; } - public Guid Id { get; } = Guid.NewGuid(); + public string DefinitionId { get; } = Guid.NewGuid().ToString(); public ConcurrentDictionary ObjectBag { get; set; } = new(); public TestContextEvents Events { get; set; } = new(); @@ -49,7 +49,7 @@ internal static TestBuilderContext FromTestContext(TestContext testContext, IDat { return new TestBuilderContext { - Events = testContext.Events, TestMetadata = testContext.Metadata.TestDetails.MethodMetadata, DataSourceAttribute = dataSourceAttribute, ObjectBag = testContext.StateBag.Items, + Events = testContext.InternalEvents, TestMetadata = testContext.Metadata.TestDetails.MethodMetadata, DataSourceAttribute = dataSourceAttribute, ObjectBag = testContext.StateBag.Items, }; } } diff --git a/TUnit.Core/TestContext.Dependencies.cs b/TUnit.Core/TestContext.Dependencies.cs index dc63689a22..82f5183a2a 100644 --- a/TUnit.Core/TestContext.Dependencies.cs +++ b/TUnit.Core/TestContext.Dependencies.cs @@ -14,11 +14,11 @@ public partial class TestContext string? ITestDependencies.ParentTestId => ParentTestId; TestRelationship ITestDependencies.Relationship => Relationship; - IEnumerable ITestDependencies.GetTests(Func predicate) => GetTests(predicate); - List ITestDependencies.GetTests(string testName) => GetTests(testName); - List ITestDependencies.GetTests(string testName, Type classType) => GetTests(testName, classType); + IReadOnlyList ITestDependencies.GetTests(Func predicate) => GetTests(predicate); + IReadOnlyList ITestDependencies.GetTests(string testName) => GetTests(testName); + IReadOnlyList ITestDependencies.GetTests(string testName, Type classType) => GetTests(testName, classType); - internal IEnumerable GetTests(Func predicate) + internal List GetTests(Func predicate) { var testFinder = ServiceProvider.GetService()!; diff --git a/TUnit.Core/TestContext.Events.cs b/TUnit.Core/TestContext.Events.cs index 702e959322..7a44ba1362 100644 --- a/TUnit.Core/TestContext.Events.cs +++ b/TUnit.Core/TestContext.Events.cs @@ -4,7 +4,29 @@ namespace TUnit.Core; public partial class TestContext { - internal TestContextEvents Events => _testBuilderContext.Events; + /// + /// For internal framework use only. External code should use the Events property which returns ITestEvents. + /// + internal TestContextEvents InternalEvents => _testBuilderContext.Events; - TestContextEvents ITestEvents.Events => Events; + /// + AsyncEvent? ITestEvents.OnDispose => _testBuilderContext.Events.OnDispose; + + /// + AsyncEvent? ITestEvents.OnTestRegistered => _testBuilderContext.Events.OnTestRegistered; + + /// + AsyncEvent? ITestEvents.OnInitialize => _testBuilderContext.Events.OnInitialize; + + /// + AsyncEvent? ITestEvents.OnTestStart => _testBuilderContext.Events.OnTestStart; + + /// + AsyncEvent? ITestEvents.OnTestEnd => _testBuilderContext.Events.OnTestEnd; + + /// + AsyncEvent? ITestEvents.OnTestSkipped => _testBuilderContext.Events.OnTestSkipped; + + /// + AsyncEvent<(TestContext TestContext, int RetryAttempt)>? ITestEvents.OnTestRetry => _testBuilderContext.Events.OnTestRetry; } diff --git a/TUnit.Core/TestContext.Metadata.cs b/TUnit.Core/TestContext.Metadata.cs index 0206d4d439..065523cb9c 100644 --- a/TUnit.Core/TestContext.Metadata.cs +++ b/TUnit.Core/TestContext.Metadata.cs @@ -54,7 +54,7 @@ internal void InvalidateDisplayNameCache() _cachedDisplayName = null; } - Guid ITestMetadata.Id => Id; + string ITestMetadata.DefinitionId => _testBuilderContext.DefinitionId; TestDetails ITestMetadata.TestDetails { get => TestDetails; diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index bbe9fad91f..c3a1f2f0f3 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -51,14 +51,13 @@ void ITestOutput.AttachArtifact(Artifact artifact) string ITestOutput.GetStandardOutput() => GetOutput(); string ITestOutput.GetErrorOutput() => GetErrorOutput(); - // Internal methods for output capture (used by base Context class) - internal void WriteLine(string message) + void ITestOutput.WriteLine(string message) { _outputWriter ??= new StringWriter(); _outputWriter.WriteLine(message); } - internal void WriteError(string message) + void ITestOutput.WriteError(string message) { _errorWriter ??= new StringWriter(); _errorWriter.WriteLine(message); diff --git a/TUnit.Core/TestContext.Parallelization.cs b/TUnit.Core/TestContext.Parallelization.cs index 762b06acce..c89e968265 100644 --- a/TUnit.Core/TestContext.Parallelization.cs +++ b/TUnit.Core/TestContext.Parallelization.cs @@ -24,9 +24,4 @@ void ITestParallelization.AddConstraint(IParallelConstraint constraint) _parallelConstraints.Add(constraint); } } - - void ITestParallelization.SetLimiter(IParallelLimit parallelLimit) - { - ParallelLimiter = parallelLimit; - } } diff --git a/TUnit.Core/TestContext.StateBag.cs b/TUnit.Core/TestContext.StateBag.cs index 20312230dc..5d7ef3d0ac 100644 --- a/TUnit.Core/TestContext.StateBag.cs +++ b/TUnit.Core/TestContext.StateBag.cs @@ -1,9 +1,56 @@ using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using TUnit.Core.Interfaces; namespace TUnit.Core; public partial class TestContext { + /// ConcurrentDictionary ITestStateBag.Items => ObjectBag; + + /// + object? ITestStateBag.this[string key] + { + get => ObjectBag[key]; + set => ObjectBag[key] = value; + } + + /// + int ITestStateBag.Count => ObjectBag.Count; + + /// + bool ITestStateBag.ContainsKey(string key) => ObjectBag.ContainsKey(key); + + /// + T ITestStateBag.GetOrAdd(string key, Func valueFactory) + { + var value = ObjectBag.GetOrAdd(key, k => valueFactory(k)!); + + if (value is T typedValue) + { + return typedValue; + } + + throw new InvalidCastException($"The value for key '{key}' is of type '{value?.GetType().Name}' and cannot be cast to '{typeof(T).Name}'."); + } + + /// + bool ITestStateBag.TryGetValue(string key, [MaybeNullWhen(false)] out T value) + { + if (ObjectBag.TryGetValue(key, out var objValue) && objValue is T typedValue) + { + value = typedValue; + return true; + } + + value = default; + return false; + } + + /// + bool ITestStateBag.TryRemove(string key, [MaybeNullWhen(false)] out object? value) + { + return ObjectBag.TryRemove(key, out value); + } } diff --git a/TUnit.Core/TestContext.cs b/TUnit.Core/TestContext.cs index 66d6da6b09..2956d78eb9 100644 --- a/TUnit.Core/TestContext.cs +++ b/TUnit.Core/TestContext.cs @@ -17,7 +17,7 @@ namespace TUnit.Core; public partial class TestContext : Context, ITestExecution, ITestParallelization, ITestOutput, ITestMetadata, ITestDependencies, ITestStateBag, ITestEvents { - private static readonly ConcurrentDictionary _testContextsById = new(); + private static readonly ConcurrentDictionary _testContextsById = new(); private readonly TestBuilderContext _testBuilderContext; private string? _cachedDisplayName; @@ -28,10 +28,13 @@ public TestContext(string testName, IServiceProvider serviceProvider, ClassHookC ServiceProvider = serviceProvider; ClassContext = classContext; - _testContextsById[_testBuilderContext.Id] = this; + // Generate unique ID for this test instance + Id = Guid.NewGuid().ToString(); + + _testContextsById[Id] = this; } - public Guid Id => _testBuilderContext.Id; + public string Id { get; } // Zero-allocation interface properties for organized API access public ITestExecution Execution => this; @@ -40,7 +43,9 @@ public TestContext(string testName, IServiceProvider serviceProvider, ClassHookC public ITestMetadata Metadata => this; public ITestDependencies Dependencies => this; public ITestStateBag StateBag => this; - public IServiceProvider Services => ServiceProvider; + public ITestEvents Events => this; + + internal IServiceProvider Services => ServiceProvider; private static readonly AsyncLocal TestContexts = new(); @@ -60,7 +65,7 @@ internal set } } - public static TestContext? GetById(Guid id) => _testContextsById.GetValueOrDefault(id); + public static TestContext? GetById(string id) => _testContextsById.GetValueOrDefault(id); public static IReadOnlyDictionary> Parameters => InternalParametersDictionary; @@ -98,7 +103,7 @@ public static string WorkingDirectory internal TestDetails TestDetails { get; set; } = null!; - internal IParallelLimit? ParallelLimiter { get; private set; } + internal IParallelLimit? ParallelLimiter { get; set; } internal Type? DisplayNameFormatter { get; set; } @@ -123,12 +128,6 @@ internal IServiceProvider ServiceProvider get; } - - public T? GetService() where T : class - { - return ServiceProvider.GetService(typeof(T)) as T; - } - internal override void SetAsyncLocalContext() { TestContexts.Value = this; diff --git a/TUnit.Core/TestContextEvents.cs b/TUnit.Core/TestContextEvents.cs index 48c122a99e..9ba1587ab6 100644 --- a/TUnit.Core/TestContextEvents.cs +++ b/TUnit.Core/TestContextEvents.cs @@ -1,9 +1,11 @@ +using TUnit.Core.Interfaces; + namespace TUnit.Core; /// -/// Simplified test context events +/// Test context events container /// -public class TestContextEvents +public class TestContextEvents : ITestEvents { public AsyncEvent? OnDispose { get; set; } public AsyncEvent? OnTestRegistered { get; set; } @@ -11,5 +13,5 @@ public class TestContextEvents public AsyncEvent? OnTestStart { get; set; } public AsyncEvent? OnTestEnd { get; set; } public AsyncEvent? OnTestSkipped { get; set; } - public AsyncEvent<(TestContext, int RetryAttempt)>? OnTestRetry { get; set; } + public AsyncEvent<(TestContext TestContext, int RetryAttempt)>? OnTestRetry { get; set; } } diff --git a/TUnit.Engine/Services/TestArgumentRegistrationService.cs b/TUnit.Engine/Services/TestArgumentRegistrationService.cs index 89cac70f2a..85ff2d26e4 100644 --- a/TUnit.Engine/Services/TestArgumentRegistrationService.cs +++ b/TUnit.Engine/Services/TestArgumentRegistrationService.cs @@ -46,14 +46,14 @@ await _objectRegistrationService.RegisterArgumentsAsync( classArguments, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, - testContext.Events); + testContext.InternalEvents); // Register method arguments (registration phase) await _objectRegistrationService.RegisterArgumentsAsync( methodArguments, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, - testContext.Events); + testContext.InternalEvents); // Register properties that will be injected into the test class await RegisterPropertiesAsync(testContext); @@ -95,7 +95,7 @@ private async ValueTask RegisterPropertiesAsync(TestContext testContext) { TestMetadata = testContext.Metadata.TestDetails.MethodMetadata, DataSourceAttribute = dataSource, - Events = testContext.Events, + Events = testContext.InternalEvents, ObjectBag = testContext.StateBag.Items }; @@ -132,7 +132,7 @@ await _objectRegistrationService.RegisterObjectAsync( data, testContext.StateBag.Items, testContext.Metadata.TestDetails.MethodMetadata, - testContext.Events); + testContext.InternalEvents); } } break; // Only take the first result for property injection diff --git a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs index ed4c2a4b61..c34181a332 100644 --- a/TUnit.Engine/Services/TestExecution/TestCoordinator.cs +++ b/TUnit.Engine/Services/TestExecution/TestCoordinator.cs @@ -134,9 +134,10 @@ await TimeoutHelper.ExecuteWithTimeoutAsync( { // Dispose test instance and fire OnDispose after each attempt // This ensures each retry gets a fresh instance - if (test.Context.Events.OnDispose?.InvocationList != null) + var onDispose = test.Context.InternalEvents.OnDispose; + if (onDispose?.InvocationList != null) { - foreach (var invocation in test.Context.Events.OnDispose.InvocationList) + foreach (var invocation in onDispose.InvocationList) { try { diff --git a/TUnit.Engine/TestInitializer.cs b/TUnit.Engine/TestInitializer.cs index 6c3f1a6f55..2c8e0c7156 100644 --- a/TUnit.Engine/TestInitializer.cs +++ b/TUnit.Engine/TestInitializer.cs @@ -29,7 +29,7 @@ await _propertyInjectionService.InjectPropertiesIntoObjectAsync( testClassInstance, test.Context.StateBag.Items, test.Context.Metadata.TestDetails.MethodMetadata, - test.Context.Events); + test.Context.InternalEvents); _eventReceiverOrchestrator.RegisterReceivers(test.Context, cancellationToken); diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 5123c8b109..25d13ea363 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1245,8 +1245,8 @@ namespace { public TestBuilderContext() { } public .IDataSourceAttribute? DataSourceAttribute { get; set; } + public string DefinitionId { get; } public .TestContextEvents Events { get; set; } - public Id { get; } public . ObjectBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1276,24 +1276,22 @@ namespace public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } public . Dependencies { get; } + public . Events { get; } public . Execution { get; } - public Id { get; } + public string Id { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } public . Parallelism { get; } - public Services { get; } public . StateBag { get; } public static . Configuration { get; } public new static .TestContext? Current { get; } public static string? OutputDirectory { get; } public static .> Parameters { get; } public static string WorkingDirectory { get; set; } - public T? GetService() - where T : class { } - public static .TestContext? GetById( id) { } + public static .TestContext? GetById(string id) { } } - public class TestContextEvents + public class TestContextEvents : . { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1301,7 +1299,7 @@ namespace public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } [.(new string[] { - null, + "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } @@ -1892,8 +1890,6 @@ namespace .Extensions [.("Creating test variants requires runtime compilation and reflection")] public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } - public static T? GetService(this .TestContext context) - where T : class { } } } namespace .Helpers @@ -2332,7 +2328,16 @@ namespace .Interfaces } public interface ITestEvents { - .TestContextEvents Events { get; } + .AsyncEvent<.TestContext>? OnDispose { get; } + .AsyncEvent<.TestContext>? OnInitialize { get; } + .AsyncEvent<.TestContext>? OnTestEnd { get; } + .AsyncEvent<.TestContext>? OnTestRegistered { get; } + [.(new string[] { + "TestContext", + "RetryAttempt"})] + .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; } + .AsyncEvent<.TestContext>? OnTestSkipped { get; } + .AsyncEvent<.TestContext>? OnTestStart { get; } } public interface ITestExecution { @@ -2371,9 +2376,9 @@ namespace .Interfaces } public interface ITestMetadata { + string DefinitionId { get; } string DisplayName { get; set; } ? DisplayNameFormatter { get; set; } - Id { get; } .TestDetails TestDetails { get; } string TestName { get; } } @@ -2400,6 +2405,8 @@ namespace .Interfaces string GetErrorOutput(); string GetStandardOutput(); void RecordTiming(.Timing timing); + void WriteError(string message); + void WriteLine(string message); } public interface ITestParallelization { @@ -2407,7 +2414,6 @@ namespace .Interfaces . ExecutionPriority { get; set; } .? Limiter { get; } void AddConstraint(. constraint); - void SetLimiter(. parallelLimit); } public interface ITestRegisteredEventReceiver : . { @@ -2437,7 +2443,13 @@ namespace .Interfaces } public interface ITestStateBag { + int Count { get; } + object? this[string key] { get; set; } . Items { get; } + bool ContainsKey(string key); + T GetOrAdd(string key, valueFactory); + bool TryGetValue(string key, [.(false)] out T value); + bool TryRemove(string key, [.(false)] out object? value); } public interface ITypedTestMetadata { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 244cd57cc3..37128c1376 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1245,8 +1245,8 @@ namespace { public TestBuilderContext() { } public .IDataSourceAttribute? DataSourceAttribute { get; set; } + public string DefinitionId { get; } public .TestContextEvents Events { get; set; } - public Id { get; } public . ObjectBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1276,24 +1276,22 @@ namespace public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } public . Dependencies { get; } + public . Events { get; } public . Execution { get; } - public Id { get; } + public string Id { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } public . Parallelism { get; } - public Services { get; } public . StateBag { get; } public static . Configuration { get; } public new static .TestContext? Current { get; } public static string? OutputDirectory { get; } public static .> Parameters { get; } public static string WorkingDirectory { get; set; } - public T? GetService() - where T : class { } - public static .TestContext? GetById( id) { } + public static .TestContext? GetById(string id) { } } - public class TestContextEvents + public class TestContextEvents : . { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1301,7 +1299,7 @@ namespace public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } [.(new string[] { - null, + "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } @@ -1892,8 +1890,6 @@ namespace .Extensions [.("Creating test variants requires runtime compilation and reflection")] public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } - public static T? GetService(this .TestContext context) - where T : class { } } } namespace .Helpers @@ -2332,7 +2328,16 @@ namespace .Interfaces } public interface ITestEvents { - .TestContextEvents Events { get; } + .AsyncEvent<.TestContext>? OnDispose { get; } + .AsyncEvent<.TestContext>? OnInitialize { get; } + .AsyncEvent<.TestContext>? OnTestEnd { get; } + .AsyncEvent<.TestContext>? OnTestRegistered { get; } + [.(new string[] { + "TestContext", + "RetryAttempt"})] + .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; } + .AsyncEvent<.TestContext>? OnTestSkipped { get; } + .AsyncEvent<.TestContext>? OnTestStart { get; } } public interface ITestExecution { @@ -2371,9 +2376,9 @@ namespace .Interfaces } public interface ITestMetadata { + string DefinitionId { get; } string DisplayName { get; set; } ? DisplayNameFormatter { get; set; } - Id { get; } .TestDetails TestDetails { get; } string TestName { get; } } @@ -2400,6 +2405,8 @@ namespace .Interfaces string GetErrorOutput(); string GetStandardOutput(); void RecordTiming(.Timing timing); + void WriteError(string message); + void WriteLine(string message); } public interface ITestParallelization { @@ -2407,7 +2414,6 @@ namespace .Interfaces . ExecutionPriority { get; set; } .? Limiter { get; } void AddConstraint(. constraint); - void SetLimiter(. parallelLimit); } public interface ITestRegisteredEventReceiver : . { @@ -2437,7 +2443,13 @@ namespace .Interfaces } public interface ITestStateBag { + int Count { get; } + object? this[string key] { get; set; } . Items { get; } + bool ContainsKey(string key); + T GetOrAdd(string key, valueFactory); + bool TryGetValue(string key, [.(false)] out T value); + bool TryRemove(string key, [.(false)] out object? value); } public interface ITypedTestMetadata { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 35f27e41d5..a9e36a8b75 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1245,8 +1245,8 @@ namespace { public TestBuilderContext() { } public .IDataSourceAttribute? DataSourceAttribute { get; set; } + public string DefinitionId { get; } public .TestContextEvents Events { get; set; } - public Id { get; } public . ObjectBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1276,24 +1276,22 @@ namespace public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } public . Dependencies { get; } + public . Events { get; } public . Execution { get; } - public Id { get; } + public string Id { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } public . Parallelism { get; } - public Services { get; } public . StateBag { get; } public static . Configuration { get; } public new static .TestContext? Current { get; } public static string? OutputDirectory { get; } public static .> Parameters { get; } public static string WorkingDirectory { get; set; } - public T? GetService() - where T : class { } - public static .TestContext? GetById( id) { } + public static .TestContext? GetById(string id) { } } - public class TestContextEvents + public class TestContextEvents : . { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1301,7 +1299,7 @@ namespace public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } [.(new string[] { - null, + "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } @@ -1892,8 +1890,6 @@ namespace .Extensions [.("Creating test variants requires runtime compilation and reflection")] public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } - public static T? GetService(this .TestContext context) - where T : class { } } } namespace .Helpers @@ -2332,7 +2328,16 @@ namespace .Interfaces } public interface ITestEvents { - .TestContextEvents Events { get; } + .AsyncEvent<.TestContext>? OnDispose { get; } + .AsyncEvent<.TestContext>? OnInitialize { get; } + .AsyncEvent<.TestContext>? OnTestEnd { get; } + .AsyncEvent<.TestContext>? OnTestRegistered { get; } + [.(new string[] { + "TestContext", + "RetryAttempt"})] + .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; } + .AsyncEvent<.TestContext>? OnTestSkipped { get; } + .AsyncEvent<.TestContext>? OnTestStart { get; } } public interface ITestExecution { @@ -2371,9 +2376,9 @@ namespace .Interfaces } public interface ITestMetadata { + string DefinitionId { get; } string DisplayName { get; set; } ? DisplayNameFormatter { get; set; } - Id { get; } .TestDetails TestDetails { get; } string TestName { get; } } @@ -2400,6 +2405,8 @@ namespace .Interfaces string GetErrorOutput(); string GetStandardOutput(); void RecordTiming(.Timing timing); + void WriteError(string message); + void WriteLine(string message); } public interface ITestParallelization { @@ -2407,7 +2414,6 @@ namespace .Interfaces . ExecutionPriority { get; set; } .? Limiter { get; } void AddConstraint(. constraint); - void SetLimiter(. parallelLimit); } public interface ITestRegisteredEventReceiver : . { @@ -2437,7 +2443,13 @@ namespace .Interfaces } public interface ITestStateBag { + int Count { get; } + object? this[string key] { get; set; } . Items { get; } + bool ContainsKey(string key); + T GetOrAdd(string key, valueFactory); + bool TryGetValue(string key, [.(false)] out T value); + bool TryRemove(string key, [.(false)] out object? value); } public interface ITypedTestMetadata { diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index f34a16e691..9f1ea00c1a 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1200,8 +1200,8 @@ namespace { public TestBuilderContext() { } public .IDataSourceAttribute? DataSourceAttribute { get; set; } + public string DefinitionId { get; } public .TestContextEvents Events { get; set; } - public Id { get; } public . ObjectBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1231,24 +1231,22 @@ namespace public TestContext(string testName, serviceProvider, .ClassHookContext classContext, .TestBuilderContext testBuilderContext, .CancellationToken cancellationToken) { } public .ClassHookContext ClassContext { get; } public . Dependencies { get; } + public . Events { get; } public . Execution { get; } - public Id { get; } + public string Id { get; } public object Lock { get; } public . Metadata { get; } public . Output { get; } public . Parallelism { get; } - public Services { get; } public . StateBag { get; } public static . Configuration { get; } public new static .TestContext? Current { get; } public static string? OutputDirectory { get; } public static .> Parameters { get; } public static string WorkingDirectory { get; set; } - public T? GetService() - where T : class { } - public static .TestContext? GetById( id) { } + public static .TestContext? GetById(string id) { } } - public class TestContextEvents + public class TestContextEvents : . { public TestContextEvents() { } public .AsyncEvent<.TestContext>? OnDispose { get; set; } @@ -1256,7 +1254,7 @@ namespace public .AsyncEvent<.TestContext>? OnTestEnd { get; set; } public .AsyncEvent<.TestContext>? OnTestRegistered { get; set; } [.(new string[] { - null, + "TestContext", "RetryAttempt"})] public .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; set; } public .AsyncEvent<.TestContext>? OnTestSkipped { get; set; } @@ -1842,8 +1840,6 @@ namespace .Extensions where T : class { } public static . CreateTestVariant(this .TestContext context, object?[]? arguments = null, .? properties = null, . relationship = 3, string? displayName = null) { } public static string GetClassTypeName(this .TestContext context) { } - public static T? GetService(this .TestContext context) - where T : class { } } } namespace .Helpers @@ -2263,7 +2259,16 @@ namespace .Interfaces } public interface ITestEvents { - .TestContextEvents Events { get; } + .AsyncEvent<.TestContext>? OnDispose { get; } + .AsyncEvent<.TestContext>? OnInitialize { get; } + .AsyncEvent<.TestContext>? OnTestEnd { get; } + .AsyncEvent<.TestContext>? OnTestRegistered { get; } + [.(new string[] { + "TestContext", + "RetryAttempt"})] + .AsyncEvent<<.TestContext, int>>? OnTestRetry { get; } + .AsyncEvent<.TestContext>? OnTestSkipped { get; } + .AsyncEvent<.TestContext>? OnTestStart { get; } } public interface ITestExecution { @@ -2302,9 +2307,9 @@ namespace .Interfaces } public interface ITestMetadata { + string DefinitionId { get; } string DisplayName { get; set; } ? DisplayNameFormatter { get; set; } - Id { get; } .TestDetails TestDetails { get; } string TestName { get; } } @@ -2331,6 +2336,8 @@ namespace .Interfaces string GetErrorOutput(); string GetStandardOutput(); void RecordTiming(.Timing timing); + void WriteError(string message); + void WriteLine(string message); } public interface ITestParallelization { @@ -2338,7 +2345,6 @@ namespace .Interfaces . ExecutionPriority { get; set; } .? Limiter { get; } void AddConstraint(. constraint); - void SetLimiter(. parallelLimit); } public interface ITestRegisteredEventReceiver : . { @@ -2364,7 +2370,13 @@ namespace .Interfaces } public interface ITestStateBag { + int Count { get; } + object? this[string key] { get; set; } . Items { get; } + bool ContainsKey(string key); + T GetOrAdd(string key, valueFactory); + bool TryGetValue(string key, [.(false)] out T value); + bool TryRemove(string key, [.(false)] out object? value); } public interface ITypedTestMetadata { diff --git a/TUnit.TestProject/UniqueBuilderContextsOnEnumerableDataGeneratorTests.cs b/TUnit.TestProject/UniqueBuilderContextsOnEnumerableDataGeneratorTests.cs index 4cef7c675f..b792103e31 100644 --- a/TUnit.TestProject/UniqueBuilderContextsOnEnumerableDataGeneratorTests.cs +++ b/TUnit.TestProject/UniqueBuilderContextsOnEnumerableDataGeneratorTests.cs @@ -16,16 +16,16 @@ public class UniqueBuilderContextsOnEnumerableDataGeneratorTestsGenerator : Data { protected override IEnumerable> GenerateDataSources(DataGeneratorMetadata dataGeneratorMetadata) { - var id1 = dataGeneratorMetadata.TestBuilderContext.Current.Id; - var id2 = dataGeneratorMetadata.TestBuilderContext.Current.Id; + var id1 = dataGeneratorMetadata.TestBuilderContext.Current.DefinitionId; + var id2 = dataGeneratorMetadata.TestBuilderContext.Current.DefinitionId; yield return () => 1; - var id3 = dataGeneratorMetadata.TestBuilderContext.Current.Id; + var id3 = dataGeneratorMetadata.TestBuilderContext.Current.DefinitionId; yield return () => 2; - var id4 = dataGeneratorMetadata.TestBuilderContext.Current.Id; + var id4 = dataGeneratorMetadata.TestBuilderContext.Current.DefinitionId; id1.ShouldBe(id2); diff --git a/docs/docs/migration/testcontext-interface-organization.md b/docs/docs/migration/testcontext-interface-organization.md index 50b879f1ca..a408d112cc 100644 --- a/docs/docs/migration/testcontext-interface-organization.md +++ b/docs/docs/migration/testcontext-interface-organization.md @@ -30,7 +30,8 @@ public partial class TestContext : public ITestDependencies Dependencies => this; public ITestStateBag StateBag => this; public ITestEvents Events => this; - public IServiceProvider Services => ServiceProvider; + + // Note: Services property is internal - use dependency injection instead } ``` @@ -62,15 +63,24 @@ Several properties have been moved from the main `TestContext` class into their - `DisplayNameFormatter` - Custom display name formatter type **Existing members:** -- `Id` - Unique identifier for this test instance - `TestDetails` - Detailed metadata about the test - `TestName` - Base name of the test method - `DisplayName` - Display name for the test (get/set) +**Note:** `Id` is now a public property directly on `TestContext`, not on `ITestMetadata`. + #### `ITestEvents` - Test Event Integration -**New interface** exposing: -- `Events` - Event manager for test lifecycle integration +**New interface** exposing nullable event properties for lazy initialization: +- `OnDispose` - Event raised when test context is disposed +- `OnTestRegistered` - Event raised when test is registered +- `OnInitialize` - Event raised before test initialization +- `OnTestStart` - Event raised before test method execution +- `OnTestEnd` - Event raised after test method completion +- `OnTestSkipped` - Event raised when test is skipped +- `OnTestRetry` - Event raised before test retry + +All events are nullable (`AsyncEvent?`) to avoid allocating unused event handlers. ## Migration Steps @@ -114,16 +124,24 @@ TestContext.Current.Metadata.DisplayNameFormatter = typeof(MyFormatter); #### Event Access +Events are now accessed directly through the `Events` interface property, and all events are nullable for lazy initialization: + **Before:** ```csharp -// ❌ Old - Direct access +// ❌ Old - Accessing through a nested Events property TestContext.Current.Events.OnTestStart += handler; ``` **After:** ```csharp -// ✅ New - Through Events interface (note: access is still the same, but now properly exposed through interface) +// ✅ New - Direct access to nullable event properties TestContext.Current.Events.OnTestStart += handler; + +// Events are nullable and lazily initialized +if (TestContext.Current.Events.OnTestStart != null) +{ + await TestContext.Current.Events.OnTestStart.InvokeAsync(testContext, testContext); +} ``` ### Custom Hook Executors @@ -304,7 +322,6 @@ Test metadata and identity: ```csharp public interface ITestMetadata { - Guid Id { get; } TestDetails TestDetails { get; } string TestName { get; } string DisplayName { get; set; } @@ -312,17 +329,27 @@ public interface ITestMetadata } ``` +**Note:** `Id` is available only through the `ITestMetadata` interface (accessed via `TestContext.Metadata.Id`), not as a direct property on `TestContext`. + ### ITestEvents -Test event integration: +Test event integration with nullable lazy-initialized event properties: ```csharp public interface ITestEvents { - TestContextEvents Events { get; } + AsyncEvent? OnDispose { get; } + AsyncEvent? OnTestRegistered { get; } + AsyncEvent? OnInitialize { get; } + AsyncEvent? OnTestStart { get; } + AsyncEvent? OnTestEnd { get; } + AsyncEvent? OnTestSkipped { get; } + AsyncEvent<(TestContext TestContext, int RetryAttempt)>? OnTestRetry { get; } } ``` +**Important:** All event properties are nullable to enable lazy initialization. Events are only allocated when subscribers are added, avoiding unnecessary allocations for unused events. + ### Other Interfaces For completeness, here are the other interface properties available: @@ -344,7 +371,21 @@ public interface ITestOutput ```csharp public interface ITestParallelization { - // Parallelization configuration + IReadOnlyList Constraints { get; } + Priority ExecutionPriority { get; set; } + IParallelLimit? Limiter { get; } // Read-only - use TestRegisteredContext to set + void AddConstraint(IParallelConstraint constraint); +} +``` + +**Important:** The `Limiter` property is **read-only** on the public interface. To set the parallel limiter, use the phase-specific `TestRegisteredContext.SetParallelLimiter()` method during test registration: + +```csharp +[TestRegistered] +public static void OnTestRegistered(TestRegisteredContext context) +{ + // ✅ Correct - Use phase-specific context + context.SetParallelLimiter(new ParallelLimit3()); } ``` @@ -353,21 +394,31 @@ public interface ITestParallelization ```csharp public interface ITestDependencies { - IEnumerable GetTests(Func predicate); - List GetTests(string testName); - List GetTests(string testName, Type classType); + IReadOnlyList GetTests(Func predicate); + IReadOnlyList GetTests(string testName); + IReadOnlyList GetTests(string testName, Type classType); } ``` +**Changed:** All `GetTests` methods now return `IReadOnlyList` for consistency and to better express the immutable nature of the returned collection. + #### ITestStateBag ```csharp public interface ITestStateBag { - ConcurrentDictionary ObjectBag { get; } + ConcurrentDictionary Items { get; } + object? this[string key] { get; set; } + int Count { get; } + bool ContainsKey(string key); + T GetOrAdd(string key, Func valueFactory); + bool TryGetValue(string key, out T value); + bool TryRemove(string key, out object? value); } ``` +The `StateBag` interface provides both direct dictionary access via `Items` and type-safe helper methods for common operations. + ## Summary The TestContext interface organization provides: