Skip to content

Commit 96d9720

Browse files
authored
+semver:minor - fix(assertions): resolve StackOverflowException in chaining with property tests (#3461) (#3468)
1 parent dc06b60 commit 96d9720

File tree

4 files changed

+34
-12
lines changed

4 files changed

+34
-12
lines changed

TUnit.Assertions.Tests/TypeOfTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -679,4 +679,31 @@ public async Task IsTypeOf_EmptyArray_Success()
679679

680680
await Assert.That(result.Length).IsEqualTo(0);
681681
}
682+
683+
// ============ CHAINING WITH PROPERTY TESTS (Issue #3461) ============
684+
685+
private record TestRecord(int Id, string Name);
686+
687+
[Test]
688+
public async Task IsTypeOf_WithHasProperty_OnRecord_DoesNotStackOverflow()
689+
{
690+
// Regression test for issue #3461
691+
// This previously caused a StackOverflowException due to duplicate pending link consumption
692+
object record = new TestRecord(2, "Test");
693+
694+
await Assert.That(record)
695+
.IsTypeOf<TestRecord>()
696+
.And.HasProperty(x => x.Id).IsEqualTo(2);
697+
}
698+
699+
[Test]
700+
public async Task IsTypeOf_WithMultipleHasProperty_OnRecord()
701+
{
702+
object record = new TestRecord(42, "Hello");
703+
704+
var casted = await Assert.That(record).IsTypeOf<TestRecord>();
705+
706+
await Assert.That(casted).HasProperty(x => x.Id).IsEqualTo(42);
707+
await Assert.That(casted).HasProperty(x => x.Name).IsEqualTo("Hello");
708+
}
682709
}

TUnit.Assertions/Conditions/TypeOfAssertion.cs

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,8 @@ public TypeOfAssertion(
2525
$"Value is of type {value?.GetType().Name ?? "null"}, not {typeof(TTo).Name}");
2626
}))
2727
{
28-
// Transfer pending links from parent context to handle cross-type chaining
29-
// e.g., Assert.That(obj).IsNotNull().And.IsTypeOf<string>()
30-
var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink();
31-
if (pendingAssertion != null)
32-
{
33-
// Store the pending assertion execution as pre-work
34-
// It will be executed before any assertions on the casted value
35-
Context.PendingPreWork = async () => await pendingAssertion.ExecuteCoreAsync();
36-
}
37-
28+
// Note: Pending link transfer is handled by AssertionContext<T>.Map()
29+
// No need to duplicate that logic here
3830
_expectedType = typeof(TTo);
3931
}
4032

TUnit.Assertions/Core/Assertion.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,9 @@ public Assertion<TValue> Because(string message)
126126
// Execute any pending cross-type assertions first (e.g., string assertions before WhenParsedInto<int>)
127127
if (Context.PendingPreWork != null)
128128
{
129-
await Context.PendingPreWork();
130-
Context.PendingPreWork = null; // Execute only once
129+
var preWork = Context.PendingPreWork;
130+
Context.PendingPreWork = null; // Clear BEFORE execution to prevent re-entry
131+
await preWork();
131132
}
132133

133134
// If this is an And/OrAssertion (composite), delegate to AssertAsync which has custom logic

TUnit.Assertions/Core/AssertionContext.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ public AssertionContext(TValue? value, StringBuilder expressionBuilder)
4646
/// - Transferring the expression builder
4747
/// - Consuming pending links from the source context
4848
/// - Setting up pre-work to execute previous assertions before the transformation
49+
///
50+
/// Uses lazy evaluation to prevent stack overflow in circular reference scenarios.
4951
/// </summary>
5052
/// <typeparam name="TNew">The target type after transformation</typeparam>
5153
/// <param name="evaluationFactory">Factory to create the new evaluation context from the current one</param>

0 commit comments

Comments
 (0)