Skip to content

perf: lazily initialize optional MockEngine collections#5289

Merged
thomhurst merged 6 commits intomainfrom
perf/lazy-init-mock-engine-collections
Mar 29, 2026
Merged

perf: lazily initialize optional MockEngine collections#5289
thomhurst merged 6 commits intomainfrom
perf/lazy-init-mock-engine-collections

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

  • Lazily initialize 4 ConcurrentDictionaries + 1 ConcurrentQueue in MockEngine<T> that support optional features (auto-track properties, event subscriptions/callbacks, auto-mock cache)
  • These collections are now only allocated on first use, not at mock creation time
  • Read paths use null checks on the backing field to avoid triggering allocation

Benchmark Results

Metric Before After Change
TUnit.Mocks (Calculator) 2,654 ns / 4.47 KB 893 ns / 1.13 KB 3x faster, 4x less memory
TUnit.Mocks (Repository) 1,862 ns / 4.47 KB 839 ns / 1.13 KB 2.2x faster, 4x less memory

TUnit.Mocks now has the lowest allocation of all benchmarked frameworks (1.13 KB vs Moq 2 KB, FakeItEasy 2.66 KB, NSubstitute 4.88 KB).

Test plan

  • All 672 TUnit.Mocks.Tests pass
  • MockCreation benchmarks verified locally
  • CI passes

MockEngine<T> was eagerly allocating 4 ConcurrentDictionaries and 1
ConcurrentQueue for optional features (auto-track properties, event
subscriptions, auto-mocking) that most mocks never use.

Deferring these allocations to first use reduces mock creation from
2,654ns/4.47KB to 893ns/1.13KB — 3x faster with 4x less memory.
TUnit.Mocks now has the lowest allocation of all benchmarked frameworks.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

This is a well-targeted performance PR with impressive benchmark results (3x faster, 4x less memory). The approach is clean and consistent. One meaningful concern worth addressing before merge:


Thread-Safety Risk with ??= Lazy Initialization

The lazy property accessors use the ??= operator:

private ConcurrentDictionary<string, object?> AutoTrackValues
    => _autoTrackValues ??= new();

This compiles to a non-atomic read-check-write, roughly:

get {
    if (_autoTrackValues == null) _autoTrackValues = new();
    return _autoTrackValues;
}

Under concurrent access, two threads can both see null, both allocate a new instance, and one thread's allocation gets overwritten — any data written to the losing instance is silently dropped. This matters here because ConcurrentDictionary was deliberately chosen for thread-safe concurrent access, but the lazy initialization pattern breaks that guarantee on the initial write race.

Better approach — use LazyInitializer.EnsureInitialized:

private ConcurrentDictionary<string, object?> AutoTrackValues
    => LazyInitializer.EnsureInitialized(ref _autoTrackValues);

LazyInitializer.EnsureInitialized uses Interlocked.CompareExchange internally, ensuring only one instance is ever published. It has zero overhead on the hot (already-initialized) read path — same as a null check — and no wrapper allocation at all, so the benchmark gains are fully preserved.

This applies to all five lazily-initialized fields:

  • _autoTrackValues
  • _eventSubscriptions
  • _onSubscribeCallbacks
  • _onUnsubscribeCallbacks
  • _autoMockCache

Everything Else Looks Good

  • Asymmetric read/write paths are intentional and correct: write paths use the property (allocating on first use), read-only paths check the backing field directly (e.g. HandleCallWithReturn, RecordEventSubscription, GetEventSubscriberCount, WasEventSubscribed) — this is the right pattern.
  • Reset() correctly uses null-conditional operators — no spurious allocations.
  • TryGetAutoMock null guard is correctly added alongside the lazy property.
  • _callHistory staying eager is the right call — call history is always used.
  • The benchmark data is solid and the commit message explains the motivation clearly.

The thread-safety fix is a one-line change per property and doesn't affect the performance gains — worth doing before merge.

- Use Interlocked.CompareExchange + volatile for thread-safe lazy
  initialization (??= is not atomic and could lose writes)
- Reset() nulls out fields instead of clearing — reclaims memory
  and is simpler
- Remove unnecessary comment
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review — perf: lazily initialize optional MockEngine collections

The previous review flagged the ??= thread-safety risk. The author has since addressed it with Interlocked.CompareExchange + volatile. That fix is correct — this is a follow-up pass on the updated code.


The Lazy Initialization Pattern Is Correct

The current pattern:

private ConcurrentDictionary<string, object?> AutoTrackValues
    => _autoTrackValues ?? Interlocked.CompareExchange(ref _autoTrackValues, new(), null) ?? _autoTrackValues;

This is a well-known lock-free lazy init idiom and it is correct:

  • Fast path: _autoTrackValues ?? ...volatile ensures a fresh read, short-circuits immediately if already initialized.
  • Slow path: ICE atomically publishes the new instance only if the field is still null; it returns the previous (old) value.
    • If we won the race: ICE returns null → second ?? reads _autoTrackValues, which is now our newly written instance.
    • If we lost the race: ICE returns the other thread's instance (non-null) → second ?? short-circuits on that.

The volatile qualifier is also appropriate: it prevents the JIT from caching the field in a register between the two reads, which would be incorrect in the "won the race" branch.

No correctness concern with this pattern.


One Remaining Observation — RecordEventSubscription Write-Then-Null-Check Asymmetry

public void RecordEventSubscription(string eventName, bool isSubscribe)
{
    EventSubscriptions.Enqueue((eventName, isSubscribe));   // allocates via property

    if (isSubscribe)
    {
        if (_onSubscribeCallbacks is not null && _onSubscribeCallbacks.TryGetValue(...))  // direct field check

EventSubscriptions (the property) is called unconditionally — so it allocates the queue on the first event subscription even if no OnSubscribe callback was ever registered. The intent of the PR is to only allocate when a feature is actively used. For most test scenarios that use events but have no OnSubscribe callbacks, this still allocates _eventSubscriptions eagerly.

This is a pre-existing design decision, not a regression introduced by this PR, so it's not a blocker. But if _eventSubscriptions were guarded the same way as the callbacks — only allocated when explicitly needed — it would be fully consistent with the PR's lazy strategy. Worth a comment explaining why the queue is allocated on subscription recording vs. only on GetEventSubscriberCount/WasEventSubscribed calls.


Minor — Use Range Syntax Over Substring(4)

These two lines use the older API:

AutoTrackValues[memberName.Substring(4)] = args[0];
// ...
if (_autoTrackValues.TryGetValue(memberName.Substring(4), out var trackedValue))

With LangVersion set to preview, these should be memberName[4..]. Pre-existing, but these lines are touched in this diff — good opportunity to modernize.


Reset() — Nulling vs. Clearing Is the Right Call

_autoTrackValues = null;
_eventSubscriptions = null;
// etc.

This is strictly better than the old .Clear() approach: it reclaims the actual backing collection objects (GC can collect them), whereas .Clear() just empties them and leaves the empty ConcurrentDictionary/ConcurrentQueue alive. It also keeps Reset() consistent with the lazy allocation model — post-reset, features are back to the unallocated state.


TryGetAutoMock Null Guard Is Correct and Necessary

public bool TryGetAutoMock(string cacheKey, out IMock? mock)
{
    if (_autoMockCache is null) { mock = null; return false; }
    return _autoMockCache.TryGetValue(cacheKey, out mock);
}

This is the correct asymmetric read path — check the backing field directly (no allocation) on a read-only query. All five "read-only query" sites follow this pattern consistently.


Summary

The thread-safety concern from the previous review has been correctly resolved. The implementation is sound. Two minor observations:

  1. RecordEventSubscription allocates _eventSubscriptions on first event use — not a bug, but worth a comment if the laziness invariant is intended to extend to the subscription queue.
  2. memberName.Substring(4)memberName[4..] — pre-existing, but these lines are touched in this diff.

The benchmark gains are real and the implementation is correct. LGTM with those minor observations noted.

Address review feedback: use modern C# range syntax on lines
touched by this PR.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review

The lazy-init approach is a solid optimization — 4x memory reduction is meaningful for a mocking framework where many tests create multiple mocks. The volatile + Interlocked.CompareExchange pattern for lazy initialization is correct and well-understood.

Bug: TOCTOU race condition on volatile field reads

There are three places where the same volatile field is read twice without capturing it in a local variable. Between the null check and the .TryGetValue() call, another thread can call Reset() and null out the field, causing a NullReferenceException.

HandleCallWithReturn (line ~230):

// UNSAFE: _autoTrackValues can be nulled by Reset() between these two reads
if (AutoTrackProperties && _autoTrackValues is not null && memberName.StartsWith("get_", StringComparison.Ordinal))
{
    if (_autoTrackValues.TryGetValue(memberName[4..], out var trackedValue))

RecordEventSubscription (lines ~542, ~549):

if (_onSubscribeCallbacks is not null && _onSubscribeCallbacks.TryGetValue(eventName, out var callback))
// ...
if (_onUnsubscribeCallbacks is not null && _onUnsubscribeCallbacks.TryGetValue(eventName, out var callback))

TryGetAutoMock (lines ~484-489):

if (_autoMockCache is null) { ... }
return _autoMockCache.TryGetValue(cacheKey, out mock);  // can throw if Reset() races here

Fix: Capture into a local variable before the null check. This is the standard pattern when working with volatile nullable references:

// HandleCallWithReturn
var autoTrackValues = _autoTrackValues;
if (AutoTrackProperties && autoTrackValues is not null && memberName.StartsWith("get_", StringComparison.Ordinal))
{
    if (autoTrackValues.TryGetValue(memberName[4..], out var trackedValue))

// RecordEventSubscription
var onSubscribe = _onSubscribeCallbacks;
if (onSubscribe is not null && onSubscribe.TryGetValue(eventName, out var callback))

var onUnsubscribe = _onUnsubscribeCallbacks;
if (onUnsubscribe is not null && onUnsubscribe.TryGetValue(eventName, out var callback))

// TryGetAutoMock
var cache = _autoMockCache;
if (cache is null) { mock = null; return false; }
return cache.TryGetValue(cacheKey, out mock);

Why this matters: volatile guarantees visibility (no stale caches), but it doesn't prevent a second thread from modifying the field between your two reads. A local snapshot gives you a stable reference for the duration of the operation.

Notably, GetEventSubscriberCount and WasEventSubscribed already do this correctly — they read _eventSubscriptions once and early-return if null, never touching the field again. The three cases above need the same treatment.


The lazy-init property pattern is correct

private ConcurrentDictionary<string, object?> AutoTrackValues
    => _autoTrackValues ?? Interlocked.CompareExchange(ref _autoTrackValues, new(), null) ?? _autoTrackValues;

This is a well-known, correct pattern: if CompareExchange returns null (we won the race), read back the field we just wrote; if it returns non-null (another thread won), use that value. The volatile ensures the final _autoTrackValues read sees the most recent write. ✓


Minor: Reset() nulling vs. clearing

Setting fields to null in Reset() is correct and consistent with the lazy approach. It also slightly improves Reset performance by avoiding the lock-per-operation on each collection. One subtle implication: any thread that captured a collection reference before Reset() fires will still operate on the old (now-abandoned) collection rather than the newly reset one — this is generally acceptable behavior for a Reset, but worth being aware of.


Summary: The optimization is well-executed and the performance results are impressive. The one concrete issue is the TOCTOU pattern on three volatile field reads; the fix is a one-liner in each case (capture to local). Everything else looks good.

- Replace hand-rolled Interlocked.CompareExchange with
  LazyInitializer.EnsureInitialized (handles Volatile.Read internally)
- Remove volatile from fields (no longer needed)
- Fix double-read race: capture volatile fields to locals before use,
  preventing NRE if Reset() nulls the field between null-check and access
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review — Follow-up Pass

All three concerns from the previous reviews have been correctly addressed in this iteration:

  1. Thread-safety () — replaced with LazyInitializer.EnsureInitialized, which uses Interlocked.CompareExchange internally. Correct and zero-overhead on the hot (already-initialized) path. ✓
  2. TOCTOU race — all read-only paths now capture to a local via Volatile.Read(ref _field) is { } local before the null check and subsequent use. Pattern is consistent across all five fields and matches what GetEventSubscriberCount/WasEventSubscribed already did correctly. ✓
  3. Range syntaxmemberName[4..] replaces memberName.Substring(4) in the touched lines. ✓

One Remaining Subtle Concern — Reset() Uses Plain Writes

_autoTrackValues = null;    // plain write
_eventSubscriptions = null;
// etc.

Read paths use Volatile.Read (acquire fence), but Reset() uses plain assignment (no release fence). On weakly-ordered architectures (ARM), a thread doing Volatile.Read might not see a null that was written with a plain store. The formally correct pairing is Volatile.Write:

Volatile.Write(ref _autoTrackValues, null);
Volatile.Write(ref _eventSubscriptions, null);
// etc.

In practice on x64/.NET runtime this works due to the stronger-than-ECMA implementation guarantees, and Reset() is typically called in single-threaded teardown anyway — so this is not a blocker. But it's worth noting for completeness given the careful use of Volatile.Read elsewhere.

Summary

The implementation is correct and the benchmark results are compelling (4x less memory, lowest allocation among benchmarked frameworks). The LazyInitializer.EnsureInitialized + Volatile.Read combination is the right idiom for this pattern. LGTM — the Volatile.Write in Reset() is the only remaining observation and it's minor.

Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review — Follow-up Pass

All three concerns from the previous reviews have been correctly addressed in this iteration:

  1. Thread-safety (??=) — replaced with LazyInitializer.EnsureInitialized, which uses Interlocked.CompareExchange internally. Correct and zero-overhead on the hot (already-initialized) path. ✓
  2. TOCTOU race — all read-only paths now capture to a local via Volatile.Read(ref _field) is { } local before the null check and subsequent use. Pattern is consistent across all five fields and matches what GetEventSubscriberCount/WasEventSubscribed already did correctly. ✓
  3. Range syntaxmemberName[4..] replaces memberName.Substring(4) in the touched lines. ✓

One Remaining Subtle Concern — Reset() Uses Plain Writes

_autoTrackValues = null;    // plain write
_eventSubscriptions = null;
// etc.

Read paths use Volatile.Read (acquire fence), but Reset() uses plain assignment (no release fence). On weakly-ordered architectures (ARM), a thread doing Volatile.Read might not see a null that was written with a plain store. The formally correct pairing is Volatile.Write:

Volatile.Write(ref _autoTrackValues, null);
Volatile.Write(ref _eventSubscriptions, null);
// etc.

In practice on x64/.NET runtime this works due to the stronger-than-ECMA implementation guarantees, and Reset() is typically called in single-threaded teardown anyway — so this is not a blocker. But it's worth noting for completeness given the careful use of Volatile.Read elsewhere.

Summary

The implementation is correct and the benchmark results are compelling (4x less memory, lowest allocation among benchmarked frameworks). The LazyInitializer.EnsureInitialized + Volatile.Read combination is the right idiom for this pattern. LGTM — the Volatile.Write in Reset() is the only remaining observation and it's minor.

Plain writes in Reset() don't guarantee visibility on weakly-ordered
architectures (ARM). Volatile.Write provides the release fence needed
to pair with the Volatile.Read acquire fences on read paths.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

Code Review — Final Pass

This is the final review following four rounds of iteration. All previously identified concerns have been resolved.

All Previous Concerns Addressed ✓

Round Issue Status
1 ??= non-atomic lazy init Fixed with LazyInitializer.EnsureInitialized
2 TOCTOU double-read race Fixed with Volatile.Read(ref _field) is { } local captures
2 Range syntax on touched lines Fixed: memberName[4..]
3–4 Reset() plain writes unpaired with Volatile.Read Fixed with Volatile.Write

Final Implementation Is Correct

The complete pattern is now internally consistent:

  • Write paths (AutoTrackValues[key] = ..., EventSubscriptions.Enqueue(...), etc.): use properties backed by LazyInitializer.EnsureInitialized — lock-free, safe under concurrent allocation races.
  • Read-only paths (HandleCallWithReturn, RecordEventSubscription, TryGetAutoMock): capture via Volatile.Read(ref _field) is { } local then use the local exclusively — no TOCTOU window.
  • Reset path: Volatile.Write(ref _field, null) provides a proper release fence, pairing correctly with the acquire fence in Volatile.Read on all read paths. This is the right idiom for weakly-ordered architectures (ARM).

Why This Final State Is Right

The Volatile.Read / Volatile.Write pairing matters for memory model correctness: Volatile.Read emits an acquire barrier (no reads/writes after this can be reordered before it) and Volatile.Write emits a release barrier (no reads/writes before this can be reordered after it). Pairing them ensures the null set by Reset() is visible to subsequent Volatile.Read calls on other threads, even on ARM64.

Result

The optimization delivers on its goals — 3x faster construction, 4x less memory, now the lowest allocation of any benchmarked framework. The threading model is sound throughout. LGTM.

The netstandard2.0 overload lacks [NotNull] annotations, so the
compiler warns about possible null returns. Add null-forgiving
operator since EnsureInitialized guarantees non-null.
intellitect-bot pushed a commit to IntelliTect/EssentialCSharp.Web that referenced this pull request Mar 30, 2026
Updated [TUnit](https://github.com/thomhurst/TUnit) from 1.21.30 to
1.23.7.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.23.7

<!-- Release notes generated using configuration in .github/release.yml
at v1.23.7 -->

## What's Changed
### Other Changes
* feat: use results directory provided by Microsoft Testing Platform in
HtmlReporter by @​DavidZidar in
thomhurst/TUnit#5294
* feat: add benchmarks for Imposter and Mockolate mocking frameworks by
@​vbreuss in thomhurst/TUnit#5295
* feat: add TUnit0080 analyzer for missing polyfill types by @​thomhurst
in thomhurst/TUnit#5292
* fix: respect user-set TUnitImplicitUsings from Directory.Build.props
by @​thomhurst in thomhurst/TUnit#5280
* perf: optimize TUnit.Mocks hot paths by @​thomhurst in
thomhurst/TUnit#5300
### Dependencies
* chore(deps): update tunit to 1.22.19 by @​thomhurst in
thomhurst/TUnit#5296

## New Contributors
* @​DavidZidar made their first contribution in
thomhurst/TUnit#5294

**Full Changelog**:
thomhurst/TUnit@v1.22.19...v1.23.7

## 1.22.19

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.19 -->

## What's Changed
### Other Changes
* Add mock library benchmarks: TUnit.Mocks vs Moq, NSubstitute,
FakeItEasy by @​Copilot in thomhurst/TUnit#5284
* perf: lazily initialize optional MockEngine collections by @​thomhurst
in thomhurst/TUnit#5289
* Always emit TUnit.Mocks.Generated namespace from source generator by
@​Copilot in thomhurst/TUnit#5282
### Dependencies
* chore(deps): update tunit to 1.22.6 by @​thomhurst in
thomhurst/TUnit#5285


**Full Changelog**:
thomhurst/TUnit@v1.22.6...v1.22.19

## 1.22.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.6 -->

## What's Changed
### Other Changes
* fix: use IComputeResource to filter waitable Aspire resources by
@​thomhurst in thomhurst/TUnit#5278
* fix: preserve StateBag when creating per-test TestBuilderContext by
@​thomhurst in thomhurst/TUnit#5279
### Dependencies
* chore(deps): update tunit to 1.22.3 by @​thomhurst in
thomhurst/TUnit#5275


**Full Changelog**:
thomhurst/TUnit@v1.22.3...v1.22.6

## 1.22.3

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.3 -->

## What's Changed
### Other Changes
* fix: pass assembly version properties to dotnet pack by @​thomhurst in
thomhurst/TUnit#5274
### Dependencies
* chore(deps): update tunit to 1.22.0 by @​thomhurst in
thomhurst/TUnit#5272


**Full Changelog**:
thomhurst/TUnit@v1.22.0...v1.22.3

## 1.22.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.0 -->

## What's Changed
### Other Changes
* perf: run GitVersion once in CI instead of per-project by @​slang25 in
thomhurst/TUnit#5259
* perf: disable GitVersion MSBuild task globally by @​thomhurst in
thomhurst/TUnit#5266
* fix: skip IResourceWithoutLifetime resources in Aspire fixture wait
logic by @​thomhurst in thomhurst/TUnit#5268
* fix: relax docs site Node.js engine constraint to >=24 by @​thomhurst
in thomhurst/TUnit#5269
* fix: catch unhandled exceptions in ExecuteRequestAsync to prevent IDE
RPC crashes by @​thomhurst in
thomhurst/TUnit#5271
* feat: register HTML report as MTP session artifact by @​thomhurst in
thomhurst/TUnit#5270
### Dependencies
* chore(deps): update tunit to 1.21.30 by @​thomhurst in
thomhurst/TUnit#5254
* chore(deps): update opentelemetry to 1.15.1 by @​thomhurst in
thomhurst/TUnit#5258
* chore(deps): bump node-forge from 1.3.1 to 1.4.0 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5255
* chore(deps): bump picomatch from 2.3.1 to 2.3.2 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5256
* chore(deps): update react by @​thomhurst in
thomhurst/TUnit#5261
* chore(deps): update node.js to >=18.20.8 by @​thomhurst in
thomhurst/TUnit#5262
* chore(deps): update node.js to v24 by @​thomhurst in
thomhurst/TUnit#5264


**Full Changelog**:
thomhurst/TUnit@v1.21.30...v1.22.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.21.30...v1.23.7).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit&package-manager=nuget&previous-version=1.21.30&new-version=1.23.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
github-actions bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Mar 30, 2026
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.21.6 to
1.23.7.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.23.7

<!-- Release notes generated using configuration in .github/release.yml
at v1.23.7 -->

## What's Changed
### Other Changes
* feat: use results directory provided by Microsoft Testing Platform in
HtmlReporter by @​DavidZidar in
thomhurst/TUnit#5294
* feat: add benchmarks for Imposter and Mockolate mocking frameworks by
@​vbreuss in thomhurst/TUnit#5295
* feat: add TUnit0080 analyzer for missing polyfill types by @​thomhurst
in thomhurst/TUnit#5292
* fix: respect user-set TUnitImplicitUsings from Directory.Build.props
by @​thomhurst in thomhurst/TUnit#5280
* perf: optimize TUnit.Mocks hot paths by @​thomhurst in
thomhurst/TUnit#5300
### Dependencies
* chore(deps): update tunit to 1.22.19 by @​thomhurst in
thomhurst/TUnit#5296

## New Contributors
* @​DavidZidar made their first contribution in
thomhurst/TUnit#5294

**Full Changelog**:
thomhurst/TUnit@v1.22.19...v1.23.7

## 1.22.19

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.19 -->

## What's Changed
### Other Changes
* Add mock library benchmarks: TUnit.Mocks vs Moq, NSubstitute,
FakeItEasy by @​Copilot in thomhurst/TUnit#5284
* perf: lazily initialize optional MockEngine collections by @​thomhurst
in thomhurst/TUnit#5289
* Always emit TUnit.Mocks.Generated namespace from source generator by
@​Copilot in thomhurst/TUnit#5282
### Dependencies
* chore(deps): update tunit to 1.22.6 by @​thomhurst in
thomhurst/TUnit#5285


**Full Changelog**:
thomhurst/TUnit@v1.22.6...v1.22.19

## 1.22.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.6 -->

## What's Changed
### Other Changes
* fix: use IComputeResource to filter waitable Aspire resources by
@​thomhurst in thomhurst/TUnit#5278
* fix: preserve StateBag when creating per-test TestBuilderContext by
@​thomhurst in thomhurst/TUnit#5279
### Dependencies
* chore(deps): update tunit to 1.22.3 by @​thomhurst in
thomhurst/TUnit#5275


**Full Changelog**:
thomhurst/TUnit@v1.22.3...v1.22.6

## 1.22.3

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.3 -->

## What's Changed
### Other Changes
* fix: pass assembly version properties to dotnet pack by @​thomhurst in
thomhurst/TUnit#5274
### Dependencies
* chore(deps): update tunit to 1.22.0 by @​thomhurst in
thomhurst/TUnit#5272


**Full Changelog**:
thomhurst/TUnit@v1.22.0...v1.22.3

## 1.22.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.22.0 -->

## What's Changed
### Other Changes
* perf: run GitVersion once in CI instead of per-project by @​slang25 in
thomhurst/TUnit#5259
* perf: disable GitVersion MSBuild task globally by @​thomhurst in
thomhurst/TUnit#5266
* fix: skip IResourceWithoutLifetime resources in Aspire fixture wait
logic by @​thomhurst in thomhurst/TUnit#5268
* fix: relax docs site Node.js engine constraint to >=24 by @​thomhurst
in thomhurst/TUnit#5269
* fix: catch unhandled exceptions in ExecuteRequestAsync to prevent IDE
RPC crashes by @​thomhurst in
thomhurst/TUnit#5271
* feat: register HTML report as MTP session artifact by @​thomhurst in
thomhurst/TUnit#5270
### Dependencies
* chore(deps): update tunit to 1.21.30 by @​thomhurst in
thomhurst/TUnit#5254
* chore(deps): update opentelemetry to 1.15.1 by @​thomhurst in
thomhurst/TUnit#5258
* chore(deps): bump node-forge from 1.3.1 to 1.4.0 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5255
* chore(deps): bump picomatch from 2.3.1 to 2.3.2 in /docs by
@​dependabot[bot] in thomhurst/TUnit#5256
* chore(deps): update react by @​thomhurst in
thomhurst/TUnit#5261
* chore(deps): update node.js to >=18.20.8 by @​thomhurst in
thomhurst/TUnit#5262
* chore(deps): update node.js to v24 by @​thomhurst in
thomhurst/TUnit#5264


**Full Changelog**:
thomhurst/TUnit@v1.21.30...v1.22.0

## 1.21.30

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.30 -->

## What's Changed
### Other Changes
* feat: add test discovery Activity span for tracing by @​thomhurst in
thomhurst/TUnit#5246
* Fix mock generator not preserving nullable annotations on reference
types by @​Copilot in thomhurst/TUnit#5251
* Fix ITestSkippedEventReceiver not firing for [Skip]-attributed tests
by @​thomhurst in thomhurst/TUnit#5253
* Use CallerArgumentExpression for TestDataRow by default. by @​m-gasser
in thomhurst/TUnit#5135
### Dependencies
* chore(deps): update tunit to 1.21.24 by @​thomhurst in
thomhurst/TUnit#5247


**Full Changelog**:
thomhurst/TUnit@v1.21.24...v1.21.30

## 1.21.24

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.24 -->

## What's Changed
### Other Changes
* Fix OpenTelemetry missing root span by reordering session activity
lifecycle by @​Copilot in thomhurst/TUnit#5245
### Dependencies
* chore(deps): update tunit to 1.21.20 by @​thomhurst in
thomhurst/TUnit#5241
* chore(deps): update dependency stackexchange.redis to 2.12.8 by
@​thomhurst in thomhurst/TUnit#5243


**Full Changelog**:
thomhurst/TUnit@v1.21.20...v1.21.24

## 1.21.20

<!-- Release notes generated using configuration in .github/release.yml
at v1.21.20 -->

## What's Changed
### Other Changes
* fix: respect TUnitImplicitUsings set in Directory.Build.props by
@​thomhurst in thomhurst/TUnit#5225
* feat: covariant assertions for interfaces and non-sealed classes by
@​thomhurst in thomhurst/TUnit#5226
* feat: support string-to-parseable type conversions in [Arguments] by
@​thomhurst in thomhurst/TUnit#5227
* feat: add string length range assertions by @​thomhurst in
thomhurst/TUnit#4935
* Fix BeforeEvery/AfterEvery hooks for Class and Assembly not being
executed by @​Copilot in thomhurst/TUnit#5239
### Dependencies
* chore(deps): update tunit to 1.21.6 by @​thomhurst in
thomhurst/TUnit#5228
* chore(deps): update dependency gitversion.msbuild to 6.7.0 by
@​thomhurst in thomhurst/TUnit#5229
* chore(deps): update dependency gitversion.tool to v6.7.0 by
@​thomhurst in thomhurst/TUnit#5230
* chore(deps): update aspire to 13.2.0 - autoclosed by @​thomhurst in
thomhurst/TUnit#5232
* chore(deps): update dependency typescript to v6 by @​thomhurst in
thomhurst/TUnit#5233
* chore(deps): update dependency polyfill to 9.23.0 by @​thomhurst in
thomhurst/TUnit#5235
* chore(deps): update dependency polyfill to 9.23.0 by @​thomhurst in
thomhurst/TUnit#5236


**Full Changelog**:
thomhurst/TUnit@v1.21.6...v1.21.20

Commits viewable in [compare
view](thomhurst/TUnit@v1.21.6...v1.23.7).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.21.6&new-version=1.23.7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant