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..63a103dc5b
--- /dev/null
+++ b/test/UnitTests/TestFramework.UnitTests/Assertions/AssertTests.RenderingPrecision.cs
@@ -0,0 +1,351 @@
+// 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);
+
+ 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()
+ {
+ DateTime d = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc).AddTicks(42);
+
+ Action action = () => Assert.AreNotEqual(d, d);
+
+ 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()
+ {
+ var d1 = new DateTimeOffset(2026, 6, 9, 13, 12, 21, TimeSpan.FromHours(2));
+ DateTimeOffset d2 = d1.AddTicks(1);
+
+ Action action = () => Assert.AreEqual(d1, d2);
+
+ 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()
+ {
+ double expected = 0.1 + 0.2;
+ double actual = 0.3;
+
+ Action action = () => Assert.AreEqual(expected, actual);
+
+ 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()
+ {
+ CultureInfo previousCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
+
+ Action action = () => Assert.AreEqual(1.5, 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
+ {
+ 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);
+
+ // 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);
+
+ action.Should().Throw()
+ .WithMessage(
+ $"""
+ Assertion failed. Expected values to be equal.
+
+ expected: {expectedRendering}
+ actual: {actualRendering}
+
+ Assert.AreEqual(a, b)
+ """);
+ }
+
+ public void AreEqual_TimeSpan_RendersSubSecondPrecisionInFailureMessage()
+ {
+ var t1 = TimeSpan.FromSeconds(1);
+ TimeSpan t2 = t1.Add(TimeSpan.FromTicks(1));
+
+ Action action = () => Assert.AreEqual(t1, t2);
+
+ 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()
+ {
+ CultureInfo previousCulture = CultureInfo.CurrentCulture;
+ try
+ {
+ CultureInfo.CurrentCulture = new CultureInfo("fr-FR");
+
+ Action action = () => Assert.AreEqual(1.5m, 2.5m);
+
+ action.Should().Throw()
+ .WithMessage(
+ """
+ Assertion failed. Expected values to be equal.
+
+ expected: 1.5
+ actual: 2.5
+
+ Assert.AreEqual(1.5m, 2.5m)
+ """);
+ }
+ 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);
+
+ 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()
+ {
+ var d1 = new DateOnly(2026, 6, 9);
+ var d2 = new DateOnly(2026, 6, 10);
+
+ Action action = () => Assert.AreEqual(d1, d2);
+
+ action.Should().Throw()
+ .WithMessage(
+ """
+ Assertion failed. Expected values to be equal.
+
+ expected: 2026-06-09
+ actual: 2026-06-10
+
+ Assert.AreEqual(d1, d2)
+ """);
+ }
+#endif
+
+ public void AreEqual_WithDoubleDelta_RendersFullPrecisionInFailureMessage()
+ {
+ double expected = 1.0;
+ double actual = 1.0 + 1e-10;
+
+ Action action = () => Assert.AreEqual(expected, actual, 1e-12);
+
+ 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()
+ {
+ 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);
+
+ 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()
+ {
+ var a = new DateTime(2026, 6, 9, 13, 12, 21, DateTimeKind.Utc);
+ DateTime b = a.AddTicks(1);
+
+ Action action = () => Assert.IsGreaterThan(b, a);
+
+ 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()
+ {
+ 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);
+
+ 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()
+ {
+ 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);
+
+ 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()
+ {
+ 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);
+
+ 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)
+ """);
+ }
+}
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");