From 0ca448b84cef8e489b0c0f5d4269f714d557eb36 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Jul 2026 12:46:21 +0100
Subject: [PATCH 1/2] test(mocks): show the snapshot diff in the failure
message
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A mock snapshot mismatch reported only the two file paths. That is
enough locally, but a mismatch that only reproduces on CI — currently
GenerateMock_Attribute_With_Concrete_Class and
KiotaIRequestAdapter_ConstrainedGenericMethod_EmitsDefaultConstraint on
the windows and macos jobs, both green on ubuntu and on a local windows
run — leaves nothing in the log to diagnose from, because the
.received.txt never leaves the runner.
Print the first divergence with three lines of context and a 40-line
cap, plus both line counts.
---
.../SnapshotTestBase.cs | 70 ++++++++++++++++++-
1 file changed, 69 insertions(+), 1 deletion(-)
diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
index b95b91b163..79acf5e83e 100644
--- a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
+++ b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
@@ -303,7 +303,8 @@ private static async Task VerifySnapshot(
$"Snapshot mismatch for '{testName}'.\n" +
$"Received: {receivedPath}\n" +
$"Verified: {verifiedPath}\n" +
- $"Update the .verified.txt file if this change is intentional.");
+ $"Update the .verified.txt file if this change is intentional.\n" +
+ $"{DescribeDifference(verified, generatedOutput)}");
}
// Clean up any leftover .received.txt on success
@@ -317,4 +318,71 @@ private static string NormalizeNewlines(string text)
{
return text.Replace("\r\n", "\n").Replace("\r", "\n");
}
+
+ ///
+ /// Renders the first divergence between the two snapshots inline in the failure message.
+ /// The .received.txt file is only useful to someone with the working tree in front of
+ /// them; a mismatch that reproduces on CI (or on one OS only) is otherwise undiagnosable from
+ /// the log alone.
+ ///
+ private static string DescribeDifference(string verified, string received)
+ {
+ const int ContextLines = 3;
+ const int MaxReportedLines = 40;
+
+ var verifiedLines = verified.Split('\n');
+ var receivedLines = received.Split('\n');
+
+ var firstDifference = 0;
+ while (firstDifference < verifiedLines.Length
+ && firstDifference < receivedLines.Length
+ && string.Equals(verifiedLines[firstDifference], receivedLines[firstDifference], StringComparison.Ordinal))
+ {
+ firstDifference++;
+ }
+
+ var report = new StringBuilder();
+ report.Append("Verified has ").Append(verifiedLines.Length)
+ .Append(" line(s), received has ").Append(receivedLines.Length)
+ .Append(" line(s); first difference at line ").Append(firstDifference + 1).Append('.')
+ .Append('\n');
+
+ var contextStart = Math.Max(0, firstDifference - ContextLines);
+ for (var i = contextStart; i < firstDifference; i++)
+ {
+ report.Append(" ").Append(verifiedLines[i]).Append('\n');
+ }
+
+ var reported = 0;
+ for (var i = firstDifference; i < Math.Max(verifiedLines.Length, receivedLines.Length) && reported < MaxReportedLines; i++)
+ {
+ var verifiedLine = i < verifiedLines.Length ? verifiedLines[i] : null;
+ var receivedLine = i < receivedLines.Length ? receivedLines[i] : null;
+
+ if (string.Equals(verifiedLine, receivedLine, StringComparison.Ordinal))
+ {
+ report.Append(" ").Append(verifiedLine).Append('\n');
+ continue;
+ }
+
+ if (verifiedLine is not null)
+ {
+ report.Append("- ").Append(verifiedLine).Append('\n');
+ }
+
+ if (receivedLine is not null)
+ {
+ report.Append("+ ").Append(receivedLine).Append('\n');
+ }
+
+ reported++;
+ }
+
+ if (reported == MaxReportedLines)
+ {
+ report.Append(" ... (truncated)\n");
+ }
+
+ return report.ToString();
+ }
}
From a9647d065fbf20f0930f7dce54462137268d8450 Mon Sep 17 00:00:00 2001
From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com>
Date: Tue, 28 Jul 2026 13:14:57 +0100
Subject: [PATCH 2/2] test(mocks): reference TUnit.Mocks exactly once in
snapshot compilations
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The test compilations were handed TUnit.Mocks twice: once from the
AppDomain assembly list and once from a ref\TUnit.Mocks.dll copy of the
netstandard2.0 build. Unless the two are byte-identical, Mock and
GenerateMockAttribute bind to an error type, the generator finds no mock
targets, and it emits only its post-init namespace stub — with no
diagnostic from Roslyn or the generator. The affected tests fail as a
bare snapshot mismatch whose received file is five lines long.
That is what broke GenerateMock_Attribute_With_Concrete_Class and
KiotaIRequestAdapter_ConstrainedGenericMethod_EmitsDefaultConstraint on
the windows and macos jobs while ubuntu stayed green: the copy came from
a glob expanded at evaluation time, so whether it shipped
at all — and which build it came from — depended on build ordering.
Reference the already-loaded assembly and exclude it from the AppDomain
sweep, and drop the ref copy and its netstandard2.0 ProjectReference.
Verified by planting a version-skewed TUnit.Mocks.dll in the old ref
location: before, 8+ tests fail with five-line output; after, 84/84 pass
with it still present.
---
.../SnapshotTestBase.cs | 47 ++++++++-----------
.../TUnit.Mocks.SourceGenerator.Tests.csproj | 23 ++++-----
2 files changed, 27 insertions(+), 43 deletions(-)
diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
index 79acf5e83e..b1a8948709 100644
--- a/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
+++ b/tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
@@ -16,44 +16,35 @@ public abstract class SnapshotTestBase
///
/// Returns the shared, lazily-loaded set of metadata references used by the test compilations
- /// (current AppDomain assemblies plus the ref/TUnit.Mocks.dll if present). Derived
- /// test classes should reuse this instead of re-discovering references per test invocation.
+ /// (current AppDomain assemblies, with exactly one TUnit.Mocks reference). Derived test classes
+ /// should reuse this instead of re-discovering references per test invocation.
///
protected static IEnumerable GetCachedReferences() => _references.Value;
private static List LoadReferences()
{
+ // TUnit.Mocks must appear exactly once. Two references to it — the loaded assembly plus a
+ // second copy on disk — leave `Mock`/`GenerateMockAttribute` bound to an error type unless
+ // the two are byte-identical, and neither Roslyn nor the generator reports that: the
+ // generator simply finds no mock targets and emits only its post-init namespace stub, so
+ // every affected test fails as a bare snapshot mismatch. That is what a `ref/` copy of the
+ // netstandard2.0 build used to risk, and it broke the [assembly: GenerateMock] tests on
+ // the windows and macos CI jobs while ubuntu stayed green.
+ var mocksAssembly = typeof(global::TUnit.Mocks.Mock).Assembly;
+
+ if (string.IsNullOrWhiteSpace(mocksAssembly.Location))
+ {
+ throw new InvalidOperationException(
+ "No TUnit.Mocks reference available to the test compilations — the generator would silently produce nothing.");
+ }
+
var refs = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => !a.IsDynamic && !string.IsNullOrWhiteSpace(a.Location))
+ .Where(a => a != mocksAssembly)
.Select(a => MetadataReference.CreateFromFile(a.Location))
.ToList();
- // Add TUnit.Mocks.dll from the ref subfolder (netstandard2.0 build)
- var refDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "ref");
- var mockDll = Path.Combine(refDir, "TUnit.Mocks.dll");
- if (File.Exists(mockDll))
- {
- refs.Add(MetadataReference.CreateFromFile(mockDll));
- }
- else
- {
- // The ref copy comes from a whose glob is expanded at evaluation time,
- // so a clean build that evaluates this project before TUnit.Mocks/netstandard2.0 has
- // produced output silently ships without it. Fall back to the assembly this project
- // actually references. GetAssemblies() above can't be relied on for it either: it only
- // returns assemblies the runtime has already faulted in, which makes the reference set
- // depend on which test happened to run first.
- refs.Add(MetadataReference.CreateFromFile(typeof(global::TUnit.Mocks.Mock).Assembly.Location));
- }
-
- // Without a TUnit.Mocks reference, Mock.Of() and [assembly: GenerateMock] bind to
- // nothing, the generator emits no mock, and every test fails as a confusing "expected
- // output to contain ..." or snapshot mismatch. Fail on the actual cause instead.
- if (!refs.Any(r => Path.GetFileName(r.FilePath ?? "").Equals("TUnit.Mocks.dll", StringComparison.OrdinalIgnoreCase)))
- {
- throw new InvalidOperationException(
- "No TUnit.Mocks reference available to the test compilations — the generator would silently produce nothing.");
- }
+ refs.Add(MetadataReference.CreateFromFile(mocksAssembly.Location));
return refs;
}
diff --git a/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj b/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj
index affa8e16d2..2a1a0194ae 100644
--- a/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj
+++ b/tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj
@@ -15,21 +15,14 @@
-
-
-
-
-
-
-
-
- PreserveNewest
-
-
+