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 - - +