You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add a public API for registering custom value formatters that control how values are rendered inside the new structured assertion failure messages (the format introduced by RFC-012 and improved by #8964). Registration is scoped via AsyncLocal<T> anchored on the current test context, so formatters added inside a test affect only that test, while formatters added in [ClassInitialize] / [AssemblyInitialize] affect their corresponding scope.
Background and Motivation
#8966 (Asserts for DateTime do not take into account the Kind) surfaced a generalizable need:
Users sometimes want a different string representation for a specific type when it appears in a failure message (e.g. show DateTime.Kind explicitly, show a domain type as User(id=42, role=Admin) instead of MyCompany.Foo.User).
They also sometimes want a different equality semantics when comparing two values of a specific type — but that is already covered today via the existing Assert.AreEqual<T>(T, T, IEqualityComparer<T>) / Assert.AreNotEqual<T>(T, T, IEqualityComparer<T>) overloads. This proposal is only about rendering, not equality.
#8964 centralized all value rendering in AssertionValueRenderer.RenderValue (src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs), which means a single, clean hook point now exists for every assertion that surfaces values through structured messages: AreEqual, AreNotEqual, AreEqual with delta, AreSequenceEqual, Contains, ContainsAll, DoesNotContain, IsInRange, IsGreaterThan, IsLessThan, AreEquivalent, etc.
Other frameworks in the ecosystem ship this:
NUnit — TestContext.AddFormatter (two overloads: typed and chain factory). Scoped to the current TestContext.
FluentAssertions / AwesomeAssertions — AssertionConfiguration.Current.Formatting.AddFormatter(...) plus an [ValueFormatter] discovery mechanism.
Today MSTest has no equivalent: the only way to influence rendering is to override ToString() on the type, which is often impossible (BCL types, third-party types) or undesirable (production code).
Proposed Feature
1. Imperative API (primary, ship first)
Two overloads on Assert — typed sugar plus a chain-factory primitive:
publicpartialclassAssert{// Typed sugar: return null to fall through to the default/next formatter.publicstaticIDisposableAddValueFormatter<T>(Func<T,string?>formatter);// Chain of responsibility. Receives the "next" formatter so user formatters// can selectively handle a value and otherwise delegate.publicstaticIDisposableAddValueFormatter(Func<Func<object?,string>,Func<object?,string>>factory);}
Return value (IDisposable). Disposing removes the registration. This makes per-test usage ergonomic:
[TestMethod]publicvoidFoo(){usingvar_=Assert.AddValueFormatter<DateTime>(dt =>$"{dt:O} [{dt.Kind}]");Assert.AreEqual(d1,d2);// failure message uses the custom formatter}
Scope semantics. The registry is backed by AsyncLocal<ImmutableStack<Formatter>> keyed off the current test context, so:
Registration site
Effective scope
Inside a [TestMethod]
That test method (and any async continuations it awaits)
[TestInitialize]
Each test in that class
[ClassInitialize]
All tests in that class
[AssemblyInitialize]
All tests in that assembly
Static ctor / module init
Process-wide ambient (last-resort fallback)
Registrations stack — inner scopes layer on top of outer scopes and are visible first.
Chain of responsibility. Formatters return null to mean "I do not handle this value"; the renderer then falls through to the next registered formatter and finally to the built-in switch (string, bool, DateTime, double, IEnumerable, …). This is the same shape NUnit uses (Func<next, Func<object, string>>) and it composes cleanly when multiple libraries register formatters.
2. Hook point
AssertionValueRenderer.RenderValue (already centralized in #8964) adds a single check at the top of the switch:
Built-in formatters remain the default — user formatters only win when they return non-null.
3. Discovery (deferred follow-up, not in this issue's scope)
A future issue could add [AssertionValueFormatter] attribute + IAssertionValueFormatter<T> interface discovery resolved at [AssemblyInitialize] time. Keeping it explicit and tied to assembly init preserves AOT/trimming friendliness because the AOT reflection source generator can preserve the marked types. This issue intentionally proposes only the imperative API so the primitive lands first.
4. Explicitly NOT in scope
No built-in formatters changed. PR Render BCL values with full precision in assertion failure messages #8964's defaults (O for DateTime/DateTimeOffset, R for double/float, etc.) remain. Shipping a Kind-aware DateTime formatter by default would silently change every existing test's failure message and break snapshot/log assertions.
No structural/multi-line rendering (no diffs, no colors). Formatters return a single string; richer rendering is a separate, much larger feature.
No equality customization. Use the existing IEqualityComparer<T> overloads on AreEqual / AreNotEqual.
5. Open questions
Should the typed overload accept Func<T, string?> (null = fall through) or Func<T, string> plus a separate bool TryFormat shape? Func<T, string?> is simpler and matches NUnit.
Should we expose this on Assert, on TestContext (NUnit's choice), or both? Recommendation: Assert (one entry point, single static class users already know), with TestContext.AddValueFormatter as a thin forwarder if there is appetite.
Should we treat null as a special case the registry can override, or should null => "null" always win? Recommendation: the built-in null => "null" always wins to avoid NRE risk inside user formatters.
Thread safety: per-AsyncLocal stack means no locking required for reads; writes (Add/Dispose) only touch the current async flow. Confirm acceptable.
Alternative Designs
Alternative
Why not
Attribute/interface discovery only
AOT/trim hostile by default; hidden behavior ("why is this rendered that way?"); awkward for one-off / in-test customization; doesn't allow lambdas.
Process-wide static registry only (no scoping)
Races horribly with [Parallelize]; no way to isolate per-test customization; can't be reset between tests without manual bookkeeping.
Ship a built-in Kind-aware DateTime formatter
Silently changes failure messages for every existing assertion — high risk of breaking snapshot tests, log scrapers, doc samples, and existing user expectations.
TestContext.AddFormatter only (mirror NUnit exactly)
Discoverability is worse — TestContext is one extra hop most users skip. Putting it on Assert matches where users already look for assertion-related APIs.
Custom asserter / extension method per type
Doesn't scale — every type that needs custom rendering would require a new method on Assert. The formatter primitive subsumes this with one API.
Summary
Add a public API for registering custom value formatters that control how values are rendered inside the new structured assertion failure messages (the format introduced by RFC-012 and improved by #8964). Registration is scoped via
AsyncLocal<T>anchored on the current test context, so formatters added inside a test affect only that test, while formatters added in[ClassInitialize]/[AssemblyInitialize]affect their corresponding scope.Background and Motivation
#8966 (Asserts for DateTime do not take into account the Kind) surfaced a generalizable need:
DateTime.Kindexplicitly, show a domain type asUser(id=42, role=Admin)instead ofMyCompany.Foo.User).Assert.AreEqual<T>(T, T, IEqualityComparer<T>)/Assert.AreNotEqual<T>(T, T, IEqualityComparer<T>)overloads. This proposal is only about rendering, not equality.#8964 centralized all value rendering in
AssertionValueRenderer.RenderValue(src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs), which means a single, clean hook point now exists for every assertion that surfaces values through structured messages:AreEqual,AreNotEqual,AreEqualwith delta,AreSequenceEqual,Contains,ContainsAll,DoesNotContain,IsInRange,IsGreaterThan,IsLessThan,AreEquivalent, etc.Other frameworks in the ecosystem ship this:
TestContext.AddFormatter(two overloads: typed and chain factory). Scoped to the currentTestContext.AssertionConfiguration.Current.Formatting.AddFormatter(...)plus an[ValueFormatter]discovery mechanism.Today MSTest has no equivalent: the only way to influence rendering is to override
ToString()on the type, which is often impossible (BCL types, third-party types) or undesirable (production code).Proposed Feature
1. Imperative API (primary, ship first)
Two overloads on
Assert— typed sugar plus a chain-factory primitive:Return value (
IDisposable). Disposing removes the registration. This makes per-test usage ergonomic:Scope semantics. The registry is backed by
AsyncLocal<ImmutableStack<Formatter>>keyed off the current test context, so:[TestMethod][TestInitialize][ClassInitialize][AssemblyInitialize]Registrations stack — inner scopes layer on top of outer scopes and are visible first.
Chain of responsibility. Formatters return
nullto mean "I do not handle this value"; the renderer then falls through to the next registered formatter and finally to the built-in switch (string,bool,DateTime,double,IEnumerable, …). This is the same shape NUnit uses (Func<next, Func<object, string>>) and it composes cleanly when multiple libraries register formatters.2. Hook point
AssertionValueRenderer.RenderValue(already centralized in #8964) adds a single check at the top of the switch:Built-in formatters remain the default — user formatters only win when they return non-null.
3. Discovery (deferred follow-up, not in this issue's scope)
A future issue could add
[AssertionValueFormatter]attribute +IAssertionValueFormatter<T>interface discovery resolved at[AssemblyInitialize]time. Keeping it explicit and tied to assembly init preserves AOT/trimming friendliness because the AOT reflection source generator can preserve the marked types. This issue intentionally proposes only the imperative API so the primitive lands first.4. Explicitly NOT in scope
OforDateTime/DateTimeOffset,Rfordouble/float, etc.) remain. Shipping a Kind-awareDateTimeformatter by default would silently change every existing test's failure message and break snapshot/log assertions.IEqualityComparer<T>overloads onAreEqual/AreNotEqual.5. Open questions
Func<T, string?>(null = fall through) orFunc<T, string>plus a separatebool TryFormatshape?Func<T, string?>is simpler and matches NUnit.Assert, onTestContext(NUnit's choice), or both? Recommendation:Assert(one entry point, single static class users already know), withTestContext.AddValueFormatteras a thin forwarder if there is appetite.nullas a special case the registry can override, or shouldnull => "null"always win? Recommendation: the built-innull => "null"always wins to avoid NRE risk inside user formatters.AsyncLocalstack means no locking required for reads; writes (Add/Dispose) only touch the current async flow. Confirm acceptable.Alternative Designs
[Parallelize]; no way to isolate per-test customization; can't be reset between tests without manual bookkeeping.DateTimeformatterTestContext.AddFormatteronly (mirror NUnit exactly)TestContextis one extra hop most users skip. Putting it onAssertmatches where users already look for assertion-related APIs.Assert. The formatter primitive subsumes this with one API.Related
AssertionValueRenderer.RenderValue)TestContext.AddFormatter)