From 04d87c758727ef2db429bee665159f080103ccd3 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:58:39 +0100 Subject: [PATCH 1/7] chore(deps): update microsoft.extensions (#3389) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- .../ExampleNamespace.ServiceDefaults.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 5984944456..68396877ab 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,7 +33,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.ServiceDefaults/ExampleNamespace.ServiceDefaults.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.ServiceDefaults/ExampleNamespace.ServiceDefaults.csproj index ae6ba2734e..145a4ab04b 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.ServiceDefaults/ExampleNamespace.ServiceDefaults.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.ServiceDefaults/ExampleNamespace.ServiceDefaults.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index 828927e44a..ed2ed070a1 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -9,7 +9,7 @@ - + From 0dcf2c0572ebe6fca2defb937f8877073480ce7f Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Wed, 15 Oct 2025 20:51:13 +0100 Subject: [PATCH 2/7] chore(deps): update tunit to 0.72.0 (#3358) Co-authored-by: Renovate Bot --- Directory.Packages.props | 6 +++--- .../TUnit.AspNet.FSharp/TestProject/TestProject.fsproj | 4 ++-- .../content/TUnit.AspNet/TestProject/TestProject.csproj | 2 +- .../ExampleNamespace.TestProject.csproj | 2 +- .../content/TUnit.Aspire.Test/ExampleNamespace.csproj | 2 +- TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj | 4 ++-- TUnit.Templates/content/TUnit.Playwright/TestProject.csproj | 2 +- TUnit.Templates/content/TUnit.VB/TestProject.vbproj | 2 +- TUnit.Templates/content/TUnit/TestProject.csproj | 2 +- 9 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 68396877ab..d0bf6d57fa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -81,9 +81,9 @@ - - - + + + diff --git a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj index 0a7884f573..41e322e43c 100644 --- a/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.AspNet.FSharp/TestProject/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj index af5010ea56..b3a97ed21a 100644 --- a/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.AspNet/TestProject/TestProject.csproj @@ -9,7 +9,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj index ad2e7f40c0..c745d587f1 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Starter/ExampleNamespace.TestProject/ExampleNamespace.TestProject.csproj @@ -11,7 +11,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj index 71d57db7c1..daa4fc3ae0 100644 --- a/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj +++ b/TUnit.Templates/content/TUnit.Aspire.Test/ExampleNamespace.csproj @@ -10,7 +10,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj index ed2ed070a1..2bf7bf07f7 100644 --- a/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj +++ b/TUnit.Templates/content/TUnit.FSharp/TestProject.fsproj @@ -10,8 +10,8 @@ - - + + diff --git a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj index b32bc698dd..2a1820d4fb 100644 --- a/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj +++ b/TUnit.Templates/content/TUnit.Playwright/TestProject.csproj @@ -8,7 +8,7 @@ - + diff --git a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj index 5e967dc3d0..37b455bdd4 100644 --- a/TUnit.Templates/content/TUnit.VB/TestProject.vbproj +++ b/TUnit.Templates/content/TUnit.VB/TestProject.vbproj @@ -8,6 +8,6 @@ - + diff --git a/TUnit.Templates/content/TUnit/TestProject.csproj b/TUnit.Templates/content/TUnit/TestProject.csproj index 6decaf09e3..e118650e5d 100644 --- a/TUnit.Templates/content/TUnit/TestProject.csproj +++ b/TUnit.Templates/content/TUnit/TestProject.csproj @@ -8,7 +8,7 @@ - + \ No newline at end of file From 0c7560a3477b9ac935763a983629e53df7e561e5 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Oct 2025 06:50:07 +0100 Subject: [PATCH 3/7] chore(deps): update dependency verify.nunit to 31.0.2 (#3403) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index d0bf6d57fa..f36c1ce7bd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -80,7 +80,7 @@ - + From ebe45903d528e788e762fa29915ad393e1da423e Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Oct 2025 08:02:18 +0100 Subject: [PATCH 4/7] chore(deps): update dependency verify to 31.0.2 (#3402) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index f36c1ce7bd..2cc09977ec 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -79,7 +79,7 @@ - + From ac2141efc55d33597b19f6fac3fe56e1bb773738 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Oct 2025 08:49:15 +0100 Subject: [PATCH 5/7] chore(deps): update dependency verify.tunit to 31.0.2 (#3404) Co-authored-by: Renovate Bot --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2cc09977ec..175adba300 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -84,7 +84,7 @@ - + From ad752d5c83f6f48d0a3fa2c06f0bf72a45fd859d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Oct 2025 14:17:06 +0100 Subject: [PATCH 6/7] chore(deps): update docker/setup-docker-action action to v4.4.0 (#3408) Co-authored-by: Renovate Bot --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 53a8a62f0d..112c2ecbeb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -55,7 +55,7 @@ jobs: - name: Docker Setup Docker if: matrix.os == 'ubuntu-latest' - uses: docker/setup-docker-action@v4.3.0 + uses: docker/setup-docker-action@v4.4.0 - name: Install Playwright Dependencies run: npx playwright install-deps From 3dc8088020f1b5fb31834c42bb49c5f73072ed73 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 16 Oct 2025 18:34:45 +0100 Subject: [PATCH 7/7] +semver:minor - refactor(assertions): update member assertion syntax for improved chaining (#3409) --- .../AwaitAssertionAnalyzerTests.cs | 4 +- TUnit.Assertions.Tests/Old/MemberTests.cs | 146 ++++++++++-- .../Conditions/MemberAssertion.cs | 224 ++++++++++++++---- .../Conditions/Wrappers/CountWrapper.cs | 2 +- .../Extensions/AssertionExtensions.cs | 67 +++++- ...Has_No_API_Changes.DotNet10_0.verified.txt | 10 +- ..._Has_No_API_Changes.DotNet8_0.verified.txt | 10 +- ..._Has_No_API_Changes.DotNet9_0.verified.txt | 10 +- ...ary_Has_No_API_Changes.Net4_7.verified.txt | 10 +- .../Bugs/Issue2993/ImplicitConversionTests.cs | 16 +- .../CombinedConstraintsSelfContainedTest.cs | 42 ++-- TUnit.TestProject/GlobalTestHooks.cs | 2 +- .../NestedTupleDataSourceTests.cs | 2 +- .../NotInParallelClassGroupingTests.cs | 10 +- .../TestContextIsolationTests.cs | 118 ++++----- docs/docs/assertions/awaiting.md | 29 +-- docs/docs/assertions/member-assertions.md | 140 +++++++++++ 17 files changed, 627 insertions(+), 215 deletions(-) create mode 100644 docs/docs/assertions/member-assertions.md diff --git a/TUnit.Assertions.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs b/TUnit.Assertions.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs index ae08788385..b260910502 100644 --- a/TUnit.Assertions.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs +++ b/TUnit.Assertions.Analyzers.Tests/AwaitAssertionAnalyzerTests.cs @@ -133,7 +133,7 @@ public async Task MyTest() using (Assert.Multiple()) { await Assert.That(list).IsEquivalentTo(new[] { 1, 2, 3, 4, 5 }); - await Assert.That(list).HasCount().EqualTo(5); + await Assert.That(list).Count().EqualTo(5); } } } @@ -163,7 +163,7 @@ public async Task MyTest() using var _ = Assert.Multiple(); await Assert.That(list).IsEquivalentTo(new[] { 1, 2, 3, 4, 5 }); - await Assert.That(list).HasCount().EqualTo(5); + await Assert.That(list).Count().EqualTo(5); } } """ diff --git a/TUnit.Assertions.Tests/Old/MemberTests.cs b/TUnit.Assertions.Tests/Old/MemberTests.cs index f5505413cc..c20251b751 100644 --- a/TUnit.Assertions.Tests/Old/MemberTests.cs +++ b/TUnit.Assertions.Tests/Old/MemberTests.cs @@ -13,7 +13,7 @@ public async Task Number_Truthy() Flag = false }; - await TUnitAssert.That(myClass).HasMember(x => x.Number).EqualTo(123); + await TUnitAssert.That(myClass).Member(x => x.Number, num => num.IsEqualTo(123)); } [Test] @@ -26,15 +26,11 @@ public async Task Number_Falsey() Flag = false }; - var exception = await TUnitAssert.ThrowsAsync(async () => await TUnitAssert.That(myClass).HasMember(x => x.Number).EqualTo(1)); - await TUnitAssert.That(exception).HasMessageEqualTo( - """ - Expected to be equal to 1 - but found 123 + var exception = await TUnitAssert.ThrowsAsync(async () => + await TUnitAssert.That(myClass).Member(x => x.Number, num => num.IsEqualTo(1))); - at Assert.That(myClass).HasMember(x => x.Number).EqualTo(1) - """ - ); + await TUnitAssert.That(exception.Message).Contains("to be equal to 1"); + await TUnitAssert.That(exception.Message).Contains("but found 123"); } [Test] @@ -47,15 +43,11 @@ public async Task Number_Nested_Falsey() Flag = false }; - var exception = await TUnitAssert.ThrowsAsync(async () => await TUnitAssert.That(myClass).HasMember(x => x.Nested.Nested.Nested.Number).EqualTo(1)); - await TUnitAssert.That(exception).HasMessageEqualTo( - """ - Expected to be equal to 1 - but found 123 + var exception = await TUnitAssert.ThrowsAsync(async () => + await TUnitAssert.That(myClass).Member(x => x.Nested.Nested.Nested.Number, num => num.IsEqualTo(1))); - at Assert.That(myClass).HasMember(x => x.Nested.Nested.Nested.Number).EqualTo(1) - """ - ); + await TUnitAssert.That(exception.Message).Contains("to be equal to 1"); + await TUnitAssert.That(exception.Message).Contains("but found 123"); } [Test] @@ -63,15 +55,111 @@ public async Task Number_Null() { MyClass myClass = null!; - var exception = await TUnitAssert.ThrowsAsync(async () => await TUnitAssert.That(myClass).HasMember(x => x.Number).EqualTo(1)); - await TUnitAssert.That(exception).HasMessageEqualTo( - """ - Expected to be equal to 1 - but threw System.InvalidOperationException + var exception = await TUnitAssert.ThrowsAsync(async () => + await TUnitAssert.That(myClass).Member(x => x.Number, num => num.IsEqualTo(1))); - at Assert.That(myClass).HasMember(x => x.Number).EqualTo(1) - """ - ); + await TUnitAssert.That(exception.Message).Contains("InvalidOperationException"); + } + + [Test] + public async Task Multiple_HasMember_Chained_With_And() + { + var myClass = new MyClass + { + Number = 123, + Text = "Blah", + Flag = true + }; + + await TUnitAssert.That(myClass) + .Member(x => x.Number, num => num.IsEqualTo(123)) + .And.Member(x => x.Text, text => text.IsEqualTo("Blah")) + .And.Member(x => x.Flag, flag => flag.IsTrue()); + } + + [Test] + public async Task Multiple_HasMember_Chained_Second_Fails() + { + var myClass = new MyClass + { + Number = 123, + Text = "Blah", + Flag = true + }; + + var exception = await TUnitAssert.ThrowsAsync(async () => + await TUnitAssert.That(myClass) + .Member(x => x.Number, num => num.IsEqualTo(123)) + .And.Member(x => x.Text, text => text.IsEqualTo("Wrong"))); + + await TUnitAssert.That(exception.Message).Contains("to be equal to \"Wrong\""); + } + + [Test] + public async Task Multiple_HasMember_Chained_First_Fails() + { + var myClass = new MyClass + { + Number = 123, + Text = "Blah", + Flag = true + }; + + var exception = await TUnitAssert.ThrowsAsync(async () => + await TUnitAssert.That(myClass) + .Member(x => x.Number, num => num.IsEqualTo(999)) + .And.Member(x => x.Text, text => text.IsEqualTo("Blah"))); + + await TUnitAssert.That(exception.Message).Contains("to be equal to 999"); + } + + [Test] + public async Task Multiple_HasMember_Chained_With_Or() + { + var myClass = new MyClass + { + Number = 123, + Text = "Blah", + Flag = true + }; + + await TUnitAssert.That(myClass) + .Member(x => x.Number, num => num.IsEqualTo(999)) + .Or.Member(x => x.Text, text => text.IsEqualTo("Blah")); + } + + [Test] + public async Task Chained_HasMember_With_IsNotNull() + { + var myClass = new MyClass + { + Number = 123, + Text = "Blah", + Flag = true + }; + + await TUnitAssert.That(myClass) + .IsNotNull() + .And.Member(x => x.Number, num => num.IsEqualTo(123)) + .And.Member(x => x.Text, text => text.IsEqualTo("Blah")); + } + + [Test] + public async Task Chained_HasMember_Different_Types() + { + var complexObject = new ComplexClass + { + Name = "Test", + Age = 25, + IsActive = true, + Tags = ["tag1", "tag2"] + }; + + await TUnitAssert.That(complexObject) + .Member(x => x.Name, name => name.IsEqualTo("Test")) + .And.Member(x => x.Age, age => age.IsGreaterThan(18)) + .And.Member(x => x.IsActive, active => active.IsTrue()) + .And.Member(x => x.Tags, tags => tags.Contains("tag1")); } private class MyClass @@ -82,4 +170,12 @@ private class MyClass public MyClass Nested => this; } + + private class ComplexClass + { + public required string Name { get; init; } + public required int Age { get; init; } + public required bool IsActive { get; init; } + public required List Tags { get; init; } + } } diff --git a/TUnit.Assertions/Conditions/MemberAssertion.cs b/TUnit.Assertions/Conditions/MemberAssertion.cs index 0560ac0010..10e5220757 100644 --- a/TUnit.Assertions/Conditions/MemberAssertion.cs +++ b/TUnit.Assertions/Conditions/MemberAssertion.cs @@ -1,69 +1,211 @@ using System.Linq.Expressions; -using System.Text; using TUnit.Assertions.Core; +using TUnit.Assertions.Exceptions; namespace TUnit.Assertions.Conditions; /// -/// Asserts on a member of an object selected via an expression. -/// This allows chaining assertions on object properties/fields. +/// Result of a member assertion that allows returning to the parent object context. +/// Enables chaining multiple member assertions on the same parent object. /// -public class MemberAssertion : Assertion, IAssertionSource +public class MemberAssertionResult { - private readonly Expression> _memberSelector; - private readonly string _memberPath; + private readonly AssertionContext _parentContext; + private readonly Assertion _memberAssertion; - AssertionContext IAssertionSource.Context => Context; + internal MemberAssertionResult(AssertionContext parentContext, Assertion memberAssertion) + { + _parentContext = parentContext ?? throw new ArgumentNullException(nameof(parentContext)); + _memberAssertion = memberAssertion ?? throw new ArgumentNullException(nameof(memberAssertion)); + } - public MemberAssertion( - AssertionContext parentContext, - Expression> memberSelector) - : base( - parentContext.Map(obj => - { - if (obj == null) - { - throw new InvalidOperationException($"Object `{typeof(TObject).Name}` was null"); - } + /// + /// Returns an And continuation that operates on the parent object's context, + /// allowing chaining of multiple member assertions on the same parent object. + /// + public AndContinuation And + { + get + { + // Create a wrapper that executes the member assertion then returns to parent context + var wrapper = new MemberExecutionWrapper(_parentContext, _memberAssertion); + return new AndContinuation(_parentContext, wrapper); + } + } - var compiled = memberSelector.Compile(); - return compiled(obj); - })) + /// + /// Returns an Or continuation that operates on the parent object's context. + /// + public OrContinuation Or { - _memberSelector = memberSelector; - _memberPath = GetMemberPath(memberSelector); + get + { + var wrapper = new MemberExecutionWrapper(_parentContext, _memberAssertion); + return new OrContinuation(_parentContext, wrapper); + } + } - parentContext.ExpressionBuilder.Append($".HasMember(x => x.{_memberPath})"); + /// + /// Enables await syntax by executing the member assertion and returning the parent object. + /// + public System.Runtime.CompilerServices.TaskAwaiter GetAwaiter() + { + return ExecuteAsync().GetAwaiter(); } - protected override Task CheckAsync(EvaluationMetadata metadata) + private async Task ExecuteAsync() { - var value = metadata.Value; - var exception = metadata.Exception; + // Execute the member assertion + await _memberAssertion.AssertAsync(); - // HasMember itself doesn't perform a check - it's a transformation - // The actual check comes from the chained assertion (.EqualTo, etc.) - if (exception != null) - { - return Task.FromResult(AssertionResult.Failed(exception.Message)); - } + // Return the parent object value + var (parentValue, _) = await _parentContext.GetAsync(); + return parentValue; + } +} + +/// +/// Internal wrapper that executes a member assertion and returns the parent object value. +/// This enables chaining multiple member assertions on the same parent object. +/// +internal class MemberExecutionWrapper : Assertion +{ + private readonly Assertion _memberAssertion; + + public MemberExecutionWrapper(AssertionContext parentContext, Assertion memberAssertion) + : base(parentContext) + { + _memberAssertion = memberAssertion ?? throw new ArgumentNullException(nameof(memberAssertion)); + } + + public override async Task AssertAsync() + { + // Execute the member assertion + await _memberAssertion.AssertAsync(); + + // Return the parent object value for further chaining + var (parentValue, _) = await Context.GetAsync(); + return parentValue; + } + + protected override string GetExpectation() => _memberAssertion.InternalGetExpectation(); +} + +/// +/// Type-erased wrapper for assertions to allow storing different types in MemberAssertionResult. +/// +internal class TypeErasedAssertion : Assertion +{ + private readonly Assertion _innerAssertion; + + public TypeErasedAssertion(Assertion innerAssertion) + : base(innerAssertion.InternalContext.Map(val => val)) + { + _innerAssertion = innerAssertion ?? throw new ArgumentNullException(nameof(innerAssertion)); + } + + public override async Task AssertAsync() + { + await _innerAssertion.AssertAsync(); + return null; + } + + protected override string GetExpectation() => _innerAssertion.InternalGetExpectation(); +} + +/// +/// Simple adapter to wrap an AssertionContext as an IAssertionSource. +/// Used to pass member context to the assertion lambda. +/// +internal class AssertionSourceAdapter : IAssertionSource +{ + public AssertionContext Context { get; } + + public AssertionSourceAdapter(AssertionContext context) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + } +} + +/// +/// Combines a pending assertion with a member assertion using AND logic. +/// Both assertions must pass for the overall assertion to succeed. +/// +internal class CombinedAndAssertion : Assertion +{ + private readonly Assertion _pendingAssertion; + private readonly Assertion _memberAssertion; + + public CombinedAndAssertion(AssertionContext parentContext, Assertion pendingAssertion, Assertion memberAssertion) + : base(parentContext.Map(val => val)) + { + _pendingAssertion = pendingAssertion ?? throw new ArgumentNullException(nameof(pendingAssertion)); + _memberAssertion = memberAssertion ?? throw new ArgumentNullException(nameof(memberAssertion)); + } + + public override async Task AssertAsync() + { + // Execute the pending assertion first (which should throw if it fails) + await _pendingAssertion.AssertAsync(); + + // Then execute the member assertion + await _memberAssertion.AssertAsync(); - return Task.FromResult(AssertionResult.Passed); + return null; } - protected override string GetExpectation() => $"to have member {_memberPath}"; + protected override string GetExpectation() + { + return $"{_pendingAssertion.InternalGetExpectation()} AND {_memberAssertion.InternalGetExpectation()}"; + } +} + +/// +/// Combines a pending assertion with a member assertion using OR logic. +/// At least one assertion must pass for the overall assertion to succeed. +/// +internal class CombinedOrAssertion : Assertion +{ + private readonly Assertion _pendingAssertion; + private readonly Assertion _memberAssertion; + + public CombinedOrAssertion(AssertionContext parentContext, Assertion pendingAssertion, Assertion memberAssertion) + : base(parentContext.Map(val => val)) + { + _pendingAssertion = pendingAssertion ?? throw new ArgumentNullException(nameof(pendingAssertion)); + _memberAssertion = memberAssertion ?? throw new ArgumentNullException(nameof(memberAssertion)); + } - private static string GetMemberPath(Expression> expression) + public override async Task AssertAsync() { - var body = expression.Body; - var parts = new List(); + Exception? firstException = null; - while (body is MemberExpression memberExpr) + try { - parts.Insert(0, memberExpr.Member.Name); - body = memberExpr.Expression; + await _pendingAssertion.AssertAsync(); + // First assertion passed, no need to check the second + return null; + } + catch (Exception ex) + { + firstException = ex; } - return parts.Count > 0 ? string.Join(".", parts) : "Unknown"; + try + { + await _memberAssertion.AssertAsync(); + // Second assertion passed, overall succeeds + return null; + } + catch (Exception secondException) + { + // Both failed, throw combined error + throw new AssertionException($"Both conditions failed:\n1) {firstException.Message}\n2) {secondException.Message}"); + } + } + + protected override string GetExpectation() + { + return $"{_pendingAssertion.InternalGetExpectation()} OR {_memberAssertion.InternalGetExpectation()}"; } } diff --git a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs index 340d19a21c..b2fb27f9a8 100644 --- a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs +++ b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs @@ -8,7 +8,7 @@ namespace TUnit.Assertions.Conditions.Wrappers; /// /// Wrapper for collection count assertions that provides .EqualTo() method. -/// Example: await Assert.That(list).HasCount().EqualTo(5); +/// Example: await Assert.That(list).Count().EqualTo(5); /// public class CountWrapper : IAssertionSource where TValue : IEnumerable diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 8e5a5a05ab..7de4c1af4b 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -344,15 +344,70 @@ public static IsTypeOfRuntimeAssertion IsOfType( } /// - /// Asserts on a member of an object using a lambda selector. - /// Returns an assertion on the member value for further chaining. - /// Example: await Assert.That(myObject).HasMember(x => x.PropertyName).IsEqualTo(expectedValue); + /// Asserts on a member of an object using a lambda selector and assertion lambda. + /// The assertion lambda receives the member value and can perform any assertions on it. + /// After the member assertion completes, returns to the parent object context for further chaining. + /// Example: await Assert.That(myObject).Member(x => x.PropertyName, value => value.IsEqualTo(expectedValue)); /// - public static MemberAssertion HasMember( + public static MemberAssertionResult Member( this IAssertionSource source, - Expression> memberSelector) + Expression> memberSelector, + Func, Assertion> assertions) { - return new MemberAssertion(source.Context, memberSelector); + var parentContext = source.Context; + var memberPath = GetMemberPath(memberSelector); + + parentContext.ExpressionBuilder.Append($".Member(x => x.{memberPath}, ...)"); + + // Check if there's a pending link (from .And or .Or) that needs to be consumed + var (pendingAssertion, combinerType) = parentContext.ConsumePendingLink(); + + // Map to member context + var memberContext = parentContext.Map(obj => + { + if (obj == null) + { + throw new InvalidOperationException($"Object `{typeof(TObject).Name}` was null"); + } + + var compiled = memberSelector.Compile(); + return compiled(obj); + }); + + // Let user build assertion via lambda + // Create a simple adapter that implements IAssertionSource + var memberSource = new AssertionSourceAdapter(memberContext); + var memberAssertion = assertions(memberSource); + + // Type-erase to object? for storage + var erasedAssertion = new TypeErasedAssertion(memberAssertion); + + // If there was a pending link, wrap both assertions together + if (pendingAssertion != null && combinerType != null) + { + // Create a combined wrapper that executes the pending assertion first (or together for Or) + Assertion combinedAssertion = combinerType == CombinerType.And + ? new CombinedAndAssertion(parentContext, pendingAssertion, erasedAssertion) + : new CombinedOrAssertion(parentContext, pendingAssertion, erasedAssertion); + + return new MemberAssertionResult(parentContext, combinedAssertion); + } + + return new MemberAssertionResult(parentContext, erasedAssertion); + } + + private static string GetMemberPath(Expression> expression) + { + var body = expression.Body; + var parts = new List(); + + while (body is MemberExpression memberExpr) + { + parts.Insert(0, memberExpr.Member.Name); + body = memberExpr.Expression; + } + + return parts.Count > 0 ? string.Join(".", parts) : "Unknown"; } // ============ REFERENCE EQUALITY ============ diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt index c4e9ee2556..45eea5e5f8 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -987,11 +987,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class MemberAssertion : ., . + public class MemberAssertionResult { - public MemberAssertion(. parentContext, .<> memberSelector) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } + public . And { get; } + public . Or { get; } + public . GetAwaiter() { } } [.("IsNotEqualTo")] public class NotEqualsAssertion : . @@ -1668,7 +1668,6 @@ namespace .Extensions where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } - public static . HasMember(this . source, .<> memberSelector) { } public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) where TException : { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) @@ -1773,6 +1772,7 @@ namespace .Extensions where TValue : struct, { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } public static . Satisfies(this . source, > selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } public static . Satisfies(this . source, selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt index 8cc43acb5c..20f88b34df 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -987,11 +987,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class MemberAssertion : ., . + public class MemberAssertionResult { - public MemberAssertion(. parentContext, .<> memberSelector) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } + public . And { get; } + public . Or { get; } + public . GetAwaiter() { } } [.("IsNotEqualTo")] public class NotEqualsAssertion : . @@ -1668,7 +1668,6 @@ namespace .Extensions where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } - public static . HasMember(this . source, .<> memberSelector) { } public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) where TException : { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) @@ -1761,6 +1760,7 @@ namespace .Extensions where TValue : struct, { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } public static . Satisfies(this . source, > selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } public static . Satisfies(this . source, selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt index e292c918f5..7f639e8416 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -987,11 +987,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class MemberAssertion : ., . + public class MemberAssertionResult { - public MemberAssertion(. parentContext, .<> memberSelector) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } + public . And { get; } + public . Or { get; } + public . GetAwaiter() { } } [.("IsNotEqualTo")] public class NotEqualsAssertion : . @@ -1668,7 +1668,6 @@ namespace .Extensions where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } - public static . HasMember(this . source, .<> memberSelector) { } public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) where TException : { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) @@ -1773,6 +1772,7 @@ namespace .Extensions where TValue : struct, { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } public static . Satisfies(this . source, > selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } public static . Satisfies(this . source, selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } diff --git a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt index a809c849a2..4103591c61 100644 --- a/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Assertions_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -945,11 +945,11 @@ namespace .Conditions protected override .<.> CheckAsync(. metadata) { } protected override string GetExpectation() { } } - public class MemberAssertion : ., . + public class MemberAssertionResult { - public MemberAssertion(. parentContext, .<> memberSelector) { } - protected override .<.> CheckAsync(. metadata) { } - protected override string GetExpectation() { } + public . And { get; } + public . Or { get; } + public . GetAwaiter() { } } [.("IsNotEqualTo")] public class NotEqualsAssertion : . @@ -1573,7 +1573,6 @@ namespace .Extensions where TEnum : struct, { } public static ..LengthWrapper HasLength(this . source) { } public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } - public static . HasMember(this . source, .<> memberSelector) { } public static . HasMessageContaining(this . source, string expectedSubstring, [.("expectedSubstring")] string? expression = null) where TException : { } public static . HasMessageContaining(this . source, string expectedSubstring, comparison, [.("expectedSubstring")] string? expression = null) @@ -1664,6 +1663,7 @@ namespace .Extensions where TValue : struct, { } public static . IsTypeOf(this . source) { } public static . IsTypeOf(this . source) { } + public static . Member(this . source, .<> memberSelector, <., .> assertions) { } public static . Satisfies(this . source, predicate, [.("predicate")] string? expression = null) { } public static . Satisfies(this . source, > selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } public static . Satisfies(this . source, selector, <., .?> assertions, [.("selector")] string? selectorExpression = null) { } diff --git a/TUnit.TestProject/Bugs/Issue2993/ImplicitConversionTests.cs b/TUnit.TestProject/Bugs/Issue2993/ImplicitConversionTests.cs index 2d738e366d..79a43a14b7 100644 --- a/TUnit.TestProject/Bugs/Issue2993/ImplicitConversionTests.cs +++ b/TUnit.TestProject/Bugs/Issue2993/ImplicitConversionTests.cs @@ -26,11 +26,11 @@ public async Task PrivateType_WithNullableIntImplicitOperator_NonEmptyCollection NullableIntRecord item1 = 42; NullableIntRecord item2 = null; var items = new[] { item1, item2 }; - + await Assert.That(items).IsNotEmpty(); await Assert.That(items).HasCount(2); } - + // Test with non-nullable value type private record IntRecord(int Value) { @@ -44,21 +44,21 @@ public async Task PrivateType_WithIntImplicitOperator_ShouldCompile() var items = Enumerable.Empty(); await Assert.That(items).IsEmpty(); } - + // Test with nullable reference type private record StringRecord(string? Value) { public static implicit operator StringRecord(string? value) => new(value); public static implicit operator string?(StringRecord record) => record?.Value; } - + [Test] public async Task PrivateType_WithNullableStringImplicitOperator_ShouldCompile() { var items = Enumerable.Empty(); await Assert.That(items).IsEmpty(); } - + // Test with nested private type private class OuterClass { @@ -66,14 +66,14 @@ internal record InnerRecord(double? Value) { public static implicit operator InnerRecord(double? value) => new(value); } - + public static IEnumerable GetEmptyCollection() => Enumerable.Empty(); } - + [Test] public async Task NestedPrivateType_WithImplicitOperator_ShouldCompile() { var items = OuterClass.GetEmptyCollection(); await Assert.That(items).IsEmpty(); } -} \ No newline at end of file +} diff --git a/TUnit.TestProject/CombinedConstraintsSelfContainedTest.cs b/TUnit.TestProject/CombinedConstraintsSelfContainedTest.cs index 2b32f9b237..041eb73aca 100644 --- a/TUnit.TestProject/CombinedConstraintsSelfContainedTest.cs +++ b/TUnit.TestProject/CombinedConstraintsSelfContainedTest.cs @@ -20,20 +20,20 @@ public async Task Test1A() var start = DateTime.UtcNow; await Task.Delay(200); var end = DateTime.UtcNow; - + lock (CombinedConstraintTracker.Lock) { CombinedConstraintTracker.ExecutionLog.Add(("Test1A", start, end, "Group1", "Key1")); } } - + [Test] public async Task Test1B() { var start = DateTime.UtcNow; await Task.Delay(200); var end = DateTime.UtcNow; - + lock (CombinedConstraintTracker.Lock) { CombinedConstraintTracker.ExecutionLog.Add(("Test1B", start, end, "Group1", "Key1")); @@ -51,20 +51,20 @@ public async Task Test2A() var start = DateTime.UtcNow; await Task.Delay(200); var end = DateTime.UtcNow; - + lock (CombinedConstraintTracker.Lock) { CombinedConstraintTracker.ExecutionLog.Add(("Test2A", start, end, "Group1", "Key2")); } } - + [Test] public async Task Test2B() { var start = DateTime.UtcNow; await Task.Delay(200); var end = DateTime.UtcNow; - + lock (CombinedConstraintTracker.Lock) { CombinedConstraintTracker.ExecutionLog.Add(("Test2B", start, end, "Group1", "Key2")); @@ -81,20 +81,20 @@ public async Task Test3A() var start = DateTime.UtcNow; await Task.Delay(200); var end = DateTime.UtcNow; - + lock (CombinedConstraintTracker.Lock) { CombinedConstraintTracker.ExecutionLog.Add(("Test3A", start, end, "Group2", "None")); } } - + [Test] public async Task Test3B() { var start = DateTime.UtcNow; await Task.Delay(200); var end = DateTime.UtcNow; - + lock (CombinedConstraintTracker.Lock) { CombinedConstraintTracker.ExecutionLog.Add(("Test3B", start, end, "Group2", "None")); @@ -112,12 +112,12 @@ public async Task VerifyConstraintsCombineCorrectly() { // Wait a bit to ensure all tests have completed await Task.Delay(100); - + var log = CombinedConstraintTracker.ExecutionLog.OrderBy(x => x.Start).ToList(); - + // We should have 6 test executions await Assert.That(log).HasCount().EqualTo(6); - + // 1. Tests with same key should not overlap var key1Tests = log.Where(x => x.Key == "Key1").ToList(); for (int i = 0; i < key1Tests.Count - 1; i++) @@ -127,7 +127,7 @@ await Assert.That(noOverlap) .IsTrue() .Because($"Key1 tests should not overlap: {key1Tests[i].TestName} and {key1Tests[i + 1].TestName}"); } - + var key2Tests = log.Where(x => x.Key == "Key2").ToList(); for (int i = 0; i < key2Tests.Count - 1; i++) { @@ -136,33 +136,33 @@ await Assert.That(noOverlap) .IsTrue() .Because($"Key2 tests should not overlap: {key2Tests[i].TestName} and {key2Tests[i + 1].TestName}"); } - + // 2. Tests with different keys in same group CAN overlap if (key1Tests.Any() && key2Tests.Any()) { var key1Range = (Start: key1Tests.Min(t => t.Start), End: key1Tests.Max(t => t.End)); var key2Range = (Start: key2Tests.Min(t => t.Start), End: key2Tests.Max(t => t.End)); - + // They should be able to overlap (but don't have to) Console.WriteLine($"Key1 range: {key1Range.Start:HH:mm:ss.fff} - {key1Range.End:HH:mm:ss.fff}"); Console.WriteLine($"Key2 range: {key2Range.Start:HH:mm:ss.fff} - {key2Range.End:HH:mm:ss.fff}"); } - + // 3. Different groups should NOT overlap var group1Tests = log.Where(x => x.Group == "Group1").ToList(); var group2Tests = log.Where(x => x.Group == "Group2").ToList(); - + if (group1Tests.Any() && group2Tests.Any()) { var group1Range = (Start: group1Tests.Min(t => t.Start), End: group1Tests.Max(t => t.End)); var group2Range = (Start: group2Tests.Min(t => t.Start), End: group2Tests.Max(t => t.End)); - + var noOverlap = group1Range.End <= group2Range.Start || group2Range.End <= group1Range.Start; await Assert.That(noOverlap) .IsTrue() .Because($"Different parallel groups should not overlap. Group1: {group1Range.Start:HH:mm:ss.fff}-{group1Range.End:HH:mm:ss.fff}, Group2: {group2Range.Start:HH:mm:ss.fff}-{group2Range.End:HH:mm:ss.fff}"); } - + // 4. Tests in Group2 (no NotInParallel) can run in parallel var group2OnlyTests = log.Where(x => x.Group == "Group2" && x.Key == "None").ToList(); if (group2OnlyTests.Count > 1) @@ -174,7 +174,7 @@ await Assert.That(noOverlap) Console.WriteLine($" {test.TestName}: {test.Start:HH:mm:ss.fff} - {test.End:HH:mm:ss.fff}"); } } - + Console.WriteLine("\n✅ Combined ParallelGroup + NotInParallel constraints work correctly!"); } -} \ No newline at end of file +} diff --git a/TUnit.TestProject/GlobalTestHooks.cs b/TUnit.TestProject/GlobalTestHooks.cs index 6edae4e17a..dc48e6bf4a 100644 --- a/TUnit.TestProject/GlobalTestHooks.cs +++ b/TUnit.TestProject/GlobalTestHooks.cs @@ -12,7 +12,7 @@ public static void SetUp(TestContext testContext) public static async Task CleanUp(TestContext testContext) { testContext.ObjectBag.TryAdd("CleanUpCustomTestNameProperty", testContext.TestDetails.TestName); - + // Result may be null for skipped tests or tests that fail during initialization // Only validate Result for tests that actually executed if (testContext.Result != null) diff --git a/TUnit.TestProject/NestedTupleDataSourceTests.cs b/TUnit.TestProject/NestedTupleDataSourceTests.cs index 4f31d9b19e..fe508d3a11 100644 --- a/TUnit.TestProject/NestedTupleDataSourceTests.cs +++ b/TUnit.TestProject/NestedTupleDataSourceTests.cs @@ -139,4 +139,4 @@ public static (int[], (string, double)) ArrayNestedTupleData() yield return (i, (i + 10, i + 20)); } } -} \ No newline at end of file +} diff --git a/TUnit.TestProject/NotInParallelClassGroupingTests.cs b/TUnit.TestProject/NotInParallelClassGroupingTests.cs index 2613d6ec4e..33891373bc 100644 --- a/TUnit.TestProject/NotInParallelClassGroupingTests.cs +++ b/TUnit.TestProject/NotInParallelClassGroupingTests.cs @@ -12,7 +12,7 @@ namespace TUnit.TestProject; public class NotInParallelClassGroupingTests_ClassA { internal static readonly ConcurrentQueue ExecutionOrder = new(); - + [Test, NotInParallel(Order = 1)] public async Task Test1() { @@ -92,7 +92,7 @@ public async Task VerifyClassGrouping() var maxRetries = 10; var retryDelay = 100; List order = []; - + for (int i = 0; i < maxRetries; i++) { order = NotInParallelClassGroupingTests_ClassA.ExecutionOrder.ToList(); @@ -100,14 +100,14 @@ public async Task VerifyClassGrouping() break; await Task.Delay(retryDelay); } - + // We should have 8 test executions (3 from ClassA, 2 from ClassB, 3 from ClassC) await Assert.That(order).HasCount(8); // Verify that all tests from one class complete before another class starts var classSequence = new List(); string? lastClass = null; - + foreach (var execution in order) { var className = execution.Split('.')[0]; @@ -145,4 +145,4 @@ public async Task VerifyClassGrouping() await Assert.That(classCTests).Contains("ClassC.Test2"); await Assert.That(classCTests).Contains("ClassC.Test3"); } -} \ No newline at end of file +} diff --git a/TUnit.TestProject/TestContextIsolationTests.cs b/TUnit.TestProject/TestContextIsolationTests.cs index 0f8dd04b94..b5b39f2361 100644 --- a/TUnit.TestProject/TestContextIsolationTests.cs +++ b/TUnit.TestProject/TestContextIsolationTests.cs @@ -15,100 +15,100 @@ public class TestContextIsolationTests private static readonly ConcurrentDictionary TestIdToTestName = new(); private static readonly AsyncLocal TestLocalValue = new(); private static readonly Random RandomInstance = new Random(); - + [Before(Test)] public void BeforeEachTest(TestContext context) { // Set a unique value for this test var testId = Guid.NewGuid().ToString(); TestLocalValue.Value = testId; - + // CRITICAL: Capture AsyncLocal values so they flow to the test context.AddAsyncLocalValues(); - + // Store mapping for later verification TestIdToTestName[testId] = context.TestName; - + // Add to context for verification in test context.ObjectBag["TestLocalId"] = testId; context.ObjectBag["TestStartThread"] = Thread.CurrentThread.ManagedThreadId; } - + [Test] [Repeat(5)] // Run multiple times to increase chance of catching issues public async Task TestContext_Should_Be_Isolated_In_Parallel_Test1() { var context = TestContext.Current; await Assert.That(context).IsNotNull(); - + var testId = context!.ObjectBag["TestLocalId"] as string; await Assert.That(testId).IsNotNull(); - + // Simulate some async work await Task.Delay(RandomInstance.Next(10, 50)); - + // Verify context hasn't changed await Assert.That(TestContext.Current).IsSameReferenceAs(context); await Assert.That(TestContext.Current!.ObjectBag["TestLocalId"]).IsEqualTo(testId); - + // Verify AsyncLocal is preserved await Assert.That(TestLocalValue.Value).IsEqualTo(testId); - + // Store for cross-test verification CapturedContexts[testId!] = context; - + // More async work await Task.Yield(); - + // Final verification await Assert.That(TestContext.Current).IsSameReferenceAs(context); } - + [Test] [Repeat(5)] public async Task TestContext_Should_Be_Isolated_In_Parallel_Test2() { var context = TestContext.Current; await Assert.That(context).IsNotNull(); - + var testId = context!.ObjectBag["TestLocalId"] as string; await Assert.That(testId).IsNotNull(); - + // Different delay pattern await Task.Delay(RandomInstance.Next(5, 30)); - + // Verify isolation await Assert.That(TestContext.Current).IsSameReferenceAs(context); await Assert.That(TestContext.Current!.ObjectBag["TestLocalId"]).IsEqualTo(testId); await Assert.That(TestLocalValue.Value).IsEqualTo(testId); - + CapturedContexts[testId!] = context; - + await Task.Yield(); await Assert.That(TestContext.Current).IsSameReferenceAs(context); } - + [Test] [Repeat(5)] public async Task TestContext_Should_Be_Isolated_In_Sync_Test() { var context = TestContext.Current; await Assert.That(context).IsNotNull(); - + var testId = context!.ObjectBag["TestLocalId"] as string; await Assert.That(testId).IsNotNull(); - + // Simulate work Thread.Sleep(RandomInstance.Next(10, 50)); - + // Verify context remains the same await Assert.That(TestContext.Current).IsSameReferenceAs(context); await Assert.That(TestContext.Current!.ObjectBag["TestLocalId"]).IsEqualTo(testId); await Assert.That(TestLocalValue.Value).IsEqualTo(testId); - + CapturedContexts[testId!] = context; } - + [Test] [DependsOn(nameof(TestContext_Should_Be_Isolated_In_Parallel_Test1))] [DependsOn(nameof(TestContext_Should_Be_Isolated_In_Parallel_Test2))] @@ -117,17 +117,17 @@ public async Task Verify_All_Contexts_Were_Unique() { // Wait a bit to ensure all tests have completed storing their contexts await Task.Delay(100); - + // Each test execution should have had a unique context var allContexts = CapturedContexts.Values.Where(c => c != null).ToList(); - + // Verify we captured contexts await Assert.That(allContexts).HasCount().GreaterThanOrEqualTo(15); // 3 tests * 5 repeats - + // Verify all contexts are unique instances var uniqueContexts = allContexts.Distinct().ToList(); await Assert.That(uniqueContexts).HasCount().EqualTo(allContexts.Count); - + // Verify each test had its own TestLocalId var allTestIds = CapturedContexts.Keys.ToList(); var uniqueTestIds = allTestIds.Distinct().ToList(); @@ -142,43 +142,43 @@ public async Task Verify_All_Contexts_Were_Unique() public class TestContextNestedAsyncIsolationTests { private static readonly ConcurrentBag<(string TestName, TestContext? Context, int ThreadId)> ObservedContexts = new(); - + [Test] [Repeat(3)] public async Task Context_Should_Be_Preserved_Through_Nested_Async_Calls_Test1() { var initialContext = TestContext.Current; await Assert.That(initialContext).IsNotNull(); - + var testName = initialContext!.TestName; ObservedContexts.Add((testName, initialContext, Thread.CurrentThread.ManagedThreadId)); - + await NestedAsyncMethod1(initialContext); - + // Context should still be the same after async operations await Assert.That(TestContext.Current).IsSameReferenceAs(initialContext); } - + [Test] [Repeat(3)] public async Task Context_Should_Be_Preserved_Through_Nested_Async_Calls_Test2() { var initialContext = TestContext.Current; await Assert.That(initialContext).IsNotNull(); - + var testName = initialContext!.TestName; ObservedContexts.Add((testName, initialContext, Thread.CurrentThread.ManagedThreadId)); - + await NestedAsyncMethod2(initialContext); - + await Assert.That(TestContext.Current).IsSameReferenceAs(initialContext); } - + private async Task NestedAsyncMethod1(TestContext expectedContext) { await Task.Delay(10); await Assert.That(TestContext.Current).IsSameReferenceAs(expectedContext); - + await Task.Run(async () => { // Even in Task.Run, context should be preserved @@ -186,22 +186,22 @@ await Task.Run(async () => await Task.Delay(5); await Assert.That(TestContext.Current).IsSameReferenceAs(expectedContext); }); - + await Assert.That(TestContext.Current).IsSameReferenceAs(expectedContext); } - + private async Task NestedAsyncMethod2(TestContext expectedContext) { // Different async pattern await Task.Yield(); await Assert.That(TestContext.Current).IsSameReferenceAs(expectedContext); - + var tasks = Enumerable.Range(0, 3).Select(async i => { await Task.Delay(i * 5); await Assert.That(TestContext.Current).IsSameReferenceAs(expectedContext); }); - + await Task.WhenAll(tasks); await Assert.That(TestContext.Current).IsSameReferenceAs(expectedContext); } @@ -216,20 +216,20 @@ public class TestContextRaceConditionTests private static readonly object LockObject = new(); private static volatile int ConcurrentTestCount = 0; private static readonly ConcurrentBag DetectedContextMismatches = new(); - + [Test] [Repeat(10)] // More repeats to catch race conditions public async Task Concurrent_Tests_Should_Not_Share_Context() { var myContext = TestContext.Current; await Assert.That(myContext).IsNotNull(); - + var myTestName = myContext!.TestName; var myTestId = Guid.NewGuid().ToString(); myContext.ObjectBag["UniqueTestId"] = myTestId; - + Interlocked.Increment(ref ConcurrentTestCount); - + // Try to create race conditions var tasks = new List(); for (int i = 0; i < 5; i++) @@ -239,31 +239,31 @@ public async Task Concurrent_Tests_Should_Not_Share_Context() for (int j = 0; j < 10; j++) { await Task.Yield(); - + // Check if context has changed unexpectedly var currentContext = TestContext.Current; if (currentContext != myContext) { DetectedContextMismatches.Add($"Context mismatch in {myTestName}: Expected {myTestId}, Current context: {currentContext?.ObjectBag.GetValueOrDefault("UniqueTestId")}"); } - + if (currentContext?.ObjectBag.GetValueOrDefault("UniqueTestId") as string != myTestId) { DetectedContextMismatches.Add($"TestId mismatch in {myTestName}: Expected {myTestId}, Got {currentContext?.ObjectBag.GetValueOrDefault("UniqueTestId")}"); } - + await Task.Delay(1); } })); } - + await Task.WhenAll(tasks); - + // Final verification await Assert.That(TestContext.Current).IsSameReferenceAs(myContext); await Assert.That(TestContext.Current!.ObjectBag["UniqueTestId"]).IsEqualTo(myTestId); } - + [Test] [DependsOn(nameof(Concurrent_Tests_Should_Not_Share_Context))] public async Task Verify_No_Context_Mismatches_Detected() @@ -274,7 +274,7 @@ public async Task Verify_No_Context_Mismatches_Detected() var mismatches = string.Join("\n", DetectedContextMismatches.Distinct()); Assert.Fail($"Context mismatches detected:\n{mismatches}"); } - + // Verify we actually ran concurrent tests await Assert.That(ConcurrentTestCount).IsGreaterThanOrEqualTo(10); } @@ -289,32 +289,32 @@ public class TestContextHookIsolationTests private static TestContext? BeforeTestContext; private static TestContext? TestMethodContext; private static TestContext? AfterTestContext; - + [Before(Test)] public async Task BeforeTest() { BeforeTestContext = TestContext.Current; await Assert.That(BeforeTestContext).IsNotNull(); } - + [Test] public async Task TestContext_Should_Be_Same_In_Hooks_And_Test() { TestMethodContext = TestContext.Current; await Assert.That(TestMethodContext).IsNotNull(); - + // Context in Before hook should be the same as in test await Assert.That(TestMethodContext).IsSameReferenceAs(BeforeTestContext); } - + [After(Test)] public async Task AfterTest() { AfterTestContext = TestContext.Current; await Assert.That(AfterTestContext).IsNotNull(); - + // Context should be consistent across all hooks and test await Assert.That(AfterTestContext).IsSameReferenceAs(TestMethodContext); await Assert.That(AfterTestContext).IsSameReferenceAs(BeforeTestContext); } -} \ No newline at end of file +} diff --git a/docs/docs/assertions/awaiting.md b/docs/docs/assertions/awaiting.md index 498927a9c7..e87dec0060 100644 --- a/docs/docs/assertions/awaiting.md +++ b/docs/docs/assertions/awaiting.md @@ -51,9 +51,9 @@ public async Task ComplexObjectValidation() // Chain multiple member assertions await Assert.That(user) .IsNotNull() - .And.HasMember(u => u.Email).IsEqualTo("john.doe@example.com") - .And.HasMember(u => u.Age).IsGreaterThan(18) - .And.HasMember(u => u.Roles).Contains("Admin"); + .And.Member(u => u.Email, email => email.IsEqualTo("john.doe@example.com")) + .And.Member(u => u.Age, age => age.IsGreaterThan(18)) + .And.Member(u => u.Roles, roles => roles.Contains("Admin")); } ``` @@ -101,27 +101,6 @@ public async Task AsyncOperationAssertions() } ``` -### Nested Object Assertions - -```csharp -[Test] -public async Task NestedObjectAssertions() -{ - var company = await GetCompanyAsync(); - - await Assert.That(company) - .IsNotNull() - .And.HasMember(c => c.Name).IsEqualTo("TechCorp") - .And.HasMember(c => c.Address.City).IsEqualTo("Seattle") - .And.HasMember(c => c.Address.ZipCode).Matches(@"^\d{5}$") - .And.HasMember(c => c.Employees).Satisfies(employees => - Assert.That(employees) - .HasCount().IsBetween(100, 500) - .And.All(e => e.Email.EndsWith("@techcorp.com")) - ); -} -``` - ### Exception Assertions with Details ```csharp @@ -297,7 +276,7 @@ public async Task PerformanceAssertions() await Assert.That(results.Max()) .IsLessThan(500); // No operation over 500ms - await Assert.That(results.Where(r => r > 200).Count()) + await Assert.That(results.Where(r => r > 200).HasCount()) .IsLessThan(5); // Less than 5% over 200ms } ``` diff --git a/docs/docs/assertions/member-assertions.md b/docs/docs/assertions/member-assertions.md new file mode 100644 index 0000000000..ea0b7e4e4d --- /dev/null +++ b/docs/docs/assertions/member-assertions.md @@ -0,0 +1,140 @@ +# Member Assertions + +The `.Member()` method allows you to assert on object properties while maintaining the parent object's context for chaining. This is useful when you need to validate multiple properties of the same object. + +## Basic Usage + +```csharp +[Test] +public async Task BasicMemberAssertions() +{ + var user = await GetUserAsync(); + + // Assert on a single property + await Assert.That(user) + .Member(u => u.Email, email => email.IsEqualTo("user@example.com")); + + // Chain multiple property assertions + await Assert.That(user) + .Member(u => u.FirstName, name => name.IsEqualTo("John")) + .And.Member(u => u.LastName, name => name.IsEqualTo("Doe")) + .And.Member(u => u.Age, age => age.IsGreaterThan(18)); +} +``` + +## Why Use Member Assertions? + +The key advantage of `.Member()` is that it returns to the parent context after each assertion, allowing you to chain multiple property checks: + +```csharp +[Test] +public async Task MemberAssertionsWithFullContext() +{ + var order = await GetOrderAsync(); + + // Each .Member() call works on the order object + await Assert.That(order) + .IsNotNull() + .And.Member(o => o.OrderId, id => id.IsGreaterThan(0)) + .And.Member(o => o.Status, status => status.IsEqualTo(OrderStatus.Pending)) + .And.Member(o => o.Total, total => total.IsGreaterThan(0)); +} +``` + +## Nested Properties + +Member assertions support nested properties: + +```csharp +[Test] +public async Task NestedPropertyAssertions() +{ + var customer = await GetCustomerAsync(); + + // Access nested properties directly + await Assert.That(customer) + .Member(c => c.Address.Street, street => street.IsNotNull()) + .And.Member(c => c.Address.City, city => city.IsEqualTo("Seattle")) + .And.Member(c => c.Address.ZipCode, zip => zip.Matches(@"^\d{5}$")); +} +``` + +## Complex Assertions on Members + +You can perform complex assertions on member values, including collections: + +```csharp +[Test] +public async Task ComplexMemberAssertions() +{ + var team = await GetTeamAsync(); + + await Assert.That(team) + .Member(t => t.Name, name => name.StartsWith("Team")) + .And.Member(t => t.Members, members => members + .HasCount().IsGreaterThan(0) + .And.All(m => m.IsActive) + .And.Any(m => m.Role == "Lead")) + .And.Member(t => t.CreatedDate, date => date + .IsGreaterThan(DateTime.UtcNow.AddYears(-1))); +} +``` + +## Using Or Logic + +Member assertions work with both `.And` and `.Or` combinators: + +```csharp +[Test] +public async Task MemberAssertionsWithOrLogic() +{ + var product = await GetProductAsync(); + + // Use Or to check alternative conditions + await Assert.That(product) + .Member(p => p.Status, status => status.IsEqualTo(ProductStatus.Active)) + .Or.Member(p => p.Status, status => status.IsEqualTo(ProductStatus.Preview)); + + // Mix And and Or for complex logic + await Assert.That(product) + .Member(p => p.Price, price => price.IsGreaterThan(0)) + .And.Member(p => p.Stock, stock => stock.IsGreaterThan(0)) + .Or.Member(p => p.BackorderAllowed, allowed => allowed.IsTrue()); +} +``` + +## Complete Example + +```csharp +[Test] +public async Task ComplexObjectValidation() +{ + var user = await GetUserAsync("john.doe"); + + // Chain multiple member assertions + await Assert.That(user) + .IsNotNull() + .And.Member(u => u.Email, email => email.IsEqualTo("john.doe@example.com")) + .And.Member(u => u.Age, age => age.IsGreaterThan(18)) + .And.Member(u => u.Roles, roles => roles.Contains("Admin")); +} +``` + +## Nested Object Assertions + +```csharp +[Test] +public async Task NestedObjectAssertions() +{ + var company = await GetCompanyAsync(); + + await Assert.That(company) + .IsNotNull() + .And.Member(c => c.Name, name => name.IsEqualTo("TechCorp")) + .And.Member(c => c.Address.City, city => city.IsEqualTo("Seattle")) + .And.Member(c => c.Address.ZipCode, zip => zip.Matches(@"^\d{5}$")) + .And.Member(c => c.Employees, employees => employees + .HasCount().IsBetween(100, 500) + .And.All(e => e.Email.EndsWith("@techcorp.com"))); +} +```