From 9184f91a8adb393dc883560f58038175f9717549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 9 Jun 2026 14:39:15 +0200 Subject: [PATCH 1/2] Render BCL values with full precision in assertion failure messages (#8963) Centralizes type-specific, full-precision, culture-invariant formatting in AssertionValueRenderer.RenderValue so RFC-012 structured assertion messages no longer collapse precision when comparing BCL values such as DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly, float, double, and decimal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Assertions/AssertionValueRenderer.cs | 10 + .../AssertTests.RenderingPrecision.cs | 236 ++++++++++++++++++ .../Assertions/AssertionValueRendererTests.cs | 184 +++++++++++++- 3 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs diff --git a/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs b/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs index b49b85823e..0f293449f1 100644 --- a/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs +++ b/src/TestFramework/TestFramework/Assertions/AssertionValueRenderer.cs @@ -19,6 +19,16 @@ internal static string RenderValue(object? value) string s => RenderString(s), bool b => b ? "true" : "false", char c => RenderChar(c), + DateTime dt => dt.ToString("O", CultureInfo.InvariantCulture), + DateTimeOffset dto => dto.ToString("O", CultureInfo.InvariantCulture), + TimeSpan ts => ts.ToString("c", CultureInfo.InvariantCulture), +#if NET6_0_OR_GREATER + DateOnly d => d.ToString("O", CultureInfo.InvariantCulture), + TimeOnly t => t.ToString("O", CultureInfo.InvariantCulture), +#endif + float f => f.ToString("R", CultureInfo.InvariantCulture), + double d => d.ToString("R", CultureInfo.InvariantCulture), + decimal m => m.ToString(CultureInfo.InvariantCulture), IEnumerable enumerable => RenderCollection(enumerable), _ => RenderObject(value), }; diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs new file mode 100644 index 0000000000..085129c915 --- /dev/null +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs @@ -0,0 +1,236 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using AwesomeAssertions; + +using TestFramework.ForTestingMSTest; + +namespace Microsoft.VisualStudio.TestTools.UnitTesting.UnitTests; + +/// +/// Regression tests for https://github.com/microsoft/testfx/issues/8963 — assertion failure messages +/// must render BCL values (DateTime, DateTimeOffset, TimeSpan, DateOnly, TimeOnly, float, double, decimal) +/// with enough precision and in a culture-invariant way so distinct values never collapse to the same text. +/// +public partial class AssertTests : TestContainer +{ + public void AreEqual_DateTime_RendersFullTickPrecisionInFailureMessage() + { + var d1 = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + DateTime d2 = d1.AddTicks(1); + + Action action = () => Assert.AreEqual(d1, d2); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 2026-06-09T13:12:21.0000000Z"); + ex.Message.Should().Contain("actual: 2026-06-09T13:12:21.0000001Z"); + } + + public void AreNotEqual_DateTime_RendersWithFullTickPrecisionInFailureMessage() + { + DateTime d = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc).AddTicks(42); + + Action action = () => Assert.AreNotEqual(d, d); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("2026-06-09T13:12:21.0000042Z"); + } + + public void AreEqual_DateTimeOffset_RendersFullTickPrecisionInFailureMessage() + { + var d1 = new DateTimeOffset(2026, 6, 9, 13, 12, 21, TimeSpan.FromHours(2)); + DateTimeOffset d2 = d1.AddTicks(1); + + Action action = () => Assert.AreEqual(d1, d2); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 2026-06-09T13:12:21.0000000+02:00"); + ex.Message.Should().Contain("actual: 2026-06-09T13:12:21.0000001+02:00"); + } + + public void AreEqual_Double_RendersFullRoundTripPrecisionInFailureMessage() + { + double expected = 0.1 + 0.2; + double actual = 0.3; + + Action action = () => Assert.AreEqual(expected, actual); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("0.30000000000000004"); + ex.Message.Should().Contain("0.3"); + } + + public void AreEqual_Double_RendersUsingInvariantCultureEvenInCommaLocale() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + + Action action = () => Assert.AreEqual(1.5, 2.5); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 1.5"); + ex.Message.Should().Contain("actual: 2.5"); + ex.Message.Should().NotContain("1,5"); + ex.Message.Should().NotContain("2,5"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + public void AreEqual_Float_DistinguishesTwoNearEqualFloats() + { + float a = 1.0f; + byte[] bytes = BitConverter.GetBytes(a); + uint asInt = BitConverter.ToUInt32(bytes, 0); + float b = BitConverter.ToSingle(BitConverter.GetBytes(asInt + 1u), 0); + + Action action = () => Assert.AreEqual(a, b); + + Exception ex = action.Should().Throw().Which; + // Build the two renderings the same way the assertion does and assert the + // failure message preserves enough precision to keep them distinguishable. + string expectedRendering = AssertionValueRenderer.RenderValue(a); + string actualRendering = AssertionValueRenderer.RenderValue(b); + expectedRendering.Should().NotBe(actualRendering); + ex.Message.Should().Contain(expectedRendering); + ex.Message.Should().Contain(actualRendering); + } + + public void AreEqual_TimeSpan_RendersSubSecondPrecisionInFailureMessage() + { + var t1 = TimeSpan.FromSeconds(1); + TimeSpan t2 = t1.Add(TimeSpan.FromTicks(1)); + + Action action = () => Assert.AreEqual(t1, t2); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 00:00:01"); + ex.Message.Should().Contain("actual: 00:00:01.0000001"); + } + + public void AreEqual_Decimal_RendersUsingInvariantCultureEvenInCommaLocale() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + + Action action = () => Assert.AreEqual(1.5m, 2.5m); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 1.5"); + ex.Message.Should().Contain("actual: 2.5"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + +#if NET6_0_OR_GREATER + public void AreEqual_TimeOnly_RendersSubSecondPrecisionInFailureMessage() + { + var t1 = new TimeOnly(13, 12, 21); + TimeOnly t2 = t1.Add(TimeSpan.FromTicks(1)); + + Action action = () => Assert.AreEqual(t1, t2); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 13:12:21.0000000"); + ex.Message.Should().Contain("actual: 13:12:21.0000001"); + } + + public void AreEqual_DateOnly_RendersIsoDateInFailureMessage() + { + var d1 = new DateOnly(2026, 6, 9); + var d2 = new DateOnly(2026, 6, 10); + + Action action = () => Assert.AreEqual(d1, d2); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("expected: 2026-06-09"); + ex.Message.Should().Contain("actual: 2026-06-10"); + } +#endif + + public void AreEqual_WithDoubleDelta_RendersFullPrecisionInFailureMessage() + { + double expected = 1.0; + double actual = 1.0 + 1e-10; + + Action action = () => Assert.AreEqual(expected, actual, 1e-12); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("1.0000000001"); + ex.Message.Should().Contain("delta:"); + } + + public void IsInRange_DateTime_RendersBoundsAndValueWithTickPrecision() + { + var min = new DateTime(2026, 6, 9, 0, 0, 0, DateTimeKind.Utc); + DateTime max = min.AddTicks(1000); + DateTime value = max.AddTicks(1); + + Action action = () => Assert.IsInRange(min, max, value); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("2026-06-09T00:00:00.0000000Z"); + ex.Message.Should().Contain("2026-06-09T00:00:00.0001000Z"); + ex.Message.Should().Contain("2026-06-09T00:00:00.0001001Z"); + } + + public void IsGreaterThan_DateTime_RendersBothValuesWithTickPrecision() + { + var a = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + DateTime b = a.AddTicks(1); + + Action action = () => Assert.IsGreaterThan(b, a); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("2026-06-09T13:12:21.0000000Z"); + ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + } + + public void Contains_DateTimeNotInCollection_RendersTargetWithTickPrecision() + { + var d1 = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + DateTime target = d1.AddTicks(1); + DateTime[] collection = [d1, d1.AddTicks(2)]; + + Action action = () => Assert.Contains(target, collection); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + } + + public void ContainsAll_DateTimeMissing_RendersExpectedAndCollectionWithTickPrecision() + { + var d1 = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + DateTime missing = d1.AddTicks(1); + DateTime[] collection = [d1]; + DateTime[] expected = [d1, missing]; + + Action action = () => Assert.ContainsAll(expected, collection); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("2026-06-09T13:12:21.0000000Z"); + ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + } + + public void AreSequenceEqual_DateTime_RendersMismatchedElementsWithTickPrecision() + { + var d1 = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + DateTime[] expected = [d1]; + DateTime[] actual = [d1.AddTicks(1)]; + + Action action = () => Assert.AreSequenceEqual(expected, actual); + + Exception ex = action.Should().Throw().Which; + ex.Message.Should().Contain("2026-06-09T13:12:21.0000000Z"); + ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + } +} diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs index bdbc362377..a92ea6fbd9 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertionValueRendererTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Globalization; @@ -50,7 +50,187 @@ public void RenderValue_NegativeInteger_ReturnsUnquoted() => AssertionValueRenderer.RenderValue(-7).Should().Be("-7"); public void RenderValue_Double_ReturnsUnquoted() => - AssertionValueRenderer.RenderValue(3.14).Should().Be(3.14.ToString(CultureInfo.CurrentCulture)); + AssertionValueRenderer.RenderValue(3.14).Should().Be("3.14"); + + public void RenderValue_Double_RendersFullPrecision() + { +#if NET + double d1 = 0.1 + 0.2; + AssertionValueRenderer.RenderValue(d1).Should().Be("0.30000000000000004"); +#else + // .NET Framework "R" format yields the same string on this particular value + // but its general behavior differs from .NET Core (15-digit-first fallback); + // assert the precision-preserving property structurally instead. + AssertionValueRenderer.RenderValue(0.1 + 0.2).Should().NotBe(AssertionValueRenderer.RenderValue(0.3)); +#endif + } + + public void RenderValue_Double_RendersInvariantCulture() + { + // Even under cultures whose decimal separator is a comma, we always emit a dot. + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + AssertionValueRenderer.RenderValue(3.14).Should().Be("3.14"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + public void RenderValue_Double_NaN_RendersNaN() => + AssertionValueRenderer.RenderValue(double.NaN).Should().Be("NaN"); + + public void RenderValue_Double_PositiveInfinity_RendersInfinity() => + AssertionValueRenderer.RenderValue(double.PositiveInfinity).Should().Be("Infinity"); + + public void RenderValue_Double_NegativeInfinity_RendersInfinity() => + AssertionValueRenderer.RenderValue(double.NegativeInfinity).Should().Be("-Infinity"); + + public void RenderValue_Float_RendersInvariantCulture() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + AssertionValueRenderer.RenderValue(3.5f).Should().Be("3.5"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + public void RenderValue_Float_DistinguishesNearEqualValues() + { + // Construct two adjacent float values via byte-level bit manipulation so the test + // works on net48 (which lacks BitConverter.SingleToInt32Bits) and net6+. + float a = 1.0f; + byte[] bytes = BitConverter.GetBytes(a); + uint asInt = BitConverter.ToUInt32(bytes, 0); + float b = BitConverter.ToSingle(BitConverter.GetBytes(asInt + 1u), 0); + AssertionValueRenderer.RenderValue(a).Should().NotBe(AssertionValueRenderer.RenderValue(b)); + } + + public void RenderValue_Decimal_RendersInvariantCulture() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + AssertionValueRenderer.RenderValue(1.5m).Should().Be("1.5"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + public void RenderValue_DateTime_RendersWithFullTickPrecision() + { + DateTime dt = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Unspecified).AddTicks(1234567); + AssertionValueRenderer.RenderValue(dt).Should().Be("2026-06-09T13:12:21.1234567"); + } + + public void RenderValue_DateTime_DistinguishesTwoValuesOneTickApart() + { + var d1 = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + DateTime d2 = d1.AddTicks(1); + AssertionValueRenderer.RenderValue(d1).Should().NotBe(AssertionValueRenderer.RenderValue(d2)); + } + + public void RenderValue_DateTime_PreservesUtcKind() + { + var dt = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + AssertionValueRenderer.RenderValue(dt).Should().Be("2026-06-09T13:12:21.0000000Z"); + } + + public void RenderValue_DateTime_RendersInvariantCulture() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + var dt = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Unspecified); + AssertionValueRenderer.RenderValue(dt).Should().Be("2026-06-09T13:12:21.0000000"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + public void RenderValue_DateTimeOffset_RendersWithOffsetAndTicks() + { + DateTimeOffset dto = new DateTimeOffset(2026, 6, 9, 13, 12, 21, TimeSpan.FromHours(2)).AddTicks(7); + AssertionValueRenderer.RenderValue(dto).Should().Be("2026-06-09T13:12:21.0000007+02:00"); + } + + public void RenderValue_DateTimeOffset_DistinguishesTwoValuesOneTickApart() + { + var d1 = new DateTimeOffset(2026, 6, 9, 13, 12, 21, TimeSpan.Zero); + DateTimeOffset d2 = d1.AddTicks(1); + AssertionValueRenderer.RenderValue(d1).Should().NotBe(AssertionValueRenderer.RenderValue(d2)); + } + + public void RenderValue_TimeSpan_RendersInvariantConstantFormat() + { + var ts = new TimeSpan(1, 2, 3, 4, 5); + AssertionValueRenderer.RenderValue(ts).Should().Be("1.02:03:04.0050000"); + } + + public void RenderValue_TimeSpan_DistinguishesOneTickApart() + { + var t1 = TimeSpan.FromSeconds(1); + TimeSpan t2 = t1.Add(TimeSpan.FromTicks(1)); + AssertionValueRenderer.RenderValue(t1).Should().NotBe(AssertionValueRenderer.RenderValue(t2)); + } + +#if NET6_0_OR_GREATER + public void RenderValue_DateOnly_RendersIsoDate() + { + var d = new DateOnly(2026, 6, 9); + AssertionValueRenderer.RenderValue(d).Should().Be("2026-06-09"); + } + + public void RenderValue_DateOnly_RendersInvariantCulture() + { + CultureInfo previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr-FR"); + var d = new DateOnly(2026, 6, 9); + AssertionValueRenderer.RenderValue(d).Should().Be("2026-06-09"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + public void RenderValue_TimeOnly_RendersWithSubSecondPrecision() + { + TimeOnly t = new TimeOnly(13, 12, 21, 456).Add(TimeSpan.FromTicks(7)); + AssertionValueRenderer.RenderValue(t).Should().Be("13:12:21.4560007"); + } + + public void RenderValue_TimeOnly_DistinguishesTwoValuesOneTickApart() + { + var t1 = new TimeOnly(13, 12, 21); + TimeOnly t2 = t1.Add(TimeSpan.FromTicks(1)); + AssertionValueRenderer.RenderValue(t1).Should().NotBe(AssertionValueRenderer.RenderValue(t2)); + } +#endif + + public void RenderValue_DateTimeInList_RendersEachWithFullPrecision() + { + var d1 = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc); + var list = new List { d1, d1.AddTicks(1) }; + AssertionValueRenderer.RenderValue(list).Should().Be( + "[2026-06-09T13:12:21.0000000Z, 2026-06-09T13:12:21.0000001Z]"); + } public void RenderValue_BoolTrue_ReturnsLowercase() => AssertionValueRenderer.RenderValue(true).Should().Be("true"); From 643421b3c3447e7ede12154b16fca80da2dd2018 Mon Sep 17 00:00:00 2001 From: Evangelink Date: Tue, 9 Jun 2026 16:19:01 +0200 Subject: [PATCH 2/2] Tighten BCL precision tests to assert full failure message Replace ex.Message.Should().Contain(...) substring checks with exact full-message assertions via .WithMessage(raw-string) so we catch any change in the structured message layout (summary, evidence block padding, call-site expression) in addition to the rendered values themselves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AssertTests.RenderingPrecision.cs | 217 ++++++++++++++---- 1 file changed, 166 insertions(+), 51 deletions(-) diff --git a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs index 085129c915..63a103dc5b 100644 --- a/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs +++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs @@ -21,9 +21,16 @@ public void AreEqual_DateTime_RendersFullTickPrecisionInFailureMessage() Action action = () => Assert.AreEqual(d1, d2); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 2026-06-09T13:12:21.0000000Z"); - ex.Message.Should().Contain("actual: 2026-06-09T13:12:21.0000001Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 2026-06-09T13:12:21.0000000Z + actual: 2026-06-09T13:12:21.0000001Z + + Assert.AreEqual(d1, d2) + """); } public void AreNotEqual_DateTime_RendersWithFullTickPrecisionInFailureMessage() @@ -32,8 +39,16 @@ public void AreNotEqual_DateTime_RendersWithFullTickPrecisionInFailureMessage() Action action = () => Assert.AreNotEqual(d, d); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("2026-06-09T13:12:21.0000042Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to differ. + + notExpected: 2026-06-09T13:12:21.0000042Z + actual: 2026-06-09T13:12:21.0000042Z + + Assert.AreNotEqual(d, d) + """); } public void AreEqual_DateTimeOffset_RendersFullTickPrecisionInFailureMessage() @@ -43,9 +58,16 @@ public void AreEqual_DateTimeOffset_RendersFullTickPrecisionInFailureMessage() Action action = () => Assert.AreEqual(d1, d2); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 2026-06-09T13:12:21.0000000+02:00"); - ex.Message.Should().Contain("actual: 2026-06-09T13:12:21.0000001+02:00"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 2026-06-09T13:12:21.0000000+02:00 + actual: 2026-06-09T13:12:21.0000001+02:00 + + Assert.AreEqual(d1, d2) + """); } public void AreEqual_Double_RendersFullRoundTripPrecisionInFailureMessage() @@ -55,9 +77,16 @@ public void AreEqual_Double_RendersFullRoundTripPrecisionInFailureMessage() Action action = () => Assert.AreEqual(expected, actual); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("0.30000000000000004"); - ex.Message.Should().Contain("0.3"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 0.30000000000000004 + actual: 0.3 + + Assert.AreEqual(expected, actual) + """); } public void AreEqual_Double_RendersUsingInvariantCultureEvenInCommaLocale() @@ -69,11 +98,16 @@ public void AreEqual_Double_RendersUsingInvariantCultureEvenInCommaLocale() Action action = () => Assert.AreEqual(1.5, 2.5); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 1.5"); - ex.Message.Should().Contain("actual: 2.5"); - ex.Message.Should().NotContain("1,5"); - ex.Message.Should().NotContain("2,5"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 1.5 + actual: 2.5 + + Assert.AreEqual(1.5, 2.5) + """); } finally { @@ -90,14 +124,23 @@ public void AreEqual_Float_DistinguishesTwoNearEqualFloats() Action action = () => Assert.AreEqual(a, b); - Exception ex = action.Should().Throw().Which; - // Build the two renderings the same way the assertion does and assert the - // failure message preserves enough precision to keep them distinguishable. + // The exact "R" format rendering of an adjacent float differs across .NET Framework and modern + // .NET (e.g. "1.00000012" vs "1.0000001"), so build the expected message from the renderer rather + // than hard-coding it. AssertionValueRendererTests verifies that the two renderings differ. string expectedRendering = AssertionValueRenderer.RenderValue(a); string actualRendering = AssertionValueRenderer.RenderValue(b); expectedRendering.Should().NotBe(actualRendering); - ex.Message.Should().Contain(expectedRendering); - ex.Message.Should().Contain(actualRendering); + + action.Should().Throw() + .WithMessage( + $""" + Assertion failed. Expected values to be equal. + + expected: {expectedRendering} + actual: {actualRendering} + + Assert.AreEqual(a, b) + """); } public void AreEqual_TimeSpan_RendersSubSecondPrecisionInFailureMessage() @@ -107,9 +150,16 @@ public void AreEqual_TimeSpan_RendersSubSecondPrecisionInFailureMessage() Action action = () => Assert.AreEqual(t1, t2); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 00:00:01"); - ex.Message.Should().Contain("actual: 00:00:01.0000001"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 00:00:01 + actual: 00:00:01.0000001 + + Assert.AreEqual(t1, t2) + """); } public void AreEqual_Decimal_RendersUsingInvariantCultureEvenInCommaLocale() @@ -121,9 +171,16 @@ public void AreEqual_Decimal_RendersUsingInvariantCultureEvenInCommaLocale() Action action = () => Assert.AreEqual(1.5m, 2.5m); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 1.5"); - ex.Message.Should().Contain("actual: 2.5"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 1.5 + actual: 2.5 + + Assert.AreEqual(1.5m, 2.5m) + """); } finally { @@ -139,9 +196,16 @@ public void AreEqual_TimeOnly_RendersSubSecondPrecisionInFailureMessage() Action action = () => Assert.AreEqual(t1, t2); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 13:12:21.0000000"); - ex.Message.Should().Contain("actual: 13:12:21.0000001"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 13:12:21.0000000 + actual: 13:12:21.0000001 + + Assert.AreEqual(t1, t2) + """); } public void AreEqual_DateOnly_RendersIsoDateInFailureMessage() @@ -151,9 +215,16 @@ public void AreEqual_DateOnly_RendersIsoDateInFailureMessage() Action action = () => Assert.AreEqual(d1, d2); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("expected: 2026-06-09"); - ex.Message.Should().Contain("actual: 2026-06-10"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal. + + expected: 2026-06-09 + actual: 2026-06-10 + + Assert.AreEqual(d1, d2) + """); } #endif @@ -164,9 +235,17 @@ public void AreEqual_WithDoubleDelta_RendersFullPrecisionInFailureMessage() Action action = () => Assert.AreEqual(expected, actual, 1e-12); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("1.0000000001"); - ex.Message.Should().Contain("delta:"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected values to be equal within tolerance. + + expected: 1 + actual: 1.0000000001 + delta: 1E-12 + + Assert.AreEqual(expected, actual, ) + """); } public void IsInRange_DateTime_RendersBoundsAndValueWithTickPrecision() @@ -177,10 +256,16 @@ public void IsInRange_DateTime_RendersBoundsAndValueWithTickPrecision() Action action = () => Assert.IsInRange(min, max, value); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("2026-06-09T00:00:00.0000000Z"); - ex.Message.Should().Contain("2026-06-09T00:00:00.0001000Z"); - ex.Message.Should().Contain("2026-06-09T00:00:00.0001001Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected value to be within the inclusive range. + + expected: [2026-06-09T00:00:00.0000000Z, 2026-06-09T00:00:00.0001000Z] + actual: 2026-06-09T00:00:00.0001001Z + + Assert.IsInRange(min, max, value) + """); } public void IsGreaterThan_DateTime_RendersBothValuesWithTickPrecision() @@ -190,9 +275,16 @@ public void IsGreaterThan_DateTime_RendersBothValuesWithTickPrecision() Action action = () => Assert.IsGreaterThan(b, a); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("2026-06-09T13:12:21.0000000Z"); - ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected value to be greater than the lower bound. + + lower bound: 2026-06-09T13:12:21.0000001Z + actual: 2026-06-09T13:12:21.0000000Z + + Assert.IsGreaterThan(b, a) + """); } public void Contains_DateTimeNotInCollection_RendersTargetWithTickPrecision() @@ -203,8 +295,15 @@ public void Contains_DateTimeNotInCollection_RendersTargetWithTickPrecision() Action action = () => Assert.Contains(target, collection); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected collection to contain the specified element. + + expected: 2026-06-09T13:12:21.0000001Z + + Assert.Contains(target, collection) + """); } public void ContainsAll_DateTimeMissing_RendersExpectedAndCollectionWithTickPrecision() @@ -216,9 +315,17 @@ public void ContainsAll_DateTimeMissing_RendersExpectedAndCollectionWithTickPrec Action action = () => Assert.ContainsAll(expected, collection); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("2026-06-09T13:12:21.0000000Z"); - ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected collection to contain all specified items. + + missing: [2026-06-09T13:12:21.0000001Z] + expected: [2026-06-09T13:12:21.0000000Z, 2026-06-09T13:12:21.0000001Z] + collection: [2026-06-09T13:12:21.0000000Z] + + Assert.ContainsAll(expected, collection) + """); } public void AreSequenceEqual_DateTime_RendersMismatchedElementsWithTickPrecision() @@ -229,8 +336,16 @@ public void AreSequenceEqual_DateTime_RendersMismatchedElementsWithTickPrecision Action action = () => Assert.AreSequenceEqual(expected, actual); - Exception ex = action.Should().Throw().Which; - ex.Message.Should().Contain("2026-06-09T13:12:21.0000000Z"); - ex.Message.Should().Contain("2026-06-09T13:12:21.0000001Z"); + action.Should().Throw() + .WithMessage( + """ + Assertion failed. Expected sequences to be equal. + Sequences have 1 element(s). 1 element(s) differ. First difference at index 0. + + expected: [2026-06-09T13:12:21.0000000Z] + actual: [2026-06-09T13:12:21.0000001Z] + + Assert.AreSequenceEqual(expected, actual) + """); } }