Skip to content

Assertion Tracking

Aryeh Citron edited this page May 6, 2026 · 24 revisions

Added in v2.28.30

Assertion tracking renders your test assertions as styled inline notes in sequence diagrams. Passing assertions appear as green notes (✓), and failing assertions appear as red notes (✗) with the failure message.

This makes it immediately visible what was verified at each point in a test flow — directly inside the diagram, without navigating to test code.


Automatic Assertion Rewriting (Recommended)

Added in v2.30.0

The recommended approach is the TestTrackingDiagrams.AssertionRewriter package — an MSBuild task that automatically wraps your .Should() assertions at compile time. No changes to your test code required.

Requirements: The AssertionRewriter only works with FluentAssertions or AwesomeAssertions (i.e. assertions using the .Should() API). If you use a different assertion library, see Manual Tracking below.

SDK requirement: The AssertionRewriter MSBuild task requires the .NET 9 SDK or later (it depends on Roslyn 4.12.0 which ships with .NET 9). Your projects can still target net8.0 or earlier — only the build SDK needs to be 9.0+. If your global.json pins to an 8.x SDK, update it to "version": "9.0.0" with "rollForward": "latestFeature".

Compatibility: The AssertionRewriter operates as an MSBuild task that rewrites source files before compilation (BeforeTargets="CoreCompile"). It does not participate in the Roslyn source generator pipeline, so it coexists cleanly with all source generators (ReqNRoll, Blazor Razor, RegexGenerator, etc.) and other MSBuild-based code generation tools.

Quick Start

  1. Install the package:
<PackageReference Include="TestTrackingDiagrams.AssertionRewriter" Version="2.30.0" />
  1. Add the assembly-level opt-in attribute anywhere in your test project (commonly in a GlobalUsings.cs or AssertionTracking.cs file):
using TestTrackingDiagrams.Tracking;

[assembly: TrackAssertions]

The TrackAssertionsAttribute and SuppressAssertionTrackingAttribute types are auto-generated into your project at build time — no manual type definitions needed.

That's it. Your existing assertions now appear in diagrams automatically:

[Fact]
public async Task Creates_order_and_returns_201()
{
    var response = await _client.PostAsJsonAsync("/orders", new { Item = "Widget", Qty = 3 });

    response.StatusCode.Should().Be(HttpStatusCode.Created);  // ✓ appears in diagram

    var order = await response.Content.ReadFromJsonAsync<Order>();

    order!.Item.Should().Be("Widget");  // ✓ appears in diagram
    order!.Qty.Should().Be(3);          // ✓ appears in diagram
}

How It Works

The MSBuild task runs before compilation (BeforeTargets="CoreCompile") and:

  1. Auto-generates the TrackAssertionsAttribute and SuppressAssertionTrackingAttribute types into your project's intermediate output
  2. Scans for the [assembly: TrackAssertions] attribute in your source files
  3. Finds all expression statements containing .Should() calls
  4. Wraps them in Track.That(() => ...) (or Track.ThatAsync(async () => ...) for awaited assertions)
  5. Adds using TestTrackingDiagrams.Tracking; if not already present

Your source code stays unchanged — the wrapping is only applied to the compiled output.

Before (your source code):

response.StatusCode.Should().Be(HttpStatusCode.OK);
await result.Should().BeEquivalentToAsync(expected);

After (at compile time):

Track.That(() => response.StatusCode.Should().Be(HttpStatusCode.OK));
await Track.ThatAsync(async () => await result.Should().BeEquivalentToAsync(expected));

Suppressing Rewriting

You can selectively opt out of assertion wrapping using either attributes or pragma comments.

Attribute Suppression

Apply [SuppressAssertionTracking] to a method or class to skip all assertions within:

using TestTrackingDiagrams.Tracking;

[SuppressAssertionTracking]
public void HelperMethod()
{
    // Assertions in here won't be wrapped
    result.Should().NotBeNull();
}

[SuppressAssertionTracking]
public class InfrastructureTests
{
    // No assertions in this class are wrapped
}

Pragma Comment Suppression

Use // pragma:TrackAssertions:disable and // pragma:TrackAssertions:enable comments for finer-grained control — either inline (single statement) or as a range (multiple statements).

Single statement — add the pragma as a trailing comment:

x.Should().Be(1); // pragma:TrackAssertions:disable

Range — wrap a block of statements between disable/enable comments:

// pragma:TrackAssertions:disable
x.Should().Be(1);   // ← not wrapped
y.Should().Be(2);   // ← not wrapped
// pragma:TrackAssertions:enable

z.Should().Be(3);   // ← this one gets wrapped normally

Note: If you use // pragma:TrackAssertions:disable without a matching // pragma:TrackAssertions:enable, all subsequent assertions in that file will be skipped.

Null Propagation and Nullable Warnings

Important: Wrapping assertions in lambdas (whether by the AssertionRewriter or manually with Track.That()) can introduce nullable analysis warnings when your assertion uses the null-conditional operator (?.) or involves nullable reference types.

Why This Happens

C#'s nullable flow analysis doesn't propagate null-state information into lambdas. When code like this:

var order = await response.Content.ReadFromJsonAsync<Order>();
order?.Status.Should().Be("Shipped");

…is wrapped to become:

Track.That(() => order?.Status.Should().Be("Shipped"));

The compiler can no longer see any null-checks you performed before the lambda. Inside the lambda, it sees order?.Status might be null, so calling .Should() on it triggers:

  • CS8602 — Dereference of a possibly null reference
  • CS8604 — Possible null reference argument
  • CS8629 — Nullable value type may be null

What the AssertionRewriter Does Automatically

When using the AssertionRewriter, these warnings are handled for you. The rewriter automatically emits #pragma warning disable CS8602,CS8604,CS8629 and #pragma warning restore around each wrapped statement:

// Generated at compile time (you won't see this in your source):
#pragma warning disable CS8602, CS8604, CS8629
Track.That(() => order?.Status.Should().Be("Shipped"));
#pragma warning restore CS8602, CS8604, CS8629

This means if you use the AssertionRewriter, you don't need to do anything — nullable warnings from assertion wrapping are already suppressed.

Manual Track.That() — Your Options

If you're writing Track.That() calls manually and encounter nullable warnings, you have several options:

Option 1: Suppress the warning inline (quickest fix)

#pragma warning disable CS8602
Track.That(() => order?.Status.Should().Be("Shipped"));
#pragma warning restore CS8602

Option 2: Rewrite the assertion to avoid null propagation (cleanest)

Replace ?. with a null-forgiving operator ! or a separate null check:

// Use ! if you're confident it's not null at this point
Track.That(() => order!.Status.Should().Be("Shipped"));

// Or assert non-null first, then assert the property
Track.That(() => order.Should().NotBeNull());
Track.That(() => order!.Status.Should().Be("Shipped"));

Option 3: Disable tracking for that assertion (when it's not worth the noise)

// Just run the assertion without tracking
order?.Status.Should().Be("Shipped");

Option 4: Accept the risk (when you know the value won't be null at runtime)

If your test would fail with NullReferenceException anyway when the value is null (which is often an acceptable test failure mode), you can suppress the warning project-wide in your test .csproj:

<PropertyGroup>
  <NoWarn>$(NoWarn);CS8602;CS8604;CS8629</NoWarn>
</PropertyGroup>

Recommendation: For most test projects, Option 2 (using ! instead of ?.) is preferred because it makes the intent explicit — you expect the value to be non-null, and if it is null, the test should throw immediately rather than silently skipping the assertion chain via ?..

Integration with Project Templates

All project templates ship with the AssertionRewriter pre-configured — the [assembly: TrackAssertions] attribute and package reference are included by default.


IL Weaving (Beta)

Added in v2.30.7

⚠️ Beta Feature — This package is functional and tested but its API may change in future releases. Use [assembly: TrackAssertionsBeta] to opt in (note the Beta suffix).

The TestTrackingDiagrams.AssertionTracking package is an alternative to the AssertionRewriter that operates on compiled IL rather than source code. It uses Mono.Cecil to post-process your test assembly after compilation, wrapping each .Should() assertion statement in a try/catch that reports pass/fail to the tracking system.

Why IL Weaving?

The source-level AssertionRewriter is the simpler and more mature tool for most projects. The IL weaver exists for scenarios where source rewriting is problematic:

AssertionRewriter (source) AssertionTracking (IL)
Approach Rewrites source before compile Instruments IL after compile
Async methods Full support Full support (synchronous assertions in async methods)
Null propagation (?.) May introduce nullable warnings Fully transparent — ?. semantics preserved at IL level
ref / out parameters Cannot wrap in lambda Handles natively (no lambda involved)
Expression evaluation Captured in lambda (deferred) Original evaluation order preserved
Value resolution Closure-based (resolves captured variables via reflection) Array-based (captures variables after .Should(), resolves dotted paths via reflection)
Activation [assembly: TrackAssertions] [assembly: TrackAssertionsBeta]
SDK requirement .NET 9+ SDK (Roslyn 4.12) Any SDK (netstandard2.0 task)
Maturity Stable Beta

Quick Start

  1. Install the package:
<PackageReference Include="TestTrackingDiagrams.AssertionTracking" Version="2.30.8" />
  1. Add the assembly-level opt-in attribute anywhere in your test project:
using TestTrackingDiagrams.Tracking;

[assembly: TrackAssertionsBeta]

That's it. After compilation, the weaver instruments your .Should() assertions automatically. No source code changes are visible — your code stays exactly as written.

How It Works

The MSBuild integration runs in two phases:

  1. Before compilation (BeforeTargets="CoreCompile"): Auto-generates TrackAssertionsBetaAttribute and SuppressAssertionTrackingAttribute into your project's intermediate output directory.

  2. After compilation (AfterTargets="CoreCompile"): The WeaveAssertionsTask opens the compiled assembly with Mono.Cecil, finds all methods containing .Should() call chains (from FluentAssertions or AwesomeAssertions), and wraps each assertion statement in:

try {
    [original assertion IL]
    Track.AssertionPassedWithValues(expression, varNames, varValues, filePath, lineNumber)
} catch (Exception ex) {
    Track.AssertionFailedWithValues(expression, ex.Message, varNames, varValues, filePath, lineNumber)
    rethrow
}

The expression parameter is read from the original source file using PDB sequence point information — so your assertion notes display the actual source text, not decompiled IL.

Variable Value Resolution

Added in v2.30.12

The IL weaver captures the runtime values of variables used as assertion arguments and substitutes them into the diagram note text. For example:

var expected = HttpStatusCode.OK;
response.StatusCode.Should().Be(expected);

Instead of showing response status code should be expected in the diagram, the note displays:

✓ response status code should be 'OK'

The weaver detects variable loads (both regular locals and async state machine fields) that occur after the .Should() call — these are the arguments to assertion methods like .Be(), .BeInRange(), etc. At runtime, the captured values are formatted using these rules:

  • null'null'
  • Strings → displayed as-is (truncated at 50 characters)
  • Small scalar collections (≤10 items) → inline values, e.g. '[ 1, 2, 3 ]', '[ "Milk", "Sugar" ]', '[ Monday, Friday ]'
  • Large collections (>10 items) or collections with complex objects → '[N items]'
  • Empty collections → '[0 items]'
  • Other objects → ToString() result (if meaningful; type-name-only results are suppressed)

Scalar types that qualify for inline display: primitives (int, bool, double, etc.), string, enum, decimal, Guid, DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly. Strings are quoted in inline format; other scalars are unquoted. Null items within collections display as null.

Dotted property chain walking (up to 3 levels): When the expression references a property path like config.MaxRetries, the weaver captures the root object (config) and resolves the property chain via reflection at runtime.

When all assertion arguments are constants (e.g. .Be(42)), no variable capture is emitted — the zero-overhead Track.AssertionPassed path is used instead.

Lambda Closure Captures

Added in v2.30.13

Variables captured inside lambda predicates are also resolved. This is common with FluentAssertions collection assertions:

var expectedId = "abc";
var list = new List<Item> { new Item { Id = "abc" } };
list.Should().Contain(x => x.Id == expectedId);

Instead of showing expectedId in the diagram note, the note displays the resolved value 'abc'.

This works for:

  • Single captured variables: x => x.Id == expectedId
  • Multiple captured variables: x => x.Id == id && x.Name == name
  • OR conditions: x => x.Id == id1 || x.Id == id2
  • Async method closures (variables captured across await boundaries)

The weaver detects compiler-generated display class fields (<>c__DisplayClass) and emits IL to read the captured field value at runtime.

Null Propagation

Unlike the source-level rewriter (which wraps assertions in lambdas and may trigger nullable warnings), the IL weaver handles null-conditional operators transparently:

// This works correctly with the IL weaver — no warnings, no workarounds needed
order?.Status.Should().Be("Shipped");

When order is null, the ?. operator's IL branch is retargeted to exit the try block cleanly via leave. No assertion is tracked (correct — .Should() was never called), and no InvalidProgramException or NullReferenceException is thrown.

Suppressing Instrumentation

Apply [SuppressAssertionTracking] to a method or class to skip all assertions within:

using TestTrackingDiagrams.Tracking;

[SuppressAssertionTracking]
public void HelperAssertions()
{
    // Assertions here are NOT instrumented
    result.Should().NotBeNull();
}

Disabling the Weaver

To disable IL weaving entirely (e.g. for debugging), set the MSBuild property in your .csproj:

<PropertyGroup>
  <TrackAssertionsBetaEnabled>false</TrackAssertionsBetaEnabled>
</PropertyGroup>

Limitations

  • Awaited assertions: Assertions that themselves use await (e.g. await result.Should().BeEquivalentToAsync(expected)) span multiple state machine states and cannot be instrumented by the IL weaver. Use Track.ThatAsync() for those cases.
  • Non-FluentAssertions: Only .Should() on FluentAssertions/AwesomeAssertions types is detected. For other assertion libraries, use Track.That() manually.
  • IntelliSense: Since the weaver runs post-compilation, IntelliSense and IDE analysis see your unmodified code — this is a feature (no noise), but means assertion tracking won't show in design-time analysis.

Manual Tracking (Track.That())

If you can't use the AssertionRewriter (e.g. you use a different assertion library that doesn't have .Should()), you can manually wrap assertions with Track.That():

using TestTrackingDiagrams.Tracking;

[Fact]
public async Task Creates_order_and_returns_201()
{
    var response = await _client.PostAsJsonAsync("/orders", new { Item = "Widget", Qty = 3 });

    Track.That(() => response.StatusCode.Should().Be(HttpStatusCode.Created));

    var order = await response.Content.ReadFromJsonAsync<Order>();

    Track.That(() => order!.Item.Should().Be("Widget"));
    Track.That(() => order!.Qty.Should().Be(3));
}

This produces three green assertion notes in the sequence diagram:

  • ✓ Response status code should be Created
  • ✓ Order item should be Widget
  • ✓ Order qty should be 3

API

All methods live in the TestTrackingDiagrams.Tracking namespace on the static Track class.

Track.That(Action)

public static void That(
    Action assertion,
    [CallerArgumentExpression(nameof(assertion))] string? expression = null)

Executes the assertion and logs an inline note. On failure, logs a red note and re-throws the exception.

Track.That<T>(Func<T>)

public static T That<T>(
    Func<T> assertion,
    [CallerArgumentExpression(nameof(assertion))] string? expression = null)

Same as above but returns the value produced by the expression. Useful when you want to capture a result and assert on it in one step:

var count = Track.That(() => items.Should().HaveCount(5).And.Subject.Count);

Track.ThatAsync(Func<Task>)

public static async Task ThatAsync(
    Func<Task> assertion,
    [CallerArgumentExpression(nameof(assertion))] string? expression = null)

Async variant for assertions that involve await:

await Track.ThatAsync(async () =>
{
    var content = await response.Content.ReadAsStringAsync();
    content.Should().Contain("success");
});

Works With Any Assertion Library

Track.That() captures the expression text regardless of which assertion library you use:

// FluentAssertions/AwesomeAssertions
Track.That(() => result.Should().Be(42));

// xUnit Assert
Track.That(() => Assert.Equal(42, result));

// NUnit Assert
Track.That(() => Assert.That(result, Is.EqualTo(42)));

// Shouldly
Track.That(() => result.ShouldBe(42));

The AssertionExpressionFormatter produces the best output for FluentAssertions/AwesomeAssertions .Should(). patterns. For other libraries, it falls back to displaying the raw expression text (which is still readable and useful).


How It Works

  1. Expression capture: C# 10's [CallerArgumentExpression] captures the source text of the lambda passed to Track.That() at compile time.

  2. Formatting: AssertionExpressionFormatter transforms the raw expression (e.g. response.StatusCode.Should().Be(HttpStatusCode.Created)) into readable English (response status code should be Created).

  3. PlantUML injection: The formatted text is injected into the sequence diagram as a styled hnote across <<assertionNote>> via DefaultTrackingDiagramOverride.InsertPlantUml().

  4. Conditional styling: A <<assertionNote>> PlantUML style (smaller font, rounded corners) is only emitted when assertion notes are present in the diagram.


Expression Formatting

The formatter handles common FluentAssertions/AwesomeAssertions patterns:

Raw Expression Formatted Output
result.Count.Should().Be(3) Result count should be 3
order.Status.Should().Be(OrderStatus.Shipped) Order status should be Shipped
items.Should().HaveCount(5) Items should have count 5
response.Should().BeOfType<OrderResponse>() Response should be of type OrderResponse
list.Should().ContainSingle(x => x.IsAdmin) List should contain single x => x.IsAdmin
result.Should().NotBeNull() Result should not be null

Rules applied:

  • Splits on .Should(). — left side becomes the subject, right side becomes the predicate
  • PascalCase is split into space-separated words (HaveCounthave count)
  • Enum prefixes are stripped for simple arguments (HttpStatusCode.OKOK)
  • Generic type arguments are preserved (BeOfType<string>()be of type string)
  • .And. chains take only the first assertion
  • Lambdas in arguments are preserved as-is

Report Toggle

When any test in the report uses Track.That(), an Assertions: Show / Hide toggle appears in the report toolbar (alongside the existing Details and Headers toggles).

  • Default: Hidden — assertion notes are stripped from the diagram source before rendering
  • Show: Re-renders all diagrams with assertion notes visible
  • The toggle works at both report level (affects all diagrams) and scenario level (affects a single test)

No toggle when unused: If no tests in the report use Track.That(), the Assertions toggle is not shown.


Prerequisites

Track.That() resolves the current test identity using the following priority order:

  1. Track.TestIdResolver (static delegate — set automatically by framework integrations: LightBDD, BDDfy, ReqNRoll)
  2. TestIdentityScope.Current (AsyncLocal — set via TestIdentityScope.Begin())
  3. TestIdentityScope.GlobalFallback (static — useful in single-threaded test runners)

If none of these resolve a test ID, the assertion executes normally but no diagram note is logged. This means Track.That() is safe to use in shared helper methods that may run outside a test context.

Framework Integration (Automatic)

When using one of the framework adapter packages (TestTrackingDiagrams.LightBDD.*, TestTrackingDiagrams.BDDfy.*, TestTrackingDiagrams.ReqNRoll.*), Track.TestIdResolver is set up automatically during configuration — no manual wiring needed.

Custom Resolver

If you're using a framework that doesn't have a built-in adapter, set Track.TestIdResolver during test setup:

// Example: wire up xUnit v3 TestContext directly
Track.TestIdResolver = () => Xunit.TestContext.Current.Test?.UniqueID;

Home


Demo


Getting Started

Common Tasks

Integration Guides

Extensions

Configuration

Features

Reference

Clone this wiki locally