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
35 changes: 35 additions & 0 deletions TUnit.Assertions.Tests/Bugs/Tests1600.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,39 @@ public async Task Custom_Comparer()
await Assert.That(array1).IsEquivalentTo(array2).Using(new MyModelComparer());
}

[Test]
public async Task Custom_Predicate()
{
MyModel[] array1 = [new(), new(), new()];
MyModel[] array2 = [new(), new(), new()];

// Using a lambda predicate instead of implementing IEqualityComparer
await Assert.That(array1).IsEquivalentTo(array2).Using((x, y) => true);
}

[Test]
public async Task Custom_Predicate_With_Property_Comparison()
{
var users1 = new[] { new User("Alice", 30), new User("Bob", 25) };
var users2 = new[] { new User("Bob", 25), new User("Alice", 30) };

// Elements have different order but are equivalent by name and age
await Assert.That(users1)
.IsEquivalentTo(users2)
.Using((u1, u2) => u1?.Name == u2?.Name && u1?.Age == u2?.Age);
}

[Test]
public async Task Custom_Predicate_Not_Equivalent()
{
var users1 = new[] { new User("Alice", 30), new User("Bob", 25) };
var users2 = new[] { new User("Charlie", 35), new User("Diana", 28) };

await Assert.That(users1)
.IsNotEquivalentTo(users2)
.Using((u1, u2) => u1?.Name == u2?.Name && u1?.Age == u2?.Age);
}
Comment on lines +25 to +56
Copy link

Copilot AI Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test coverage for the new predicate overloads is incomplete. While IsEquivalentTo and NotEquivalentTo assertions are well tested, there are no tests for the other two assertion types that received the same Using(Func) overload:

  1. IsEquatableOrEqualToAssertion (in PredicateAssertions.cs)
  2. DictionaryContainsKeyAssertion (in DictionaryAssertions.cs)

Consider adding test cases similar to these existing ones to verify that the predicate overload works correctly for IsEquatableOrEqualTo and ContainsKey assertions. This would ensure complete coverage of the new API surface.

Copilot uses AI. Check for mistakes.

public class MyModel
{
public string Id { get; } = Guid.NewGuid().ToString();
Expand All @@ -39,4 +72,6 @@ public int GetHashCode(MyModel obj)
return 1;
}
}

public record User(string Name, int Age);
}
7 changes: 7 additions & 0 deletions TUnit.Assertions/Conditions/DictionaryAssertions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;

namespace TUnit.Assertions.Conditions;
Expand Down Expand Up @@ -29,6 +30,12 @@ public DictionaryContainsKeyAssertion<TDictionary, TKey, TValue> Using(IEquality
return new DictionaryContainsKeyAssertion<TDictionary, TKey, TValue>(Context, _expectedKey, comparer);
}

public DictionaryContainsKeyAssertion<TDictionary, TKey, TValue> Using(Func<TKey?, TKey?, bool> equalityPredicate)
{
return new DictionaryContainsKeyAssertion<TDictionary, TKey, TValue>(
Context, _expectedKey, new FuncEqualityComparer<TKey>(equalityPredicate));
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TDictionary> metadata)
{
var value = metadata.Value;
Expand Down
25 changes: 25 additions & 0 deletions TUnit.Assertions/Conditions/Helpers/FuncEqualityComparer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
namespace TUnit.Assertions.Conditions.Helpers;

/// <summary>
/// An IEqualityComparer implementation that uses a custom Func for equality comparison.
/// This allows users to pass lambda predicates to assertion methods like Using().
/// </summary>
/// <typeparam name="T">The type of objects to compare.</typeparam>
internal sealed class FuncEqualityComparer<T> : IEqualityComparer<T>
{
private readonly Func<T?, T?, bool> _equals;

public FuncEqualityComparer(Func<T?, T?, bool> equals)
{
_equals = equals ?? throw new ArgumentNullException(nameof(equals));
}

public bool Equals(T? x, T? y) => _equals(x, y);

// Return a constant hash code to force linear search in collection equivalency.
// This is intentional because:
// 1. We cannot derive a meaningful hash function from an equality predicate
// 2. CollectionEquivalencyChecker already uses O(n²) linear search for custom comparers
// 3. This matches the expected behavior for all custom IEqualityComparer implementations
public int GetHashCode(T obj) => 0;
}
6 changes: 6 additions & 0 deletions TUnit.Assertions/Conditions/IsEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public IsEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TItem
return this;
}

public IsEquivalentToAssertion<TCollection, TItem> Using(Func<TItem?, TItem?, bool> equalityPredicate)
{
SetComparer(new FuncEqualityComparer<TItem>(equalityPredicate));
return this;
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
{
var value = metadata.Value;
Expand Down
6 changes: 6 additions & 0 deletions TUnit.Assertions/Conditions/NotEquivalentToAssertion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ public NotEquivalentToAssertion<TCollection, TItem> Using(IEqualityComparer<TIte
return this;
}

public NotEquivalentToAssertion<TCollection, TItem> Using(Func<TItem?, TItem?, bool> equalityPredicate)
{
SetComparer(new FuncEqualityComparer<TItem>(equalityPredicate));
return this;
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TCollection> metadata)
{
var value = metadata.Value;
Expand Down
7 changes: 7 additions & 0 deletions TUnit.Assertions/Conditions/PredicateAssertions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text;
using TUnit.Assertions.Attributes;
using TUnit.Assertions.Conditions.Helpers;
using TUnit.Assertions.Core;

namespace TUnit.Assertions.Conditions;
Expand Down Expand Up @@ -66,6 +67,12 @@ public IsEquatableOrEqualToAssertion<TValue> Using(IEqualityComparer<TValue> com
return this;
}

public IsEquatableOrEqualToAssertion<TValue> Using(Func<TValue?, TValue?, bool> equalityPredicate)
{
SetComparer(new FuncEqualityComparer<TValue>(equalityPredicate));
return this;
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TValue> metadata)
{
var value = metadata.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TDictionary> metadata) { }
protected override string GetExpectation() { }
public .<TDictionary, TKey, TValue> Using(.<TKey> comparer) { }
public .<TDictionary, TKey, TValue> Using(<TKey?, TKey?, bool> equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion<TDictionary, TKey, TValue> : .<TDictionary, TKey, TValue>
where TDictionary : .<TKey, TValue>
Expand Down Expand Up @@ -840,6 +841,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
public .<TValue> Using(<TValue?, TValue?, bool> equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
Expand All @@ -852,6 +854,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class IsNotAssignableToAssertion<TTarget, TValue> : .<TValue>
{
Expand Down Expand Up @@ -953,6 +956,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class NotNullAssertion<TValue> : .<TValue>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TDictionary> metadata) { }
protected override string GetExpectation() { }
public .<TDictionary, TKey, TValue> Using(.<TKey> comparer) { }
public .<TDictionary, TKey, TValue> Using(<TKey?, TKey?, bool> equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion<TDictionary, TKey, TValue> : .<TDictionary, TKey, TValue>
where TDictionary : .<TKey, TValue>
Expand Down Expand Up @@ -835,6 +836,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
public .<TValue> Using(<TValue?, TValue?, bool> equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
Expand All @@ -847,6 +849,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class IsNotAssignableToAssertion<TTarget, TValue> : .<TValue>
{
Expand Down Expand Up @@ -948,6 +951,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class NotNullAssertion<TValue> : .<TValue>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TDictionary> metadata) { }
protected override string GetExpectation() { }
public .<TDictionary, TKey, TValue> Using(.<TKey> comparer) { }
public .<TDictionary, TKey, TValue> Using(<TKey?, TKey?, bool> equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion<TDictionary, TKey, TValue> : .<TDictionary, TKey, TValue>
where TDictionary : .<TKey, TValue>
Expand Down Expand Up @@ -840,6 +841,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
public .<TValue> Using(<TValue?, TValue?, bool> equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
Expand All @@ -852,6 +854,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class IsNotAssignableToAssertion<TTarget, TValue> : .<TValue>
{
Expand Down Expand Up @@ -953,6 +956,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class NotNullAssertion<TValue> : .<TValue>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TDictionary> metadata) { }
protected override string GetExpectation() { }
public .<TDictionary, TKey, TValue> Using(.<TKey> comparer) { }
public .<TDictionary, TKey, TValue> Using(<TKey?, TKey?, bool> equalityPredicate) { }
}
public class DictionaryDoesNotContainKeyAssertion<TDictionary, TKey, TValue> : .<TDictionary, TKey, TValue>
where TDictionary : .<TKey, TValue>
Expand Down Expand Up @@ -810,6 +811,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TValue> metadata) { }
protected override string GetExpectation() { }
public .<TValue> Using(.<TValue> comparer) { }
public .<TValue> Using(<TValue?, TValue?, bool> equalityPredicate) { }
}
[.("IsEquivalentTo")]
public class IsEquivalentToAssertion<TCollection, TItem> : .<TCollection, TItem>
Expand All @@ -820,6 +822,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class IsNotAssignableToAssertion<TTarget, TValue> : .<TValue>
{
Expand Down Expand Up @@ -919,6 +922,7 @@ namespace .Conditions
protected override .<.> CheckAsync(.<TCollection> metadata) { }
protected override string GetExpectation() { }
public .<TCollection, TItem> Using(.<TItem> comparer) { }
public .<TCollection, TItem> Using(<TItem?, TItem?, bool> equalityPredicate) { }
}
public class NotNullAssertion<TValue> : .<TValue>
{
Expand Down
Loading