Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
3d8ca4c
test: make HttpStatusCode generator snapshot independent of test host
Quattordici Oct 17, 2026
eae530f
test(engine): build and locate launched test apps by own configuration
Quattordici Oct 17, 2026
b81ed96
test: run SourceGenerator.IncrementalTests on xUnit v3 / MTP
Quattordici Oct 17, 2026
6aa6737
test: start child dotnet processes with the running .NET installation
Quattordici Oct 17, 2026
e762a87
test: pin invariant culture where tests assert invariant number text
Quattordici Oct 17, 2026
9285edd
fix(aspire): format resource exit codes with the invariant culture
Quattordici Oct 17, 2026
69f6caf
test: make bug-2136 test culture-invariant without changing its metadata
Quattordici Oct 17, 2026
34aaea5
test: drop unused xunit references from source generator test projects
Quattordici Oct 17, 2026
8356d0d
test: make culture-sensitive tests culture-independent instead of pin…
Quattordici Oct 17, 2026
573e8fc
test: migrate SourceGenerator.IncrementalTests from xUnit to TUnit
Quattordici Oct 17, 2026
0a2261b
ci: run TUnit.SourceGenerator.IncrementalTests in the pipeline
Quattordici Oct 17, 2026
0d30ca0
fix(assertions): say "to" in [AssertionFrom] custom expectation messages
Quattordici Oct 18, 2026
c15bb51
test(assertions): revive tests disabled for APIs that now exist
Quattordici Oct 18, 2026
aefa1fa
test(mocks): start the design-time build with the running .NET instal…
Quattordici Oct 18, 2026
584d2e3
fix(analyzers): don't flag interface implementations with TUnit0014
Quattordici Oct 18, 2026
15a2d86
ci: run the assertions generator snapshot tests and core net9.0 snaps…
Quattordici Oct 18, 2026
da3b139
test: reference the test host's deployment, not its loaded assemblies
Quattordici Oct 18, 2026
cac343e
ci: run test projects on every framework they target
Quattordici Oct 18, 2026
52c4341
test: move orphaned ArgumentFormatter tests into TUnit.UnitTests, dro…
Quattordici Oct 18, 2026
1c396f4
test: put OpenTelemetry.Tests and Aspire.Tests on the shared test pro…
Quattordici Oct 18, 2026
0d3ba67
test: name SourceGenerator.IncrementalTests' namespace after the project
Quattordici Oct 18, 2026
72134d3
build: add the fast test projects to TUnit.Dev.slnx
Quattordici Oct 18, 2026
2ad9694
test(mocks): reference Mono.Cecil directly in InternalsAccess.Tests
Quattordici Oct 18, 2026
157efd3
test: sort host assembly references so generator snapshots match on e…
Quattordici Oct 18, 2026
a901388
test: scrub wrapped source paths in PublicAPI snapshots
Quattordici Oct 18, 2026
b872289
Merge branch 'main' into fix/httpstatuscode-snapshot-test-host
Quattordici Oct 18, 2026
9d1855b
fix: address review feedback on expectation messages, dotnet resoluti…
Quattordici Oct 18, 2026
500f30e
fix(assertions): substitute {param} placeholders in [AssertionFrom] m…
Quattordici Oct 18, 2026
fd09e0d
Merge branch 'main' into fix/httpstatuscode-snapshot-test-host
Quattordici Oct 18, 2026
695c892
fix(assertions): render [AssertionFrom] message text in a single pass
Quattordici Oct 18, 2026
349b3f4
test: note that DotNetCli's SDK check ignores global.json
Quattordici Oct 18, 2026
630da58
fix(assertions): keep each [AssertionFrom] message when attributes sh…
Quattordici Oct 18, 2026
427f034
refactor(assertions): drop the [AssertionFrom] Memory<T> declarations…
Quattordici Oct 18, 2026
38ddb7f
Merge branch 'main' into fix/httpstatuscode-snapshot-test-host
Quattordici Oct 18, 2026
c1beaf4
fix(assertions): substitute non-ASCII and {_name} placeholders in [As…
Quattordici Oct 18, 2026
ca3dff5
fix(assertions): keep SpanAssertionExtensions as obsolete empty classes
Quattordici Oct 18, 2026
8f3ba74
docs: document the members this PR adds or changes
Quattordici Oct 18, 2026
1425adb
refactor(assertions): resolve generated class names through markers, …
Quattordici Oct 18, 2026
66c5a3d
test: check that the running .NET installation resolves an SDK for th…
Quattordici Oct 18, 2026
0e6f2e0
docs: write [AssertionFrom] expectation message examples in the canon…
Quattordici Oct 18, 2026
76f6200
fix(assertions): report [AssertionFrom] diagnostics at the attribute,…
Quattordici Oct 18, 2026
715f690
fix(aspire): format the out-of-memory exit code with the invariant cu…
Quattordici Oct 18, 2026
bea54c9
perf(assertions): compute the [AssertionFrom] attribute location only…
Quattordici Oct 19, 2026
66ba36b
fix(assertions): give each [AssertionFrom] overload that shares a cla…
Quattordici Oct 19, 2026
628c858
revert: move the production fixes to their own pull request
Quattordici Oct 19, 2026
d748eee
fix: reapply the analyzer, Aspire and [AssertionFrom] fixes split out…
Quattordici Oct 19, 2026
faf29b0
test: keep main's .NET 8, .NET 9 and .NET Framework assertion generat…
Quattordici Oct 19, 2026
8809149
test: resolve DotNetCli lazily without blocking on async reads
Quattordici Oct 19, 2026
abdd036
ci: keep the test framework lists in TestBaseModule
Quattordici Oct 19, 2026
f1292ad
Merge branch 'fix/httpstatuscode-snapshot-test-host' into fix/asserti…
Quattordici Oct 19, 2026
5b78999
test: update the .NET 8, .NET 9 and .NET Framework assertion generato…
Quattordici Oct 19, 2026
5a3f17d
Merge branch 'main' into fix/httpstatuscode-snapshot-test-host
Quattordici Oct 19, 2026
46dc951
test: build generator test references through one shared helper
Quattordici Oct 19, 2026
591858f
Merge branch 'fix/httpstatuscode-snapshot-test-host' into fix/asserti…
Quattordici Oct 19, 2026
fd3143e
test: explain the reference sort and scrub wrapped forward-slash Wind…
Quattordici Oct 19, 2026
839de7a
Merge branch 'fix/httpstatuscode-snapshot-test-host' into fix/asserti…
Quattordici Oct 19, 2026
f656344
fix: report TU0001 in the compilation's own syntax tree
Quattordici Oct 19, 2026
1ac3274
test: pin more expectation message forms and overload class names
Quattordici Oct 19, 2026
4734421
test: set the minus sign explicitly in the invariant exit code test
Quattordici Oct 19, 2026
4b49342
test: only scrub source paths passed to hook attributes
Quattordici Oct 19, 2026
2ca1cdf
Merge branch 'fix/httpstatuscode-snapshot-test-host' into fix/asserti…
Quattordici Oct 19, 2026
8e4ecb1
test: build the Should generator test references once
Quattordici Oct 19, 2026
fe724a0
Merge branch 'fix/httpstatuscode-snapshot-test-host' into fix/asserti…
Quattordici Oct 19, 2026
e438fcc
Merge remote-tracking branch 'upstream/main' into fix/assertionfrom-e…
Quattordici Oct 20, 2026
8d7b295
fix: name generated condition classes deterministically
Quattordici Oct 20, 2026
e6dc51f
fix: hash condition class sources with host-independent line endings
Quattordici Oct 20, 2026
8a3463d
test: cover overloads and messages sharing one condition class name
Quattordici Oct 20, 2026
2991651
Merge remote-tracking branch 'upstream/main' into fix/assertionfrom-e…
Quattordici Oct 20, 2026
fd22a98
fix(assertions-sg): rename generic condition class references by type…
Quattordici Oct 20, 2026
610e2fa
Merge branch 'main' into fix/assertionfrom-expectation-messages
Quattordici Oct 20, 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
20 changes: 14 additions & 6 deletions docs/docs/assertions/extensibility/source-generator-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,9 @@ Use `[AssertionFrom]` to create assertions from existing methods in libraries or
```csharp
using TUnit.Assertions.Attributes;

[AssertionFrom<string>(nameof(string.IsNullOrEmpty), CustomName = "IsBlank", ExpectationMessage = "to be null or empty")]
[AssertionFrom<string>(nameof(string.StartsWith), CustomName = "BeginsWithText", ExpectationMessage = "to start with {value}")]
[AssertionFrom<string>(nameof(string.EndsWith), CustomName = "EndsWithText", ExpectationMessage = "to end with {value}")]
[AssertionFrom<string>(nameof(string.IsNullOrEmpty), CustomName = "IsBlank", ExpectationMessage = "be null or empty")]
[AssertionFrom<string>(nameof(string.StartsWith), CustomName = "BeginsWithText", ExpectationMessage = "start with '{value}'")]
[AssertionFrom<string>(nameof(string.EndsWith), CustomName = "EndsWithText", ExpectationMessage = "end with '{value}'")]
public static partial class StringAssertionExtensions
{
}
Expand All @@ -256,7 +256,7 @@ await Assert.That("hello").BeginsWithText("he");
### With Custom Names

```csharp
[AssertionFrom<string>(nameof(string.Contains), CustomName = "Has", ExpectationMessage = "to have '{value}'")]
[AssertionFrom<string>(nameof(string.Contains), CustomName = "Has", ExpectationMessage = "have '{value}'")]
public static partial class StringAssertionExtensions
{
}
Expand All @@ -271,7 +271,7 @@ await Assert.That("hello").Has("world"); // ❌ Fails: "Expected to have
For `bool`-returning methods, you can generate negated versions:

```csharp
[AssertionFrom<string>(nameof(string.Contains), CustomName = "LacksText", NegateLogic = true, ExpectationMessage = "to not contain '{value}'")]
[AssertionFrom<string>(nameof(string.Contains), CustomName = "LacksText", NegateLogic = true, ExpectationMessage = "contain '{value}'")]
public static partial class StringAssertionExtensions
{
}
Expand All @@ -283,11 +283,19 @@ await Assert.That("hello").LacksText("ell"); // ❌ Fails: "Expected to not con

**Note:** Negation only works with `bool`-returning methods. `AssertionResult` methods determine their own pass/fail logic.

### How `ExpectationMessage` Is Rendered

For `[AssertionFrom]`, the message is the phrase that follows "Expected to". Write it in the positive form, without the leading "to", as in the examples above:

- The generator adds "to", plus "not" when `NegateLogic = true`: `"contain '{value}'"` renders as "to contain 'x'", or "to not contain 'x'" when negated.
- For compatibility, a leading "to" is ignored (`"to be null or empty"` renders the same as `"be null or empty"`), and a phrase that already starts with "not" (`"to not contain '{value}'"`) is used as written rather than negated again.
- `{paramName}` is replaced with that parameter's value. Everything else is literal text: write `{{` and `}}` for literal braces, and a placeholder that names no parameter is printed as is.

### Referencing Methods on Different Types

```csharp
// Reference static methods from another type
[AssertionFrom<string>(typeof(StringHelper), nameof(StringHelper.IsValidEmail), ExpectationMessage = "to be a valid email")]
[AssertionFrom<string>(typeof(StringHelper), nameof(StringHelper.IsValidEmail), ExpectationMessage = "be a valid email")]
public static partial class StringAssertionExtensions
{
}
Expand Down
24 changes: 24 additions & 0 deletions src/TUnit.Analyzers/PublicMethodMissingTestAttributeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ private void AnalyzeSymbol(SymbolAnalysisContext context)
.Where(x => !IsDisposableDispose(x))
.Where(x => !IsAsyncDisposableDispose(x))
.Where(x => !IsInitializeAsync(x))
.Where(x => !ImplementsInterfaceMember(x))
.Where(x => !IsTestDataSource(x, testMethods)))
{
context.ReportDiagnostic(Diagnostic.Create(Rules.PublicMethodMissingTestAttribute, method.Locations.FirstOrDefault()));
Expand All @@ -74,6 +75,29 @@ private bool IsTestDataSource(IMethodSymbol methodSymbol, IMethodSymbol[] testMe
return false;
}

/// <summary>
/// Whether <paramref name="method"/> implicitly implements an interface member of its containing type (e.g.
/// <c>ITestStartEventReceiver.OnTestStart</c>). Such a method cannot be made private, so the diagnostic's advice
/// would not apply.
/// </summary>
private static bool ImplementsInterfaceMember(IMethodSymbol method)
{
var containingType = method.ContainingType;

foreach (var @interface in containingType.AllInterfaces)
{
foreach (var member in @interface.GetMembers(method.Name))
{
if (SymbolEqualityComparer.Default.Equals(containingType.FindImplementationForInterfaceMember(member), method))
{
return true;
}
}
}

return false;
}

private bool IsDisposableDispose(IMethodSymbol method)
{
return method is { ReturnsVoid: true, Name: "Dispose" } &&
Expand Down
20 changes: 15 additions & 5 deletions src/TUnit.Aspire.Core/AspireFixture.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
Expand Down Expand Up @@ -1705,13 +1706,22 @@ internal static string FormatExitCode(int? exitCode)
return "(unknown)";
}

return DecodeExitCode(code) is { } meaning ? $"{code} ({meaning})" : code.ToString();
var number = FormatExitCodeNumber(code);
return DecodeExitCode(code) is { } meaning ? $"{number} ({meaning})" : number;
}

/// <summary>
/// Formats an exit code with the invariant culture: exit codes are identifiers to search for, and cultures such
/// as sv-SE would otherwise print negative Windows codes with U+2212 MINUS SIGN.
/// </summary>
private static string FormatExitCodeNumber(int code) => code.ToString(CultureInfo.InvariantCulture);

/// <summary>One-line human description of a resource's failure state for the concise exception message.</summary>
internal static string DescribeState(in ResourceDiagnosis d) => d.Class switch
{
ResourceFailureClass.OutOfMemory => $"{d.State}, exit code {d.ExitCode} (OOM / SIGKILL)",
ResourceFailureClass.OutOfMemory => d.ExitCode is int oomCode
? $"{d.State}, exit code {FormatExitCodeNumber(oomCode)} (OOM / SIGKILL)"
: $"{d.State} (OOM / SIGKILL)",
ResourceFailureClass.NonZeroExit => $"{d.State}, exit code {FormatExitCode(d.ExitCode)}",
ResourceFailureClass.CrashedNoCode => $"{d.State} (no exit code)",
ResourceFailureClass.HealthCheckFailing => d.Detail is { Length: > 0 } health
Expand Down Expand Up @@ -1807,7 +1817,7 @@ private static string SummarizeHealthReport(HealthReportSnapshot report)
}

return dep.Snapshot.ExitCode is int code
? $"'{depName}' ({depState}, exit code {code})"
? $"'{depName}' ({depState}, exit code {FormatExitCodeNumber(code)})"
: $"'{depName}' ({depState})";
}

Expand Down Expand Up @@ -1987,7 +1997,7 @@ private void AppendResourceDiagnosticBlock(
sb.AppendLine($" State: {diagnosis.State}");
if (diagnosis.ExitCode is int code)
{
sb.Append($" Exit code: {code}");
sb.Append($" Exit code: {FormatExitCodeNumber(code)}");
if (DecodeExitCode(code) is { } meaning)
{
sb.Append($" ({meaning})");
Expand Down Expand Up @@ -2078,7 +2088,7 @@ private static void AppendDependencyChain(StringBuilder sb, ResourceNotification
if (notificationService.TryGetCurrentState(depName, out var dep) && dep is not null)
{
var depState = dep.Snapshot.State?.Text ?? "unknown";
description = dep.Snapshot.ExitCode is int code ? $"{depState}, exit code {code}" : depState;
description = dep.Snapshot.ExitCode is int code ? $"{depState}, exit code {FormatExitCodeNumber(code)}" : depState;
}
else
{
Expand Down
Loading
Loading