From 6f5e56985c54ded3e86163f13b2a7deffa7fbf6a Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 27 Feb 2026 10:08:58 +0000
Subject: [PATCH 1/3] docs: consolidate hooks, trim duplication, and
restructure sidebar
Merge hooks-setup.md and hooks-cleanup.md into a single hooks.md,
add a data-driven testing overview page, trim verbose tabbed examples
across 5 files, consolidate duplicated "awaiting assertions" content
into links to the canonical page, and restructure the sidebar
(merge Comparing + Migration, relocate Guides pages, add Test Data
overview entry).
Net reduction of ~750 lines while preserving all content.
---
docs/docs/assertions/getting-started.md | 146 +-------
.../writing-your-first-test.md | 9 +-
docs/docs/guides/best-practices.md | 22 +-
docs/docs/intro.md | 4 +-
docs/docs/troubleshooting.md | 6 +-
docs/docs/writing-tests/artifacts.md | 2 +-
.../writing-tests/data-driven-overview.md | 80 +++++
docs/docs/writing-tests/hooks-cleanup.md | 119 -------
docs/docs/writing-tests/hooks-setup.md | 332 ------------------
docs/docs/writing-tests/hooks.md | 227 ++++++++++++
docs/docs/writing-tests/lifecycle.md | 3 +-
docs/docs/writing-tests/property-injection.md | 117 +-----
docs/docs/writing-tests/things-to-know.md | 40 +--
docs/sidebars.ts | 30 +-
14 files changed, 348 insertions(+), 789 deletions(-)
create mode 100644 docs/docs/writing-tests/data-driven-overview.md
delete mode 100644 docs/docs/writing-tests/hooks-cleanup.md
delete mode 100644 docs/docs/writing-tests/hooks-setup.md
create mode 100644 docs/docs/writing-tests/hooks.md
diff --git a/docs/docs/assertions/getting-started.md b/docs/docs/assertions/getting-started.md
index 9900306af2..3ab6ada819 100644
--- a/docs/docs/assertions/getting-started.md
+++ b/docs/docs/assertions/getting-started.md
@@ -21,20 +21,7 @@ The basic flow is:
## Why Await?
-TUnit assertions must be awaited. This design enables:
-- **Async support**: Seamlessly test async operations
-- **Rich error messages**: Build detailed failure messages during execution
-- **Extensibility**: Create custom assertions that can perform async operations
-
-```csharp
-// ✅ Correct - awaited
-await Assert.That(result).IsEqualTo(42);
-
-// ❌ Wrong - will cause compiler warning
-Assert.That(result).IsEqualTo(42);
-```
-
-TUnit includes a built-in analyzer that warns you if you forget to `await` an assertion.
+TUnit assertions must be awaited — they won't execute without `await`, and a built-in analyzer warns if you forget. See [Awaiting Assertions](awaiting.md) for detailed examples and design rationale.
## Assertion Categories
@@ -250,133 +237,12 @@ await Assert.That(number).IsEqualTo(42);
// await Assert.That(number).IsEqualTo("42");
```
-## Common Mistakes & Best Practices
-
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
-### Forgetting to Await
-
-
-
-
-```csharp
-[Test]
-public void TestValue()
-{
- // Compiler warning: assertion not awaited
- Assert.That(result).IsEqualTo(42);
-}
-```
-
-**Problem:** Assertion never executes, test always passes even if it should fail.
-
-
-
-
-```csharp
-[Test]
-public async Task TestValue()
-{
- await Assert.That(result).IsEqualTo(42);
-}
-```
-
-**Why:** Awaiting ensures the assertion executes and can fail the test.
-
-
-
-
-### Comparing Different Types
-
-
-
-
-```csharp
-int number = 42;
-// This won't compile - can't compare int to string
-// await Assert.That(number).IsEqualTo("42");
-
-// Or this pattern that converts implicitly
-string value = GetValue();
-await Assert.That(value).IsEqualTo(42); // Won't compile
-```
-
-**Problem:** Type mismatches are caught at compile time, preventing runtime surprises.
-
-
-
-
-```csharp
-string value = GetValue();
-int parsed = int.Parse(value);
-await Assert.That(parsed).IsEqualTo(42);
-
-// Or test the string directly
-await Assert.That(value).IsEqualTo("42");
-```
-
-**Why:** Be explicit about what you're testing - the string value or its parsed equivalent.
-
-
-
-
-### Collection Ordering
-
-
-
-
-```csharp
-var items = GetItemsFromDatabase(); // Order not guaranteed
-await Assert.That(items).IsEqualTo(new[] { 1, 2, 3 });
-```
-
-**Problem:** Fails unexpectedly if database returns `[3, 1, 2]` even though items are equivalent.
-
-
-
-
-```csharp
-var items = GetItemsFromDatabase();
-await Assert.That(items).IsEquivalentTo(new[] { 1, 2, 3 });
-```
-
-**Why:** `IsEquivalentTo` checks for same items regardless of order, making tests more resilient.
-
-
-
-
-### Multiple Related Assertions
-
-
-
-
-```csharp
-await Assert.That(user.FirstName).IsEqualTo("John");
-await Assert.That(user.LastName).IsEqualTo("Doe");
-await Assert.That(user.Age).IsGreaterThan(18);
-// If first assertion fails, you won't see the other failures
-```
-
-**Problem:** Stops at first failure, hiding other issues.
-
-
-
-
-```csharp
-using (Assert.Multiple())
-{
- await Assert.That(user.FirstName).IsEqualTo("John");
- await Assert.That(user.LastName).IsEqualTo("Doe");
- await Assert.That(user.Age).IsGreaterThan(18);
-}
-// Shows ALL failures at once
-```
-
-**Why:** See all failures in one test run, saving debugging time.
+## Common Mistakes
-
-
+- **Forgetting `await`** — Unawaited assertions never execute; the test passes silently. Always `await Assert.That(...)`. The compiler warns about this, but it's the most common TUnit mistake. See [Awaiting Assertions](awaiting.md).
+- **Type confusion** — `Assert.That(number).IsEqualTo("42")` won't compile. TUnit assertions are strongly typed. Convert explicitly before asserting.
+- **Assuming collection order** — Use `IsEquivalentTo()` instead of `IsEqualTo()` when item order doesn't matter (e.g., database results).
+- **Sequential assertions hiding failures** — Wrap related assertions in `using (Assert.Multiple()) { ... }` to see all failures at once instead of stopping at the first.
## Next Steps
diff --git a/docs/docs/getting-started/writing-your-first-test.md b/docs/docs/getting-started/writing-your-first-test.md
index e47d91ea97..dafd790215 100644
--- a/docs/docs/getting-started/writing-your-first-test.md
+++ b/docs/docs/getting-started/writing-your-first-test.md
@@ -90,12 +90,9 @@ public async Task AsyncTestWithAssertions() // ✅ Recommended - asynchronous t
}
```
-**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
+:::tip Assertions Must Be Awaited
+If you use `Assert.That(...)`, your test **must** be `async Task` — assertions are awaitable and won't execute without `await`. `async void` tests are not allowed. See [Awaiting Assertions](../assertions/awaiting.md) for details.
+:::
Let's add some code to show you how a test might look once finished:
diff --git a/docs/docs/guides/best-practices.md b/docs/docs/guides/best-practices.md
index d152bc15a4..3d8dd4790b 100644
--- a/docs/docs/guides/best-practices.md
+++ b/docs/docs/guides/best-practices.md
@@ -4,27 +4,7 @@ TUnit-specific tips to avoid common mistakes.
## Always Await Assertions
-TUnit assertions are async and return `Task`. Forgetting `await` means the assertion never executes — the test passes silently:
-
-```csharp
-// Wrong: assertion is never checked
-[Test]
-public async Task MyTest()
-{
- Assert.That(result).IsEqualTo(5); // passes without checking!
-}
-
-// Correct: assertion is awaited
-[Test]
-public async Task MyTest()
-{
- await Assert.That(result).IsEqualTo(5);
-}
-```
-
-The compiler warns about unawaited tasks, but this remains the most common TUnit mistake.
-
-See [Awaiting Assertions](../assertions/awaiting.md) for details.
+TUnit assertions won't execute without `await` — the test passes silently. A built-in analyzer warns about this, but it remains the most common TUnit mistake. See [Awaiting Assertions](../assertions/awaiting.md) for details and examples.
## New Instance Per Test
diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index 838d0d5de4..e07b3c1a55 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -19,6 +19,4 @@ TUnit is designed for speed. Through source generation and compile-time optimiza
- **[Running Tests](execution/test-filters.md)** — Filters, timeouts, retries, CI/CD reporting, and AOT
- **[Integrations](examples/aspnet.md)** — ASP.NET Core, Aspire, Playwright, and other integration examples
- **[Extending TUnit](extending/extension-points.md)** — Custom data sources, formatters, and event subscribers
-- **[Comparing Frameworks](comparison/framework-differences.md)** — Feature comparisons with xUnit, NUnit, and MSTest
-- **[Migration](migration/xunit.md)** — Step-by-step guides for switching frameworks
-- **[Guides](guides/best-practices.md)** — Tips, performance guidance, and philosophy
+- **[Comparing & Migrating](comparison/framework-differences.md)** — Feature comparisons and step-by-step migration guides for xUnit, NUnit, and MSTest
diff --git a/docs/docs/troubleshooting.md b/docs/docs/troubleshooting.md
index 42e8f48f56..a91ac30d93 100644
--- a/docs/docs/troubleshooting.md
+++ b/docs/docs/troubleshooting.md
@@ -8,11 +8,7 @@ These are conceptual questions about TUnit's design and capabilities.
### Why do I have to await all assertions? Can I use synchronous assertions?
-All TUnit assertions must be awaited. Assertions don't execute until awaited — forgetting `await` means the test passes without checking anything. The compiler warns about unawaited tasks, but watch for this common mistake.
-
-If your test uses `Assert.That(...)`, the method **must** be `async Task`. Tests without assertions can remain synchronous. See [Awaiting Assertions](assertions/awaiting.md) for details, examples, and the design rationale.
-
-For migrating from other frameworks, TUnit includes code fixers that automate the conversion — see the [xUnit](migration/xunit.md#automated-migration-with-code-fixers), [NUnit](migration/nunit.md#automated-migration-with-code-fixers), or [MSTest](migration/mstest.md#automated-migration-with-code-fixers) migration guides.
+Assertions don't execute until awaited — forgetting `await` means the test passes silently. See [Awaiting Assertions](assertions/awaiting.md) for details. For migrating from other frameworks, TUnit includes code fixers — see the [xUnit](migration/xunit.md#automated-migration-with-code-fixers), [NUnit](migration/nunit.md#automated-migration-with-code-fixers), or [MSTest](migration/mstest.md#automated-migration-with-code-fixers) migration guides.
### Does TUnit work with Coverlet for code coverage?
diff --git a/docs/docs/writing-tests/artifacts.md b/docs/docs/writing-tests/artifacts.md
index 71cce9a3db..a260300f0c 100644
--- a/docs/docs/writing-tests/artifacts.md
+++ b/docs/docs/writing-tests/artifacts.md
@@ -446,5 +446,5 @@ The exact behavior depends on your test runner configuration and CI/CD platform.
## See Also
- [Test Context](./test-context.md) - Overview of TestContext
-- [Test Lifecycle Hooks](./hooks-setup.md) - Using Before/After hooks
+- [Hooks](./hooks.md) - Using Before/After hooks
- [CI/CD Reporting](../execution/ci-cd-reporting.md) - Integrating with CI systems
diff --git a/docs/docs/writing-tests/data-driven-overview.md b/docs/docs/writing-tests/data-driven-overview.md
new file mode 100644
index 0000000000..ec297a2f47
--- /dev/null
+++ b/docs/docs/writing-tests/data-driven-overview.md
@@ -0,0 +1,80 @@
+# Choosing a Data Approach
+
+TUnit offers several ways to provide data to your tests. Use this guide to pick the right one.
+
+## Decision Table
+
+| Scenario | Approach | Page |
+|----------|----------|------|
+| Fixed inline values | `[Arguments(...)]` | [Arguments](arguments.md) |
+| Data from a method | `[MethodDataSource]` | [Method Data Sources](method-data-source.md) |
+| Shared object with lifecycle | `[ClassDataSource]` | [Class Data Source](class-data-source.md) |
+| Reusable data rows | `[TestDataRow]` | [Test Data Row](test-data-row.md) |
+| All parameter combinations | `[Matrix]` | [Matrix Tests](matrix-tests.md) |
+| Multiple sources on one method | Combined attributes | [Combined Data Sources](combined-data-source.md) |
+| Hierarchical injection | Nested properties | [Nested Data Sources](nested-data-sources.md) |
+| Custom generic attributes | `[GenericArguments]` | [Generic Attributes](generic-attributes.md) |
+
+## Quick Examples
+
+### Inline arguments
+
+```csharp
+[Test]
+[Arguments(1, 2, 3)]
+[Arguments(0, 0, 0)]
+public async Task Add_ReturnsSum(int a, int b, int expected)
+{
+ await Assert.That(a + b).IsEqualTo(expected);
+}
+```
+
+### Method data source
+
+```csharp
+[Test]
+[MethodDataSource(nameof(GetCases))]
+public async Task MyTest(string input)
+{
+ await Assert.That(input).IsNotEmpty();
+}
+
+public static IEnumerable GetCases() => ["hello", "world"];
+```
+
+### Class data source (shared fixture)
+
+```csharp
+[ClassDataSource(Shared = SharedType.PerTestSession)]
+public class MyTests(DatabaseFixture db)
+{
+ [Test]
+ public async Task QueryWorks()
+ {
+ var result = await db.QueryAsync("SELECT 1");
+ await Assert.That(result).IsNotNull();
+ }
+}
+```
+
+### Matrix (combinatorial)
+
+```csharp
+[Test]
+[Matrix]
+public async Task Multiply(
+ [Matrix(2, 3)] int a,
+ [Matrix(4, 5)] int b)
+{
+ await Assert.That(a * b).IsGreaterThan(0);
+}
+// Generates: (2,4), (2,5), (3,4), (3,5)
+```
+
+## Quick Rules
+
+- **`[Arguments]`** is the simplest — use it when values are known at compile time.
+- **`[MethodDataSource]`** is best for computed or complex data.
+- **`[ClassDataSource]`** manages object lifecycles (initialization, disposal, sharing across tests).
+- **`[Matrix]`** generates the Cartesian product of all parameter values.
+- Attributes can be combined on a single method — see [Combined Data Sources](combined-data-source.md).
diff --git a/docs/docs/writing-tests/hooks-cleanup.md b/docs/docs/writing-tests/hooks-cleanup.md
deleted file mode 100644
index 0a10553ecf..0000000000
--- a/docs/docs/writing-tests/hooks-cleanup.md
+++ /dev/null
@@ -1,119 +0,0 @@
-# Cleanup Hooks
-
-TUnit supports having your test class implement `IDisposable` or `IAsyncDisposable`. These will be called after your test has finished executing. However, using the attributes below offers better support for running multiple methods, and without having to implement your own try/catch logic. Every `[After]` method will be run, and any exceptions will be lazily thrown afterwards.
-
-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
-
-### Hook Parameters
-
-:::info
-`[After]` hooks accept the same parameters as `[Before]` hooks. See [Setup Hooks — Hook Parameters](hooks-setup.md#hook-parameters) for the full reference.
-:::
-
-A common pattern in cleanup hooks is checking the test result to perform conditional cleanup:
-
-```csharp
-[After(Test)]
-public async Task Cleanup(TestContext context, CancellationToken cancellationToken)
-{
- // Access test results via context
- if (context.Execution.Result?.State == TestState.Failed)
- {
- await CaptureScreenshot(cancellationToken);
- }
-}
-```
-
-## [After(HookType)]
-
-### [After(Test)]
-Must be an instance method. Will be executed after each test in the class it's defined in.
-Methods will be executed top-down, so the current class clean ups will execute first, then the base classes' last.
-
-### [After(Class)]
-Must be a static method. Will run once after the last test in the class it's defined in finishes.
-
-### [After(Assembly)]
-Must be a static method. Will run once after the last test in the assembly it's defined in finishes.
-
-### [After(TestSession)]
-Must be a static method. Will run once after the last test in the test session finishes.
-
-### [After(TestDiscovery)]
-Must be a static method. Will run once after tests are discovered.
-
-## [AfterEvery(HookType)]
-All [AfterEvery(...)] methods must be static. They should ideally be placed in their own file that's easy to find, as they can globally affect the test suite, so it should be easy for developers to locate this behaviour.
-e.g. `GlobalHooks.cs` at the root of the test project.
-
-:::info
-Use `[AfterEvery(...)]` for global clean-up logic that should run after every test/class/assembly/session, regardless of where the test is defined.
-:::
-
-### [AfterEvery(Test)]
-Will be executed after every test that will run in the test session.
-
-### [AfterEvery(Class)]
-Will be executed after the last test of every class that will run in the test session.
-
-### [AfterEvery(Assembly)]
-Will be executed after the last test of every assembly that will run in the test session.
-
-```csharp
-using TUnit.Core;
-
-namespace MyTestProject;
-
-public class MyTestClass
-{
- private int _value;
- private static HttpResponseMessage? _pingResponse;
-
- [After(Class)]
- public static async Task KillChromedrivers()
- {
- await Task.CompletedTask;
-
- foreach (var process in Process.GetProcessesByName("chromedriver.exe"))
- {
- process.Kill();
- }
- }
-
- [After(Test)]
- public async Task AfterEachTest()
- {
- await new HttpClient().GetAsync($"https://localhost/test-finished-notifier?testName={TestContext.Current.Metadata.TestName}");
- }
-
- [Test]
- public async Task MyTest()
- {
- // Do something
- }
-}
-```
diff --git a/docs/docs/writing-tests/hooks-setup.md b/docs/docs/writing-tests/hooks-setup.md
deleted file mode 100644
index 7146f92ffd..0000000000
--- a/docs/docs/writing-tests/hooks-setup.md
+++ /dev/null
@@ -1,332 +0,0 @@
-# Setup Hooks
-
-Most setup can go in the constructor (setting up mocks, assigning fields).
-
-For asynchronous setup -- such as pinging a service before tests run -- declare a method with a `[Before(...)]` or `[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
-
-### Hook Parameters
-
-Hooks can optionally accept context and cancellation token parameters:
-
-```csharp
-[Before(Test)]
-public async Task Setup(TestContext context, CancellationToken cancellationToken)
-{
- Console.WriteLine($"Setting up test: {context.Metadata.TestName}");
- await SomeLongRunningOperation(cancellationToken);
-}
-
-[Before(Class)]
-public static async Task ClassSetup(ClassHookContext context, CancellationToken cancellationToken)
-{
- await InitializeResources(cancellationToken);
-}
-
-[Before(Test)]
-public async Task SetupWithToken(CancellationToken cancellationToken)
-{
- await Task.Delay(100, cancellationToken);
-}
-
-[Before(Test)]
-public async Task SetupWithContext(TestContext context)
-{
- Console.WriteLine(context.Metadata.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)]
-Must be an instance method. Will be executed before each test in the class it's defined in.
-Methods will be executed bottom-up, so the base class set ups will execute first and then the inheriting classes.
-
-### [Before(Class)]
-Must be a static method. Will run once before the first test in the class it's defined in starts.
-
-### [Before(Assembly)]
-Must be a static method. Will run once before the first test in the assembly it's defined in starts.
-
-### [Before(TestSession)]
-Must be a static method. Will run once before the first test in the test session starts.
-
-### [Before(TestDiscovery)]
-Must be a static method. Will run once before any tests are discovered.
-
-## [BeforeEvery(HookType)]
-All [BeforeEvery(...)] methods must be static. Place them in a dedicated file (e.g. `GlobalHooks.cs` at the root of the test project) since they globally affect the test suite.
-
-### [BeforeEvery(Test)]
-Will be executed before every test that will run in the test session.
-
-### [BeforeEvery(Class)]
-Will be executed before the first test of every class that will run in the test session.
-
-### [BeforeEvery(Assembly)]
-Will be executed before the first test of every assembly that will run in the test session.
-
-```csharp
-using TUnit.Core;
-
-namespace MyTestProject;
-
-public class MyTestClass
-{
- private int _value;
- private static HttpResponseMessage? _pingResponse;
-
- [Before(Class)]
- public static async Task Ping()
- {
- _pingResponse = await new HttpClient().GetAsync("https://localhost/ping");
- }
-
- [Before(Test)]
- public async Task Setup()
- {
- await Task.CompletedTask;
-
- _value = 99;
- }
-
- [Test]
- public async Task MyTest()
- {
- await Assert.That(_value).IsEqualTo(99);
- await Assert.That(_pingResponse?.StatusCode)
- .IsNotNull()
- .And.IsEqualTo(HttpStatusCode.OK);
- }
-}
-```
-## Common Mistakes & Best Practices
-
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
-### Confusing Instance vs Static Hooks
-
-
-
-
-```csharp
-public class DatabaseTests
-{
- // ❌ Won't compile - Class-level hooks must be static
- [Before(Class)]
- public async Task SetupDatabase()
- {
- await InitializeDatabaseAsync();
- }
-
- // ❌ Won't compile - Test hooks cannot be static
- [Before(Test)]
- public static void SetupTest()
- {
- // Cannot access instance fields
- }
-}
-```
-
-**Problem:** Hook scope (instance/static) must match the hook level.
-
-
-
-
-```csharp
-public class DatabaseTests
-{
- // ✅ Class hooks must be static
- [Before(Class)]
- public static async Task SetupDatabase()
- {
- await InitializeDatabaseAsync();
- }
-
- // ✅ Test hooks must be instance methods
- [Before(Test)]
- public void SetupTest()
- {
- _testData = CreateTestData();
- }
-}
-```
-
-**Why:** Class-level hooks run once and cannot access instance state. Test-level hooks run per test and can access instance fields.
-
-
-
-
-### Mixing Sync and Async Incorrectly
-
-
-
-
-```csharp
-// ❌ Won't compile - async void is not allowed
-[Before(Test)]
-public async void SetupAsync()
-{
- await Task.Delay(100);
-}
-
-// ❌ Blocking on async code
-[Before(Test)]
-public void Setup()
-{
- SomeAsyncMethod().Wait(); // Can cause deadlocks
-}
-```
-
-**Problem:** Async void can't be awaited and blocking async code can cause deadlocks.
-
-
-
-
-```csharp
-// ✅ Use async Task for asynchronous operations
-[Before(Test)]
-public async Task SetupAsync()
-{
- await Task.Delay(100);
-}
-
-// ✅ Use synchronous method for synchronous work
-[Before(Test)]
-public void Setup()
-{
- _value = 42;
-}
-```
-
-**Why:** `async Task` allows proper awaiting and error handling. Synchronous hooks are fine for non-async work.
-
-
-
-
-### Expensive Setup at Wrong Level
-
-
-
-
-```csharp
-public class ApiTests
-{
- private HttpClient _client;
-
- // ❌ Creates new client for EVERY test
- [Before(Test)]
- public void Setup()
- {
- _client = new HttpClient
- {
- BaseAddress = new Uri("https://api.example.com")
- };
- }
-
- [Test]
- public async Task Test1() { /* ... */ }
-
- [Test]
- public async Task Test2() { /* ... */ }
- // Client created 2 times unnecessarily
-}
-```
-
-**Problem:** Creating expensive resources per test wastes time and resources.
-
-
-
-
-```csharp
-public class ApiTests
-{
- private static HttpClient _client;
-
- // ✅ Creates client once for all tests
- [Before(Class)]
- public static void SetupOnce()
- {
- _client = new HttpClient
- {
- BaseAddress = new Uri("https://api.example.com")
- };
- }
-
- [After(Class)]
- public static void CleanupOnce()
- {
- _client?.Dispose();
- }
-
- [Test]
- public async Task Test1() { /* ... */ }
-
- [Test]
- public async Task Test2() { /* ... */ }
- // Client created only once
-}
-```
-
-**Why:** Class-level setup runs once, sharing expensive resources across tests.
-
-
-
-
-## AsyncLocal
-
-Setting AsyncLocal values in `[Before(...)]` hooks is supported. To propagate them into the test framework, call `context.AddAsyncLocalValues()` on the context object injected into the hook method:
-
-```csharp
- [BeforeEvery(Class)]
- public static void BeforeClass(ClassHookContext context)
- {
- _myAsyncLocal.Value = "Some Value";
- context.AddAsyncLocalValues();
- }
-```
-
-## See Also
-
-- [Cleanup Hooks](hooks-cleanup.md) — Teardown logic with `[After]` and `[AfterEvery]`
-- [Test Lifecycle](lifecycle.md) — Full overview of the test execution lifecycle
diff --git a/docs/docs/writing-tests/hooks.md b/docs/docs/writing-tests/hooks.md
new file mode 100644
index 0000000000..4eb39e9d8f
--- /dev/null
+++ b/docs/docs/writing-tests/hooks.md
@@ -0,0 +1,227 @@
+# Hooks
+
+Hooks let you run code at specific points in the test lifecycle using `[Before]` / `[BeforeEvery]` and `[After]` / `[AfterEvery]` attributes. Most simple setup belongs in the constructor; use hooks for async operations or shared resource management.
+
+For the full execution order, see [Test Lifecycle](lifecycle.md).
+
+## Hook Method Signatures
+
+Hook methods can be synchronous or asynchronous:
+
+```csharp
+[Before(Test)]
+public void SynchronousSetup() // ✅ Valid
+{
+ _value = 99;
+}
+
+[Before(Test)]
+public async Task AsyncSetup() // ✅ Valid
+{
+ _response = await new HttpClient().GetAsync("https://localhost/ping");
+}
+
+[After(Test)]
+public void SynchronousCleanup() // ✅ Valid
+{
+ _resource?.Dispose();
+}
+
+[After(Test)]
+public async Task AsyncCleanup() // ✅ Valid
+{
+ await NotifyTestFinished();
+}
+```
+
+- Hooks can be `void` (synchronous) or `async Task` (asynchronous)
+- `async void` hooks are **not allowed** and will cause a compiler error
+
+## Hook Parameters
+
+Hooks can optionally accept a context object and/or a `CancellationToken`:
+
+```csharp
+[Before(Test)]
+public async Task Setup(TestContext context, CancellationToken cancellationToken)
+{
+ Console.WriteLine($"Setting up: {context.Metadata.TestName}");
+ await SomeLongRunningOperation(cancellationToken);
+}
+```
+
+**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 |
+|------------|-------------|
+| `[Before(Test)]` / `[After(Test)]` | `TestContext` |
+| `[Before(Class)]` / `[After(Class)]` | `ClassHookContext` |
+| `[Before(Assembly)]` / `[After(Assembly)]` | `AssemblyHookContext` |
+| `[Before(TestSession)]` / `[After(TestSession)]` | `TestSessionContext` |
+| `[Before(TestDiscovery)]` / `[After(TestDiscovery)]` | `BeforeTestDiscoveryContext` |
+
+### Checking Test Results in Cleanup
+
+A common pattern in `[After]` hooks is checking whether the test failed:
+
+```csharp
+[After(Test)]
+public async Task Cleanup(TestContext context, CancellationToken cancellationToken)
+{
+ if (context.Execution.Result?.State == TestState.Failed)
+ {
+ await CaptureScreenshot(cancellationToken);
+ }
+}
+```
+
+## Setup Hooks: [Before] and [BeforeEvery]
+
+### [Before(HookType)]
+
+| Level | Method Type | Scope |
+|-------|------------|-------|
+| `[Before(Test)]` | Instance | Before each test in the declaring class. Base class hooks run first (bottom-up). |
+| `[Before(Class)]` | Static | Once before the first test in the declaring class. |
+| `[Before(Assembly)]` | Static | Once before the first test in the assembly. |
+| `[Before(TestSession)]` | Static | Once before the first test in the session. |
+| `[Before(TestDiscovery)]` | Static | Once before any tests are discovered. |
+
+### [BeforeEvery(HookType)]
+
+All `[BeforeEvery]` methods must be **static**. Place them in a dedicated file (e.g., `GlobalHooks.cs`) since they globally affect the test suite.
+
+| Level | Scope |
+|-------|-------|
+| `[BeforeEvery(Test)]` | Before **every** test in the session |
+| `[BeforeEvery(Class)]` | Before the first test of **every** class |
+| `[BeforeEvery(Assembly)]` | Before the first test of **every** assembly |
+
+### Setup Example
+
+```csharp
+using TUnit.Core;
+
+namespace MyTestProject;
+
+public class MyTestClass
+{
+ private int _value;
+ private static HttpResponseMessage? _pingResponse;
+
+ [Before(Class)]
+ public static async Task Ping()
+ {
+ _pingResponse = await new HttpClient().GetAsync("https://localhost/ping");
+ }
+
+ [Before(Test)]
+ public async Task Setup()
+ {
+ await Task.CompletedTask;
+ _value = 99;
+ }
+
+ [Test]
+ public async Task MyTest()
+ {
+ await Assert.That(_value).IsEqualTo(99);
+ await Assert.That(_pingResponse?.StatusCode)
+ .IsNotNull()
+ .And.IsEqualTo(HttpStatusCode.OK);
+ }
+}
+```
+
+## Cleanup Hooks: [After] and [AfterEvery]
+
+TUnit also supports `IDisposable` and `IAsyncDisposable` on test classes, but `[After]` attributes are preferred — they support multiple methods and collect exceptions from all of them, throwing lazily afterwards.
+
+### [After(HookType)]
+
+| Level | Method Type | Scope |
+|-------|------------|-------|
+| `[After(Test)]` | Instance | After each test. Current class hooks run first (top-down). |
+| `[After(Class)]` | Static | Once after the last test in the declaring class. |
+| `[After(Assembly)]` | Static | Once after the last test in the assembly. |
+| `[After(TestSession)]` | Static | Once after the last test in the session. |
+| `[After(TestDiscovery)]` | Static | Once after tests are discovered. |
+
+### [AfterEvery(HookType)]
+
+All `[AfterEvery]` methods must be **static**. Place them in their own file (e.g., `GlobalHooks.cs`).
+
+:::info
+Use `[AfterEvery(...)]` for global cleanup logic that should run after every test/class/assembly/session, regardless of where the test is defined.
+:::
+
+| Level | Scope |
+|-------|-------|
+| `[AfterEvery(Test)]` | After **every** test in the session |
+| `[AfterEvery(Class)]` | After the last test of **every** class |
+| `[AfterEvery(Assembly)]` | After the last test of **every** assembly |
+
+### Cleanup Example
+
+```csharp
+using TUnit.Core;
+
+namespace MyTestProject;
+
+public class MyTestClass
+{
+ [After(Class)]
+ public static async Task KillChromedrivers()
+ {
+ await Task.CompletedTask;
+
+ foreach (var process in Process.GetProcessesByName("chromedriver.exe"))
+ {
+ process.Kill();
+ }
+ }
+
+ [After(Test)]
+ public async Task AfterEachTest()
+ {
+ await new HttpClient().GetAsync($"https://localhost/test-finished-notifier?testName={TestContext.Current.Metadata.TestName}");
+ }
+
+ [Test]
+ public async Task MyTest()
+ {
+ // Do something
+ }
+}
+```
+
+## Common Mistakes
+
+- **Instance vs static mismatch** — `[Before(Class)]` and higher must be `static`. `[Before(Test)]` must be an instance method. The compiler will error if you mix these up.
+- **`async void`** — Not allowed. Use `async Task` for async hooks, or `void` for synchronous hooks.
+- **Blocking on async** — Never call `.Wait()` or `.Result` inside a hook. Use `async Task` instead.
+- **Expensive per-test setup** — If setup is expensive (HTTP clients, DB connections), use `[Before(Class)]` to run it once, or use `[ClassDataSource]` for automatic lifecycle management.
+
+## AsyncLocal
+
+Setting `AsyncLocal` values in `[Before]` hooks is supported. Call `context.AddAsyncLocalValues()` to propagate them into the test framework:
+
+```csharp
+[BeforeEvery(Class)]
+public static void BeforeClass(ClassHookContext context)
+{
+ _myAsyncLocal.Value = "Some Value";
+ context.AddAsyncLocalValues();
+}
+```
+
+## See Also
+
+- [Test Lifecycle](lifecycle.md) — Full overview of the test execution lifecycle
+- [Event Subscribing](event-subscribing.md) — Event receiver interfaces for advanced scenarios
diff --git a/docs/docs/writing-tests/lifecycle.md b/docs/docs/writing-tests/lifecycle.md
index 6bac58dbc9..e155368a3e 100644
--- a/docs/docs/writing-tests/lifecycle.md
+++ b/docs/docs/writing-tests/lifecycle.md
@@ -429,8 +429,7 @@ All `[After]` hooks, `ITestEndEventReceiver` events, and disposal methods run ev
## Related Pages
-- [Test Set Ups](hooks-setup.md) - Detailed guide to `[Before]` hooks
-- [Test Clean Ups](hooks-cleanup.md) - Detailed guide to `[After]` hooks
+- [Hooks](hooks.md) - Detailed guide to `[Before]` and `[After]` hooks
- [Event Subscribing](event-subscribing.md) - Event receiver interfaces
- [Property Injection](property-injection.md) - Property injection and `IAsyncInitializer`
- [Dependency Injection](dependency-injection.md) - DI integration
diff --git a/docs/docs/writing-tests/property-injection.md b/docs/docs/writing-tests/property-injection.md
index 2bced813e2..42b722be8c 100644
--- a/docs/docs/writing-tests/property-injection.md
+++ b/docs/docs/writing-tests/property-injection.md
@@ -70,72 +70,32 @@ Most tests don't need discovery-time initialization. Discovery-time initializati
**Performance Note:** Discovery happens frequently (IDE reloads, project switches, `--list-tests`), so discovery-time initialization runs more often than test execution. Avoid expensive operations in `IAsyncDiscoveryInitializer` when possible.
-### Using IAsyncDiscoveryInitializer
+### IAsyncInitializer vs IAsyncDiscoveryInitializer
-When you need data available during discovery, implement `IAsyncDiscoveryInitializer` instead of `IAsyncInitializer`:
+| Interface | Runs During | Use Case |
+|-----------|------------|----------|
+| `IAsyncInitializer` | Test execution (after `[Before(Class)]`) | Starting containers, DB connections, expensive resources |
+| `IAsyncDiscoveryInitializer` | Test discovery (before test enumeration) | Loading data needed to generate test cases |
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
+Discovery happens frequently (IDE reloads, `--list-tests`, CI enumeration), so **prefer `IAsyncInitializer`** unless your test case generation depends on async-loaded data.
-
-
+See [Test Lifecycle — Initialization Interfaces](lifecycle.md#initialization-interfaces) for the full timing diagram.
-```csharp
-// This fixture's data won't be available during discovery
-public class TestDataFixture : IAsyncInitializer, IAsyncDisposable
-{
- private List _testCases = [];
-
- public async Task InitializeAsync()
- {
- // This runs during EXECUTION, not DISCOVERY
- _testCases = await LoadTestCasesFromDatabaseAsync();
- }
-
- // This will return empty list during discovery!
- public IEnumerable GetTestCases() => _testCases;
-
- public async ValueTask DisposeAsync()
- {
- _testCases.Clear();
- }
-}
-
-public class MyTests
-{
- [ClassDataSource(Shared = SharedType.PerClass)]
- public required TestDataFixture Fixture { get; init; }
-
- // During discovery, Fixture.GetTestCases() returns empty list
- // Result: No tests are generated!
- public IEnumerable TestCases => Fixture.GetTestCases();
-
- [Test]
- [InstanceMethodDataSource(nameof(TestCases))]
- public async Task MyTest(string testCase)
- {
- // This test is never created because TestCases was empty during discovery
- await Assert.That(testCase).IsNotNullOrEmpty();
- }
-}
-```
+### Example: Discovery-Time Initialization
-
-
+When `InstanceMethodDataSource` returns dynamically loaded data, use `IAsyncDiscoveryInitializer` so the data is available during test enumeration:
```csharp
-// This fixture's data IS available during discovery
public class TestDataFixture : IAsyncDiscoveryInitializer, IAsyncDisposable
{
private List _testCases = [];
public async Task InitializeAsync()
{
- // This runs during DISCOVERY, before test enumeration
+ // Runs during DISCOVERY, before test enumeration
_testCases = await LoadTestCasesFromDatabaseAsync();
}
- // This returns populated list during discovery!
public IEnumerable GetTestCases() => _testCases;
public async ValueTask DisposeAsync()
@@ -149,71 +109,20 @@ public class MyTests
[ClassDataSource(Shared = SharedType.PerClass)]
public required TestDataFixture Fixture { get; init; }
- // During discovery, Fixture is already initialized
- // Result: Tests are generated successfully!
public IEnumerable TestCases => Fixture.GetTestCases();
[Test]
[InstanceMethodDataSource(nameof(TestCases))]
public async Task MyTest(string testCase)
{
- // This test IS created with each test case from the fixture
await Assert.That(testCase).IsNotNullOrEmpty();
}
}
```
-
-
-
-```csharp
-// Best approach: Predefined test case IDs, expensive initialization during execution only
-public class TestDataFixture : IAsyncInitializer, IAsyncDisposable
-{
- // Test case IDs are predefined - no initialization needed for discovery
- private static readonly string[] PredefinedTestCases = ["Case1", "Case2", "Case3"];
-
- private DockerContainer? _container;
-
- public async Task InitializeAsync()
- {
- // Expensive initialization runs during EXECUTION only
- _container = await StartDockerContainerAsync();
- }
-
- // Returns predefined IDs - works during discovery without initialization
- public IEnumerable GetTestCaseIds() => PredefinedTestCases;
-
- public async ValueTask DisposeAsync()
- {
- if (_container != null)
- await _container.DisposeAsync();
- }
-}
-
-public class MyTests
-{
- [ClassDataSource(Shared = SharedType.PerClass)]
- public required TestDataFixture Fixture { get; init; }
-
- // Returns predefined IDs - no initialization required during discovery
- public IEnumerable TestCases => Fixture.GetTestCaseIds();
-
- [Test]
- [InstanceMethodDataSource(nameof(TestCases))]
- public async Task MyTest(string testCaseId)
- {
- // Fixture IS initialized by the time the test runs
- // Can now use the expensive resources (Docker container, etc.)
- await Assert.That(testCaseId).IsNotNullOrEmpty();
- }
-}
-```
-
-
-
-
-**Recommendation:** Prefer the "predefined data" approach when possible. This avoids expensive initialization during discovery, which happens frequently (IDE reloads, `--list-tests`, etc.).
+:::tip
+If your test case IDs are predefined (not loaded at runtime), use `IAsyncInitializer` instead — it avoids running expensive initialization during every discovery pass.
+:::
For more troubleshooting, see [Test Discovery Issues](../troubleshooting.md#test-discovery-issues).
diff --git a/docs/docs/writing-tests/things-to-know.md b/docs/docs/writing-tests/things-to-know.md
index e8330be916..c88111f035 100644
--- a/docs/docs/writing-tests/things-to-know.md
+++ b/docs/docs/writing-tests/things-to-know.md
@@ -31,39 +31,10 @@ Then `MyTest1` and `MyTest2` will have a different instance of `MyTests`.
This isn't that important unless you're storing state.
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-
-
-
-
-```csharp
-public class MyTests
-{
- private int _value;
-
- [Test, NotInParallel]
- public void MyTest1() { _value = 99; }
-
- [Test, NotInParallel]
- public async Task MyTest2()
- {
- // This will FAIL because _value is 0
- // Different test instance = different _value
- await Assert.That(_value).IsEqualTo(99);
- }
-}
-```
-
-**Why this fails:** Each test gets a new instance of `MyTests`, so `_value` in `MyTest2` is a different field than in `MyTest1`.
-
-
-
-
```csharp
public class MyTests
{
- private static int _value;
+ private int _value; // ❌ reset to 0 for every test — different instances!
[Test, NotInParallel]
public void MyTest1() { _value = 99; }
@@ -71,19 +42,16 @@ public class MyTests
[Test, NotInParallel]
public async Task MyTest2()
{
- // This works because _value is static
+ // FAILS — _value is 0 because this is a different instance
await Assert.That(_value).IsEqualTo(99);
}
}
```
-**Why this works:** The `static` keyword makes the field persist across instances, making it clear that data is shared.
-
-
-
+**Fix:** Use `static` fields if you genuinely need shared state, but prefer making tests independent or using `[ClassDataSource<>]` instead.
## See Also
- [Test Lifecycle](lifecycle.md) — Understand the order of setup, execution, and cleanup
-- [Hooks & Setup](hooks-setup.md) — Run code before tests, classes, or assemblies
+- [Hooks](hooks.md) — Run code before and after tests, classes, or assemblies
- [Controlling Parallelism](../execution/parallelism.md) — Configure how tests run in parallel
diff --git a/docs/sidebars.ts b/docs/sidebars.ts
index 2feb303ae3..4d077c623a 100644
--- a/docs/sidebars.ts
+++ b/docs/sidebars.ts
@@ -24,6 +24,7 @@ const sidebars: SidebarsConfig = {
label: 'Test Data',
collapsed: true,
items: [
+ 'writing-tests/data-driven-overview',
'writing-tests/arguments',
'writing-tests/method-data-source',
'writing-tests/class-data-source',
@@ -36,12 +37,11 @@ const sidebars: SidebarsConfig = {
},
{
type: 'category',
- label: 'Setup & Cleanup',
+ label: 'Lifecycle & Hooks',
collapsed: true,
items: [
'writing-tests/lifecycle',
- 'writing-tests/hooks-setup',
- 'writing-tests/hooks-cleanup',
+ 'writing-tests/hooks',
],
},
{
@@ -163,6 +163,7 @@ const sidebars: SidebarsConfig = {
'execution/ci-cd-reporting',
'execution/engine-modes',
'writing-tests/aot',
+ 'guides/performance',
],
},
{
@@ -202,32 +203,16 @@ const sidebars: SidebarsConfig = {
},
{
type: 'category',
- label: 'Comparing Frameworks',
+ label: 'Comparing & Migrating',
collapsed: true,
items: [
'comparison/framework-differences',
'comparison/attributes',
- ],
- },
- {
- type: 'category',
- label: 'Migration Guides',
- collapsed: true,
- items: [
'migration/xunit',
'migration/nunit',
'migration/mstest',
],
},
- {
- type: 'category',
- label: 'Guides',
- collapsed: true,
- items: [
- 'guides/best-practices',
- 'guides/performance',
- ],
- },
{
type: 'category',
label: 'Reference',
@@ -236,6 +221,11 @@ const sidebars: SidebarsConfig = {
'reference/command-line-flags',
'reference/environment-variables',
'reference/test-configuration',
+ {
+ type: 'doc',
+ id: 'guides/best-practices',
+ label: 'Tips & Pitfalls',
+ },
],
},
'troubleshooting',
From bc09910d9529a5706930357e04c667bb9c44cacb Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 27 Feb 2026 10:37:02 +0000
Subject: [PATCH 2/3] docs: restore inline examples on first-contact pages,
split migration bullet
Address review feedback:
- Restore inline await/no-await code snippet in assertions/getting-started.md
- Restore Important Notes bullets in writing-your-first-test.md
- Split "Comparing & Migrating" back into separate bullets in intro.md
so migration guides remain prominent for evaluating users
---
docs/docs/assertions/getting-started.md | 12 +++++++++++-
docs/docs/getting-started/writing-your-first-test.md | 9 ++++++---
docs/docs/intro.md | 3 ++-
3 files changed, 19 insertions(+), 5 deletions(-)
diff --git a/docs/docs/assertions/getting-started.md b/docs/docs/assertions/getting-started.md
index 3ab6ada819..678aaf806d 100644
--- a/docs/docs/assertions/getting-started.md
+++ b/docs/docs/assertions/getting-started.md
@@ -21,7 +21,17 @@ The basic flow is:
## Why Await?
-TUnit assertions must be awaited — they won't execute without `await`, and a built-in analyzer warns if you forget. See [Awaiting Assertions](awaiting.md) for detailed examples and design rationale.
+TUnit assertions must be awaited — they won't execute without `await`, and the test will pass silently:
+
+```csharp
+// ✅ Correct - awaited
+await Assert.That(result).IsEqualTo(42);
+
+// ❌ Wrong - assertion never runs, test passes without checking
+Assert.That(result).IsEqualTo(42);
+```
+
+A built-in analyzer warns if you forget. See [Awaiting Assertions](awaiting.md) for more examples and design rationale.
## Assertion Categories
diff --git a/docs/docs/getting-started/writing-your-first-test.md b/docs/docs/getting-started/writing-your-first-test.md
index dafd790215..9140c6b0a4 100644
--- a/docs/docs/getting-started/writing-your-first-test.md
+++ b/docs/docs/getting-started/writing-your-first-test.md
@@ -90,9 +90,12 @@ public async Task AsyncTestWithAssertions() // ✅ Recommended - asynchronous t
}
```
-:::tip Assertions Must Be Awaited
-If you use `Assert.That(...)`, your test **must** be `async Task` — assertions are awaitable and won't execute without `await`. `async void` tests are not allowed. See [Awaiting Assertions](../assertions/awaiting.md) for details.
-:::
+**Important Notes:**
+- If you use `Assert.That(...)`, your test **must** be `async Task` — assertions return awaitable objects that won't execute without `await`
+- Synchronous `void` tests are allowed but cannot use assertions
+- `async void` tests are **not allowed** and will cause a compiler error
+
+See [Awaiting Assertions](../assertions/awaiting.md) for more details.
Let's add some code to show you how a test might look once finished:
diff --git a/docs/docs/intro.md b/docs/docs/intro.md
index e07b3c1a55..17dde775a4 100644
--- a/docs/docs/intro.md
+++ b/docs/docs/intro.md
@@ -19,4 +19,5 @@ TUnit is designed for speed. Through source generation and compile-time optimiza
- **[Running Tests](execution/test-filters.md)** — Filters, timeouts, retries, CI/CD reporting, and AOT
- **[Integrations](examples/aspnet.md)** — ASP.NET Core, Aspire, Playwright, and other integration examples
- **[Extending TUnit](extending/extension-points.md)** — Custom data sources, formatters, and event subscribers
-- **[Comparing & Migrating](comparison/framework-differences.md)** — Feature comparisons and step-by-step migration guides for xUnit, NUnit, and MSTest
+- **[Comparing Frameworks](comparison/framework-differences.md)** — Feature comparisons with xUnit, NUnit, and MSTest
+- **[Migration](migration/xunit.md)** — Step-by-step guides for switching from xUnit, NUnit, or MSTest
From 5675d45297d7dc560ffe64d970e296ee3ea3feb4 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Fri, 27 Feb 2026 10:54:55 +0000
Subject: [PATCH 3/3] =?UTF-8?q?docs:=20fix=20[Matrix]=20=E2=86=92=20[Matri?=
=?UTF-8?q?xDataSource]=20in=20overview,=20clarify=20static=20fix?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Decision table and code example incorrectly used [Matrix] as the
method-level attribute; the correct attribute is [MatrixDataSource]
- Add explicit `private static int _value;` to the fix note in
things-to-know.md for clarity
---
docs/docs/writing-tests/data-driven-overview.md | 6 +++---
docs/docs/writing-tests/things-to-know.md | 2 +-
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/docs/writing-tests/data-driven-overview.md b/docs/docs/writing-tests/data-driven-overview.md
index ec297a2f47..b03b5ef140 100644
--- a/docs/docs/writing-tests/data-driven-overview.md
+++ b/docs/docs/writing-tests/data-driven-overview.md
@@ -10,7 +10,7 @@ TUnit offers several ways to provide data to your tests. Use this guide to pick
| Data from a method | `[MethodDataSource]` | [Method Data Sources](method-data-source.md) |
| Shared object with lifecycle | `[ClassDataSource]` | [Class Data Source](class-data-source.md) |
| Reusable data rows | `[TestDataRow]` | [Test Data Row](test-data-row.md) |
-| All parameter combinations | `[Matrix]` | [Matrix Tests](matrix-tests.md) |
+| All parameter combinations | `[MatrixDataSource]` | [Matrix Tests](matrix-tests.md) |
| Multiple sources on one method | Combined attributes | [Combined Data Sources](combined-data-source.md) |
| Hierarchical injection | Nested properties | [Nested Data Sources](nested-data-sources.md) |
| Custom generic attributes | `[GenericArguments]` | [Generic Attributes](generic-attributes.md) |
@@ -61,7 +61,7 @@ public class MyTests(DatabaseFixture db)
```csharp
[Test]
-[Matrix]
+[MatrixDataSource]
public async Task Multiply(
[Matrix(2, 3)] int a,
[Matrix(4, 5)] int b)
@@ -76,5 +76,5 @@ public async Task Multiply(
- **`[Arguments]`** is the simplest — use it when values are known at compile time.
- **`[MethodDataSource]`** is best for computed or complex data.
- **`[ClassDataSource]`** manages object lifecycles (initialization, disposal, sharing across tests).
-- **`[Matrix]`** generates the Cartesian product of all parameter values.
+- **`[MatrixDataSource]`** generates the Cartesian product of all `[Matrix]` parameter values.
- Attributes can be combined on a single method — see [Combined Data Sources](combined-data-source.md).
diff --git a/docs/docs/writing-tests/things-to-know.md b/docs/docs/writing-tests/things-to-know.md
index c88111f035..9e16a3f279 100644
--- a/docs/docs/writing-tests/things-to-know.md
+++ b/docs/docs/writing-tests/things-to-know.md
@@ -48,7 +48,7 @@ public class MyTests
}
```
-**Fix:** Use `static` fields if you genuinely need shared state, but prefer making tests independent or using `[ClassDataSource<>]` instead.
+**Fix:** Use `private static int _value;` if you genuinely need shared state, but prefer making tests independent or using `[ClassDataSource<>]` instead.
## See Also