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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@ requirements
TESTPROJECT_AOT*
TESTPROJECT_SINGLEFILE*
MOCKTESTS_AOT*
MOCKAOTTESTS_AOT*
NUGETTESTER_AOT*

nul
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ public static async Task TearDownDatabase(ClassHookContext context) { ... }
`TUnit.Mocks` is a source-generated, Native AOT-compatible mocking library — no runtime proxies, no `Castle.Core`. It works with any test framework:

```csharp
var gateway = IPaymentGateway.Mock(); // or Mock.Of<IPaymentGateway>()
var gateway = IPaymentGateway.Mock();

gateway.ChargeAsync(Any<decimal>()).Returns(new ChargeResult(Success: true));

Expand Down
1 change: 1 addition & 0 deletions TUnit.CI.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
<Project Path="tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj" />
<Project Path="tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Analyzers.Tests/TUnit.Mocks.Analyzers.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Aot.Tests/TUnit.Mocks.Aot.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Http.Tests/TUnit.Mocks.Http.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj" />
<Project Path="tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj" />
Expand Down
1 change: 1 addition & 0 deletions TUnit.Dev.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
<Project Path="tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj" />
<Project Path="tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Analyzers.Tests/TUnit.Mocks.Analyzers.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Aot.Tests/TUnit.Mocks.Aot.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Http.Tests/TUnit.Mocks.Http.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Logging.Tests/TUnit.Mocks.Logging.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.SourceGenerator.Tests/TUnit.Mocks.SourceGenerator.Tests.csproj" />
Expand Down
1 change: 1 addition & 0 deletions TUnit.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
<Project Path="tests/TUnit.Core.SourceGenerator.Tests/TUnit.Core.SourceGenerator.Tests.csproj" />
<Project Path="tests/TUnit.Engine.Tests/TUnit.Engine.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Analyzers.Tests/TUnit.Mocks.Analyzers.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Aot.Tests/TUnit.Mocks.Aot.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.Http.Tests/TUnit.Mocks.Http.Tests.csproj" />
<Project Path="tests/TUnit.Mocks.InternalsAccess.TargetLib/TUnit.Mocks.InternalsAccess.TargetLib.csproj" />
<Project Path="tests/TUnit.Mocks.InternalsAccess.Tests/TUnit.Mocks.InternalsAccess.Tests.csproj" />
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/migration/mocking/fakeiteasy.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ IUserRepository sut = repository; // implicit conversion
IUserRepository same = repository.Object; // explicit instance
```

`T.Mock()` requires C# 14. You can also use the factory form `Mock.Of<IUserRepository>()`.
Use `T.Mock()` to create mocks. TUnit.Mocks requires C# 14. The factory form `Mock.Of<IUserRepository>()` is equivalent, but prefer `T.Mock()`.

Both libraries are loose by default. In TUnit.Mocks, a strict mock throws `MockStrictBehaviorException` for unconfigured calls.

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/migration/mocking/moq.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ IUserRepository sut = repository; // implicit conversion
IUserRepository same = repository.Object; // explicit instance
```

`T.Mock()` requires C# 14. You can also use the factory form `Mock.Of<IUserRepository>()`. Moq's `Mock.Of<T>()` returns the mocked object, but TUnit.Mocks' `Mock.Of<T>()` returns the `Mock<T>` wrapper.
Use `T.Mock()` to create mocks. TUnit.Mocks requires C# 14. The factory form `Mock.Of<IUserRepository>()` is equivalent, but prefer `T.Mock()`. Moq's `Mock.Of<T>()` returns the mocked object, but TUnit.Mocks' `Mock.Of<T>()` returns the `Mock<T>` wrapper.

Both libraries are loose by default. `MockBehavior.Strict` makes unconfigured calls throw. TUnit.Mocks throws `MockStrictBehaviorException`.

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/migration/mocking/nsubstitute.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ IUserRepository sut = repository; // implicit conversion
IUserRepository same = repository.Object; // explicit instance
```

`T.Mock()` requires C# 14. You can also use the factory form `Mock.Of<IUserRepository>()`.
Use `T.Mock()` to create mocks. TUnit.Mocks requires C# 14. The factory form `Mock.Of<IUserRepository>()` is equivalent, but prefer `T.Mock()`.

NSubstitute has no strict mode. TUnit.Mocks is loose by default, like NSubstitute. Pass `MockBehavior.Strict` to make unconfigured calls throw `MockStrictBehaviorException`:

Expand Down
14 changes: 11 additions & 3 deletions docs/docs/writing-tests/mocking/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public interface IMessageConnection
}

// Usage
var mock = Mock.Of<IMessageConnection>();
var mock = IMessageConnection.Mock();
mock.SendMessage(Any())
.RaisesOnMessage("echo");

Expand Down Expand Up @@ -70,7 +70,7 @@ public interface IPayloadSource
}

// Usage
var mock = Mock.Of<IPayloadSource>();
var mock = IPayloadSource.Mock();
var received = 0;
mock.Object.Changed += (_, payload) => received = payload.Value;

Expand Down Expand Up @@ -273,7 +273,7 @@ repo.Reset(); // clear all mocks
Get a diagnostic report of setup coverage and call matching:

```csharp
var mock = Mock.Of<IUniversalService>();
var mock = IUniversalService.Mock();
var svc = mock.Object;

mock.GetUser(Any()).Returns(new User("Alice"));
Expand Down Expand Up @@ -318,6 +318,14 @@ var count = mock.Object.GetCount(); // -1

The provider is consulted **before** auto-mocking and built-in smart defaults.

## Native AOT and Generic Interfaces

Create generic interface mocks with the extension method, such as `IRepository<int>.Mock()`. It calls the generated factory for that closed type directly, so it needs no runtime reflection and works under Native AOT with any type argument, including value types. Closed types named in `Mock.Of<T>()`, `MockRepository.Of<T>()`, or `[assembly: GenerateMock(typeof(IRepository<int>))]` are also registered ahead of time.

If the closed type is only known at runtime, for example `Mock.Of<IRepository<T>>()` inside generic helper code, TUnit.Mocks builds the type with `MakeGenericType`. Native AOT may not have compiled that instantiation, especially for value-type arguments. In that case mock creation throws a `NotSupportedException` that names the type. To fix it, create the mock with the closed type, such as `IRepository<int>.Mock()`. This works for any type argument, including private nested types. When every type argument is accessible from the assembly, `[assembly: GenerateMock(typeof(IRepository<int>))]` also works.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

The built-in `DefaultValueProvider` uses reflection to build defaults for `Task<T>`, `ValueTask<T>`, and collection types, so it is not AOT-safe. A custom `IDefaultValueProvider` that does not use reflection works under Native AOT.

## Reset

Clear all setups, call history, state, and auto-tracked property values:
Expand Down
6 changes: 3 additions & 3 deletions docs/docs/writing-tests/mocking/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,10 @@ public class GreeterTests
}
```

The `Mock.Of<T>()` factory is also available as an alternative syntax:
Prefer `T.Mock()` for creating mocks. The `Mock.Of<T>()` factory is equivalent, but reach for it only where `T.Mock()` is not available, such as multi-interface mocks:

```csharp
var mock = Mock.Of<IGreeter>(); // equivalent to IGreeter.Mock()
var mock = Mock.Of<IGreeter>(); // equivalent to IGreeter.Mock(), which is preferred
```

## Key Concepts
Expand Down Expand Up @@ -157,7 +157,7 @@ var service = MyService.Mock("connectionString", 42);
```

:::note
`T.Mock()` requires C# 14 / .NET 10 or later (it uses C# 14 static extension members). For older language versions, or for multi-interface mocks, interfaces with static abstract members, delegates, and wrap mocks, use the `Mock.Of<T>()` / `Mock.Wrap()` / `Mock.OfDelegate<T>()` factory methods.
TUnit.Mocks requires C# 14 or later (`LangVersion` 14 or `preview`), because its generated code uses C# 14 extension members. This applies to every creation syntax, including `Mock.Of<T>()`; older language versions report **TM004**. For multi-interface mocks, delegates, and wrap mocks, use the `Mock.Of<T1, T2>()`, `Mock.OfDelegate<T>()`, and `Mock.Wrap()` factory methods. For interfaces with static abstract members, see [`[GenerateMock]`](setup#interfaces-with-static-abstract-members).
:::

### Implicit Conversion
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/writing-tests/mocking/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ public interface IProcessor
bool Process(string input);
}

var mock = Mock.Of<IProcessor>();
var mock = IProcessor.Mock();
mock.Process(Any())
.Returns(true)
.RaisesProcessCompleted(EventArgs.Empty) // strongly-typed auto-raise event
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Collections.Immutable;
using TUnit.Mocks.SourceGenerator.Models;

namespace TUnit.Mocks.SourceGenerator.Builders;

/// <summary>
/// Emits closed factory registrations for generic interface mocks named with concrete type
/// arguments, so <c>Mock.Of&lt;IRepository&lt;int&gt;&gt;()</c> never reaches the
/// <c>MakeGenericType</c> fallback in <c>MockRegistry</c>. See issue #7027.
/// </summary>
internal static class ClosedGenericRegistrationBuilder
{
public const string HintName = "TUnit.Mocks.ClosedGenericFactories.g.cs";

public static string Build(EquatableArray<ClosedGenericFactoryEmission> registrations)
{
var writer = new CodeWriter();

writer.AppendLine("// <auto-generated/>");
writer.AppendLine("#pragma warning disable");
writer.AppendLine("#nullable enable");
writer.AppendLine();
writer.AppendLine("namespace TUnit.Mocks.Generated;");
writer.AppendLine();

using (writer.Block("file static class ClosedGenericMockFactories"))
{
writer.AppendLine("[global::System.Runtime.CompilerServices.ModuleInitializer]");
using (writer.Block("internal static void Register()"))
{
foreach (var registration in registrations)
{
var closedType = registration.ClosedTypeFullyQualifiedName;
var displayName = closedType.Replace("global::", string.Empty);

writer.AppendLine($"global::TUnit.Mocks.MockRegistry.RegisterFactory<{closedType}>(static (behavior, constructorArgs) =>");
writer.AppendLine("{");
writer.AppendLine($" if (constructorArgs.Length > 0) throw new global::System.ArgumentException($\"Interface mock '{displayName}' does not support constructor arguments, but {{constructorArgs.Length}} were provided.\");");
writer.AppendLine($" return {registration.FactoryMethod}(behavior);");
writer.AppendLine("});");
}
}
}

return writer.ToString();
}

/// <summary>
/// Pairs each distinct registration with the open mock generated for it. Registrations whose
/// open mock was not generated as a plain single-interface mock (dropped, colliding, or bridged
/// for static abstract members) are skipped and keep using the runtime fallback.
/// </summary>
public static EquatableArray<ClosedGenericFactoryEmission> Resolve(
ImmutableArray<ClosedGenericMockRegistration> registrations,
ImmutableArray<MockTypeModel> models)
{
if (registrations.IsDefaultOrEmpty)
{
return EquatableArray<ClosedGenericFactoryEmission>.Empty;
}

var openModels = new Dictionary<string, MockTypeModel>(StringComparer.Ordinal);
foreach (var model in models)
{
if (model is { IsInterface: true, IsPartialMock: false, IsWrapMock: false, IsDelegateType: false, IsSecondaryMemberSurface: false, HasStaticAbstractMembers: false, CollidesWith: null }
&& model.AdditionalInterfaceNames.Length == 0
&& model.TypeParameters.Length > 0)
{
openModels[model.FullyQualifiedName] = model;
}
}

var seen = new HashSet<string>(StringComparer.Ordinal);
var emissions = new List<ClosedGenericFactoryEmission>();
foreach (var registration in registrations)
{
if (!openModels.TryGetValue(registration.OpenTypeFullyQualifiedName, out var model)
|| model.TypeParameters.Length != registration.TypeArguments.Length
|| !seen.Add(registration.ClosedTypeFullyQualifiedName))
{
continue;
}

emissions.Add(new ClosedGenericFactoryEmission(
registration.ClosedTypeFullyQualifiedName,
$"{MockImplBuilder.GetGlobalMockNamespacePrefix(model)}{MockImplBuilder.GetCompositeShortSafeName(model)}MockFactory.CreateAutoMock<{string.Join(", ", registration.TypeArguments)}>"));
}

// Ordinal order keeps the emitted file stable regardless of call-site discovery order.
emissions.Sort(static (x, y) => StringComparer.Ordinal.Compare(x.ClosedTypeFullyQualifiedName, y.ClosedTypeFullyQualifiedName));
return new EquatableArray<ClosedGenericFactoryEmission>(emissions.ToImmutableArray());
}
}
122 changes: 122 additions & 0 deletions src/TUnit.Mocks.SourceGenerator/Discovery/MockTypeDiscovery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,128 @@ private static bool ContainsTypeParameters(ITypeSymbol type)
};
}

// ─── Closed generic registrations (#7027) ─────────────────────────

/// <summary>
/// Semantic transform: for <c>Mock.Of&lt;IFoo&lt;int&gt;&gt;()</c> (and <c>MockRepository.Of</c>),
/// returns the closed interface so a closed factory can be registered ahead of time.
/// </summary>
public static ClosedGenericMockRegistration? TransformToClosedGenericRegistration(GeneratorSyntaxContext context, CancellationToken ct)
{
var invocation = (InvocationExpressionSyntax)context.Node;

if (context.SemanticModel.GetSymbolInfo(invocation, ct).Symbol is not IMethodSymbol
{
Name: "Of",
TypeArguments.Length: 1,
ContainingType.Name: "Mock" or "MockRepository"
} method
|| !IsTUnitMocksNamespace(method.ContainingNamespace))
{
return null;
}

return CreateClosedGenericRegistration(method.TypeArguments[0], context.SemanticModel.Compilation);
}

/// <summary>
/// Closed generic registrations for <c>[assembly: GenerateMock(typeof(IFoo&lt;int&gt;))]</c>.
/// </summary>
public static EquatableArray<ClosedGenericMockRegistration> TransformGenerateMockAttributeToClosedGenericRegistrations(
GeneratorAttributeSyntaxContext context, CancellationToken ct)
{
var registrations = ImmutableArray.CreateBuilder<ClosedGenericMockRegistration>();
foreach (var attr in context.Attributes)
{
if (attr.AttributeClass?.Name is not ("GenerateMockAttribute" or "GenerateMock")
|| !IsTUnitMocksNamespace(attr.AttributeClass?.ContainingNamespace)
|| attr.ConstructorArguments.Length != 1
|| attr.ConstructorArguments[0].Value is not ITypeSymbol type)
{
continue;
}

if (CreateClosedGenericRegistration(type, context.SemanticModel.Compilation) is { } registration)
{
registrations.Add(registration);
}
}

return new EquatableArray<ClosedGenericMockRegistration>(registrations.ToImmutable());
}

private static ClosedGenericMockRegistration? CreateClosedGenericRegistration(ITypeSymbol type, Compilation compilation)
{
if (type is not INamedTypeSymbol { TypeKind: TypeKind.Interface, IsGenericType: true } namedType
|| !ContainsTypeParameters(namedType.OriginalDefinition))
Comment thread
thomhurst marked this conversation as resolved.
Resolved
{
return null;
}

// Containing types count too: Outer<T>.IInner<int> inside generic code is still open.
var typeArguments = GetAllTypeArguments(namedType).ToImmutableArray();
if (typeArguments.Any(IsOpen))
{
return null;
}

// The registration is emitted into a top-level generated class, so every type argument must
// be nameable from there: private/protected nested types, file-local types and error types
// are left to the reflection fallback rather than breaking the user's build.
if (!compilation.IsSymbolAccessibleWithin(namedType, compilation.Assembly)
|| typeArguments.Any(static t => t.TypeKind == TypeKind.Error || IsOrContainsFileLocalType(t)))
{
return null;
}

return new ClosedGenericMockRegistration(
namedType.OriginalDefinition.GetFullyQualifiedName(),
namedType.GetFullyQualifiedName(),
new EquatableArray<string>(typeArguments.Select(static t => t.GetFullyQualifiedName()).ToImmutableArray()));
}

private static IEnumerable<ITypeSymbol> GetAllTypeArguments(INamedTypeSymbol namedType)
{
if (namedType.ContainingType is { } containingType)
{
foreach (var typeArgument in GetAllTypeArguments(containingType))
{
yield return typeArgument;
}
}

foreach (var typeArgument in namedType.TypeArguments)
{
yield return typeArgument;
}
}

// Unlike ContainsTypeParameters, also walks containing types (Outer<T>.Nested as an argument).
private static bool IsOpen(ITypeSymbol type)
{
return type switch
{
ITypeParameterSymbol => true,
IArrayTypeSymbol array => IsOpen(array.ElementType),
IPointerTypeSymbol pointer => IsOpen(pointer.PointedAtType),
INamedTypeSymbol named => named.TypeArguments.Any(IsOpen)
|| (named.ContainingType is { } containing && IsOpen(containing)),
_ => false
};
}

private static bool IsOrContainsFileLocalType(ITypeSymbol type)
{
return type switch
{
IArrayTypeSymbol array => IsOrContainsFileLocalType(array.ElementType),
INamedTypeSymbol named => named.IsFileLocal
|| (named.ContainingType is { } containing && IsOrContainsFileLocalType(containing))
|| named.TypeArguments.Any(IsOrContainsFileLocalType),
_ => false
};
}

// ─── T.Mock() static extension discovery ─────────────────────────

/// <summary>
Expand Down
Loading
Loading