Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Issue6296Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
namespace TUnit.Assertions.Tests.Bugs;

/// <summary>
/// Regression tests for GitHub issue #6296:
/// <c>await Assert.That(guid).IsEqualTo(guid)</c> failed to compile with
/// <c>CS0121: The call is ambiguous between IsEqualTo&lt;TValue, TOther&gt; and
/// IsEqualTo&lt;TValue&gt;</c> for consumers building with the .NET 8 SDK.
///
/// The cross-type <c>IsEqualTo&lt;TValue, TOther&gt;</c> overload (added for value-object
/// ergonomics, see <see cref="Issue5720Tests"/>) is equally applicable to the same-type
/// overload when the expected value is the same type as the source (Guid vs Guid). It was
/// previously deprioritized only by <c>[OverloadResolutionPriority]</c>, which is honored
/// solely by the Roslyn that ships with the .NET 9 SDK and later — NOT by LangVersion. The
/// .NET 8 SDK's compiler ignores it, so the call stayed ambiguous regardless of LangVersion.
///
/// The fix gives the cross-type overloads a trailing <c>params CrossTypeOverloadMarker[]</c>,
/// making them applicable only in expanded form so they lose to the normal-form same-type
/// overload via the compiler-version-independent "normal beats expanded" tie-break. The marker
/// element type (rather than <c>object</c>) has no accessible constructor, so a stray trailing
/// argument — e.g. <c>IsEqualTo("x", StringComparer.Ordinal)</c> on a value-object source — is a
/// compile error instead of being silently swallowed and discarded (PR #6313 review). The body
/// of each test below would not compile before the fix when built with the .NET 8 SDK —
/// successful compilation IS the regression assertion; the runtime assertions confirm the
/// same-type overload (default equality), not the implicit-conversion overload, is selected.
/// </summary>
public class Issue6296Tests
{
[Test]
public async Task Guid_IsEqualTo_SameType_IsNotAmbiguous()
{
var value = Guid.Parse("11111111-1111-1111-1111-111111111111");
var same = Guid.Parse("11111111-1111-1111-1111-111111111111");

await Assert.That(value).IsEqualTo(same);
}

[Test]
public async Task Guid_IsNotEqualTo_SameType_IsNotAmbiguous()
{
var value = Guid.Parse("11111111-1111-1111-1111-111111111111");
var other = Guid.Parse("22222222-2222-2222-2222-222222222222");

await Assert.That(value).IsNotEqualTo(other);
}

[Test]
public async Task Int_IsEqualTo_SameType_IsNotAmbiguous()
{
await Assert.That(42).IsEqualTo(42);
}

[Test]
public async Task DateTime_IsEqualTo_SameType_IsNotAmbiguous()
{
var now = new DateTime(2026, 6, 24, 0, 0, 0, DateTimeKind.Utc);

await Assert.That(now).IsEqualTo(now);
}

[Test]
public async Task Guid_IsEqualTo_SameType_Fails_When_Different()
{
var value = Guid.Parse("11111111-1111-1111-1111-111111111111");
var other = Guid.Parse("22222222-2222-2222-2222-222222222222");

await Assert.That(async () => await Assert.That(value).IsEqualTo(other))
.Throws<AssertionException>();
}

// The expanded-form marker on the cross-type overload must not steal a genuine
// same-type-with-comparer call: the second positional argument is an IEqualityComparer,
// which would land in the marker's params array if the marker were `params object[]`.
// CrossTypeOverloadMarker has no accessible ctor, so this binds to the real same-type
// comparer overload and the comparer is actually applied (PR #6313 review, finding #1).
[Test]
public async Task String_IsEqualTo_SameType_WithComparer_BindsComparerOverload()
{
await Assert.That("VALUE").IsEqualTo("value", StringComparer.OrdinalIgnoreCase);
}

[Test]
public async Task String_IsEqualTo_SameType_WithComparer_Fails_When_ComparerRejects()
{
await Assert.That(async () =>
await Assert.That("VALUE").IsEqualTo("value", StringComparer.Ordinal))
.Throws<AssertionException>();
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
// These overloads rely on [OverloadResolutionPriority] (honored only by C# 13+) to lose to the
// source-generated same-type IsEqualTo / IsNotEqualTo. TUnit raises consumer LangVersion so the
// attribute is honored on every target (see TUnit.Assertions.props). Issues #5765, #6276, #6280.
// These cross-type overloads must lose to the source-generated same-type IsEqualTo / IsNotEqualTo
// when the expected argument is the same type as the source (e.g. Guid vs Guid), where both are
// applicable. Two layered mechanisms achieve that:
//
// 1. The trailing `params CrossTypeOverloadMarker[]` makes each overload applicable only in
// EXPANDED form, so a same-type call binds to the normal-form same-type overload via the C#
// "normal beats expanded" tie-break (§12.6.4.2). This is honored by EVERY Roslyn version,
// including the .NET 8 SDK's compiler, which is the only mechanism that fixes #6296.
// 2. [OverloadResolutionPriority(-1)] is kept as a redundant signal for completeness on modern
// compilers, but it is NOT load-bearing — it is honored only by Roslyn 4.12+ (.NET 9 SDK and
// later), so consumers pinned to the .NET 8 SDK via global.json never see its effect.
//
// Why CrossTypeOverloadMarker and not `params object[]`: an `object[]` marker is applicable in
// expanded form to ANY trailing argument, so a call like
// `Assert.That(productCode).IsEqualTo("x", StringComparer.OrdinalIgnoreCase)` would silently bind
// here and DISCARD the comparer — a false-pass footgun for a test framework (PR #6313 review).
// CrossTypeOverloadMarker has no accessible constructor, so callers can never supply an element:
// the overload stays applicable only with ZERO trailing args (the tie-break case), and any stray
// trailing argument is a compile error again, exactly as it was before #6296.
//
// History: #5765 / #6276 / #6280 tried to fix this purely via ORP + a LangVersion bump in
// TUnit.Assertions.props. That bump is a no-op for the real failure because ORP honoring tracks
// the build SDK's Roslyn version, not LangVersion — see #6296. The `params` tie-break is what
// makes this compiler-version-independent. The unused `_` parameter is never read.
using System.Collections.Concurrent;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.CompilerServices;
Expand All @@ -13,9 +35,9 @@ namespace TUnit.Assertions.Extensions;
/// <summary>
/// Adds <c>IsEqualTo</c> overloads accepting an expected value of a different type when the
/// source type defines an implicit conversion operator to that type. Defined as a partial of
/// the source-generated <c>EqualsAssertionExtensions</c> so it lives in the same containing
/// type as the original overload — required for <see cref="OverloadResolutionPriorityAttribute"/>
/// to disambiguate calls where both overloads are applicable (e.g. enum or value-type sources).
/// the source-generated <c>EqualsAssertionExtensions</c> so it shares the containing type. The
/// trailing <c>params</c> array on each overload keeps it from displacing the same-type overload
/// for same-type calls (e.g. enum or value-type sources) — see the file header for the why.
///
/// Enables ergonomic comparison of wrapper Value Objects against their wrapped primitive,
/// e.g. <c>await Assert.That(productCode).IsEqualTo("Example")</c> when
Expand All @@ -34,7 +56,10 @@ public static partial class EqualsAssertionExtensions
public static EqualsAssertion<TOther> IsEqualTo<TValue, TOther>(
this IAssertionSource<TValue> source,
TOther? expected,
[CallerArgumentExpression(nameof(expected))] string? expectedExpression = null)
[CallerArgumentExpression(nameof(expected))] string? expectedExpression = null,
// Expanded-form-only marker so a same-type call loses to the same-type overload; never read.
// Element type has no accessible ctor, so stray trailing args stay a compile error. See file header.
params CrossTypeOverloadMarker[] _)
{
var converter = ImplicitConversionCache.GetConverter<TValue, TOther>();
source.Context.ExpressionBuilder.Append($".IsEqualTo({expectedExpression})");
Expand Down Expand Up @@ -62,7 +87,10 @@ public static partial class NotEqualsAssertionExtensions
public static NotEqualsAssertion<TOther> IsNotEqualTo<TValue, TOther>(
this IAssertionSource<TValue> source,
TOther? notExpected,
[CallerArgumentExpression(nameof(notExpected))] string? notExpectedExpression = null)
[CallerArgumentExpression(nameof(notExpected))] string? notExpectedExpression = null,
// Expanded-form-only marker so a same-type call loses to the same-type overload; never read.
// Element type has no accessible ctor, so stray trailing args stay a compile error. See file header.
params CrossTypeOverloadMarker[] _)
{
var converter = ImplicitConversionCache.GetConverter<TValue, TOther>();
source.Context.ExpressionBuilder.Append($".IsNotEqualTo({notExpectedExpression})");
Expand All @@ -71,6 +99,20 @@ public static NotEqualsAssertion<TOther> IsNotEqualTo<TValue, TOther>(
}
}

/// <summary>
/// Marker element type for the trailing <c>params</c> on the cross-type
/// <c>IsEqualTo</c> / <c>IsNotEqualTo</c> overloads. Its only purpose is to make those overloads
/// applicable solely in <em>expanded</em> form so a same-type call loses to the same-type overload
/// (see <see cref="EqualsAssertionExtensions"/>). It has no accessible constructor, so callers can
/// never pass an element — the overloads bind only with zero trailing arguments, and any stray
/// trailing argument remains a compile error rather than being silently discarded. Never instantiated.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public sealed class CrossTypeOverloadMarker
{
private CrossTypeOverloadMarker() { }
}

/// <summary>
/// Caches user-defined implicit conversion operators from <c>TFrom</c> to <c>TTo</c>.
/// Reflection lookup is performed once per type pair and the resulting delegate is reused.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3203,6 +3203,7 @@ namespace .Extensions
protected override .<.> CheckAsync(.<.Cookie> metadata) { }
protected override string GetExpectation() { }
}
public sealed class CrossTypeOverloadMarker { }
public static class CultureInfoAssertionExtensions
{
[.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")]
Expand Down Expand Up @@ -4357,7 +4358,7 @@ namespace .Extensions
[.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" +
"efined operators.")]
[.(-1)]
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null) { }
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { }
}
public static class EquatableAssertionExtensions
{
Expand Down Expand Up @@ -5310,7 +5311,7 @@ namespace .Extensions
[.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" +
"efined operators.")]
[.(-1)]
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { }
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { }
}
public static class NotEquivalentToAssertionExtensions
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3168,6 +3168,7 @@ namespace .Extensions
protected override .<.> CheckAsync(.<.Cookie> metadata) { }
protected override string GetExpectation() { }
}
public sealed class CrossTypeOverloadMarker { }
public static class CultureInfoAssertionExtensions
{
[.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")]
Expand Down Expand Up @@ -4301,7 +4302,7 @@ namespace .Extensions
public static .<TValue> IsEqualTo<TValue>(this .<TValue> source, TValue? expected, .<TValue> comparer, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null) { }
[.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" +
"efined operators.")]
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null) { }
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { }
}
public static class EquatableAssertionExtensions
{
Expand Down Expand Up @@ -5230,7 +5231,7 @@ namespace .Extensions
public static .<TValue> IsNotEqualTo<TValue>(this .<TValue> source, TValue notExpected, .<TValue>? comparer = null, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null) { }
[.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" +
"efined operators.")]
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { }
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { }
}
public static class NotEquivalentToAssertionExtensions
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3203,6 +3203,7 @@ namespace .Extensions
protected override .<.> CheckAsync(.<.Cookie> metadata) { }
protected override string GetExpectation() { }
}
public sealed class CrossTypeOverloadMarker { }
public static class CultureInfoAssertionExtensions
{
[.("Trimming", "IL2091", Justification="Generic type parameter is only used for property access, not instantiation")]
Expand Down Expand Up @@ -4357,7 +4358,7 @@ namespace .Extensions
[.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" +
"efined operators.")]
[.(-1)]
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null) { }
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { }
}
public static class EquatableAssertionExtensions
{
Expand Down Expand Up @@ -5310,7 +5311,7 @@ namespace .Extensions
[.("Looks up implicit conversion operators via reflection. Trimming may remove user-d" +
"efined operators.")]
[.(-1)]
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { }
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { }
}
public static class NotEquivalentToAssertionExtensions
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2845,6 +2845,7 @@ namespace .Extensions
protected override .<.> CheckAsync(.<.Cookie> metadata) { }
protected override string GetExpectation() { }
}
public sealed class CrossTypeOverloadMarker { }
public static class CultureInfoAssertionExtensions
{
public static ._IsEnglish_Assertion IsEnglish<TActual>(this .<TActual> source)
Expand Down Expand Up @@ -3822,7 +3823,7 @@ namespace .Extensions
{
public static .<TValue> IsEqualTo<TValue>(this .<TValue> source, TValue? expected, [.("expected")] string? expectedExpression = null) { }
public static .<TValue> IsEqualTo<TValue>(this .<TValue> source, TValue? expected, .<TValue> comparer, [.("expected")] string? expectedExpression = null, [.("comparer")] string? comparerExpression = null) { }
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null) { }
public static .<TOther> IsEqualTo<TValue, TOther>(this .<TValue> source, TOther? expected, [.("expected")] string? expectedExpression = null, params .[] _) { }
}
public static class EquatableAssertionExtensions
{
Expand Down Expand Up @@ -4651,7 +4652,7 @@ namespace .Extensions
public static class NotEqualsAssertionExtensions
{
public static .<TValue> IsNotEqualTo<TValue>(this .<TValue> source, TValue notExpected, .<TValue>? comparer = null, [.("notExpected")] string? notExpectedExpression = null, [.("comparer")] string? comparerExpression = null) { }
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null) { }
public static .<TOther> IsNotEqualTo<TValue, TOther>(this .<TValue> source, TOther? notExpected, [.("notExpected")] string? notExpectedExpression = null, params .[] _) { }
}
public static class NotEquivalentToAssertionExtensions
{
Expand Down
Loading