Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 88 additions & 29 deletions tests/TUnit.Mocks.SourceGenerator.Tests/SnapshotTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,44 +16,35 @@ public abstract class SnapshotTestBase

/// <summary>
/// Returns the shared, lazily-loaded set of metadata references used by the test compilations
/// (current AppDomain assemblies plus the <c>ref/TUnit.Mocks.dll</c> 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.
/// </summary>
protected static IEnumerable<MetadataReference> GetCachedReferences() => _references.Value;

private static List<PortableExecutableReference> 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 <None Include> 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<T>() 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;
}
Expand Down Expand Up @@ -303,7 +294,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
Expand All @@ -317,4 +309,71 @@ private static string NormalizeNewlines(string text)
{
return text.Replace("\r\n", "\n").Replace("\r", "\n");
}

/// <summary>
/// Renders the first divergence between the two snapshots inline in the failure message.
/// The <c>.received.txt</c> 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.
/// </summary>
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++;
Comment thread
thomhurst marked this conversation as resolved.
}

if (reported == MaxReportedLines)
{
report.Append(" ... (truncated)\n");
}

return report.ToString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,14 @@
<PackageReference Include="Verify.TUnit" />
</ItemGroup>

<ItemGroup>
<!-- Ensure the netstandard2.0 TFM of TUnit.Mocks is built before this project -->
<ProjectReference Include="..\..\src\TUnit.Mocks\TUnit.Mocks.csproj"
SetTargetFramework="TargetFramework=netstandard2.0"
ReferenceOutputAssembly="false"
Private="false" />
</ItemGroup>

<ItemGroup>
<!-- Copy netstandard2.0 TUnit.Mocks.dll to a ref subfolder so it can be used as compilation reference -->
<None Include="..\..\src\TUnit.Mocks\bin\$(Configuration)\netstandard2.0\TUnit.Mocks.dll"
Link="ref\TUnit.Mocks.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<!--
The test compilations reference the TUnit.Mocks assembly this project already loads, so no
second copy is staged next to the test binary. A `ref\TUnit.Mocks.dll` copy of the
netstandard2.0 build used to live here: its <None Include> glob expanded at evaluation time,
so whether it shipped at all depended on build ordering, and when it did ship the test
compilations saw TUnit.Mocks twice and the generator silently emitted nothing. See
SnapshotTestBase.LoadReferences.
-->

<Import Project="..\..\eng\TestProject.targets" />

Expand Down
Loading