Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
543e79d
feat: add TUnit.Assertions.Should package
thomhurst Apr 28, 2026
5b89e90
fix: address PR #5785 review feedback
thomhurst Apr 28, 2026
32bdd5d
feat: discover [GenerateAssertion]/[AssertionFrom]/hand-written exten…
thomhurst Apr 28, 2026
b4f2494
ci: wire TUnit.Assertions.Should into pipeline as beta package
thomhurst Apr 28, 2026
148f72f
refactor: address /simplify review findings
thomhurst Apr 28, 2026
584bfb4
feat: source-generate ShouldCollectionSource instance methods
thomhurst Apr 28, 2026
d121ad1
feat(analyzers): extend existing analyzers to recognise Should syntax
thomhurst Apr 28, 2026
0d59c81
refactor(analyzers): align Should-aware analyzer code with existing p…
thomhurst Apr 28, 2026
9f0460e
docs: add Should syntax page
thomhurst Apr 28, 2026
2830fed
fix: address PR review — CI sln, generator tests, ShouldNameAttribute
thomhurst Apr 28, 2026
8f88cc4
fix: address PR round 5 — docs example + simplify NameConjugator
thomhurst Apr 28, 2026
dd7931a
chore: round 6 cleanup — drop unused packages, kill empty if block
thomhurst Apr 28, 2026
2273360
fix(should-gen): handle -es endings in name conjugator
thomhurst Apr 28, 2026
4c54f08
test(should-gen): add Verify snapshots for generator output
thomhurst Apr 28, 2026
33a903e
fix(should): render generic exception type names without backtick man…
thomhurst Apr 28, 2026
ce5dc38
docs(should): clarify analyzer extension match, Clear() coverage, Doe…
thomhurst Apr 29, 2026
a3cba8e
perf(should-gen): cache per-reference walk by MetadataReference
thomhurst Apr 29, 2026
f8f0fa6
fix(analyzer-tests): load netstandard2.0 builds to avoid CS1705 silen…
thomhurst Apr 29, 2026
6bd1516
fix(should-gen): swap reference cache to ConditionalWeakTable to avoi…
thomhurst Apr 29, 2026
8a726e9
ci(pipeline): wire Should test modules into the release gate
thomhurst Apr 29, 2026
10b0a15
fix(assertions): IsAll -> IsFullRange; propagate Obsolete/EditorBrows…
thomhurst Apr 29, 2026
4769882
refactor(analyzer-tests): centralize GetCompatibleDllPath; document a…
thomhurst Apr 29, 2026
4f9f779
refactor(should): drop ExpressionBuilder.Clear coupling; centralize g…
thomhurst Apr 29, 2026
46ba85a
refactor: drop redundant GetCompatibleDllPath wrappers; document roun…
thomhurst Apr 29, 2026
de03ec8
fix(should): address review edge cases
thomhurst Apr 29, 2026
8d5b321
fix(should): handle z-ending verbs
thomhurst Apr 29, 2026
4fa90f1
fix(should): address review blockers
thomhurst Apr 29, 2026
d5be784
test(should): cover assertion failures
thomhurst Apr 29, 2026
5ad8a66
test(should): cover failing entry shapes
thomhurst Apr 29, 2026
37df1c8
fix(ci): align analyzer test references
thomhurst Apr 29, 2026
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ See the [documentation](https://tunit.dev/docs/getting-started/attributes) for m
| `TUnit.Core` | Shared test library components without an execution engine |
| `TUnit.Engine` | Execution engine for test projects |
| `TUnit.Assertions` | Standalone assertions — works with other test frameworks too |
| `TUnit.Assertions.Should` | Optional FluentAssertions-style `value.Should().BeEqualTo(...)` syntax over `TUnit.Assertions` (beta) |
| `TUnit.Playwright` | Playwright integration with automatic browser lifecycle management |

## Migrating from xUnit, NUnit, or MSTest?
Expand Down
39 changes: 39 additions & 0 deletions SharedTestHelpers/AnalyzerTestCompatibility.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using System;
using System.IO;
using System.Reflection;

namespace TUnit.Tests.Shared;

/// <summary>
/// Shared resolution helper for the analyzer-test framework's reference-assembly mismatch.
/// </summary>
/// <remarks>
/// <para>
/// The Microsoft.CodeAnalysis.Testing 1.1.2 framework targets net9.0 reference assemblies. Loading
/// TUnit's net10.0 builds via <c>typeof(...).Assembly.Location</c> raises CS1705 (referenced
/// assembly has a higher System.Runtime version), which the verifier suppresses via
/// <c>CompilerDiagnostics = None</c>. The suppression silently breaks symbol resolution for every
/// extension method (IsEqualTo, Throws, etc.) in the test compilation — analyzers find nothing
/// to flag and tests report "expected 1 diagnostic, actual 0" with no compiler errors visible.
/// </para>
/// <para>
/// The fix is to load the netstandard2.0 builds (compatible with both Net90 ref assemblies and the
/// test runtime). Each analyzer test csproj copies the relevant netstandard2.0 build into the test
/// bin via a <c>&lt;None Include="..." Link="X.netstandard2.0.dll" CopyToOutputDirectory="..."&gt;</c>
/// item; this helper resolves the copy when present, falling back to the runtime assembly path so
/// the build doesn't hard-fail before the copy item runs.
/// </para>
/// <para>
/// Linked into multiple test projects via <c>&lt;Compile Include="..\SharedTestHelpers\..."&gt;</c>;
/// see <c>TUnit.Assertions.Should.SourceGenerator</c> for the same pattern with
/// <c>CovarianceHelper</c>/<c>EquatableArray</c>.
/// </para>
/// </remarks>
internal static class AnalyzerTestCompatibility
{
public static string GetCompatibleDllPath(string assemblyName, Assembly fallback)
{
var ns20Path = Path.Combine(AppContext.BaseDirectory, $"{assemblyName}.netstandard2.0.dll");
return File.Exists(ns20Path) ? ns20Path : fallback.Location;
}
}
8 changes: 4 additions & 4 deletions TUnit.Analyzers.Tests/AnalyzerTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ public static CSharpAnalyzerTest<TAnalyzer, DefaultVerifier> CreateAnalyzerTest<
csTest.TestState.AdditionalReferences
.AddRange(
[
MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
MetadataReference.CreateFromFile(typeof(CircuitState).Assembly.Location),
MetadataReference.CreateFromFile(typeof(ProjectReferenceEnum).Assembly.Location)
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.TestProject.Library", typeof(ProjectReferenceEnum).Assembly))
]
);

Expand Down Expand Up @@ -145,9 +145,9 @@ public static CSharpSuppressorTest<TSuppressor, DefaultVerifier> CreateSuppresso

test.TestState.AdditionalReferences
.AddRange([
MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
MetadataReference.CreateFromFile(typeof(CircuitState).Assembly.Location),
MetadataReference.CreateFromFile(typeof(ProjectReferenceEnum).Assembly.Location)
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.TestProject.Library", typeof(ProjectReferenceEnum).Assembly))
]);

return test;
Expand Down
21 changes: 21 additions & 0 deletions TUnit.Analyzers.Tests/TUnit.Analyzers.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,27 @@
<ProjectReference Include="..\TUnit.Assertions\TUnit.Assertions.csproj" />
</ItemGroup>

<!-- Copy netstandard2.0 builds into the test bin so the analyzer-test framework's
Net90 reference assemblies don't trigger CS1705 against the net10.0 builds otherwise
loaded via typeof(...).Assembly.Location. CS1705 is suppressed silently and the resulting
symbol-resolution failures cause analyzers to find nothing to flag. -->
<ItemGroup>
<None Include="..\TUnit.Core\bin\$(Configuration)\netstandard2.0\TUnit.Core.dll"
Link="TUnit.Core.netstandard2.0.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
<None Include="..\TUnit.Assertions\bin\$(Configuration)\netstandard2.0\TUnit.Assertions.dll"
Link="TUnit.Assertions.netstandard2.0.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
<None Include="..\TUnit.TestProject.Library\bin\$(Configuration)\netstandard2.0\TUnit.TestProject.Library.dll"
Link="TUnit.TestProject.Library.netstandard2.0.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
<Compile Include="..\SharedTestHelpers\AnalyzerTestCompatibility.cs"
Link="Shared\AnalyzerTestCompatibility.cs" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" VersionOverride="9.0.0" PrivateAssets="all" GeneratePathProperty="true" />
<Reference Include="$(PkgMicrosoft_CodeAnalysis_NetAnalyzers)\analyzers\dotnet\cs\Microsoft.CodeAnalysis.NetAnalyzers.dll" Analyzer="false" />
Expand Down
10 changes: 7 additions & 3 deletions TUnit.Analyzers.Tests/Verifiers/CSharpAnalyzerVerifier`1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@ public static Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, param
/// <inheritdoc cref="AnalyzerVerifier{TAnalyzer, TTest, TVerifier}.VerifyAnalyzerAsync(string, DiagnosticResult[])"/>
public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source, Action<Test> configureTest, params DiagnosticResult[] expected)
{
// Microsoft.Bcl.AsyncInterfaces is required by TUnit.Core.netstandard2.0.dll's typeforward
// of IAsyncEnumerable; without it, tests that use IAsyncEnumerable in their source see
// CS0012/CS0508 alongside the expected analyzer diagnostic.
var test = new Test
{
TestCode = source,
ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
ReferenceAssemblies = ReferenceAssemblies.Net.Net90
.AddPackages([new PackageIdentity("Microsoft.Bcl.AsyncInterfaces", "9.0.0")]),
TestState =
{
AdditionalReferences =
{
typeof(TUnitAttribute).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
typeof(CircuitState).Assembly.Location,
typeof(ProjectReferenceEnum).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.TestProject.Library", typeof(ProjectReferenceEnum).Assembly),
},
},
};
Expand Down
4 changes: 2 additions & 2 deletions TUnit.Analyzers.Tests/Verifiers/CSharpCodeFixVerifier`2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ params DiagnosticResult[] expected
{
AdditionalReferences =
{
typeof(TUnitAttribute).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
},
}
};
Expand Down Expand Up @@ -97,7 +97,7 @@ Action<Test> configureTest
{
AdditionalReferences =
{
typeof(TUnitAttribute).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
},
},
CodeActionValidationMode = CodeActionValidationMode.SemanticStructure,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,16 @@
<ProjectReference Include="..\TUnit.Core\TUnit.Core.csproj" />
</ItemGroup>

<!-- Copy netstandard2.0 build of TUnit.Core into the test bin so Net90 ref assemblies don't
trigger CS1705 against the test process's net10.0 build. -->
<ItemGroup>
<None Include="..\TUnit.Core\bin\$(Configuration)\netstandard2.0\TUnit.Core.dll"
Link="TUnit.Core.netstandard2.0.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
<Compile Include="..\SharedTestHelpers\AnalyzerTestCompatibility.cs"
Link="Shared\AnalyzerTestCompatibility.cs" />
</ItemGroup>

<Import Project="..\TestProject.targets" />
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,13 @@ public static async Task VerifyAnalyzerAsync([StringSyntax("c#")] string source,
var test = new Test
{
TestCode = source,
ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
ReferenceAssemblies = ReferenceAssemblies.Net.Net90
.AddPackages([new PackageIdentity("Microsoft.Bcl.AsyncInterfaces", "9.0.0")]),
TestState =
{
AdditionalReferences =
{
typeof(TUnit.Core.TUnitAttribute).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnit.Core.TUnitAttribute).Assembly),
},
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@
<ProjectReference Include="..\TUnit.Assertions.Analyzers.CodeFixers\TUnit.Assertions.Analyzers.CodeFixers.csproj" />
<ProjectReference Include="..\TUnit.Assertions.Analyzers\TUnit.Assertions.Analyzers.csproj" />
</ItemGroup>

<!-- Copy netstandard2.0 builds of TUnit.Core / TUnit.Assertions into the test bin so the
analyzer-test framework's net9.0 reference assemblies don't trigger CS1705 against the
net10.0 builds otherwise loaded via typeof(...).Assembly.Location. CS1705 is suppressed
silently by CompilerDiagnostics.None and the resulting symbol-resolution failures
appear as analyzers/codefixers seeing zero diagnostics. -->
<ItemGroup>
<None Include="..\TUnit.Core\bin\$(Configuration)\netstandard2.0\TUnit.Core.dll"
Link="TUnit.Core.netstandard2.0.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
<None Include="..\TUnit.Assertions\bin\$(Configuration)\netstandard2.0\TUnit.Assertions.dll"
Link="TUnit.Assertions.netstandard2.0.dll"
CopyToOutputDirectory="PreserveNewest"
Visible="false" />
<Compile Include="..\SharedTestHelpers\AnalyzerTestCompatibility.cs"
Link="Shared\AnalyzerTestCompatibility.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ params DiagnosticResult[] expected
{
AdditionalReferences =
{
typeof(TUnitAttribute).Assembly.Location,
typeof(Assert).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
},
},
};
Expand Down Expand Up @@ -102,8 +102,8 @@ public static async Task VerifyCodeFixAsync(
{
AdditionalReferences =
{
typeof(TUnitAttribute).Assembly.Location,
typeof(Assert).Assembly.Location,
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly),
TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly),
},
},
CodeActionValidationMode = CodeActionValidationMode.SemanticStructure,
Expand Down
14 changes: 7 additions & 7 deletions TUnit.Assertions.Analyzers.Tests/AnalyzerTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ public static CSharpAnalyzerTest<TAnalyzer, DefaultVerifier> CreateAnalyzerTest<
csTest.TestState.AdditionalReferences
.AddRange(
[
MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Assert).Assembly.Location),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly)),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions.Should", typeof(TUnit.Assertions.Should.ShouldExtensions).Assembly)),
]
);

Expand Down Expand Up @@ -139,8 +140,9 @@ public static CSharpSuppressorTest<TSuppressor, DefaultVerifier> CreateSuppresso

test.TestState.AdditionalReferences
.AddRange([
MetadataReference.CreateFromFile(typeof(TUnitAttribute).Assembly.Location),
MetadataReference.CreateFromFile(typeof(Assert).Assembly.Location),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Core", typeof(TUnitAttribute).Assembly)),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions", typeof(Assert).Assembly)),
MetadataReference.CreateFromFile(TUnit.Tests.Shared.AnalyzerTestCompatibility.GetCompatibleDllPath("TUnit.Assertions.Should", typeof(TUnit.Assertions.Should.ShouldExtensions).Assembly)),
]);

return test;
Expand All @@ -152,9 +154,7 @@ private static ReferenceAssemblies GetReferenceAssemblies()
return ReferenceAssemblies.NetFramework.Net472.Default;
#elif NET8_0
return ReferenceAssemblies.Net.Net80;
#elif NET9_0
return ReferenceAssemblies.Net.Net90;
#elif NET10_0_OR_GREATER
#elif NET9_0_OR_GREATER
return ReferenceAssemblies.Net.Net90;
#else
return ReferenceAssemblies.Net.Net80; // Default fallback
Expand Down
133 changes: 133 additions & 0 deletions TUnit.Assertions.Analyzers.Tests/ShouldAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
using AwaitVerifier = TUnit.Assertions.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier<TUnit.Assertions.Analyzers.AwaitAssertionAnalyzer>;
using MixVerifier = TUnit.Assertions.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier<TUnit.Assertions.Analyzers.MixAndOrOperatorsAnalyzer>;

namespace TUnit.Assertions.Analyzers.Tests;

public class ShouldAnalyzerTests
{
[Test]
public async Task Should_chain_not_awaited_is_flagged()
{
await AwaitVerifier
.VerifyAnalyzerAsync(
"""
using System.Threading.Tasks;
using TUnit.Assertions.Should;
using TUnit.Assertions.Should.Extensions;

public class MyClass
{
public async Task MyTest()
{
var one = 1;
{|#0:one.Should()|}.BeEqualTo(1);
}
}
""",

AwaitVerifier.Diagnostic(Rules.AwaitAssertion)
.WithLocation(0)
);
}

[Test]
public async Task Should_chain_awaited_is_clean()
{
await AwaitVerifier
.VerifyAnalyzerAsync(
"""
using System.Threading.Tasks;
using TUnit.Assertions.Should;
using TUnit.Assertions.Should.Extensions;

public class MyClass
{
public async Task MyTest()
{
var one = 1;
await one.Should().BeEqualTo(1);
}
}
"""
);
}

[Test]
public async Task Mixed_And_Or_in_Should_chain_is_flagged()
{
await MixVerifier
.VerifyAnalyzerAsync(
"""
using System.Threading.Tasks;
using TUnit.Assertions.Should;
using TUnit.Assertions.Should.Extensions;

public class MyClass
{
public async Task MyTest()
{
var one = 1;
{|#0:await one.Should().BeEqualTo(1).And.BeEqualTo(1).Or.BeEqualTo(2)|};
}
}
""",

MixVerifier.Diagnostic(Rules.MixAndOrConditionsAssertion)
.WithLocation(0)
);
}

[Test]
public async Task Pure_And_in_Should_chain_is_clean()
{
await MixVerifier
.VerifyAnalyzerAsync(
"""
using System.Threading.Tasks;
using TUnit.Assertions.Should;
using TUnit.Assertions.Should.Extensions;

public class MyClass
{
public async Task MyTest()
{
var one = 1;
await one.Should().BeEqualTo(1).And.NotBeEqualTo(7);
}
}
"""
);
}

[Test]
public async Task Unrelated_Should_extension_in_other_namespace_is_not_flagged()
{
// Confirms the analyzer's TUnit-namespace check rules out unrelated `Should()`
// extensions (e.g. FluentAssertions, custom user libraries) from triggering
// TUnitAssertions0002.
await AwaitVerifier
.VerifyAnalyzerAsync(
"""
using System.Threading.Tasks;

namespace OtherLibrary
{
public class Wrapper { public Wrapper Should() => this; public void BeFoo() { } }
public static class WrapperExtensions
{
public static Wrapper Should(this object value) => new Wrapper();
}
}

public class MyClass
{
public async Task MyTest()
{
var one = 1;
OtherLibrary.WrapperExtensions.Should(one).BeFoo();
}
}
"""
);
}
}
Loading
Loading