From cedc2c5e4f9b90282f11453546eb15640f8d8231 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:36:59 +0100 Subject: [PATCH 1/2] fix: restore [Obsolete] members removed in v1.27 (#5539) PR #5384 deleted previously [Obsolete]-marked public APIs in a minor release, breaking semver. Restore them with [Obsolete] reapplied so v1.x consumers can upgrade without compile errors. Actual deletion is deferred to the v2 major bump (tracked in #5604). Restored: - TUnit.Assertions: CountWrapper, LengthWrapper, HasCount/HasLength overloads on CollectionAssertionBase / AssertionExtensions - TUnit.Core: ObjectBag on TestBuilderContext + TestRegisteredContext, Timing record, ITestOutput.Timings + RecordTiming bridged to internal TimingEntry storage with new _timingsLock for user-facing concurrent RecordTiming calls - PublicAPI snapshots regenerated for net8/9/10/472 --- .../Conditions/Wrappers/CountWrapper.cs | 288 ++++++++++++++++++ .../Conditions/Wrappers/LengthWrapper.cs | 93 ++++++ .../Extensions/AssertionExtensions.cs | 27 ++ .../Sources/CollectionAssertionBase.cs | 27 ++ TUnit.Core/Contexts/TestRegisteredContext.cs | 6 + TUnit.Core/Interfaces/ITestOutput.cs | 15 + TUnit.Core/TestBuilderContext.cs | 6 + TUnit.Core/TestContext.Output.cs | 19 ++ TUnit.Core/Timing.cs | 7 + ...Has_No_API_Changes.DotNet10_0.verified.txt | 32 ++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 32 ++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 32 ++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 32 ++ ...Has_No_API_Changes.DotNet10_0.verified.txt | 20 ++ ..._Has_No_API_Changes.DotNet8_0.verified.txt | 20 ++ ..._Has_No_API_Changes.DotNet9_0.verified.txt | 20 ++ ...ary_Has_No_API_Changes.Net4_7.verified.txt | 20 ++ 17 files changed, 696 insertions(+) create mode 100644 TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs create mode 100644 TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs create mode 100644 TUnit.Core/Timing.cs diff --git a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs new file mode 100644 index 0000000000..2c06445e74 --- /dev/null +++ b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs @@ -0,0 +1,288 @@ +using System.Collections; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using TUnit.Assertions.Conditions; +using TUnit.Assertions.Core; +using TUnit.Assertions.Extensions; + +namespace TUnit.Assertions.Conditions.Wrappers; + +/// +/// Wrapper for collection count assertions that provides .EqualTo() method. +/// Example: await Assert.That(list).Count().EqualTo(5); +/// +public class CountWrapper : IAssertionSource + where TCollection : IEnumerable +{ + private readonly AssertionContext _context; + + public CountWrapper(AssertionContext context) + { + _context = context; + } + + AssertionContext IAssertionSource.Context => _context; + + /// + /// Not supported on CountWrapper - use IsTypeOf on the assertion source before calling HasCount(). + /// + TypeOfAssertion IAssertionSource.IsTypeOf() + { + throw new NotSupportedException( + "IsTypeOf is not supported after HasCount(). " + + "Use: Assert.That(value).IsTypeOf>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsAssignableTo on the assertion source before calling HasCount(). + /// + IsAssignableToAssertion IAssertionSource.IsAssignableTo() + { + throw new NotSupportedException( + "IsAssignableTo is not supported after HasCount(). " + + "Use: Assert.That(value).IsAssignableTo>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsNotAssignableTo on the assertion source before calling HasCount(). + /// + IsNotAssignableToAssertion IAssertionSource.IsNotAssignableTo() + { + throw new NotSupportedException( + "IsNotAssignableTo is not supported after HasCount(). " + + "Use: Assert.That(value).IsNotAssignableTo>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsAssignableFrom on the assertion source before calling HasCount(). + /// + IsAssignableFromAssertion IAssertionSource.IsAssignableFrom() + { + throw new NotSupportedException( + "IsAssignableFrom is not supported after HasCount(). " + + "Use: Assert.That(value).IsAssignableFrom>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsNotAssignableFrom on the assertion source before calling HasCount(). + /// + IsNotAssignableFromAssertion IAssertionSource.IsNotAssignableFrom() + { + throw new NotSupportedException( + "IsNotAssignableFrom is not supported after HasCount(). " + + "Use: Assert.That(value).IsNotAssignableFrom>().HasCount().EqualTo(5)"); + } + + /// + /// Not supported on CountWrapper - use IsNotTypeOf on the assertion source before calling HasCount(). + /// + IsNotTypeOfAssertion IAssertionSource.IsNotTypeOf() + { + throw new NotSupportedException( + "IsNotTypeOf is not supported after HasCount(). " + + "Use: Assert.That(value).IsNotTypeOf>().HasCount().EqualTo(5)"); + } + + /// + /// Asserts that the collection count is equal to the expected count. + /// + public CollectionCountAssertion EqualTo( + int expectedCount, + [CallerArgumentExpression(nameof(expectedCount))] string? expression = null) + { + _context.ExpressionBuilder.Append($".EqualTo({expression})"); + return new CollectionCountAssertion(_context, expectedCount); + } + + /// + /// Asserts that the collection count is greater than or equal to the expected count. + /// + public TValue_IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".GreaterThanOrEqualTo({expression})"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new TValue_IsGreaterThanOrEqualTo_TValue_Assertion(countContext, expected); + } + + /// + /// Asserts that the collection count is positive (greater than 0). + /// + public TValue_IsGreaterThan_TValue_Assertion Positive() + { + _context.ExpressionBuilder.Append(".Positive()"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new TValue_IsGreaterThan_TValue_Assertion(countContext, 0); + } + + /// + /// Asserts that the collection count is greater than the expected count. + /// + public TValue_IsGreaterThan_TValue_Assertion GreaterThan( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".GreaterThan({expression})"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new TValue_IsGreaterThan_TValue_Assertion(countContext, expected); + } + + /// + /// Asserts that the collection count is less than the expected count. + /// + public TValue_IsLessThan_TValue_Assertion LessThan( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".LessThan({expression})"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new TValue_IsLessThan_TValue_Assertion(countContext, expected); + } + + /// + /// Asserts that the collection count is less than or equal to the expected count. + /// + public TValue_IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".LessThanOrEqualTo({expression})"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new TValue_IsLessThanOrEqualTo_TValue_Assertion(countContext, expected); + } + + /// + /// Asserts that the collection count is between the minimum and maximum values. + /// + public BetweenAssertion Between( + int minimum, + int maximum, + [CallerArgumentExpression(nameof(minimum))] string? minExpression = null, + [CallerArgumentExpression(nameof(maximum))] string? maxExpression = null) + { + _context.ExpressionBuilder.Append($".Between({minExpression}, {maxExpression})"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new BetweenAssertion(countContext, minimum, maximum); + } + + /// + /// Asserts that the collection count is zero (empty collection). + /// + public CollectionCountAssertion Zero() + { + _context.ExpressionBuilder.Append(".Zero()"); + return new CollectionCountAssertion(_context, 0); + } + + /// + /// Asserts that the collection count is not equal to the expected count. + /// + public NotEqualsAssertion NotEqualTo( + int expected, + [CallerArgumentExpression(nameof(expected))] string? expression = null) + { + _context.ExpressionBuilder.Append($".NotEqualTo({expression})"); + // Map context to get the count + var countContext = _context.Map(value => + { + if (value == null) + { + return 0; + } + + if (value is ICollection collection) + { + return collection.Count; + } + + return value.Cast().Count(); + }); + return new NotEqualsAssertion(countContext, expected); + } +} diff --git a/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs new file mode 100644 index 0000000000..4518e6068f --- /dev/null +++ b/TUnit.Assertions/Conditions/Wrappers/LengthWrapper.cs @@ -0,0 +1,93 @@ +using System.Runtime.CompilerServices; +using System.Text; +using TUnit.Assertions.Conditions; +using TUnit.Assertions.Core; + +namespace TUnit.Assertions.Conditions.Wrappers; + +/// +/// Wrapper for string length assertions that provides .EqualTo() method. +/// Example: await Assert.That(str).HasLength().EqualTo(5); +/// +public class LengthWrapper : IAssertionSource +{ + private readonly AssertionContext _context; + + public LengthWrapper(AssertionContext context) + { + _context = context; + } + + AssertionContext IAssertionSource.Context => _context; + + /// + /// Not supported on LengthWrapper - use IsTypeOf on the assertion source before calling HasLength(). + /// + TypeOfAssertion IAssertionSource.IsTypeOf() + { + throw new NotSupportedException( + "IsTypeOf is not supported after HasLength(). " + + "Use: Assert.That(value).IsTypeOf().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsAssignableTo on the assertion source before calling HasLength(). + /// + IsAssignableToAssertion IAssertionSource.IsAssignableTo() + { + throw new NotSupportedException( + "IsAssignableTo is not supported after HasLength(). " + + "Use: Assert.That(value).IsAssignableTo().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsNotAssignableTo on the assertion source before calling HasLength(). + /// + IsNotAssignableToAssertion IAssertionSource.IsNotAssignableTo() + { + throw new NotSupportedException( + "IsNotAssignableTo is not supported after HasLength(). " + + "Use: Assert.That(value).IsNotAssignableTo().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsAssignableFrom on the assertion source before calling HasLength(). + /// + IsAssignableFromAssertion IAssertionSource.IsAssignableFrom() + { + throw new NotSupportedException( + "IsAssignableFrom is not supported after HasLength(). " + + "Use: Assert.That(value).IsAssignableFrom().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsNotAssignableFrom on the assertion source before calling HasLength(). + /// + IsNotAssignableFromAssertion IAssertionSource.IsNotAssignableFrom() + { + throw new NotSupportedException( + "IsNotAssignableFrom is not supported after HasLength(). " + + "Use: Assert.That(value).IsNotAssignableFrom().HasLength().EqualTo(5)"); + } + + /// + /// Not supported on LengthWrapper - use IsNotTypeOf on the assertion source before calling HasLength(). + /// + IsNotTypeOfAssertion IAssertionSource.IsNotTypeOf() + { + throw new NotSupportedException( + "IsNotTypeOf is not supported after HasLength(). " + + "Use: Assert.That(value).IsNotTypeOf().HasLength().EqualTo(5)"); + } + + /// + /// Asserts that the string length is equal to the expected length. + /// + public StringLengthAssertion EqualTo( + int expectedLength, + [CallerArgumentExpression(nameof(expectedLength))] string? expression = null) + { + _context.ExpressionBuilder.Append($".EqualTo({expression})"); + return new StringLengthAssertion(_context, expectedLength); + } +} diff --git a/TUnit.Assertions/Extensions/AssertionExtensions.cs b/TUnit.Assertions/Extensions/AssertionExtensions.cs index 8772e932ba..9f8a5e918b 100644 --- a/TUnit.Assertions/Extensions/AssertionExtensions.cs +++ b/TUnit.Assertions/Extensions/AssertionExtensions.cs @@ -5,6 +5,7 @@ using System.Text.RegularExpressions; using TUnit.Assertions.Chaining; using TUnit.Assertions.Conditions; +using TUnit.Assertions.Conditions.Wrappers; using TUnit.Assertions.Core; using TUnit.Assertions.Sources; @@ -881,6 +882,32 @@ public static StringLengthWithInlineAssertionAssertion Length( return new StringLengthWithInlineAssertionAssertion(source.Context, lengthAssertion); } + /// + /// Returns a wrapper for string length assertions. + /// Example: await Assert.That(str).HasLength().EqualTo(5); + /// + [Obsolete("Use Length() instead, which provides all numeric assertion methods. Example: Assert.That(str).Length().IsGreaterThan(5)")] + public static LengthWrapper HasLength( + this IAssertionSource source) + { + source.Context.ExpressionBuilder.Append(".HasLength()"); + return new LengthWrapper(source.Context); + } + + /// + /// Asserts that the string has the expected length. + /// Example: await Assert.That(str).HasLength(5); + /// + [Obsolete("Use Length().IsEqualTo(expectedLength) instead.")] + public static StringLengthAssertion HasLength( + this IAssertionSource source, + int expectedLength, + [CallerArgumentExpression(nameof(expectedLength))] string? expression = null) + { + source.Context.ExpressionBuilder.Append($".HasLength({expression})"); + return new StringLengthAssertion(source.Context, expectedLength); + } + /// /// Asserts that the value is structurally equivalent to the expected value. /// Performs deep comparison of properties and fields. diff --git a/TUnit.Assertions/Sources/CollectionAssertionBase.cs b/TUnit.Assertions/Sources/CollectionAssertionBase.cs index 00dc5d3d29..62447cf0ed 100644 --- a/TUnit.Assertions/Sources/CollectionAssertionBase.cs +++ b/TUnit.Assertions/Sources/CollectionAssertionBase.cs @@ -1,6 +1,7 @@ using System.Collections; using System.Runtime.CompilerServices; using TUnit.Assertions.Conditions; +using TUnit.Assertions.Conditions.Wrappers; using TUnit.Assertions.Core; namespace TUnit.Assertions.Sources; @@ -299,6 +300,32 @@ public CollectionHasAtMostAssertion HasAtMost( return new CollectionHasAtMostAssertion(Context, maxCount); } + /// + /// Asserts that the collection has the specified number of items. + /// This instance method enables calling HasCount with proper type inference. + /// Example: await Assert.That(list).HasCount(5); + /// + [Obsolete("Use Count().IsEqualTo(expectedCount) instead.")] + public CollectionCountAssertion HasCount( + int expectedCount, + [CallerArgumentExpression(nameof(expectedCount))] string? expression = null) + { + Context.ExpressionBuilder.Append($".HasCount({expression})"); + return new CollectionCountAssertion(Context, expectedCount); + } + + /// + /// Returns a wrapper for fluent count assertions. + /// This enables the pattern: .HasCount().GreaterThan(5) + /// Example: await Assert.That(list).HasCount().EqualTo(5); + /// + [Obsolete("Use Count() instead, which provides all numeric assertion methods. Example: Assert.That(list).Count().IsGreaterThan(5)")] + public CountWrapper HasCount() + { + Context.ExpressionBuilder.Append(".HasCount()"); + return new CountWrapper(Context); + } + /// /// Asserts that the collection count is between the specified minimum and maximum (inclusive). /// This instance method enables calling HasCountBetween with proper type inference. diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 1ef19e648d..796f1b2944 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -25,6 +25,12 @@ public TestRegisteredContext(TestContext testContext) /// public ConcurrentDictionary StateBag => TestContext.StateBag.Items; + /// + /// Gets the object bag from the underlying TestContext + /// + [Obsolete("Use StateBag property instead.")] + public ConcurrentDictionary ObjectBag => StateBag; + /// /// Gets the test details from the underlying TestContext /// diff --git a/TUnit.Core/Interfaces/ITestOutput.cs b/TUnit.Core/Interfaces/ITestOutput.cs index 74aed40d9b..2546cf1674 100644 --- a/TUnit.Core/Interfaces/ITestOutput.cs +++ b/TUnit.Core/Interfaces/ITestOutput.cs @@ -20,12 +20,27 @@ public interface ITestOutput /// TextWriter ErrorOutput { get; } + /// + /// Gets the collection of timing measurements recorded during test execution. + /// Useful for performance profiling and identifying bottlenecks. + /// + [Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] + IReadOnlyCollection Timings { get; } + /// /// Gets the collection of artifacts (files, screenshots, logs) attached to this test. /// Artifacts are preserved after test execution for review and debugging. /// IReadOnlyCollection Artifacts { get; } + /// + /// Records a timing measurement for a specific operation or phase. + /// Thread-safe for concurrent calls. + /// + /// The timing information to record + [Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] + void RecordTiming(Timing timing); + /// /// Attaches an artifact (file, screenshot, log, etc.) to this test. /// Artifacts are preserved after test execution. diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs index e6dbbdc43f..83256bfa02 100644 --- a/TUnit.Core/TestBuilderContext.cs +++ b/TUnit.Core/TestBuilderContext.cs @@ -37,6 +37,12 @@ public static TestBuilderContext? Current set => _stateBag = value; } + /// + /// Gets the state bag for storing arbitrary data during test building. + /// + [Obsolete("Use StateBag property instead.")] + public ConcurrentDictionary ObjectBag => StateBag; + internal void CopyStateBagTo(TestBuilderContext target) { if (_stateBag is { IsEmpty: false } bag) diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index a09356e184..e95babfd6e 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -17,6 +17,7 @@ public partial class TestContext // Internal backing fields and properties // Timings are written sequentially by the framework during test execution, never by user code. internal List Timings { get; } = []; + private readonly Lock _timingsLock = new(); // Artifacts use a lock because AttachArtifact is user-facing and can be called // from parallel Task.WhenAll branches within a single test. private readonly Lock _artifactsLock = new(); @@ -29,6 +30,24 @@ public partial class TestContext TextWriter ITestOutput.ErrorOutput => ErrorOutputWriter; IReadOnlyCollection ITestOutput.Artifacts => Artifacts; +#pragma warning disable CS0618 // Obsolete Timing API — bridge to internal TimingEntry storage + IReadOnlyCollection ITestOutput.Timings + { + get + { + lock (_timingsLock) + { + return Timings.ConvertAll(t => new Timing(t.StepName, t.Start, t.End)); + } + } + } + + void ITestOutput.RecordTiming(Timing timing) + { + lock (_timingsLock) Timings.Add(new TimingEntry(timing.StepName, timing.Start, timing.End)); + } +#pragma warning restore CS0618 + void ITestOutput.AttachArtifact(Artifact artifact) { lock (_artifactsLock) _artifacts.Add(artifact); diff --git a/TUnit.Core/Timing.cs b/TUnit.Core/Timing.cs new file mode 100644 index 0000000000..096c9f6a78 --- /dev/null +++ b/TUnit.Core/Timing.cs @@ -0,0 +1,7 @@ +namespace TUnit.Core; + +[Obsolete("Use OpenTelemetry activity spans instead. Hook timings are now automatically recorded as OTel child spans of the test activity.")] +public record Timing(string StepName, DateTimeOffset Start, DateTimeOffset End) +{ + public TimeSpan Duration => End - Start; +} 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 7d9f179b50..3fd21b094e 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 @@ -2363,6 +2363,28 @@ namespace . public static . IsValidJsonObject(this string value) { } } } +namespace . +{ + public class CountWrapper : ., . + where TCollection : . + { + public CountWrapper(. context) { } + public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { } + public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public . NotEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion Positive() { } + public . Zero() { } + } + public class LengthWrapper : ., . + { + public LengthWrapper(. context) { } + public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { } + } +} namespace .Core { public class AndContinuation : . { } @@ -2606,6 +2628,11 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } + [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + + "(str).Length().IsGreaterThan(5)")] + public static ..LengthWrapper HasLength(this . source) { } + [("Use Length().IsEqualTo(expectedLength) instead.")] + public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) @@ -6120,6 +6147,11 @@ namespace .Sources protected override string GetExpectation() { } public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { } public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { } + [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" + + "(list).Count().IsGreaterThan(5)")] + public ..CountWrapper HasCount() { } + [("Use Count().IsEqualTo(expectedCount) instead.")] + public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { } public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { } public . HasDistinctItems() { } public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = 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 ca201b0d65..137df82a5a 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 @@ -2342,6 +2342,28 @@ namespace . public static . IsValidJsonObject(this string value) { } } } +namespace . +{ + public class CountWrapper : ., . + where TCollection : . + { + public CountWrapper(. context) { } + public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { } + public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public . NotEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion Positive() { } + public . Zero() { } + } + public class LengthWrapper : ., . + { + public LengthWrapper(. context) { } + public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { } + } +} namespace .Core { public class AndContinuation : . { } @@ -2585,6 +2607,11 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } + [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + + "(str).Length().IsGreaterThan(5)")] + public static ..LengthWrapper HasLength(this . source) { } + [("Use Length().IsEqualTo(expectedLength) instead.")] + public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) @@ -6053,6 +6080,11 @@ namespace .Sources protected override string GetExpectation() { } public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { } public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { } + [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" + + "(list).Count().IsGreaterThan(5)")] + public ..CountWrapper HasCount() { } + [("Use Count().IsEqualTo(expectedCount) instead.")] + public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { } public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { } public . HasDistinctItems() { } public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = 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 6d6b727f5d..948eae45c8 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 @@ -2363,6 +2363,28 @@ namespace . public static . IsValidJsonObject(this string value) { } } } +namespace . +{ + public class CountWrapper : ., . + where TCollection : . + { + public CountWrapper(. context) { } + public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { } + public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public . NotEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion Positive() { } + public . Zero() { } + } + public class LengthWrapper : ., . + { + public LengthWrapper(. context) { } + public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { } + } +} namespace .Core { public class AndContinuation : . { } @@ -2606,6 +2628,11 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } + [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + + "(str).Length().IsGreaterThan(5)")] + public static ..LengthWrapper HasLength(this . source) { } + [("Use Length().IsEqualTo(expectedLength) instead.")] + public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) @@ -6120,6 +6147,11 @@ namespace .Sources protected override string GetExpectation() { } public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { } public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { } + [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" + + "(list).Count().IsGreaterThan(5)")] + public ..CountWrapper HasCount() { } + [("Use Count().IsEqualTo(expectedCount) instead.")] + public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { } public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { } public . HasDistinctItems() { } public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = 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 87a84f8adc..e9024b14e6 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 @@ -2114,6 +2114,28 @@ namespace . public static . IsValidJsonObject(this string value) { } } } +namespace . +{ + public class CountWrapper : ., . + where TCollection : . + { + public CountWrapper(. context) { } + public . Between(int minimum, int maximum, [.("minimum")] string? minExpression = null, [.("maximum")] string? maxExpression = null) { } + public . EqualTo(int expectedCount, [.("expectedCount")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion GreaterThan(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThan_TValue_Assertion LessThan(int expected, [.("expected")] string? expression = null) { } + public ._IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo(int expected, [.("expected")] string? expression = null) { } + public . NotEqualTo(int expected, [.("expected")] string? expression = null) { } + public ._IsGreaterThan_TValue_Assertion Positive() { } + public . Zero() { } + } + public class LengthWrapper : ., . + { + public LengthWrapper(. context) { } + public . EqualTo(int expectedLength, [.("expectedLength")] string? expression = null) { } + } +} namespace .Core { public class AndContinuation : . { } @@ -2341,6 +2363,11 @@ namespace .Extensions public static . CompletesWithin(this . source, timeout, [.("timeout")] string? expression = null) { } public static . EqualTo(this . source, TValue? expected, [.("expected")] string? expression = null) { } public static . Eventually(this . source, <., .> assertionBuilder, timeout, ? pollingInterval = default, [.("timeout")] string? timeoutExpression = null, [.("pollingInterval")] string? pollingIntervalExpression = null) { } + [("Use Length() instead, which provides all numeric assertion methods. Example: Asse" + + "(str).Length().IsGreaterThan(5)")] + public static ..LengthWrapper HasLength(this . source) { } + [("Use Length().IsEqualTo(expectedLength) instead.")] + public static . HasLength(this . source, int expectedLength, [.("expectedLength")] string? expression = null) { } public static . HasMessage(this . source, string expectedMessage, [.("expectedMessage")] string? expression = null) where TException : { } public static . HasMessage(this . source, string expectedMessage, comparison, [.("expectedMessage")] string? expression = null) @@ -5296,6 +5323,11 @@ namespace .Sources protected override string GetExpectation() { } public . HasAtLeast(int minCount, [.("minCount")] string? expression = null) { } public . HasAtMost(int maxCount, [.("maxCount")] string? expression = null) { } + [("Use Count() instead, which provides all numeric assertion methods. Example: Asser" + + "(list).Count().IsGreaterThan(5)")] + public ..CountWrapper HasCount() { } + [("Use Count().IsEqualTo(expectedCount) instead.")] + public . HasCount(int expectedCount, [.("expectedCount")] string? expression = null) { } public . HasCountBetween(int min, int max, [.("min")] string? minExpression = null, [.("max")] string? maxExpression = null) { } public . HasDistinctItems() { } public . HasDistinctItems(. comparer, [.("comparer")] string? comparerExpression = null) { } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt index 41a65d4040..f06c61bcb3 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet10_0.verified.txt @@ -1352,6 +1352,8 @@ namespace public .IDataSourceAttribute? DataSourceAttribute { get; set; } public string DefinitionId { get; } public .TestContextEvents Events { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1647,6 +1649,8 @@ namespace public TestRegisteredContext(.TestContext testContext) { } public string? CustomDisplayName { get; } public .DiscoveredTest DiscoveredTest { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; } public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } @@ -1717,6 +1721,16 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + public class Timing : <.Timing> + { + public Timing(string StepName, Start, End) { } + public Duration { get; } + public End { get; init; } + public Start { get; init; } + public string StepName { get; init; } + } public sealed class TypeArrayComparer : .<[]> { public static readonly .TypeArrayComparer Instance; @@ -2644,10 +2658,16 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt index ca86d44ac6..4fa9b816b6 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet8_0.verified.txt @@ -1352,6 +1352,8 @@ namespace public .IDataSourceAttribute? DataSourceAttribute { get; set; } public string DefinitionId { get; } public .TestContextEvents Events { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1647,6 +1649,8 @@ namespace public TestRegisteredContext(.TestContext testContext) { } public string? CustomDisplayName { get; } public .DiscoveredTest DiscoveredTest { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; } public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } @@ -1717,6 +1721,16 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + public class Timing : <.Timing> + { + public Timing(string StepName, Start, End) { } + public Duration { get; } + public End { get; init; } + public Start { get; init; } + public string StepName { get; init; } + } public sealed class TypeArrayComparer : .<[]> { public static readonly .TypeArrayComparer Instance; @@ -2644,10 +2658,16 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt index 16d097c942..f9f244f3d2 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.DotNet9_0.verified.txt @@ -1352,6 +1352,8 @@ namespace public .IDataSourceAttribute? DataSourceAttribute { get; set; } public string DefinitionId { get; } public .TestContextEvents Events { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1647,6 +1649,8 @@ namespace public TestRegisteredContext(.TestContext testContext) { } public string? CustomDisplayName { get; } public .DiscoveredTest DiscoveredTest { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; } public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } @@ -1717,6 +1721,16 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + public class Timing : <.Timing> + { + public Timing(string StepName, Start, End) { } + public Duration { get; } + public End { get; init; } + public Start { get; init; } + public string StepName { get; init; } + } public sealed class TypeArrayComparer : .<[]> { public static readonly .TypeArrayComparer Instance; @@ -2644,10 +2658,16 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); } diff --git a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt index 7f85cf8aa8..3993b342f2 100644 --- a/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt +++ b/TUnit.PublicAPI/Tests.Core_Library_Has_No_API_Changes.Net4_7.verified.txt @@ -1300,6 +1300,8 @@ namespace public .IDataSourceAttribute? DataSourceAttribute { get; set; } public string DefinitionId { get; } public .TestContextEvents Events { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; set; } public required .MethodMetadata TestMetadata { get; init; } public static .TestBuilderContext? Current { get; } @@ -1589,6 +1591,8 @@ namespace public TestRegisteredContext(.TestContext testContext) { } public string? CustomDisplayName { get; } public .DiscoveredTest DiscoveredTest { get; set; } + [("Use StateBag property instead.")] + public . ObjectBag { get; } public . StateBag { get; } public .TestContext TestContext { get; } public .TestDetails TestDetails { get; } @@ -1659,6 +1663,16 @@ namespace public . OnHookRegistered(.HookRegisteredContext context) { } public . OnTestDiscovered(.DiscoveredTestContext context) { } } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + public class Timing : <.Timing> + { + public Timing(string StepName, Start, End) { } + public Duration { get; } + public End { get; init; } + public Start { get; init; } + public string StepName { get; init; } + } public sealed class TypeArrayComparer : .<[]> { public static readonly .TypeArrayComparer Instance; @@ -2578,10 +2592,16 @@ namespace .Interfaces .<.Artifact> Artifacts { get; } .TextWriter ErrorOutput { get; } .TextWriter StandardOutput { get; } + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + .<.Timing> Timings { get; } void AttachArtifact(.Artifact artifact); void AttachArtifact(string filePath, string? displayName = null, string? description = null); string GetErrorOutput(); string GetStandardOutput(); + [("Use OpenTelemetry activity spans instead. Hook timings are now automatically reco" + + "rded as OTel child spans of the test activity.")] + void RecordTiming(.Timing timing); void WriteError(string message); void WriteLine(string message); } From b1161435479d69486acc59c173a54e6f89c14373 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:47:27 +0100 Subject: [PATCH 2/2] refactor: address PR review feedback - Extract GetCount/MapToCount helpers in CountWrapper to remove 6x duplication - Use on ObjectBag aliases (drop duplicated XML) - Clarify _timingsLock comment: engine writes are sequential, lock guards user-facing obsolete RecordTiming --- .../Conditions/Wrappers/CountWrapper.cs | 126 ++---------------- TUnit.Core/Contexts/TestRegisteredContext.cs | 4 +- TUnit.Core/TestBuilderContext.cs | 4 +- TUnit.Core/TestContext.Output.cs | 6 +- 4 files changed, 20 insertions(+), 120 deletions(-) diff --git a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs index 2c06445e74..0bf6549781 100644 --- a/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs +++ b/TUnit.Assertions/Conditions/Wrappers/CountWrapper.cs @@ -24,6 +24,13 @@ public CountWrapper(AssertionContext context) AssertionContext IAssertionSource.Context => _context; + private static int GetCount(TCollection? value) => + value is null ? 0 + : value is ICollection collection ? collection.Count + : value.Cast().Count(); + + private AssertionContext MapToCount() => _context.Map(GetCount); + /// /// Not supported on CountWrapper - use IsTypeOf on the assertion source before calling HasCount(). /// @@ -103,22 +110,7 @@ public TValue_IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo( [CallerArgumentExpression(nameof(expected))] string? expression = null) { _context.ExpressionBuilder.Append($".GreaterThanOrEqualTo({expression})"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new TValue_IsGreaterThanOrEqualTo_TValue_Assertion(countContext, expected); + return new TValue_IsGreaterThanOrEqualTo_TValue_Assertion(MapToCount(), expected); } /// @@ -127,22 +119,7 @@ public TValue_IsGreaterThanOrEqualTo_TValue_Assertion GreaterThanOrEqualTo( public TValue_IsGreaterThan_TValue_Assertion Positive() { _context.ExpressionBuilder.Append(".Positive()"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new TValue_IsGreaterThan_TValue_Assertion(countContext, 0); + return new TValue_IsGreaterThan_TValue_Assertion(MapToCount(), 0); } /// @@ -153,22 +130,7 @@ public TValue_IsGreaterThan_TValue_Assertion GreaterThan( [CallerArgumentExpression(nameof(expected))] string? expression = null) { _context.ExpressionBuilder.Append($".GreaterThan({expression})"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new TValue_IsGreaterThan_TValue_Assertion(countContext, expected); + return new TValue_IsGreaterThan_TValue_Assertion(MapToCount(), expected); } /// @@ -179,22 +141,7 @@ public TValue_IsLessThan_TValue_Assertion LessThan( [CallerArgumentExpression(nameof(expected))] string? expression = null) { _context.ExpressionBuilder.Append($".LessThan({expression})"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new TValue_IsLessThan_TValue_Assertion(countContext, expected); + return new TValue_IsLessThan_TValue_Assertion(MapToCount(), expected); } /// @@ -205,22 +152,7 @@ public TValue_IsLessThanOrEqualTo_TValue_Assertion LessThanOrEqualTo( [CallerArgumentExpression(nameof(expected))] string? expression = null) { _context.ExpressionBuilder.Append($".LessThanOrEqualTo({expression})"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new TValue_IsLessThanOrEqualTo_TValue_Assertion(countContext, expected); + return new TValue_IsLessThanOrEqualTo_TValue_Assertion(MapToCount(), expected); } /// @@ -233,22 +165,7 @@ public BetweenAssertion Between( [CallerArgumentExpression(nameof(maximum))] string? maxExpression = null) { _context.ExpressionBuilder.Append($".Between({minExpression}, {maxExpression})"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new BetweenAssertion(countContext, minimum, maximum); + return new BetweenAssertion(MapToCount(), minimum, maximum); } /// @@ -268,21 +185,6 @@ public NotEqualsAssertion NotEqualTo( [CallerArgumentExpression(nameof(expected))] string? expression = null) { _context.ExpressionBuilder.Append($".NotEqualTo({expression})"); - // Map context to get the count - var countContext = _context.Map(value => - { - if (value == null) - { - return 0; - } - - if (value is ICollection collection) - { - return collection.Count; - } - - return value.Cast().Count(); - }); - return new NotEqualsAssertion(countContext, expected); + return new NotEqualsAssertion(MapToCount(), expected); } } diff --git a/TUnit.Core/Contexts/TestRegisteredContext.cs b/TUnit.Core/Contexts/TestRegisteredContext.cs index 796f1b2944..7c0c462760 100644 --- a/TUnit.Core/Contexts/TestRegisteredContext.cs +++ b/TUnit.Core/Contexts/TestRegisteredContext.cs @@ -25,9 +25,7 @@ public TestRegisteredContext(TestContext testContext) /// public ConcurrentDictionary StateBag => TestContext.StateBag.Items; - /// - /// Gets the object bag from the underlying TestContext - /// + /// [Obsolete("Use StateBag property instead.")] public ConcurrentDictionary ObjectBag => StateBag; diff --git a/TUnit.Core/TestBuilderContext.cs b/TUnit.Core/TestBuilderContext.cs index 83256bfa02..80809b8e0b 100644 --- a/TUnit.Core/TestBuilderContext.cs +++ b/TUnit.Core/TestBuilderContext.cs @@ -37,9 +37,7 @@ public static TestBuilderContext? Current set => _stateBag = value; } - /// - /// Gets the state bag for storing arbitrary data during test building. - /// + /// [Obsolete("Use StateBag property instead.")] public ConcurrentDictionary ObjectBag => StateBag; diff --git a/TUnit.Core/TestContext.Output.cs b/TUnit.Core/TestContext.Output.cs index e95babfd6e..57e3a8bc01 100644 --- a/TUnit.Core/TestContext.Output.cs +++ b/TUnit.Core/TestContext.Output.cs @@ -14,8 +14,10 @@ internal record TimingEntry(string StepName, DateTimeOffset Start, DateTimeOffse /// public partial class TestContext { - // Internal backing fields and properties - // Timings are written sequentially by the framework during test execution, never by user code. + // Internal backing fields and properties. + // Engine writes are sequential per-test (lifecycle-ordered). + // User-facing writes via the obsolete ITestOutput.RecordTiming API may be concurrent, + // so all access through the obsolete bridge takes _timingsLock. internal List Timings { get; } = []; private readonly Lock _timingsLock = new(); // Artifacts use a lock because AttachArtifact is user-facing and can be called